Cosen95/blog

Vue源码探秘(_update)

Opened this issue · 0 comments

引言

_update函数是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新时。这一节我们主要是看下首次渲染这种情况,数据更新的情况会在后面介绍响应式原理部分着重分析。

_update

_update 方法的作用是把 VNode 渲染成真实的 DOM,它的定义在 src/core/instance/lifecycle.js文件的lifecycleMixin函数中:

// 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
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

_update 的核心就是调用 vm.__patch__ 方法。__patch__ 函数在不同平台会有不同的定义,web 端的定义在 src/platforms/web/runtime/index.js 文件中:

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

// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

inBrowser 表示是否处于浏览器端,其实就是区分浏览器端渲染服务端渲染。因为在服务端没有真实的 DOM,它不需要把 VNode 转换成真实的 DOM,因此直接返回 noop(空函数)。而在浏览器端则使用 patch 函数,它被定义在 src/platforms/web/runtime/patch.js 文件中:

// src/platforms/web/runtime/patch.js
export const patch: Function = createPatchFunction({ nodeOps, modules })

patch 函数是调用 createPatchFunction 函数的返回值,这里传给 createPatchFunction 函数一个对象,对象的 nodeOps 属性定义在 src/platforms/web/runtime/node-ops.js 中:

// src/platforms/web/runtime/node-ops.js


import { namespaceMap } from 'web/util/index'

export function createElement (tagName: string, vnode: VNode): Element {
  const elm = document.createElement(tagName)
  if (tagName !== 'select') {
    return elm
  }
  // false or null will remove the attribute but undefined will not
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple')
  }
  return elm
}

export function createElementNS (namespace: string, tagName: string): Element {
  return document.createElementNS(namespaceMap[namespace], tagName)
}

export function createTextNode (text: string): Text {
  return document.createTextNode(text)
}

// ...

可以看到,这里面封装了各种各样的 DOM 操作。我们再来看另一个参数 modulesmodules 是这样定义的:

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

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

modules 是由 platformModulesbaseModules 合并而来,里面定义了一些模块的钩子函数的实现,我们先来看一下定义 platformModules 的目录结构(在src/platforms/web/runtime/modules):

|-- modules
    |-- attrs.js
    |-- class.js
    |-- dom-props.js
    |-- events.js
    |-- index.js
    |-- style.js
    |-- transition.js

这里面定义了各种钩子函数,用于在虚拟 DOM 转换为真实 DOM 之后给真实 DOM 添加 attrclassstyleDOM 属性,我们这里先不详细介绍,来看一下 createPatchFunction 的实现。函数定义在 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

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

  // ...

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
  }
}

这里定义了 hooks ,也就是生命周期函数,在 patch 的不同时期会调用不同的钩子函数。

createPatchFunction 函数一开始做了遍历,将 hooks 作为 cbs 属性,然后将对应的 modules 的子项 pushcbs.hooks 中。

中间定义了一些辅助函数,并在最后 return 一个 patch 函数,这个函数就是vm._update 函数里调用的 vm.__patch__

在介绍 patch 的方法实现之前,我们可以思考一下为何 Vue.js 源码绕了这么一大圈,把相关代码分散到各个目录呢?这里其实涉及到了函数柯里化的概念。

我们知道函数柯里化最大的作用是提高函数的复用性。在 Vue.js 中,不同平台(webweex)将虚拟 DOM 转换成真实 DOM 的代码实现是不一样的,给真实 DOM 添加各种属性的方法也是不同的。

具体到 createPatchFunction 函数,这里传给它的两个参数 nodeOpsmodules 在不同平台有不同的实现方法(所以定义在 src/platforms 下)。比如 modules 其中一部分是 platformModules ,这个显然就是不同平台有各自的实现。而除了参数的不同,patch 的大部分逻辑是相同的(所以定义在 src/core 下)。

通过 createPatchFunction 把差异化参数提前固化,这样不用每次调用 patch 的时候都传递 nodeOpsmodules 了,这种编程技巧是非常值得学习的。

回到 patch 方法本身,我们来逐段分析:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

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

  // ...
}

这里 patch 函数接收了四个参数:oldVnodevnodehydratingremoveOnly,回顾 _update 中的代码:

if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}

可以看到第一次执行 patch 函数时传递的 oldVnode 参数是 vm.$el ,也就是要被替换的 DOM 节点;之后更新调用 patch 函数时传递的 oldVnode 参数是 prevVnode ,所以是一个虚拟 DOM

回顾Vue 源码探秘(\_render 函数)的例子:

<script>
render: function (createElement) {
  return createElement('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
},
data() {
  return {
    message: '森林小哥哥'
  }
}
</script>

我们围绕这个例子来分析 patch 函数。

第一个 if 语句判断 vnode 是否存在,对于我们这个例子而言,显然是存在的。来到第二个 if else 语句,显然会走 else 语句。我们分段来分析 else 语句的代码:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...

  if (isUndef(oldVnode)) {
    // ...
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      if (isRealElement) {
        // mounting to a real element
        // check if this is server-rendered content and if we can perform
        // a successful hydration.
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR)
          hydrating = true
        }
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true)
            return oldVnode
          } else if (process.env.NODE_ENV !== 'production') {
            warn(
              'The client-side rendered virtual DOM tree is not matching ' +
              'server-rendered content. This is likely caused by incorrect ' +
              'HTML markup, for example nesting block-level elements inside ' +
              '<p>, or missing <tbody>. Bailing hydration and performing ' +
              'full client-side render.'
            )
          }
        }
        // either not server-rendered, or hydration failed.
        // create an empty node and replace it
        oldVnode = emptyNodeAt(oldVnode)
      }

      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)
      // ...
    }
  }
  // ...
}

第一个 if 意思是如果 oldVnode 是一个 VNode(非第一次执行 patch)并且 oldVnodevnode 是同一个 VNode, 则给现有根节点打补丁。显然现在这里不会执行,走 else 逻辑。

进到 if (isRealElement) 逻辑,第一个 if 判断的是服务端渲染,不会执行。下面的 if 由于传入的 hydratingfalse ,因此也不执行。之后调用了 emptyNodeAt 函数,返回值赋给了 oldVnodeemptyNodeAt 函数如下:

// src/core/vdom/patch.js
function emptyNodeAt (elm) {
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}

其实就是将真实 DOM 转换为 VNode ,另外这里把 oldVnode 作为第六个参数 elm 传进去,因此在下面的语句:

// src/core/vdom/patch.js
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

通过 oldVnode.elm 拿到原来的 oldVnode 赋给 oldElm , 再通过 parentNode 拿到 oldElm 的父节点,对应到我们的例子就是 body 标签。

接着上面的else语句往下看:

// src/core/vdom/patch.js
return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...

  if (isUndef(oldVnode)) {
    // ...
  } else {
    // ...
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // ...
    } else {
      // ...

      // create new node
      createElm(
        vnode,
        insertedVnodeQueue,
        // extremely rare edge case: do not insert if old element is in a
        // leaving transition. Only happens when combining transition +
        // keep-alive + HOCs. (#4590)
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // ...
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

这里调用了createElm函数:

// src/core/vdom/patch.js
function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // This vnode was used in a previous render!
    // now it's used as a new node, overwriting its elm would cause
    // potential patch errors down the road when it's used as an insertion
    // reference node. Instead, we clone the node on-demand before creating
    // associated DOM element for it.
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  // ...
}

由于代码比较长,这里我们同样分段来分析。

第一个 if 语句显然不会执行,第二个 if 语句调用了 createComponent 函数,这个函数的功能是创建子组件,这个函数会在下一章详细介绍,现在只需要知道这里会返回 false 。接着往下看:

// src/core/vdom/patch.js

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    if (process.env.NODE_ENV !== 'production') {
      if (data && data.pre) {
        creatingElmInVPre++
      }
      if (isUnknownElement(vnode, creatingElmInVPre)) {
        warn(
          'Unknown custom element: <' + tag + '> - did you ' +
          'register the component correctly? For recursive components, ' +
          'make sure to provide the "name" option.',
          vnode.context
        )
      }
    }

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

    // ...
  } // ...
}

这里 if 语句的主要逻辑是在 tag 存在的情况下判断 tag 标签是否合法,如果是未知标签抛出警告。这个警告,在平时的开发中还是可能会经常遇到的:

之后就是创建真实 DOM 节点了,这里的 createElementNScreateElement 前面已经提及了,都是调用原生 js 方法来创建节点。继续往下:

// src/core/vdom/patch.js

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...

  if (isDef(tag)) {
    // ...
    setScope(vnode)

    /* istanbul ignore if */
    if (__WEEX__) {
      // weex相关
    } else {
      createChildren(vnode, children, insertedVnodeQueue)
      if (isDef(data)) {
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      insert(parentElm, vnode.elm, refElm)
    }

    if (process.env.NODE_ENV !== 'production' && data && data.pre) {
      creatingElmInVPre--
    }
  } // ...
}

前面的if 语句是 weex 相关的,我们略过,直接看 else 语句。else 语句调用了 createChildren 来创建子节点:

// src/core/vdom/patch.js

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children)
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

这里判断如果 children 是个数组则遍历数组并递归调用 createElm 把所有虚拟子节点转换为真实 DOM 节点然后插入到父节点 vnode.elm 中;如果是一个文本 VNode 则直接 appendChild 插到 vnode.elm 里面。

回到 createElm 函数,调用完 createChildren 之后又调用了 invokeCreateHooks 函数:

// src/core/vdom/patch.js

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) i.create(emptyNode, vnode)
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

invokeCreateHooks 函数其实就是遍历 cbs.create 中的所有函数,然后把 vnode push 到 insertedVnodeQueue 中。

下面接着调用了 insert 函数,把 vnode.elm 插入到父节点 parentElm 中:

// src/core/vdom/patch.js

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

可以看到 insert 函数也是调用 nodeOps 中的操作 DOM 的方法来实现的。有参考节点 ref 就调用 insertBefore 插入到参考节点 ref 前,没有就插到父节点 parent 中。

回到 createElm 函数,我们看最后一段代码:

// src/core/vdom/patch.js

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...
  if (isDef(tag)) {
    // ...
  } 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)
  }
}

if 针对的是 tag 的情况,我们已经分析完了。而 else if 针对的是创建注释节点的情况,就直接创建注释节点并插入;else 针对的是文本节点,逻辑也一样。

createElm 函数就分析完了,其实基本流程就是先创建当前节点 vnode.elm,然后把 vnode.children 插到 vnode.elm 中,再把 vnode.elm 插到父节点 parentElm 中。回到 patch 方法:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...
  if (isUndef(oldVnode)) {
    // ...
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // ...
    } else {
      //...

      // update parent placeholder node element, recursively
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent
        const patchable = isPatchable(vnode)
        while (ancestor) {
          for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor)
          }
          ancestor.elm = vnode.elm
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor)
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            const insert = ancestor.data.hook.insert
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (let i = 1; i < insert.fns.length; i++) {
                insert.fns[i]()
              }
            }
          } else {
            registerRef(ancestor)
          }
          ancestor = ancestor.parent
        }
      }

      // destroy old node
      if (isDef(parentElm)) {
        removeVnodes(parentElm, [oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

接下来的if 判断的是 vnode.parent ,这是父占位节点

父占位节点,是和组件相关的,这里不会执行,也就不展开细讲。

然后又判断之前定义的 parentElm 是否存在,有则删除掉 vm.$el 对应的节点。在执行这一步前,浏览器的 DOM 结构是这样的:

<body>
<div id="app"></div>
<div id="app">森林小哥哥</div>
</body>

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

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