Quellcode durchsuchen

fix component client-side hydration

Evan You vor 10 Jahren
Ursprung
Commit
d40ab0ca87

+ 3 - 3
flow/component.js

@@ -26,7 +26,7 @@ declare interface Component {
   $isServer: boolean;
 
   // public methods
-  $mount: (el?: Element | string) => Component;
+  $mount: (el?: Element | string, hydrating?: boolean) => Component;
   $forceUpdate: () => void;
   $destroy: () => void;
   $watch: (expOrFn: string | Function, cb: Function, options?: Object) => Function;
@@ -60,8 +60,8 @@ declare interface Component {
   // private methods
   // lifecycle
   _init: Function;
-  _mount: () => Component;
-  _update: (vnode: VNode) => void;
+  _mount: (el?: Element | void, hydrating?: boolean) => Component;
+  _update: (vnode: VNode, hydrating?: boolean) => void;
   _updateListeners: (listeners: Object, oldListeners: ?Object) => void;
   _updateFromParent: (
     propsData: ?Object,

+ 1 - 0
flow/vnode.js

@@ -26,6 +26,7 @@ declare interface VNodeWithData {
   context: Component;
   key: string | number | void;
   parent?: VNodeWithData;
+  child?: Component;
 }
 
 declare interface VNodeData {

+ 9 - 4
src/core/instance/lifecycle.js

@@ -24,8 +24,12 @@ export function initLifecycle (vm: Component) {
 }
 
 export function lifecycleMixin (Vue: Class<Component>) {
-  Vue.prototype._mount = function (): Component {
+  Vue.prototype._mount = function (
+    el?: Element | void,
+    hydrating?: boolean
+  ): Component {
     const vm: Component = this
+    vm.$el = el
     if (!vm.$options.render) {
       vm.$options.render = () => emptyVNode
       if (process.env.NODE_ENV !== 'production') {
@@ -47,8 +51,9 @@ export function lifecycleMixin (Vue: Class<Component>) {
     }
     callHook(vm, 'beforeMount')
     vm._watcher = new Watcher(vm, () => {
-      vm._update(vm._render())
+      vm._update(vm._render(), hydrating)
     }, noop)
+    hydrating = false
     vm._isMounted = true
     // root instance, call mounted on self
     if (vm.$root === vm) {
@@ -57,7 +62,7 @@ export function lifecycleMixin (Vue: Class<Component>) {
     return vm
   }
 
-  Vue.prototype._update = function (vnode: VNode) {
+  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
     const vm: Component = this
     if (vm._isMounted) {
       callHook(vm, 'beforeUpdate')
@@ -65,7 +70,7 @@ export function lifecycleMixin (Vue: Class<Component>) {
     if (!vm._vnode) {
       // Vue.prototype.__patch__ is injected in entry points
       // based on the rendering backend used.
-      vm.$el = vm.__patch__(vm.$el, vnode)
+      vm.$el = vm.__patch__(vm.$el, vnode, hydrating)
     } else {
       vm.$el = vm.__patch__(vm._vnode, vnode)
     }

+ 2 - 2
src/core/vdom/create-component.js

@@ -96,9 +96,9 @@ export function createComponentInstanceForVnode (
   return new vnodeComponentOptions.Ctor(options)
 }
 
-function init (vnode: VNode) {
+function init (vnode: VNodeWithData, hydrating: boolean) {
   const child = vnode.child = createComponentInstanceForVnode(vnode)
-  child.$mount()
+  child.$mount(vnode.elm, hydrating)
 }
 
 function prepatch (

+ 5 - 2
src/core/vdom/patch.js

@@ -308,7 +308,7 @@ export function createPatchFunction (backend) {
     vnode.elm = elm
     const { tag, data, children } = vnode
     if (isDef(data)) {
-      if (isDef(i = data.hook) && isDef(i = i.init)) i(vnode)
+      if (isDef(i = data.hook) && isDef(i = i.init)) i(vnode, true /* hydrating */)
       if (isDef(i = vnode.child)) {
         // child component. it should have hydrated its own tree.
         invokeCreateHooks(vnode, insertedVnodeQueue)
@@ -349,7 +349,7 @@ export function createPatchFunction (backend) {
     }
   }
 
-  return function patch (oldVnode, vnode) {
+  return function patch (oldVnode, vnode, hydrating) {
     let elm, parent
     const insertedVnodeQueue = []
 
@@ -367,6 +367,9 @@ export function createPatchFunction (backend) {
           // a successful hydration.
           if (oldVnode.hasAttribute('server-rendered')) {
             oldVnode.removeAttribute('server-rendered')
+            hydrating = true
+          }
+          if (hydrating) {
             if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
               invokeInsertHook(insertedVnodeQueue)
               return oldVnode

+ 5 - 2
src/entries/web-runtime-with-compiler.js

@@ -12,7 +12,10 @@ const idToTemplate = cached(id => {
 })
 
 const mount = Vue.prototype.$mount
-Vue.prototype.$mount = function (el: string | Element): Component {
+Vue.prototype.$mount = function (
+  el?: string | Element,
+  hydrating?: boolean
+): Component {
   el = el && query(el)
   const options = this.$options
   // resolve template/el and convert to render function
@@ -44,7 +47,7 @@ Vue.prototype.$mount = function (el: string | Element): Component {
       options.staticRenderFns = staticRenderFns
     }
   }
-  return mount.call(this, el)
+  return mount.call(this, el, hydrating)
 }
 
 /**

+ 5 - 3
src/entries/web-runtime.js

@@ -19,9 +19,11 @@ Vue.options.directives = platformDirectives
 Vue.prototype.__patch__ = config._isServer ? noop : patch
 
 // wrap mount
-Vue.prototype.$mount = function (el?: string | Element): Vue {
-  this.$el = el && query(el)
-  return this._mount()
+Vue.prototype.$mount = function (
+  el?: string | Element,
+  hydrating?: boolean
+): Component {
+  return this._mount(el && query(el), hydrating)
 }
 
 export default Vue

+ 70 - 38
test/unit/modules/vdom/patch/hydration.spec.js

@@ -1,5 +1,5 @@
+import Vue from 'vue'
 import { patch } from 'web/runtime/patch'
-import * as nodeOps from 'web/runtime/node-ops'
 import VNode from 'core/vdom/vnode'
 
 describe('hydration', () => {
@@ -13,13 +13,13 @@ describe('hydration', () => {
     const result = []
     function init (vnode) { result.push(vnode) }
     function createServerRenderedDOM () {
-      const root = nodeOps.createElement('div')
+      const root = document.createElement('div')
       root.setAttribute('server-rendered', 'true')
-      const span = nodeOps.createElement('span')
+      const span = document.createElement('span')
       root.appendChild(span)
-      const div = nodeOps.createElement('div')
-      const child1 = nodeOps.createElement('span')
-      const child2 = nodeOps.createElement('span')
+      const div = document.createElement('div')
+      const child1 = document.createElement('span')
+      const child2 = document.createElement('span')
       child1.textContent = 'hi'
       child2.textContent = 'ho'
       div.appendChild(child1)
@@ -62,42 +62,14 @@ describe('hydration', () => {
     expect(node0.children[0].id).toBe('bar')
   })
 
-  it('should hydrate components when server-rendered DOM tree is same as virtual DOM tree', () => {
-    const result = []
-    function init (vnode) { result.push(vnode) }
-    function createServerRenderedDOM () {
-      const root = nodeOps.createElement('vue-component-1')
-      root.setAttribute('server-rendered', 'true')
-      const span = nodeOps.createElement('span')
-      root.appendChild(span)
-      const div = nodeOps.createElement('div')
-      const child1 = nodeOps.createElement('span')
-      const child2 = nodeOps.createElement('span')
-      div.appendChild(child1)
-      div.appendChild(child2)
-      root.appendChild(div)
-      return root
-    }
-    const node0 = createServerRenderedDOM()
-    const vnode1 = new VNode('vue-component-1', {}, [
-      new VNode('span', {}),
-      new VNode('div', { hook: { init }}, [
-        new VNode('span', {}),
-        new VNode('span', {})
-      ])
-    ])
-    patch(node0, vnode1)
-    expect(result.length).toBe(1)
-  })
-
   it('should warn message that virtual DOM tree is not matching when hydrate element', () => {
     function createServerRenderedDOM () {
-      const root = nodeOps.createElement('div')
+      const root = document.createElement('div')
       root.setAttribute('server-rendered', 'true')
-      const span = nodeOps.createElement('span')
+      const span = document.createElement('span')
       root.appendChild(span)
-      const div = nodeOps.createElement('div')
-      const child1 = nodeOps.createElement('span')
+      const div = document.createElement('div')
+      const child1 = document.createElement('span')
       div.appendChild(child1)
       root.appendChild(div)
       return root
@@ -113,4 +85,64 @@ describe('hydration', () => {
     patch(node0, vnode1)
     expect('The client-side rendered virtual DOM tree is not matching').toHaveBeenWarned()
   })
+
+  // component hydration is better off with a more e2e approach
+  it('should hydrate components when server-rendered DOM tree is same as virtual DOM tree', done => {
+    const dom = document.createElement('div')
+    dom.setAttribute('server-rendered', 'true')
+    dom.innerHTML = '<span>foo</span><div class="b a"><span>foo qux</span></div>'
+    const originalNode1 = dom.children[0]
+    const originalNode2 = dom.children[1]
+
+    const vm = new Vue({
+      template: '<div><span>{{msg}}</span><test class="a" :msg="msg"></test></div>',
+      data: {
+        msg: 'foo'
+      },
+      components: {
+        test: {
+          props: ['msg'],
+          data () {
+            return { a: 'qux' }
+          },
+          template: '<div class="b"><span>{{msg}} {{a}}</span></div>'
+        }
+      }
+    })
+
+    expect(() => { vm.$mount(dom) }).not.toThrow()
+    expect('not matching server-rendered content').not.toHaveBeenWarned()
+    expect(vm.$el).toBe(dom)
+    expect(vm.$children[0].$el).toBe(originalNode2)
+    expect(vm.$el.children[0]).toBe(originalNode1)
+    expect(vm.$el.children[1]).toBe(originalNode2)
+    vm.msg = 'bar'
+    waitForUpdate(() => {
+      expect(vm.$el.innerHTML).toBe('<span>bar</span><div class="b a"><span>bar qux</span></div>')
+      vm.$children[0].a = 'ququx'
+    }).then(() => {
+      expect(vm.$el.innerHTML).toBe('<span>bar</span><div class="b a"><span>bar ququx</span></div>')
+    }).then(done)
+  })
+
+  it('should warn failed hydration for non-matching DOM in child component', () => {
+    const dom = document.createElement('div')
+    dom.setAttribute('server-rendered', 'true')
+    dom.innerHTML = '<div><span>foo</span></div>'
+
+    const vm = new Vue({
+      template: '<div><test :msg="msg"></test></div>',
+      components: {
+        test: {
+          data () {
+            return { a: 'qux' }
+          },
+          template: '<div><span>{{a}}</span></div>'
+        }
+      }
+    })
+
+    vm.$mount(dom)
+    expect('not matching server-rendered content').toHaveBeenWarned()
+  })
 })