Vue源码学习1.6:_update

338 阅读6分钟

ps:建议PC端观看,移动端代码高亮错乱

1. _update

_update 被调用的时机有 2 个:

  • 首次渲染
  • 数据更新

由于我们这一章节只分析首次渲染部分,数据更新部分会在之后分析响应式原理的时候涉及。_update 方法的作用是把 VNode 渲染成真实的 DOM,它的定义在 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
    // ...
    vm._vnode = vnode
    if (!prevVnode) {
      // 首次渲染
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 响应式更新
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    // ...
  }
}

preVnode 来判断是否是首次渲染。

__patch__ 函数在不同平台会有不同的定义,web 端的定义在 src/platforms/web/runtime/index.js 文件中:

// src/platforms/web/runtime/index.js

Vue.prototype.__patch__ = inBrowser ? patch : noop

如果是浏览器环境,则是 patch 方法,否则是一个空函数。

patch 函数,它被定义在 src/platforms/web/runtime/patch.js 文件中:

// src/platforms/web/runtime/patch.js

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

可以看到这一步还不是真正的 patch 函数,而是通过 createPatchFunction 创建并返回的。

  • nodeOps 封装了平台的 DOM 操作方法。
  • modules 表示平台的模块,它们会在整个 patch 过程的不同阶段执行相应的钩子函数。

这样通过函数柯里化, createPatchFunction 把差异化参数提前固化,这样不用每次调用 patch 的时候都传递 nodeOpsmodules 了,可以抹去不同平台的差异,真正的 patch 函数不需要关心这些差异。

下面就来看看 createPatchFunction 是怎么创建并返回真正的 patch 函数吧, 定义在:src/core/vdom/patch.js

// src/core/vdom/patch.js

const hooks = ['create''activate''update''remove''destroy']

export function createPatchFunction (backend{
  let i, j
  const cbs = {}

  const { modules, nodeOps } = backend

  // 遍历,将 hooks 作为 cbs 属性,然后将对应的 modules 的子项 push 到 cbs.hooks 中。
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  
  // ... 这里定义了很多辅助函数
  
  // 返回真正的patch
  return function patch(oldVnode, vnode, hydarting, removeOnly{
    ...  
  }
}

2. patch函数

因为 patch 的逻辑非常多且复杂,所以我们结合实际的例子来分析首次渲染的 patch 大致干了啥:

<body>
    <div id="app"></div>
</body>

var app = new Vue({
    el: '#app',
    render: function(createElement) {
        return createElement('div', {
            attrs: { id: 'app' }
        }, 'Hello Vue!')
    }
})

当初次渲染流程执行到 vm._update 的时:

vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)

所以传入 patch 的参数分别是:

  • oldVnode:例子中 idappDOM 对象,也就是在 HTML 模板中写的 <div id="app">vm.$el 的赋值是在之前 mountComponent 函数做的。
  • vnode:调用 render 函数的返回值
  • hydratingfalse,表示非服务端情况
  • removeOnly:是给 transition-group 用的,之后会介绍。

确定了这些入参后,我们进入 patch 函数的执行过程,看几个关键步骤。

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  return function patch(oldVnode, vnode, hydarting, removeOnly{
    // ...

    if (isUndef(oldVnode)) {
      // ...
    } else {
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // ...
      } else {
        if (isRealElement) {
          // ...
          // 将真实的 DOM 转换成 vnode,也就是 <div id="app"></div>
          oldVnode = emptyNodeAt(oldVnode)
        }

        const oldElm = oldVnode.elm // 保存真实的DOM
        const parentElm = nodeOps.parentNode(oldElm) // body

        // oldEm._leaveCb 在这是 undefined
        // insertedVnodeQueue 在这是空数组
        // nextSibling表示DOM的右边的节点,在这是换行text节点
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        // update parent placeholder node element, recursively
        if (isDef(vnode.parent)) {
          // ...
        }

        // 销毁旧节点
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 00)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    // ...
    
    return vnode.elm
  }
}
  • oldVnode 是真实的 DOM,通过 emptyNodeAt 将真实的 DOM 转换成 vnode
  • parentElm 在这个例子中是 body 节点(<body><div id="app"></div></body>
  • 调用 createElm 函数,它的作用是通过 vnode 创建真实的 DOM 并插入到它的父节点中
  • vnode.parent ,这是父占位节点。和组件相关的,这里不会执行,也就不展开细讲。
  • 判断之前定义的 parentElm 是否存在,有则删除掉 vm.$el 对应的节点。在执行这一步前,浏览器的 DOM 结构是这样的:
<body>
    <div id="app"></div>
    <div id="app">Hello Vue!</div>
</body>

之后删除 <div id="app"></div> 完成新旧节点替换工作。

  • 最后将 vnode.elm(也就是真实DOM)返回。

2.1 emptyNodeAt

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function emptyNodeAt (elm{
    return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
  }
  // ...
}

2.2 createElm

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function createElm ( vnode, insertedVnodeQueue, parentElm, 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)) {
      // ...
      
      // 调用平台 DOM 的操作去创建一个占位符元素。
      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)
      
      // ...

      if (__WEEX__) {
        // ...
      } else {
        // 创建子元素
        createChildren(vnode, children, 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
  • nodeOps.createElement 实际上就是通过 document.createElement 创建一个元素,并赋值给 vnode.elm
  • createChildren 创建子元素,遍历子虚拟节点,递归调用 createElm,遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符传入。
  • 最后调用 insert 方法把 DOM 插入到父节点中,因为是递归调用,子元素会优先调用 insert,所以整个 vnode 树节点的插入顺序是先子后父

2.2.1 createChildren

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function createChildren (vnode, children, insertedVnodeQueue{
    if (Array.isArray(children)) {
      // ...
      for (let i = 0; i < children.length; ++i) {
        // 递归调用createElm
        createElm(children[i], insertedVnodeQueue, vnode.elm, nulltrue, children, i)
      }
    } else if (isPrimitive(vnode.text)) {
      nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
    }
  }
  // ...
}

2.2.2 insert

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  // ...
  function insert (parent, elm, ref{
    if (isDef(parent)) {
      if (isDef(ref)) {
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      } else {
        nodeOps.appendChild(parent, elm)
      }
    }
  }
  // ...
}

总结

patch 方法,首次渲染我们调用了 createElm 方法,这里传入的 parentElmoldVnode.elm 的父元素,在我们的例子是 id#appdiv 的父元素,也就是 Body;实际上整个过程就是递归创建了一个完整的 DOM 树并插入到 Body 上。