vue源码解读五:组件化之组件的patch过程

1,575 阅读3分钟

本文主要内容摘抄自黄轶老师的慕课网课程Vue.js 源码全方位深入解析 全面深入理解Vue实现原理,主要用于个人学习和复习,不用作其他用途。

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

patch 的过程会调用 createElm 创建元素节点,回顾一下 createElm 的实现,它的定义在 src/core/vdom/patch.js 中:

function createElm (vnode, insertedVnodeQueue, parentElm..){
  ...
  // 判断是否是组件vnode
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }
  ...
}

createComponent

在render函数中也有一个createComponent函数,这个函数的作用产生组件占位符vnode,在patch中的createComponent函数则要复杂的多,那么接下来看一下 createComponent 方法的实现:

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      // 执行init函数
      i(vnode, false)
    }
    
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

如果 vnode 是一个组件 VNode,那么条件会满足,并且得到 i 就是 init 钩子函数,回顾上节我们在创建组件 VNode 的时候合并钩子函数中就包含 init 钩子函数,定义在 src/core/vdom/create-component.js 中:

init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    ...
    const child = vnode.componentInstance = createComponentInstanceForVnode(
      vnode,
      activeInstance
    )
    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
},

init 钩子函数执行也很简单,它是通过 createComponentInstanceForVnode 创建一个 Vue 的实例,然后调用 $mount 方法挂载子组件, 先来看一下 createComponentInstanceForVnode 的实现:

export function createComponentInstanceForVnode (vnode, parent) {
  const options: InternalComponentOptions = {
    // `_isComponent` 为 `true` 表示它是一个组件
    _isComponent: true,
    // 占位符vnode
    _parentVnode: vnode,
    // 当前激活的实例,即Vue的实例,不是VueComponent的实例
    parent
  }
  ...
  // 这里才是VueComponent的实例
  return new vnode.componentOptions.Ctor(options)
}

createComponentInstanceForVnode 函数生成一个组件的参数options,然后执行 new vnode.componentOptions.Ctor(options)

image.png

所以子组件的实例化实际上就是在这个时机执行的,并且它会执行实例的 _init 方法,这个过程有一些和之前不同的地方需要挑出来说,代码在 src/core/instance/init.js 中:

Vue.prototype._init = function (options?: Object) {
  const vm: Component = this
  // merge options
  if (options && options._isComponent) {
    initInternalComponent(vm, options)
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )
  }
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  ...
  ...
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  } 
}

这里首先是合并 options 的过程有变化,_isComponent 为 true,所以走到了 initInternalComponent 过程,这个函数的实现也简单看一下:

export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode
  ...  
}

这个过程我们重点记住以下几个点即可:opts.parent = options.parentopts._parentVnode = parentVnode,它们是把之前我们通过 createComponentInstanceForVnode 函数传入的几个参数合并到内部的选项 $options 里了。

再来看一下 _init 函数最后执行的代码:

if (vm.$options.el) {
   vm.$mount(vm.$options.el)
}

由于组件初始化的时候是不传 el 的,因此组件是自己接管了 $mount 的过程。

const child = vnode.componentInstance = createComponentInstanceForVnode(
      vnode,
      activeInstance
)

组件实例vnode.componentInstance(VueComponent)如下图所示:

image.png

回到组件 init 的过程,componentVNodeHooks 的 init 钩子函数,在完成实例化的 _init 后,接着会执行 child.$mount(hydrating ? vnode.elm : undefined, hydrating) 。它最终会调用 mountComponent 方法,进而执行 vm._render() 方法:

Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options
  ...  
  // 父vnode,也就是占位符vnode
  vm.$vnode = _parentVnode
  let vnode
  try {
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    // ...
  }
  vnode.parent = _parentVnode
  return vnode
}

这里的 _parentVnode 就是当前组件的父 VNode(占位符vnode),而 render 函数生成的 vnode 当前组件的渲染 vnodevnode 的 parent 指向了 _parentVnode,也就是 vm.$vnode,它们是一种父子的关系。

这个函数返回的才是渲染vnode。结构如下:

app.vue结构

<template>
  <div id="app">
    <div>
      {{ name }}
    </div>
  </div>
</template>

转化为虚拟渲染vnode:

{
  tag: 'div',
  data: {
    attrs: {id: 'app'}
  },
  // 组件的父vnode,即占位符vnode
  parent: {
    tag: "vue-component-1-App",
    componentInstance: 'VueComponent实例',
    componentOptions: {},
    children: undefined
  },
  children: [
    {
      tag: "div",
      data: undefined,
      children: [
        {
          tag: undefined,
          data: undefined,
          text: " pengchangjun "
        }
      ]
    }
  ]
}

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

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    ...
  }

_update 过程中有几个关键的代码,首先 vm._vnode = vnode 的逻辑,这个 vnode 是通过 vm._render() 返回的组件渲染 VNode,vm._vnode 和 vm.$vnode 的关系就是一种父子关系,用代码表达就是 vm._vnode.parent === vm.$vnode。还有一段比较有意思的代码:

export let activeInstance: any = null

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

这个 activeInstance 作用就是保持当前上下文的 Vue 实例,它是在 lifecycle 模块的全局变量,定义是 export let activeInstance: any = null,并且在之前我们调用 createComponentInstanceForVnode 方法的时候从 lifecycle 模块获取,并且作为参数传入的。

因为实际上 JavaScript 是一个单线程,Vue 整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。比如在实例化子组件过程中当前的上下文是Vue实例,不是VueComponent实例,下面代码中的parent就是activeInstance,这个activeInstance是Vue实例:

export function createComponentInstanceForVnode (vnode, parent) {
  const options: InternalComponentOptions = {
    // `_isComponent` 为 `true` 表示它是一个组件
    _isComponent: true,
    // 占位符vnode
    _parentVnode: vnode,
    // 当前激活的实例,即Vue的实例,不是VueComponent的实例
    parent
  }
  ...
  return new vnode.componentOptions.Ctor(options)
}

之前我们提到过对子组件的实例化过程先会调用 initInternalComponent(vm, options) 合并 options,把 parent 存储在 vm.$options 中,在 $mount 之前会调用 initLifecycle(vm) 方法:

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

  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.$parent 就是用来保留当前 vm 的父实例,并且通过 parent.$children.push(vm) 来把当前的 vm 存储到父实例的 $children 中。

在 vm._update 的过程中,把当前的 vm 赋值给 activeInstance,同时通过 const prevActiveInstance = activeInstance 用 prevActiveInstance 保留上一次的 activeInstance。实际上,prevActiveInstance 和当前的 vm 是一个父子关系,当一个 vm 实例完成它的所有子树的 patch 或者 update 过程后,activeInstance 会回到它的父实例,这样就完美地保证了 createComponentInstanceForVnode 整个深度遍历过程中,我们在实例化子组件的时候能传入当前子组件的父 Vue 实例,并在 _init 的过程中,通过 vm.$parent 把这个父子关系保留。

那么回到 _update,最后就是调用 __patch__ 渲染 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 {
    // ...
  }
  // ...
}

之前分析过负责渲染成 DOM 的函数是 createElm,注意这里我们只传了 2 个参数,所以对应的 parentElm 是 undefined。我们再来看看它的定义:

function createElm (vnode, insertedVnodeQueue, parentElm...) {
  // ...
  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)
    
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag

    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)
  }
}

注意,这里我们传入的 vnode 是组件渲染的 vnode,也就是我们之前说的 vm._vnode,如果组件的根节点是个普通元素,那么 vm._vnode 也是普通的 vnode,这里 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值是 false。

接下来的过程就和我们上一章一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用 createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。

由于我们这个时候传入的 parentElm 是空,所以对组件的插入,在 createComponent 有这么一段逻辑:

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    // ....
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false)
    }
    // ...
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

这个流程很长,相当于执行这个函数i(vnode, false)的时候,就把组件的vnode转化为真实的dom节点。接下来就要把真实的节点绑定到父dom上,执行initComponent(vnode),在update方法中,我们把生成的dom绑定在了vnode.componentInstance.$el上,接着又赋值给vnode.elm

 function initComponent (vnode, insertedVnodeQueue) {
    ...
    vnode.elm = vnode.componentInstance.$el;
    ...
  }

最后执行insert(parentElm, vnode.elm, refElm),此时parentElm为body节点。至此就完成组件的patch过程。

总结

对于下面一段代码我们来梳理下流程。

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

// app.vue
<template>
  <div id="app">
    <div>
      {{ name }}
    </div>
  </div>
</template>
  1. 首先实例化一个Vue函数,依次执行_init,$mount, vm_render,执行完render函数后,会得到app.vue组件的占位符vnode。
  2. 注意此时的上下文是Vue的实例,不是组件的实例。接着执行vm_update,patch方法,在patch过程中判断vnode是一个普通的vnode,还是一个组件的占位符vnode。如果是普通的vnode就生成真实dom进行挂载。
  3. 如果是组件vnode,则调用createComponent,执行init钩子函数,然后实例化VueComponent,在实例化的过程中就会把_init的逻辑再跑一遍,然后再一次执行$mount, vm_render,就会获得一个组件的渲染vnode,然后再执行vm_update把渲染vnode转化真实的dom,然后把真实的dom挂载在组件实例的$el属性上。
  4. 至此就获得了组件真实dom,然后插入到父节点上insert(parentElm, vnode.elm, refElm)

【注意】

  • 嵌套组件的插入顺序是先子后父
  • patch的整体流程:createComponent -> 子组件初始化 -> 子组件render -> 子组件patch
  • activeInstance 为当前激活的vm实例,即父实例; vm.$vnode为组件的占位符vnode; vm._vnode 为组件的渲染 vnode。