-
-
Notifications
You must be signed in to change notification settings - Fork 8.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: lazy hydration strategies for async components (#11458)
- Loading branch information
Showing
13 changed files
with
498 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
import { isString } from '@vue/shared' | ||
import { DOMNodeTypes, isComment } from './hydration' | ||
|
||
/** | ||
* A lazy hydration strategy for async components. | ||
* @param hydrate - call this to perform the actual hydration. | ||
* @param forEachElement - iterate through the root elements of the component's | ||
* non-hydrated DOM, accounting for possible fragments. | ||
* @returns a teardown function to be called if the async component is unmounted | ||
* before it is hydrated. This can be used to e.g. remove DOM event | ||
* listeners. | ||
*/ | ||
export type HydrationStrategy = ( | ||
hydrate: () => void, | ||
forEachElement: (cb: (el: Element) => any) => void, | ||
) => (() => void) | void | ||
|
||
export type HydrationStrategyFactory<Options = any> = ( | ||
options?: Options, | ||
) => HydrationStrategy | ||
|
||
export const hydrateOnIdle: HydrationStrategyFactory = () => hydrate => { | ||
const id = requestIdleCallback(hydrate) | ||
return () => cancelIdleCallback(id) | ||
} | ||
|
||
export const hydrateOnVisible: HydrationStrategyFactory<string | number> = | ||
(margin = 0) => | ||
(hydrate, forEach) => { | ||
const ob = new IntersectionObserver( | ||
entries => { | ||
for (const e of entries) { | ||
if (!e.isIntersecting) continue | ||
ob.disconnect() | ||
hydrate() | ||
break | ||
} | ||
}, | ||
{ | ||
rootMargin: isString(margin) ? margin : margin + 'px', | ||
}, | ||
) | ||
forEach(el => ob.observe(el)) | ||
return () => ob.disconnect() | ||
} | ||
|
||
export const hydrateOnMediaQuery: HydrationStrategyFactory<string> = | ||
query => hydrate => { | ||
if (query) { | ||
const mql = matchMedia(query) | ||
if (mql.matches) { | ||
hydrate() | ||
} else { | ||
mql.addEventListener('change', hydrate, { once: true }) | ||
return () => mql.removeEventListener('change', hydrate) | ||
} | ||
} | ||
} | ||
|
||
export const hydrateOnInteraction: HydrationStrategyFactory< | ||
string | string[] | ||
> = | ||
(interactions = []) => | ||
(hydrate, forEach) => { | ||
if (isString(interactions)) interactions = [interactions] | ||
let hasHydrated = false | ||
const doHydrate = (e: Event) => { | ||
if (!hasHydrated) { | ||
hasHydrated = true | ||
teardown() | ||
hydrate() | ||
// replay event | ||
e.target!.dispatchEvent(new (e.constructor as any)(e.type, e)) | ||
} | ||
} | ||
const teardown = () => { | ||
forEach(el => { | ||
for (const i of interactions) { | ||
el.removeEventListener(i, doHydrate) | ||
} | ||
}) | ||
} | ||
forEach(el => { | ||
for (const i of interactions) { | ||
el.addEventListener(i, doHydrate, { once: true }) | ||
} | ||
}) | ||
return teardown | ||
} | ||
|
||
export function forEachElement(node: Node, cb: (el: Element) => void) { | ||
// fragment | ||
if (isComment(node) && node.data === '[') { | ||
let depth = 1 | ||
let next = node.nextSibling | ||
while (next) { | ||
if (next.nodeType === DOMNodeTypes.ELEMENT) { | ||
cb(next as Element) | ||
} else if (isComment(next)) { | ||
if (next.data === ']') { | ||
if (--depth === 0) break | ||
} else if (next.data === '[') { | ||
depth++ | ||
} | ||
} | ||
next = next.nextSibling | ||
} | ||
} else { | ||
cb(node as Element) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
<script src="../../dist/vue.global.js"></script> | ||
|
||
<div><span id="custom-trigger">click here to hydrate</span></div> | ||
<div id="app"><button>0</button></div> | ||
|
||
<script> | ||
window.isHydrated = false | ||
const { createSSRApp, defineAsyncComponent, h, ref, onMounted } = Vue | ||
|
||
const Comp = { | ||
setup() { | ||
const count = ref(0) | ||
onMounted(() => { | ||
console.log('hydrated') | ||
window.isHydrated = true | ||
}) | ||
return () => { | ||
return h('button', { onClick: () => count.value++ }, count.value) | ||
} | ||
}, | ||
} | ||
|
||
const AsyncComp = defineAsyncComponent({ | ||
loader: () => Promise.resolve(Comp), | ||
hydrate: (hydrate, el) => { | ||
const triggerEl = document.getElementById('custom-trigger') | ||
triggerEl.addEventListener('click', hydrate, { once: true }) | ||
return () => { | ||
window.teardownCalled = true | ||
triggerEl.removeEventListener('click', hydrate) | ||
} | ||
} | ||
}) | ||
|
||
const show = window.show = ref(true) | ||
createSSRApp({ | ||
setup() { | ||
onMounted(() => { | ||
window.isRootMounted = true | ||
}) | ||
return () => show.value ? h(AsyncComp) : 'off' | ||
} | ||
}).mount('#app') | ||
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
<script src="../../dist/vue.global.js"></script> | ||
|
||
<div id="app"><button>0</button></div> | ||
|
||
<script> | ||
window.isHydrated = false | ||
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnIdle } = Vue | ||
|
||
const Comp = { | ||
setup() { | ||
const count = ref(0) | ||
onMounted(() => { | ||
console.log('hydrated') | ||
window.isHydrated = true | ||
}) | ||
return () => h('button', { onClick: () => count.value++ }, count.value) | ||
}, | ||
} | ||
|
||
const AsyncComp = defineAsyncComponent({ | ||
loader: () => new Promise(resolve => { | ||
setTimeout(() => { | ||
console.log('resolve') | ||
resolve(Comp) | ||
requestIdleCallback(() => { | ||
console.log('busy') | ||
}) | ||
}, 10) | ||
}), | ||
hydrate: hydrateOnIdle() | ||
}) | ||
|
||
createSSRApp({ | ||
render: () => h(AsyncComp) | ||
}).mount('#app') | ||
</script> |
48 changes: 48 additions & 0 deletions
48
packages/vue/__tests__/e2e/hydration-strat-interaction.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
<script src="../../dist/vue.global.js"></script> | ||
|
||
<div>click to hydrate</div> | ||
<div id="app"><button>0</button></div> | ||
<style>body { margin: 0 }</style> | ||
|
||
<script> | ||
const isFragment = location.search.includes('?fragment') | ||
if (isFragment) { | ||
document.getElementById('app').innerHTML = | ||
`<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->` | ||
} | ||
|
||
window.isHydrated = false | ||
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnInteraction } = Vue | ||
|
||
const Comp = { | ||
setup() { | ||
const count = ref(0) | ||
onMounted(() => { | ||
console.log('hydrated') | ||
window.isHydrated = true | ||
}) | ||
return () => { | ||
const button = h('button', { onClick: () => count.value++ }, count.value) | ||
if (isFragment) { | ||
return [[h('span', 'one')], button, h('span', 'two')] | ||
} else { | ||
return button | ||
} | ||
} | ||
}, | ||
} | ||
|
||
const AsyncComp = defineAsyncComponent({ | ||
loader: () => Promise.resolve(Comp), | ||
hydrate: hydrateOnInteraction(['click', 'wheel']) | ||
}) | ||
|
||
createSSRApp({ | ||
setup() { | ||
onMounted(() => { | ||
window.isRootMounted = true | ||
}) | ||
return () => h(AsyncComp) | ||
} | ||
}).mount('#app') | ||
</script> |
Oops, something went wrong.