From 42d80b588805d4ca31b3a41953ecd024a23a6af2 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 13 Feb 2020 23:31:03 -0500 Subject: [PATCH] wip(ssr): component hydration --- packages/runtime-core/src/apiCreateApp.ts | 18 +- .../runtime-core/src/components/Suspense.ts | 6 +- packages/runtime-core/src/hydration.ts | 141 ++++++++++++++++ packages/runtime-core/src/index.ts | 3 +- packages/runtime-core/src/renderer.ts | 154 +++--------------- packages/runtime-dom/src/index.ts | 11 +- 6 files changed, 192 insertions(+), 141 deletions(-) create mode 100644 packages/runtime-core/src/hydration.ts diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index c810fcbc1..e8888487f 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -6,7 +6,7 @@ import { RootRenderFunction } from './renderer' import { InjectionKey } from './apiInject' import { isFunction, NO, isObject } from '@vue/shared' import { warn } from './warning' -import { createVNode, cloneVNode } from './vnode' +import { createVNode, cloneVNode, VNode } from './vnode' export interface App { config: AppConfig @@ -16,7 +16,10 @@ export interface App { component(name: string, component: Component): this directive(name: string): Directive | undefined directive(name: string, directive: Directive): this - mount(rootContainer: HostElement | string): ComponentPublicInstance + mount( + rootContainer: HostElement | string, + isHydrate?: boolean + ): ComponentPublicInstance unmount(rootContainer: HostElement | string): void provide(key: InjectionKey | string, value: T): this @@ -87,7 +90,8 @@ export type CreateAppFunction = ( ) => App export function createAppAPI( - render: RootRenderFunction + render: RootRenderFunction, + hydrate: (vnode: VNode, container: Element) => void ): CreateAppFunction { return function createApp(rootComponent: Component, rootProps = null) { if (rootProps != null && !isObject(rootProps)) { @@ -182,7 +186,7 @@ export function createAppAPI( return app }, - mount(rootContainer: HostElement): any { + mount(rootContainer: HostElement, isHydrate?: boolean): any { if (!isMounted) { const vnode = createVNode(rootComponent, rootProps) // store app context on the root VNode. @@ -196,7 +200,11 @@ export function createAppAPI( } } - render(vnode, rootContainer) + if (isHydrate) { + hydrate(vnode, rootContainer as any) + } else { + render(vnode, rootContainer) + } isMounted = true app._container = rootContainer return vnode.component!.proxy diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index d075ce12d..7fd782ffe 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -219,10 +219,10 @@ export interface SuspenseBoundary< instance: ComponentInternalInstance, setupRenderEffect: ( instance: ComponentInternalInstance, - parentSuspense: SuspenseBoundary | null, initialVNode: VNode, - container: HostElement, + container: HostElement | null, anchor: HostNode | null, + parentSuspense: SuspenseBoundary | null, isSVG: boolean ) => void ): void @@ -419,11 +419,11 @@ function createSuspenseBoundary( handleSetupResult(instance, asyncSetupResult, suspense) setupRenderEffect( instance, - suspense, vnode, // component may have been moved before resolve parentNode(instance.subTree.el)!, next(instance.subTree), + suspense, isSVG ) updateHOCHostEl(instance, vnode.el) diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts new file mode 100644 index 000000000..3ac09b6f2 --- /dev/null +++ b/packages/runtime-core/src/hydration.ts @@ -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 +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 929193078..34ef0eaad 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -23,9 +23,8 @@ export { openBlock, createBlock } from './vnode' -// VNode type symbols -export { Text, Comment, Fragment, Portal } from './vnode' // Internal Components +export { Fragment, Portal } from './vnode' export { Suspense, SuspenseProps } from './components/Suspense' export { KeepAlive, KeepAliveProps } from './components/KeepAlive' export { diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 3c0828df4..afbb28987 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -30,8 +30,7 @@ import { isReservedProp, isFunction, PatchFlags, - NOOP, - isOn + NOOP } from '@vue/shared' import { queueJob, @@ -54,7 +53,7 @@ import { ShapeFlags } from './shapeFlags' import { pushWarningContext, popWarningContext, warn } from './warning' import { invokeDirectiveHook } from './directives' import { ComponentPublicInstance } from './componentProxy' -import { createAppAPI, CreateAppFunction } from './apiCreateApp' +import { createAppAPI } from './apiCreateApp' import { SuspenseBoundary, queueEffectWithSuspense, @@ -63,6 +62,7 @@ import { import { ErrorCodes, callWithErrorHandling } from './errorHandling' import { KeepAliveSink, isKeepAlive } from './components/KeepAlive' import { registerHMR, unregisterHMR } from './hmr' +import { createHydrateFn } from './hydration' const __HMR__ = __BUNDLER__ && __DEV__ @@ -185,13 +185,7 @@ export const queuePostRenderEffect = __FEATURE_SUSPENSE__ export function createRenderer< HostNode extends object = any, HostElement extends HostNode = any ->( - options: RendererOptions -): { - render: RootRenderFunction - hydrate: RootRenderFunction - createApp: CreateAppFunction -} { +>(options: RendererOptions) { type HostVNode = VNode type HostVNodeChildren = VNodeArrayChildren type HostSuspenseBoundary = SuspenseBoundary @@ -984,7 +978,7 @@ export function createRenderer< function mountComponent( initialVNode: HostVNode, - container: HostElement, + container: HostElement | null, // only null during hydration anchor: HostNode | null, parentComponent: ComponentInternalInstance | null, parentSuspense: HostSuspenseBoundary | null, @@ -1023,19 +1017,19 @@ export function createRenderer< parentSuspense.registerDep(instance, setupRenderEffect) - // give it a placeholder + // Give it a placeholder if this is not hydration const placeholder = (instance.subTree = createVNode(Comment)) - processCommentNode(null, placeholder, container, anchor) + processCommentNode(null, placeholder, container!, anchor) initialVNode.el = placeholder.el return } setupRenderEffect( instance, - parentSuspense, initialVNode, container, anchor, + parentSuspense, isSVG ) @@ -1046,10 +1040,10 @@ export function createRenderer< function setupRenderEffect( instance: ComponentInternalInstance, - parentSuspense: HostSuspenseBoundary | null, initialVNode: HostVNode, - container: HostElement, + container: HostElement | null, // only null during hydration anchor: HostNode | null, + parentSuspense: HostSuspenseBoundary | null, isSVG: boolean ) { // create reactive effect for rendering @@ -1060,8 +1054,21 @@ export function createRenderer< if (instance.bm !== null) { invokeHooks(instance.bm) } - patch(null, subTree, container, anchor, instance, parentSuspense, isSVG) - initialVNode.el = subTree.el + 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 + } // mounted hook if (instance.m !== null) { queuePostRenderEffect(instance.m, parentSuspense) @@ -1816,119 +1823,12 @@ export function createRenderer< container._vnode = vnode } - function hydrate(vnode: HostVNode, container: any) { - 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 - } + const [hydrate, hydrateNode] = createHydrateFn(mountComponent, hostPatchProp) return { render, hydrate, - createApp: createAppAPI(render) + createApp: createAppAPI(render, hydrate) } } diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts index 8f4ec06f7..102a8a738 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -35,7 +35,7 @@ export const createApp: CreateAppFunction = (...args) => { } const { mount } = app - app.mount = (container): any => { + app.mount = (container: Element | string): any => { if (isString(container)) { container = document.querySelector(container)! if (!container) { @@ -53,9 +53,12 @@ export const createApp: CreateAppFunction = (...args) => { ) { component.template = container.innerHTML } - // clear content before mounting - container.innerHTML = '' - return mount(container) + const isHydrate = container.hasAttribute('data-server-rendered') + if (!isHydrate) { + // clear content before mounting + container.innerHTML = '' + } + return mount(container, isHydrate) } return app