Vue源码学习2.2:组件patch

1,344 阅读4分钟

通过前一章的分析,当我们通过 createComponent 创建了组件 VNode,接下来会走到 vm._update,执行 vm.__patch__ 去把 VNode 转换成真正的 DOM 节点。这个过程我们已经熟悉了,但是针对的是一个普通的 VNode 节点,接下来我们来看看组件的 VNode 会有哪些不一样的地方。

贯穿全文的例子:

// index.html
<body>
  <div id="app"></div>
</body>

// main.js
new Vue({
  render: h => h(App),
}).$mount('#app')

// App.vue
<template>
  <div id="app">
    hello Vue
  </div>
</template>
<script>
export default {
  name: "app"
};
</script>

由于这节课比较复杂,所以简单画了个流程图方便理解

1. 三个概念

在进入正文之前,要先了解两个概念,这样才能方便学习

1.1 组件的两个vnode

  • 占位符 vnodevm.$vnode 只有组件实例才有。在 _render 过程中赋值
  • 渲染 vnodevm._vnode 可以直接映射成真实 DOM。在 _update 过程中赋值
  • 它们是父子关系:vm._vnode.parent = vm.$vnode

比如对于本文的例子来说:

  • 当根 Vue 实例的 render 函数执行完毕,会生成根实例的 _vnode
  • 由于 createElement 直接返回了 App 组件,所以根实例_vnode 同样也是 App 组件的$vnode

  • 对于 App 组件来说,他的占位符vnode 和渲染vnode 实际上是一种父子关系

1.2 activeInstance

整个初始化是一个深度遍历的过程。在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。

它是一个全局变量,定义在 src/core/instance/lifecycle.js

// src/core/instance/lifecycle.js

export let activeInstance: any = null

export function setActiveInstance(vm: Component{
  const prevActiveInstance = activeInstance
  activeInstance = vm
  return () => {
    activeInstance = prevActiveInstance
  }
}

export function lifecycleMixin (Vue: Class<Component>{
  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean{
    const vm: Component = this
    
    // 在调用 __patch__ 前先用 prevActiveInstance 保存 activeInstance
    // 然后将当前实例 vm 赋给 activeInstance
    const restoreActiveInstance = setActiveInstance(vm)
        
    // vm.__patch__...
    
    // 在执行完 __patch__ 后再恢复 activeInstance 原来的值
    restoreActiveInstance()
  }
}

vm._update 的过程中,把当前的 vm 赋值给 activeInstance,同时用 prevActiveInstance 保留上一次的 activeInstanceprevActiveInstance 和当前的 vm 是一个父子关系

当一个 vm 实例完成它的所有子树的 patch 或者 update 过程后,activeInstance 会回到它的父实例

这样就完美地保证了整个深度遍历过程中,我们在实例化子组件的时候能传入当前子组件的父 Vue 实例。

2. 组件patch

上一章我们其实分析了组件在 render 阶段的细节,本章分析的就是在 patch 阶段的细节

走到 patch 阶段会调用 createElm 创建元素节点,其中有这么一段逻辑:

// src/core/vdom/patch.js

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
{
  // ...
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }
  // ...
}

如果调用 createComponent 函数返回 true ,则结束执行 createElm 函数。

这里传给 createComponent 函数的 vnode 参数是 App 组件的$vnode,因此可以通过 createComponent 成功创建组件,createElm 函数也就不会再往下执行。下面来看 createComponent 函数的定义:

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm{
    let i = vnode.data
    if (isDef(i)) {
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        // 2.1 执行 init 钩子,执行完时已经创建了一个子实例并挂载
        i(vnode, false /* hydrating */)
      }
      if (isDef(vnode.componentInstance)) {
        // 2.2 设置占位符vnode的elm
        initComponent(vnode, insertedVnodeQueue)
        // 2.3 挂载子组件
        insert(parentElm, vnode.elm, refElm)

        // ...

        return true
      }
    }
  }
  // ...
}
  • if 语句的意思是判断 vnode.data.hook.init 是否存在,这里 vnode 是一个组件 VNode,那么条件满足,并且得到 i 就是 init 钩子函数。主要作用是:
    • 实例化 App 子组件
    • 执行子组件挂载 child.$mount
  • initComponent 主要是执行了 vnode.elm = vnode.componentInstance.$el
  • insert 是将其组件插入到 body

2.1 执行init钩子

回顾上一章,在执行 createComponent 函数的时候会调用 installComponentHooks 函数给 vnode.data.hook 安装四个钩子函数。回顾 init 钩子函数的代码,它被定义在 src/core/vdom/create-component.js 文件中:

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

import { activeInstance } from '../instance/lifecycle'

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // keepAlive 相关...
    } else {
      // 创建一个 Vue 的实例
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      // 子组件挂载
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  },
}
  • 关于 keepAlive 的逻辑我们暂时不关心
  • 通过 createComponentInstanceForVnode 创建一个 Vue 实例,传入两个参数:
    • vnode 表示当前 App 组件的占位符 VNode
    • activeInstance 表示根 Vue 实例
  • 返回的子组件实例保存到 App 组件的占位符 vnodecomponentInstance 属性中。
  • 通过 $mount 挂载子组件

2.1.1 创建子组件实例

先来看看 createComponentInstanceForVnode 函数是怎么创建子组件实例的,它定义在 src/core/vdom/create-component.js

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

export function createComponentInstanceForVnode (
  vnode: any,
  parent: any// activeInstance in lifecycle state
): Component 
{
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }

  // inline-template 相关...

  return new vnode.componentOptions.Ctor(options)
}
  • 创建 options 对象
    • _isComponenttrue 表示它是一个组件
    • _parentVnode 表示当前组件的vnode,也就是占位符vnode
    • parent 表示当前激活的组件实例,也就是 根Vue实例
  • 实例化App 子组件
    • vnode.componentOptions.Ctor 就是子组件的构造函数
    • 我们上一节分析了它实际上是继承于 Vue 的一个构造器 Sub,所以相当于 new Sub(options)
const sub = function VueComponent({
  // 执行 this._init 逻辑再次走到了 Vue 实例的初始化逻辑
  this._init(options)
}

这个过程有一些和之前不同的地方需要挑出来说,代码在 src/core/instance/init.js 中:

// src/core/instance/init.js

Vue.prototype._init = function (options?: Object{
  const vm: Component = this
  if (options && options._isComponent) {
    // 子组件mergeOptions
    initInternalComponent(vm, options)
  } else {
    // mergeOptions...
  }
  
  // ...
  initLifecycle(vm)
  // ...
  
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  } 
}
  • 合并 options 的过程有变化,_isComponenttrue,所以走到了 initInternalComponent 过程。合并 createComponentInstanceForVnode 函数中传入的 options 对象
  • 执行 initLifecycle,建立父子实例之间的关系
  • 组件初始化的时候是不传 el 的,因此组件是自己接管了 $mount 的过程

下面来简单看看 initInternalComponent 函数:

// src/core/instance/init.js

export function initInternalComponent (vm: Component, options: InternalComponentOptions{
  // 创建 vm.$options 对象
  const opts = vm.$options = Object.create(vm.constructor.options)
  const parentVnode = options._parentVnode
  opts.parent = options.parent // 保存父Vue实例
  opts._parentVnode = parentVnode // 保存组件占位符vnode

  // ...
}

再来简单看看 initLifecycle 函数

// src/core/instance/lifecycle.js

export function initLifecycle (vm: Component{
  const options = vm.$options

  // 找到第一个非抽象的parent
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    // 保存父子实例关系
    parent.$children.push(vm)
  }

  vm.$parent = parent
  vm.$root = parent ? parent.$root : vm
  vm.$children = []
  // ...
}

可以看到 vm.$parent 就是用来保留当前 vm 的父实例,并且通过 parent.$children.push(vm) 来把当前的 vm 存储到父实例的 $children 中。

2.1.2 子组件挂载

在完成子组件实例化的 _init 后,接着会执行

child.$mount(hydrating ? vnode.elm : undefined, hydrating) 

这里 hydrating 表示服务端渲染的情况,我们只考虑客户端渲染,所以这里相当于执行

child.$mount(undefined, false)

它最终会调用 mountComponent 方法,进而执行 vm._render() 方法:

// src/core/instance/render.js

Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options

  // 保存占位符vnode / 外壳节点
  vm.$vnode = _parentVnode

  let vnode
  try {
    // 通过render函数创建组件渲染vnode
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    // ...
  }
  // 将占位符vnode保存到渲染vnode的parent属性中
  vnode.parent = _parentVnode
  return vnode
}
  • 保存占位符 vnodeApp 组件实例的 vm.$vnode
  • 调用render 函数生成渲染 vnode
  • 保存占位符 vnode 和渲染 vnode 的父子关系。vm._vnode.parent = vm.$vnode

我们知道在执行完 vm._render 生成 VNode 后,接下来就要执行 vm._update 去渲染 VNode 了。来看一下组件渲染的过程中有哪些需要注意的,vm._update 的定义在 src/core/instance/lifecycle.js 中:

// src/core/instance/lifecycle.js

export function lifecycleMixin (Vue: Class<Component>{
  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean{
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    
    // activeInstance 保存为当前 App 组件的实例...
    
    // 保存通过render生成的渲染vnode
    vm._vnode = vnode

    if (!prevVnode) {
      // 首次渲染
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // ...
    }
    
    // 恢复 activeInstance 为根Vue实例...    
  }
}
  • 保存通过 render 生成的渲染 vnode
  • activeInstance 更新为 App 组件的实例
  • 调用 __patch__ 渲染 VNode
    • 返回结果为 App 子组件的渲染 vnodeelm,也就是 vnode.elm
    • 将结果赋值给 App 子组件实例的 $el
  • 恢复 activeInstance 为根 Vue 实例

来看看这次子组件的 patch 有何不同,当调用了 __patch__ 方法,会执行 patch 方法,传入的前两个参数分别是:

  • oldVnodeApp 子组件的 $el,此时是 undefined
  • vnodeApp 子组件的渲染vnode
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
 
function patch (oldVnode, vnode, hydrating, removeOnly{
  // ...
  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    // empty mount (likely as component), create new root element
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    // ...
  }
  // ...
  return vnode.elm
}

  • 再次执行 createElm 方法,会生成渲染 vnodeelm
  • 返回 App 组件的渲染 vnodeelm

来看看 createElm 方法,注意这里我们只传了 2 个参数,所以对应的 parentElmundefined。我们再来看看它的定义:

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function createElm (
    vnode, // 渲染vnode
    insertedVnodeQueue,
    parentElm, // undefined
    refElm,
    nested,
    ownerArray,
    index
  
{
    // ...
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      // ...

      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)
      setScope(vnode)

      if (__WEEX__) {
        // ...
      } else {
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        insert(parentElm, vnode.elm, refElm)
      }

      // ...
    } else if (isTrue(vnode.isComment)) {
      vnode.elm = nodeOps.createComment(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    } else {
      vnode.elm = nodeOps.createTextNode(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    }
  }
  // ...
}
  • 再次执行了 createComponent 判断,此时组件根节点是普通元素,因此返回 false
  • 先创建一个父节点占位符
  • 然后再遍历所有子 VNode 递归调用 createElm
  • 在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程
  • 由于我们这个时候传入的 parentElm 是空,所以实际上 insert(parentElm, vnode.elm, refElm) 不做任何事,那子组件是怎么插入的呢?带着这个疑问往下看

2.2. initComponent

当执行完 init 钩子之后,会执行 initComponent

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm{
    let i = vnode.data
    if (isDef(i)) {
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        // 2.1 执行 init 钩子,执行完时已经创建了一个子实例并挂载
        i(vnode, false /* hydrating */)
      }
      if (isDef(vnode.componentInstance)) {
        // 2.2 设置占位符vnode的elm
        initComponent(vnode, insertedVnodeQueue)
        // 2.3 挂载子组件
        insert(parentElm, vnode.elm, refElm)

        // ...

        return true
      }
    }
  }
  // ...
}

看下 initComponent 函数:

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function initComponent (vnode, insertedVnodeQueue{
    // ...
    vnode.elm = vnode.componentInstance.$el
    // ...
  }
  // ...
}

这个函数主要就是将 App 子组件实例上的 $el 保存到占位符 vnodeelm 属性上

2.3 挂载子组件

实际上对于对组件的插入,在 createComponent 中调用了 insert 方法:

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm{
    let i = vnode.data
    if (isDef(i)) {
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        // 2.1 执行 init 钩子,执行完时已经创建了一个子实例并挂载
        i(vnode, false /* hydrating */)
      }
      if (isDef(vnode.componentInstance)) {
        // 2.2 设置占位符vnode的elm
        initComponent(vnode, insertedVnodeQueue)
        // 2.3 挂载子组件
        insert(parentElm, vnode.elm, refElm)

        // ...

        return true
      }
    }
  }
  // ...
}

调用 insert 时传入的参数

  • parentElm:就是 body
  • vnode.elm:在 initComponent 中完成赋值,就是 App 子组件的 $el

这样就完成了组件的 DOM 插入

总结

那么到此,一个组件的 VNode 是如何创建、初始化、渲染的过程也就介绍完毕了。