mirror of https://github.com/vuejs/core.git
feat: lazy hydration strategies for async components (#11458)
This commit is contained in:
parent
e28c58138c
commit
d14a11c1cd
|
@ -16,6 +16,7 @@ import { ErrorCodes, handleError } from './errorHandling'
|
|||
import { isKeepAlive } from './components/KeepAlive'
|
||||
import { queueJob } from './scheduler'
|
||||
import { markAsyncBoundary } from './helpers/useId'
|
||||
import { type HydrationStrategy, forEachElement } from './hydrationStrategies'
|
||||
|
||||
export type AsyncComponentResolveResult<T = Component> = T | { default: T } // es modules
|
||||
|
||||
|
@ -30,6 +31,7 @@ export interface AsyncComponentOptions<T = any> {
|
|||
delay?: number
|
||||
timeout?: number
|
||||
suspensible?: boolean
|
||||
hydrate?: HydrationStrategy
|
||||
onError?: (
|
||||
error: Error,
|
||||
retry: () => void,
|
||||
|
@ -54,6 +56,7 @@ export function defineAsyncComponent<
|
|||
loadingComponent,
|
||||
errorComponent,
|
||||
delay = 200,
|
||||
hydrate: hydrateStrategy,
|
||||
timeout, // undefined = never times out
|
||||
suspensible = true,
|
||||
onError: userOnError,
|
||||
|
@ -118,6 +121,24 @@ export function defineAsyncComponent<
|
|||
|
||||
__asyncLoader: load,
|
||||
|
||||
__asyncHydrate(el, instance, hydrate) {
|
||||
const doHydrate = hydrateStrategy
|
||||
? () => {
|
||||
const teardown = hydrateStrategy(hydrate, cb =>
|
||||
forEachElement(el, cb),
|
||||
)
|
||||
if (teardown) {
|
||||
;(instance.bum || (instance.bum = [])).push(teardown)
|
||||
}
|
||||
}
|
||||
: hydrate
|
||||
if (resolvedComp) {
|
||||
doHydrate()
|
||||
} else {
|
||||
load().then(() => !instance.isUnmounted && doHydrate())
|
||||
}
|
||||
},
|
||||
|
||||
get __asyncResolved() {
|
||||
return resolvedComp
|
||||
},
|
||||
|
|
|
@ -199,6 +199,15 @@ export interface ComponentOptionsBase<
|
|||
* @internal
|
||||
*/
|
||||
__asyncResolved?: ConcreteComponent
|
||||
/**
|
||||
* Exposed for lazy hydration
|
||||
* @internal
|
||||
*/
|
||||
__asyncHydrate?: (
|
||||
el: Element,
|
||||
instance: ComponentInternalInstance,
|
||||
hydrate: () => void,
|
||||
) => void
|
||||
|
||||
// Type differentiators ------------------------------------------------------
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@ export type RootHydrateFunction = (
|
|||
container: (Element | ShadowRoot) & { _vnode?: VNode },
|
||||
) => void
|
||||
|
||||
enum DOMNodeTypes {
|
||||
export enum DOMNodeTypes {
|
||||
ELEMENT = 1,
|
||||
TEXT = 3,
|
||||
COMMENT = 8,
|
||||
|
@ -75,7 +75,7 @@ const getContainerType = (container: Element): 'svg' | 'mathml' | undefined => {
|
|||
return undefined
|
||||
}
|
||||
|
||||
const isComment = (node: Node): node is Comment =>
|
||||
export const isComment = (node: Node): node is Comment =>
|
||||
node.nodeType === DOMNodeTypes.COMMENT
|
||||
|
||||
// Note: hydration is DOM-specific
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -64,6 +64,12 @@ export { useAttrs, useSlots } from './apiSetupHelpers'
|
|||
export { useModel } from './helpers/useModel'
|
||||
export { useTemplateRef } from './helpers/useTemplateRef'
|
||||
export { useId } from './helpers/useId'
|
||||
export {
|
||||
hydrateOnIdle,
|
||||
hydrateOnVisible,
|
||||
hydrateOnMediaQuery,
|
||||
hydrateOnInteraction,
|
||||
} from './hydrationStrategies'
|
||||
|
||||
// <script setup> API ----------------------------------------------------------
|
||||
|
||||
|
@ -327,6 +333,10 @@ export type {
|
|||
AsyncComponentOptions,
|
||||
AsyncComponentLoader,
|
||||
} from './apiAsyncComponent'
|
||||
export type {
|
||||
HydrationStrategy,
|
||||
HydrationStrategyFactory,
|
||||
} from './hydrationStrategies'
|
||||
export type { HMRRuntime } from './hmr'
|
||||
|
||||
// Internal API ----------------------------------------------------------------
|
||||
|
|
|
@ -1325,16 +1325,11 @@ function baseCreateRenderer(
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isAsyncWrapperVNode &&
|
||||
!(type as ComponentOptions).__asyncResolved
|
||||
) {
|
||||
;(type as ComponentOptions).__asyncLoader!().then(
|
||||
// note: we are moving the render call into an async callback,
|
||||
// which means it won't track dependencies - but it's ok because
|
||||
// a server-rendered async wrapper is already in resolved state
|
||||
// and it will never need to change.
|
||||
() => !instance.isUnmounted && hydrateSubTree(),
|
||||
if (isAsyncWrapperVNode) {
|
||||
;(type as ComponentOptions).__asyncHydrate!(
|
||||
el as Element,
|
||||
instance,
|
||||
hydrateSubTree,
|
||||
)
|
||||
} else {
|
||||
hydrateSubTree()
|
||||
|
|
|
@ -30,12 +30,19 @@ export async function expectByPolling(
|
|||
}
|
||||
}
|
||||
|
||||
export function setupPuppeteer() {
|
||||
export function setupPuppeteer(args?: string[]) {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
|
||||
const resolvedOptions = args
|
||||
? {
|
||||
...puppeteerOptions,
|
||||
args: [...puppeteerOptions.args!, ...args],
|
||||
}
|
||||
: puppeteerOptions
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await puppeteer.launch(puppeteerOptions)
|
||||
browser = await puppeteer.launch(resolvedOptions)
|
||||
}, 20000)
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,36 @@
|
|||
<script src="../../dist/vue.global.js"></script>
|
||||
|
||||
<div>resize the window width to < 500px to hydrate</div>
|
||||
<div id="app"><button>0</button></div>
|
||||
|
||||
<script>
|
||||
window.isHydrated = false
|
||||
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnMediaQuery } = 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: hydrateOnMediaQuery('(max-width:500px)')
|
||||
})
|
||||
|
||||
createSSRApp({
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
window.isRootMounted = true
|
||||
})
|
||||
return () => h(AsyncComp)
|
||||
}
|
||||
}).mount('#app')
|
||||
</script>
|
|
@ -0,0 +1,49 @@
|
|||
<script src="../../dist/vue.global.js"></script>
|
||||
|
||||
<div style="height: 1000px">scroll to the bottom to hydrate</div>
|
||||
<div id="app"><button>0</button></div>
|
||||
<style>body { margin: 0 }</style>
|
||||
|
||||
<script>
|
||||
const rootMargin = location.search.match(/rootMargin=(\d+)/)?.[1] ?? 0
|
||||
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, hydrateOnVisible } = 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: hydrateOnVisible(rootMargin + 'px')
|
||||
})
|
||||
|
||||
createSSRApp({
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
window.isRootMounted = true
|
||||
})
|
||||
return () => h(AsyncComp)
|
||||
}
|
||||
}).mount('#app')
|
||||
</script>
|
|
@ -0,0 +1,118 @@
|
|||
import path from 'node:path'
|
||||
import { setupPuppeteer } from './e2eUtils'
|
||||
import type { Ref } from '../../src/runtime'
|
||||
|
||||
declare const window: Window & {
|
||||
isHydrated: boolean
|
||||
isRootMounted: boolean
|
||||
teardownCalled?: boolean
|
||||
show: Ref<boolean>
|
||||
}
|
||||
|
||||
describe('async component hydration strategies', () => {
|
||||
const { page, click, text, count } = setupPuppeteer(['--window-size=800,600'])
|
||||
|
||||
async function goToCase(name: string, query = '') {
|
||||
const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}.html${query}`)}`
|
||||
await page().goto(file)
|
||||
}
|
||||
|
||||
async function assertHydrationSuccess(n = '1') {
|
||||
await click('button')
|
||||
expect(await text('button')).toBe(n)
|
||||
}
|
||||
|
||||
test('idle', async () => {
|
||||
const messages: string[] = []
|
||||
page().on('console', e => messages.push(e.text()))
|
||||
|
||||
await goToCase('idle')
|
||||
// not hydrated yet
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
// wait for hydration
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
// assert message order: hyration should happen after already queued main thread work
|
||||
expect(messages.slice(1)).toMatchObject(['resolve', 'busy', 'hydrated'])
|
||||
await assertHydrationSuccess()
|
||||
})
|
||||
|
||||
test('visible', async () => {
|
||||
await goToCase('visible')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
// scroll down
|
||||
await page().evaluate(() => window.scrollTo({ top: 1000 }))
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
await assertHydrationSuccess()
|
||||
})
|
||||
|
||||
test('visible (with rootMargin)', async () => {
|
||||
await goToCase('visible', '?rootMargin=1000')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
// should hydrate without needing to scroll
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
await assertHydrationSuccess()
|
||||
})
|
||||
|
||||
test('visible (fragment)', async () => {
|
||||
await goToCase('visible', '?fragment')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
expect(await count('span')).toBe(2)
|
||||
// scroll down
|
||||
await page().evaluate(() => window.scrollTo({ top: 1000 }))
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
await assertHydrationSuccess()
|
||||
})
|
||||
|
||||
test('media query', async () => {
|
||||
await goToCase('media')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
// resize
|
||||
await page().setViewport({ width: 400, height: 600 })
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
await assertHydrationSuccess()
|
||||
})
|
||||
|
||||
test('interaction', async () => {
|
||||
await goToCase('interaction')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
await click('button')
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
// should replay event
|
||||
expect(await text('button')).toBe('1')
|
||||
await assertHydrationSuccess('2')
|
||||
})
|
||||
|
||||
test('interaction (fragment)', async () => {
|
||||
await goToCase('interaction', '?fragment')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
await click('button')
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
// should replay event
|
||||
expect(await text('button')).toBe('1')
|
||||
await assertHydrationSuccess('2')
|
||||
})
|
||||
|
||||
test('custom', async () => {
|
||||
await goToCase('custom')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
await click('#custom-trigger')
|
||||
await page().waitForFunction(() => window.isHydrated)
|
||||
await assertHydrationSuccess()
|
||||
})
|
||||
|
||||
test('custom teardown', async () => {
|
||||
await goToCase('custom')
|
||||
await page().waitForFunction(() => window.isRootMounted)
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
await page().evaluate(() => (window.show.value = false))
|
||||
expect(await text('#app')).toBe('off')
|
||||
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
|
||||
expect(await page().evaluate(() => window.teardownCalled)).toBe(true)
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue