Skip to content

Commit

Permalink
feat: lazy hydration strategies for async components (#11458)
Browse files Browse the repository at this point in the history
  • Loading branch information
yyx990803 committed Jul 31, 2024
1 parent e28c581 commit d14a11c
Show file tree
Hide file tree
Showing 13 changed files with 498 additions and 14 deletions.
21 changes: 21 additions & 0 deletions packages/runtime-core/src/apiAsyncComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -30,6 +31,7 @@ export interface AsyncComponentOptions<T = any> {
delay?: number
timeout?: number
suspensible?: boolean
hydrate?: HydrationStrategy
onError?: (
error: Error,
retry: () => void,
Expand All @@ -54,6 +56,7 @@ export function defineAsyncComponent<
loadingComponent,
errorComponent,
delay = 200,
hydrate: hydrateStrategy,
timeout, // undefined = never times out
suspensible = true,
onError: userOnError,
Expand Down Expand Up @@ -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
},
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime-core/src/componentOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------

Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type RootHydrateFunction = (
container: (Element | ShadowRoot) & { _vnode?: VNode },
) => void

enum DOMNodeTypes {
export enum DOMNodeTypes {
ELEMENT = 1,
TEXT = 3,
COMMENT = 8,
Expand Down Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions packages/runtime-core/src/hydrationStrategies.ts
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)
}
}
10 changes: 10 additions & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------------

Expand Down Expand Up @@ -327,6 +333,10 @@ export type {
AsyncComponentOptions,
AsyncComponentLoader,
} from './apiAsyncComponent'
export type {
HydrationStrategy,
HydrationStrategyFactory,
} from './hydrationStrategies'
export type { HMRRuntime } from './hmr'

// Internal API ----------------------------------------------------------------
Expand Down
15 changes: 5 additions & 10 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 9 additions & 2 deletions packages/vue/__tests__/e2e/e2eUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
44 changes: 44 additions & 0 deletions packages/vue/__tests__/e2e/hydration-strat-custom.html
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>
36 changes: 36 additions & 0 deletions packages/vue/__tests__/e2e/hydration-strat-idle.html
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 packages/vue/__tests__/e2e/hydration-strat-interaction.html
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>
Loading

0 comments on commit d14a11c

Please sign in to comment.