feat: lazy hydration strategies for async components (#11458)

This commit is contained in:
Evan You 2024-07-31 12:14:51 +08:00 committed by GitHub
parent e28c58138c
commit d14a11c1cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 498 additions and 14 deletions

View File

@ -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
},

View File

@ -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 ------------------------------------------------------

View File

@ -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

View File

@ -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)
}
}

View File

@ -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 ----------------------------------------------------------------

View File

@ -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()

View File

@ -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 () => {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
})
})