wip(ssr): component hydration

This commit is contained in:
Evan You 2020-02-13 23:31:03 -05:00
parent 32d6a46474
commit 42d80b5888
6 changed files with 192 additions and 141 deletions

View File

@ -6,7 +6,7 @@ import { RootRenderFunction } from './renderer'
import { InjectionKey } from './apiInject' import { InjectionKey } from './apiInject'
import { isFunction, NO, isObject } from '@vue/shared' import { isFunction, NO, isObject } from '@vue/shared'
import { warn } from './warning' import { warn } from './warning'
import { createVNode, cloneVNode } from './vnode' import { createVNode, cloneVNode, VNode } from './vnode'
export interface App<HostElement = any> { export interface App<HostElement = any> {
config: AppConfig config: AppConfig
@ -16,7 +16,10 @@ export interface App<HostElement = any> {
component(name: string, component: Component): this component(name: string, component: Component): this
directive(name: string): Directive | undefined directive(name: string): Directive | undefined
directive(name: string, directive: Directive): this directive(name: string, directive: Directive): this
mount(rootContainer: HostElement | string): ComponentPublicInstance mount(
rootContainer: HostElement | string,
isHydrate?: boolean
): ComponentPublicInstance
unmount(rootContainer: HostElement | string): void unmount(rootContainer: HostElement | string): void
provide<T>(key: InjectionKey<T> | string, value: T): this provide<T>(key: InjectionKey<T> | string, value: T): this
@ -87,7 +90,8 @@ export type CreateAppFunction<HostElement> = (
) => App<HostElement> ) => App<HostElement>
export function createAppAPI<HostNode, HostElement>( export function createAppAPI<HostNode, HostElement>(
render: RootRenderFunction<HostNode, HostElement> render: RootRenderFunction<HostNode, HostElement>,
hydrate: (vnode: VNode, container: Element) => void
): CreateAppFunction<HostElement> { ): CreateAppFunction<HostElement> {
return function createApp(rootComponent: Component, rootProps = null) { return function createApp(rootComponent: Component, rootProps = null) {
if (rootProps != null && !isObject(rootProps)) { if (rootProps != null && !isObject(rootProps)) {
@ -182,7 +186,7 @@ export function createAppAPI<HostNode, HostElement>(
return app return app
}, },
mount(rootContainer: HostElement): any { mount(rootContainer: HostElement, isHydrate?: boolean): any {
if (!isMounted) { if (!isMounted) {
const vnode = createVNode(rootComponent, rootProps) const vnode = createVNode(rootComponent, rootProps)
// store app context on the root VNode. // store app context on the root VNode.
@ -196,7 +200,11 @@ export function createAppAPI<HostNode, HostElement>(
} }
} }
if (isHydrate) {
hydrate(vnode, rootContainer as any)
} else {
render(vnode, rootContainer) render(vnode, rootContainer)
}
isMounted = true isMounted = true
app._container = rootContainer app._container = rootContainer
return vnode.component!.proxy return vnode.component!.proxy

View File

@ -219,10 +219,10 @@ export interface SuspenseBoundary<
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
setupRenderEffect: ( setupRenderEffect: (
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
parentSuspense: SuspenseBoundary<HostNode, HostElement> | null,
initialVNode: VNode<HostNode, HostElement>, initialVNode: VNode<HostNode, HostElement>,
container: HostElement, container: HostElement | null,
anchor: HostNode | null, anchor: HostNode | null,
parentSuspense: SuspenseBoundary<HostNode, HostElement> | null,
isSVG: boolean isSVG: boolean
) => void ) => void
): void ): void
@ -419,11 +419,11 @@ function createSuspenseBoundary<HostNode, HostElement>(
handleSetupResult(instance, asyncSetupResult, suspense) handleSetupResult(instance, asyncSetupResult, suspense)
setupRenderEffect( setupRenderEffect(
instance, instance,
suspense,
vnode, vnode,
// component may have been moved before resolve // component may have been moved before resolve
parentNode(instance.subTree.el)!, parentNode(instance.subTree.el)!,
next(instance.subTree), next(instance.subTree),
suspense,
isSVG isSVG
) )
updateHOCHostEl(instance, vnode.el) updateHOCHostEl(instance, vnode.el)

View File

@ -0,0 +1,141 @@
import {
VNode,
normalizeVNode,
Text,
Comment,
Static,
Fragment,
Portal
} from './vnode'
import { queuePostFlushCb, flushPostFlushCbs } from './scheduler'
import { ComponentInternalInstance } from './component'
import { invokeDirectiveHook } from './directives'
import { ShapeFlags } from './shapeFlags'
import { warn } from './warning'
import { PatchFlags, isReservedProp, isOn } from '@vue/shared'
// Note: hydration is DOM-specific
// but we have to place it in core due to tight coupling with core renderer
// logic - splitting it out
export function createHydrateFn(
mountComponent: any, // TODO
patchProp: any // TODO
) {
function hydrate(vnode: VNode, container: Element) {
if (__DEV__ && !container.hasChildNodes()) {
warn(`Attempting to hydrate existing markup but container is empty.`)
return
}
hydrateNode(container.firstChild!, vnode)
flushPostFlushCbs()
}
// TODO handle mismatches
// TODO SVG
// TODO Suspense
function hydrateNode(
node: Node,
vnode: VNode,
parentComponent: ComponentInternalInstance | null = null
): Node | null | undefined {
const { type, shapeFlag } = vnode
vnode.el = node
switch (type) {
case Text:
case Comment:
case Static:
return node.nextSibling
case Fragment:
const anchor = (vnode.anchor = hydrateChildren(
node.nextSibling,
vnode.children as VNode[],
parentComponent
)!)
// TODO handle potential hydration error if fragment didn't get
// back anchor as expected.
return anchor.nextSibling
case Portal:
// TODO
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
return hydrateElement(node as Element, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
mountComponent(vnode, null, null, parentComponent, null, false)
const subTree = vnode.component!.subTree
return (subTree.anchor || subTree.el).nextSibling
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
// TODO
} else if (__DEV__) {
warn('Invalid HostVNode type:', type, `(${typeof type})`)
}
}
}
function hydrateElement(
el: Element,
vnode: VNode,
parentComponent: ComponentInternalInstance | null
) {
const { props, patchFlag } = vnode
// skip props & children if this is hoisted static nodes
if (patchFlag !== PatchFlags.HOISTED) {
// props
if (props !== null) {
if (
patchFlag & PatchFlags.FULL_PROPS ||
patchFlag & PatchFlags.HYDRATE_EVENTS
) {
for (const key in props) {
if (!isReservedProp(key) && isOn(key)) {
patchProp(el, key, props[key], null)
}
}
} else if (props.onClick != null) {
// Fast path for click listeners (which is most often) to avoid
// iterating through props.
patchProp(el, 'onClick', props.onClick, null)
}
// vnode hooks
const { onVnodeBeforeMount, onVnodeMounted } = props
if (onVnodeBeforeMount != null) {
invokeDirectiveHook(onVnodeBeforeMount, parentComponent, vnode)
}
if (onVnodeMounted != null) {
queuePostFlushCb(() => {
invokeDirectiveHook(onVnodeMounted, parentComponent, vnode)
})
}
}
// children
if (
vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
// skip if element has innerHTML / textContent
!(props !== null && (props.innerHTML || props.textContent))
) {
hydrateChildren(
el.firstChild,
vnode.children as VNode[],
parentComponent
)
}
}
return el.nextSibling
}
function hydrateChildren(
node: Node | null | undefined,
vnodes: VNode[],
parentComponent: ComponentInternalInstance | null
): Node | null | undefined {
for (let i = 0; node != null && i < vnodes.length; i++) {
// TODO can skip normalizeVNode in optimized mode
// (need hint on rendered markup?)
const vnode = (vnodes[i] = normalizeVNode(vnodes[i]))
node = hydrateNode(node, vnode, parentComponent)
}
return node
}
return [hydrate, hydrateNode] as const
}

View File

@ -23,9 +23,8 @@ export {
openBlock, openBlock,
createBlock createBlock
} from './vnode' } from './vnode'
// VNode type symbols
export { Text, Comment, Fragment, Portal } from './vnode'
// Internal Components // Internal Components
export { Fragment, Portal } from './vnode'
export { Suspense, SuspenseProps } from './components/Suspense' export { Suspense, SuspenseProps } from './components/Suspense'
export { KeepAlive, KeepAliveProps } from './components/KeepAlive' export { KeepAlive, KeepAliveProps } from './components/KeepAlive'
export { export {

View File

@ -30,8 +30,7 @@ import {
isReservedProp, isReservedProp,
isFunction, isFunction,
PatchFlags, PatchFlags,
NOOP, NOOP
isOn
} from '@vue/shared' } from '@vue/shared'
import { import {
queueJob, queueJob,
@ -54,7 +53,7 @@ import { ShapeFlags } from './shapeFlags'
import { pushWarningContext, popWarningContext, warn } from './warning' import { pushWarningContext, popWarningContext, warn } from './warning'
import { invokeDirectiveHook } from './directives' import { invokeDirectiveHook } from './directives'
import { ComponentPublicInstance } from './componentProxy' import { ComponentPublicInstance } from './componentProxy'
import { createAppAPI, CreateAppFunction } from './apiCreateApp' import { createAppAPI } from './apiCreateApp'
import { import {
SuspenseBoundary, SuspenseBoundary,
queueEffectWithSuspense, queueEffectWithSuspense,
@ -63,6 +62,7 @@ import {
import { ErrorCodes, callWithErrorHandling } from './errorHandling' import { ErrorCodes, callWithErrorHandling } from './errorHandling'
import { KeepAliveSink, isKeepAlive } from './components/KeepAlive' import { KeepAliveSink, isKeepAlive } from './components/KeepAlive'
import { registerHMR, unregisterHMR } from './hmr' import { registerHMR, unregisterHMR } from './hmr'
import { createHydrateFn } from './hydration'
const __HMR__ = __BUNDLER__ && __DEV__ const __HMR__ = __BUNDLER__ && __DEV__
@ -185,13 +185,7 @@ export const queuePostRenderEffect = __FEATURE_SUSPENSE__
export function createRenderer< export function createRenderer<
HostNode extends object = any, HostNode extends object = any,
HostElement extends HostNode = any HostElement extends HostNode = any
>( >(options: RendererOptions<HostNode, HostElement>) {
options: RendererOptions<HostNode, HostElement>
): {
render: RootRenderFunction<HostNode, HostElement>
hydrate: RootRenderFunction<HostNode, HostElement>
createApp: CreateAppFunction<HostElement>
} {
type HostVNode = VNode<HostNode, HostElement> type HostVNode = VNode<HostNode, HostElement>
type HostVNodeChildren = VNodeArrayChildren<HostNode, HostElement> type HostVNodeChildren = VNodeArrayChildren<HostNode, HostElement>
type HostSuspenseBoundary = SuspenseBoundary<HostNode, HostElement> type HostSuspenseBoundary = SuspenseBoundary<HostNode, HostElement>
@ -984,7 +978,7 @@ export function createRenderer<
function mountComponent( function mountComponent(
initialVNode: HostVNode, initialVNode: HostVNode,
container: HostElement, container: HostElement | null, // only null during hydration
anchor: HostNode | null, anchor: HostNode | null,
parentComponent: ComponentInternalInstance | null, parentComponent: ComponentInternalInstance | null,
parentSuspense: HostSuspenseBoundary | null, parentSuspense: HostSuspenseBoundary | null,
@ -1023,19 +1017,19 @@ export function createRenderer<
parentSuspense.registerDep(instance, setupRenderEffect) parentSuspense.registerDep(instance, setupRenderEffect)
// give it a placeholder // Give it a placeholder if this is not hydration
const placeholder = (instance.subTree = createVNode(Comment)) const placeholder = (instance.subTree = createVNode(Comment))
processCommentNode(null, placeholder, container, anchor) processCommentNode(null, placeholder, container!, anchor)
initialVNode.el = placeholder.el initialVNode.el = placeholder.el
return return
} }
setupRenderEffect( setupRenderEffect(
instance, instance,
parentSuspense,
initialVNode, initialVNode,
container, container,
anchor, anchor,
parentSuspense,
isSVG isSVG
) )
@ -1046,10 +1040,10 @@ export function createRenderer<
function setupRenderEffect( function setupRenderEffect(
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
parentSuspense: HostSuspenseBoundary | null,
initialVNode: HostVNode, initialVNode: HostVNode,
container: HostElement, container: HostElement | null, // only null during hydration
anchor: HostNode | null, anchor: HostNode | null,
parentSuspense: HostSuspenseBoundary | null,
isSVG: boolean isSVG: boolean
) { ) {
// create reactive effect for rendering // create reactive effect for rendering
@ -1060,8 +1054,21 @@ export function createRenderer<
if (instance.bm !== null) { if (instance.bm !== null) {
invokeHooks(instance.bm) invokeHooks(instance.bm)
} }
patch(null, subTree, container, anchor, instance, parentSuspense, isSVG) if (initialVNode.el) {
// vnode has adopted host node - perform hydration instead of mount.
hydrateNode(initialVNode.el as Node, subTree, instance)
} else {
patch(
null,
subTree,
container!, // container is only null during hydration
anchor,
instance,
parentSuspense,
isSVG
)
initialVNode.el = subTree.el initialVNode.el = subTree.el
}
// mounted hook // mounted hook
if (instance.m !== null) { if (instance.m !== null) {
queuePostRenderEffect(instance.m, parentSuspense) queuePostRenderEffect(instance.m, parentSuspense)
@ -1816,119 +1823,12 @@ export function createRenderer<
container._vnode = vnode container._vnode = vnode
} }
function hydrate(vnode: HostVNode, container: any) { const [hydrate, hydrateNode] = createHydrateFn(mountComponent, hostPatchProp)
hydrateNode(container.firstChild, vnode, container)
flushPostFlushCbs()
}
// TODO handle mismatches
function hydrateNode(
node: any,
vnode: HostVNode,
container: any,
parentComponent: ComponentInternalInstance | null = null
): any {
const { type, shapeFlag } = vnode
switch (type) {
case Text:
case Comment:
case Static:
vnode.el = node
return node.nextSibling
case Fragment:
vnode.el = node
const anchor = (vnode.anchor = hydrateChildren(
node.nextSibling,
vnode.children as HostVNode[],
container,
parentComponent
))
return anchor.nextSibling
case Portal:
// TODO
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
return hydrateElement(node, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// TODO
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
// TODO
} else if (__DEV__) {
warn('Invalid HostVNode type:', type, `(${typeof type})`)
}
}
}
function hydrateElement(
el: any,
vnode: HostVNode,
parentComponent: ComponentInternalInstance | null
) {
vnode.el = el
const { props, patchFlag } = vnode
// skip props & children if this is hoisted static nodes
if (patchFlag !== PatchFlags.HOISTED) {
// props
if (props !== null) {
if (
patchFlag & PatchFlags.FULL_PROPS ||
patchFlag & PatchFlags.HYDRATE_EVENTS
) {
for (const key in props) {
if (!isReservedProp(key) && isOn(key)) {
hostPatchProp(el, key, props[key], null)
}
}
} else if (props.onClick != null) {
// Fast path for click listeners (which is most often) to avoid
// iterating through props.
hostPatchProp(el, 'onClick', props.onClick, null)
}
// vnode mounted hook
const { onVnodeMounted } = props
if (onVnodeMounted != null) {
queuePostFlushCb(() => {
invokeDirectiveHook(onVnodeMounted, parentComponent, vnode, null)
})
}
}
// children
if (
vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
// skip if element has innerHTML / textContent
!(props !== null && (props.innerHTML || props.textContent))
) {
hydrateChildren(
el.firstChild,
vnode.children as HostVNode[],
el,
parentComponent
)
}
}
return el.nextSibling
}
function hydrateChildren(
node: any,
vnodes: HostVNode[],
container: any,
parentComponent: ComponentInternalInstance | null = null
) {
for (let i = 0; i < vnodes.length; i++) {
// TODO can skip normalizeVNode in optimized mode
// (need hint on rendered markup?)
const vnode = (vnodes[i] = normalizeVNode(vnodes[i]))
node = hydrateNode(node, vnode, container, parentComponent)
}
return node
}
return { return {
render, render,
hydrate, hydrate,
createApp: createAppAPI(render) createApp: createAppAPI<HostNode, HostElement>(render, hydrate)
} }
} }

View File

@ -35,7 +35,7 @@ export const createApp: CreateAppFunction<Element> = (...args) => {
} }
const { mount } = app const { mount } = app
app.mount = (container): any => { app.mount = (container: Element | string): any => {
if (isString(container)) { if (isString(container)) {
container = document.querySelector(container)! container = document.querySelector(container)!
if (!container) { if (!container) {
@ -53,9 +53,12 @@ export const createApp: CreateAppFunction<Element> = (...args) => {
) { ) {
component.template = container.innerHTML component.template = container.innerHTML
} }
const isHydrate = container.hasAttribute('data-server-rendered')
if (!isHydrate) {
// clear content before mounting // clear content before mounting
container.innerHTML = '' container.innerHTML = ''
return mount(container) }
return mount(container, isHydrate)
} }
return app return app