浅曦Vue源码-39-挂载阶段-$mount-(27)patch 初次渲染

139 阅读2分钟

一、前情回顾 & 背景

上一篇小作文深入讨论了渲染 watcher 求值调用 updateComponent 方法中对 vm._rendervm._update 的调用:

  1. vm._renderVue.prototype._render 是用于调用前面 parse & generate 后得到的渲染函数,即 vm.$options.render 得到 VNode,所谓 VNode 就是传说中的虚拟 DOM 树,描述节点间的关系;

  2. vm._updateVue.prototype._update 接收上一步得到的虚拟 DOM,将其渲染到页面,变成真实 DOM,也就是 vm.__patch__ 方法的工作;

  3. 紧接着我们溯源了 vm.__patch__Vue.prototype.__patch__ 方法的过程,它是由 createPatchFunction 这个工厂返回的方法;vm.__patch__ 负责初次渲染和响应式数据更新后的更新渲染工作;

那么本篇小作文的重点将放在 patch 函数在初次渲染时所做的工作,之所以称之为初次渲染,是为了区别当响应式数据更新后触发的再次渲染更新视图的过程;

从标题可以看出,我们现在还处在挂载阶段,并没有进入到响应式更新后的 DOM diff + patch 的更新阶段,当然这是后面的内容。

二、patch 初次渲染的调用

先回顾 patch 在初次渲染时的调用过程

声明 updateComponent = () => vm._update(vm_render(), ...)
  -> new Wathcer 构造函数执行 updateComponent
     -> vm._update 执行,即 Vue.prototype._update 执行
        -> if (!preVnode) vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)

三、简化 createPatchFunction 逻辑

createPatchFunction 内部代码量非常大,首先内部方法奇多,为了便于大家的理解,根据当前是初次渲染阶段,我们只把初次渲染相关的代码留下,其余的都删除,如此依赖这个代码看着就没有这么吓人了。

export function createPatchFunction (backend) {
    // ....
    
    return function patch (oldVnode, vnode, hydrating, removeOnly) {
      // ...
      
      let isInitialPatch = false
      const insertedVnodeQueue = []

      if (isUndef(oldVnode)) {
        // 新的 VNode 存在,旧的 VNode 不存在,
        // 说明这种情况下是一个【组件】初次渲染,比如:
        // <div id='app'> <some-com></some-com> </div> 
        // 中的 some-com 的初次渲染走这里
      } else {
        // 根实例的 patch,从顶层 div#app 的初次渲染在这里
        // 上面的 if 是这个 else 的后面渲染到自定义组件后的一个分支流程
      }
      
      return vnode.elm
    }

}

四、patch 函数

方法位置:上面 createPatchFunction 返回值

方法参数:

  1. oldVnode, 旧的 vnode
  2. vnode,新的 vnode
  3. hydrating,是否合成,忽略这个参数
  4. removeOnly,仅移除

方法作用:patch 函数用于将 VNode 变成真正的 DOM,渲染到页面上,这个过程涵盖了两种情况,第一种就是初次渲染,另一种就是响应式数据发生变化视图随之更新;这里我们讨论的是初次渲染的代码;

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // 如果新节点不存在,老节点存在,调用 destroy 销毁老节点
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    // 组件初次渲染
  } else {
    // 判断 oldVnode 是否是真实元素,
    // 初次渲染时,oldVnode 是传递的 div#app 这个真是的 DOM 元素

    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 不是真实元素,但是旧节点和新节点是同一个节点,
      // 说明是更新阶段,执行 patchVnode 进行更新
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // 是真实节点,则表示初次渲染

      if (isRealElement) {
        // 挂载到真实元素以及处理服务端渲染情况(忽略服务端渲染的代码)
       
        // 走到这里说明不是服务端渲染,则根据 oldVnode 创建一个空 vnode 节点
        // 执行过这一行后,oldVnode 不再是 div#app 这个真是的 DOM 了,而是一个空的 VNode 了
        oldVnode = emptyNodeAt(oldVnode)
      }

      // 替换掉旧节点的真实元素
      const oldElm = oldVnode.elm

      // 获取旧节点的父元素,即 body
      const parentElm = nodeOps.parentNode(oldElm)


      // 用新 vnode 创建整棵 DOM 树并插入到 body 元素
      createElm(
        vnode,
        insertedVnodeQueue,
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // 递归更新父占位符元素节点     
      // 所谓占位符元素节点指的是:
      // 自定义组件创建出来的以 <vue-component-cid-自定义组件名字 /> 的 VNode
      // 比如我们的自定义组件 <some-com />,
      // 它的占位符节点是 <vue-component-1-some-com /> 这个 vnode
      // 初次渲染时 vnode.parent 为 undefined 忽略这部分
      if (isDef(vnode.parent)) {
        // .... 
      }

      // 移除旧节点,所谓旧节点就是我们在 test.html 中的模板语法,即
      // <div id="app"> <some-com></some-com> <div> 
      // 这个 html 在渲染后就被 Vue 真实的 DOM 替换掉了,所以需要这一段模板代码要移除掉
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  // 触发 insertHook
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

4.1 createElm

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  
  // 为 transition 进入检查时用
  vnode.isRootInsert = !nested 

  // 重点来啦:
  // 这个  createComponent 负责处理 vnode 是自定义组件的情况
  // 如果是 vnode 是一个普通元素,createComponent 调用后返回 false
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    // 如果 vnode 是自定义组件,createComponent 执行后返回 true,到这里就终止了
    return
  }

  // 能走到这里说明 vnode 是个普通的元素
  // 获取 data 对象
  const data = vnode.data
  
  // 获取子节点列表
  const children = vnode.children
  
  // vnode 的标签名
  const tag = vnode.tag
  if (isDef(tag)) {
    // 未知标签
    if (process.env.NODE_ENV !== 'production') {
      if (data && data.pre) {
        creatingElmInVPre++
      }
      if (isUnknownElement(vnode, creatingElmInVPre)) {
        // 不知名的标签警告
      }
    }

    // 创建新节点,并挂载到 vnode 对象上,
    // vnode.elm 是个真实的 DOM 元素
    vnode.elm = vnode.ns // ns 是命名空间,忽略他
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode) // 咱们研究这种情况
    
    // 设置 css scoped id 属性
    // 这种实现作为一种特殊 case 用以避免 patch 处理时遍历常规属性的开销
    setScope(vnode)

    if (__WEEX__) {
       // weex 处理,忽略
    } else {
      // 递归创建所有子节点(普通元素,组件)
      createChildren(vnode, children, insertedVnodeQueue)
      if (isDef(data)) {
        // 调用 createHooks
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }

      // 初次渲染时将节点插入父节点,这是至关重要的一步了,
      // vnode.elm 是创建出来的真实元素,到了这里包含所有模板内容的一整棵 DOM 树,
      // parentElm 是 body 元素
      // 把 DOM 元素插入到 body,实现渲染
      insert(parentElm, vnode.elm, refElm)
    }
  } else if (isTrue(vnode.isComment)) {
    // vnode.tag 属性不存在,即不是元素或者自定义组件
    // 到这里就是注释节点,创建注释节点并插入父节点
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    // 不是注释、也不是元素,就当文本处理了
    // 文本节点,创建文本节点并插入父节点
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

我们在这里没有展开讲 createElm 方法,因为涉及到了自定义组件的渲染过程,所以决定将这一部分单独抽离成一篇,可以更加专注、也便于大家的理解;

4.2 removeVnodes

方法位置:`src/core/vdom/patch.js -> function createPatchFunction 内部方法

方法参数:

  1. vnodes: 集合对象
  2. stardIdx: 开始索引
  3. endIdx:结束索引

方法作用:从 vnodes 列表中,从移除索引位于 [startIdx, endIdx] 这个闭区间内的所有节点;从上面的 patch 初次渲染调用这个方法的作用就是从 test.html 中移除我们写的 div#app 这个写这 Vue 模板语法的 HTML 元素;

function removeVnodes (vnodes, startIdx, endIdx) {
  // 从 startIdx 到 endIdx 内
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        // 如果有标签名,说明是元素
        // 在移除前会调用 createPatchFunction 时接收到的 modules 
        // 中的 remove 和 destroy 钩子方法处理各个功能模块对应的 remove 和 destroy 逻辑
        // 比如说 $ref 是需要在节点移除的时候移除调用该节点的 ref 引用,
        // 所以 ref 模块就导出了一个 destroy 方法
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else {
        // 移除文本节点
        removeNode(ch.elm)
      }
    }
  }
}

4.3 invokeInsertHook

方法位置:src/core/vdom/patch.js -> function createPatchFunction 的内部方法

方法参数:

  1. vnode, vnode 节点对象
  2. queue, 接收的 insertedVnodeQueue 队列
  3. initial, 是否初次渲染

方法作用:调用组件的 data.hook 上的 insert 钩子。data.hook 是前面创建组件的 vnode 的时候执行 installComponentHooks 方法为 data.hook 上添加的四个钩子:init、prepatch、insert、destroy; 这里就是调用 insert 钩子了

function invokeInsertHook (vnode, queue, initial) {
  // 对于组件根节点,推迟它的 insert 钩子调用,当他们被插入到文档中之后再调用
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue
  } else {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

五、总结

本篇小作文开始介绍 patch 函数patch 函数的作用——把 vnode 通过 DOM API 转成真实 DOM 并插入到页面。这个过程既包括初次渲染,也包括因响应式数据发生变化而进行的更新渲染。

今天我们重点讨论的是进行初次渲染的过程,我们把 createPatchFunction 和它返回的 patch 函数进行了简化,只留下能够表达初次渲染过程的代码,具体如下:

  1. patch 判断没有 oldVnode旧节点不存在,说明这是个自定义组件的初次渲染,这其实是初次渲染时渲染到自定义组件的一个分支流程
  2. 如果 oldVnode 存在,那么判断 oldVnode 是否是真实的元素节点,如果是就是根实例挂载时触发的首次渲染;这里的 oldVnode 是页面中的真实元素 div#app,也就是我们写在 test.html 中的模板部分;
  3. 根据 oldVnode 创建一个新的空节点,这个空节点的作用相当于再造一个 div#appoldVnode.elm 表示当前 Vnode 对应的真实元素;
  4. 获取 oldVnode 的父元素,即 div#app 的父元素 body 元素;
  5. 调用 createElm 方法将 vnode 节点树变成真实 DOM 树并插入到 bodycreateElm 中会创建原生的 HTML 元素和自定义组件,因涉及了自定义组件的渲染是一个大篇幅的工作,下一篇单独开篇聊;
  6. 移除旧节点即 div#app,这也就解释了为啥 Vue 渲染完成后我们写的那些带指令的模板比如 {{}}、v-bind在浏览器中就没有了,是因为虚拟 DOM 得到的真实 DOM 替代了模板 DOM
  7. 触发 insertHook,如果节点是组件的根节点则要等他插入到父节点以后再触发;