Vue.js源码学习 —— patch

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

如果您才开始准备看Vue.js中虚拟DOM相关的内容,建议先看一下本系列的Vue.js源码学习 —— VNode/createElementVue.js源码学习 —— createComponent,有了前面这些铺垫再来看今天这篇会轻松很多。

今天这篇文章要看的代码主要位于文件core/vdom/patch.js中。主要会先看一下patch执行的整个流程,以及从vnodenode的转换过程。

我没有在文章中大量的贴源码,一定要将源码clone到本地哦。

预备知识

patch

patch也就是打补丁,是将虚拟DOM节点转为真实DOM节点的过程。

directives

directives也即指令,通过使用指令用户获得了能够直接操作真实DOM的能力。

如果你细心观察项目中的文件,可能已经发现了有两处出现了directives,一处是core/vdom/modules/directives.js,一处是platforms/web/runtime/directives(当然,serverweex也有相应的directives文件夹),这两处的用途是什么呢?

  • core/vdom/modules/directives.js:既然位于core文件夹中,它就是与平台无关的,这里实现了 指令 这个功能,而不是实现某个具体的指令。打开文件你会发现,就是在这里在合适的时候调用了我们自定义指令时实现的钩子函数:bindinsertedupdatecomponentUpdatedunbind

  • platforms/web/runtime/directives:这里显然就是与平台有关的了,打开文件夹会发现,这里就是v-modelv-show两个内置指令的实现。

    • v-model实现了两个钩子函数:insertedcomponentUpdated

      1
      2
      3
      4
      5
      6
      7
      8
      const directive = {
      inserted (el, binding, vnode, oldVnode) {
      // ...
      },
      componentUpdated (el, binding, vnode) {
      // ...
      }
      }
    • v-show实现了三个钩子函数:bindupdateunbind

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      export default {
      bind (el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
      // ...
      },
      update (el: any, { value, oldValue }: VNodeDirective, vnode: VNodeWithData) {
      // ...
      },
      unbind (
      el: any,
      binding: VNodeDirective,
      vnode: VNodeWithData,
      oldVnode: VNodeWithData,
      isDestroy: boolean
      ) {
      // ...
      }
      }

入口

在深入到patch的具体实现之前,咱们先来看一下patch是怎么与Vue实例联系起来的呢?在Vue.js源码学习 —— 起步中的 Vue实例的初始化过程 一节,找到./runtime/index.js文件时,有出现这样一行代码:

1
2
3
// install platform patch function
// noop 函数没有做任何实际的工作
Vue.prototype.__patch__ = inBrowser ? patch : noop

也就是在浏览器环境中会将patch赋值给Vue的原型对象的__patch__属性,这样Vue实例都可以使用__patch__了。

patch函数来自于文件platforms/web/runtime/patch.js

1
2
3
4
5
6
7
8
9
10
11
// nodeOps中主要是DOM相关的操作
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 })

通过createPatchFunction创建了patch函数,同时注入了nodeOps和各个模块。这样的设计使得patch可以专注于核心功能,并且使用方还能灵活添加模块。模块只要实现patch提供的钩子函数就行了,这些钩子函数包括:create, activate, update, remove, destroy

platformModules中包含了attrsclassdom-propseventstyletransition等几个模块,对真实DOM节点的这几个方面的操作主要是在这些对应的模块中。

baseModules中包含了directivesref两个模块。“预备知识”一节中说了这里的directives就是对 指令 这个功能的实现。ref就是对我们平时开发中常用的vm.$refs的实现。

patch

patch.js这个文件中的代码没有用flow进行类型注解,作者在文件开头给了注释说明:

Not type-checking this because this file is perf-critical and the cost of making flow understand it is not worth it.

没有做类型检查,因为这个文件对性能至关重要,让flow来理解它带来的成本是不值得的。

patch函数的参数和返回如下:

1
2
3
4
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// ...
return vnode.elm
}

分别说一下这几个参数都是什么,用来干什么的:

  • oldVnode:旧的vnode,需要被更新掉。从patch的代码中会看到,它也可能是一个真实的DOM元素。
  • vnode:新的vnode,用来替换oldVnode的。
  • hydrating:用于标识是服务器端渲染的。
  • removeOnly:只在<transition-group>的时候使用,在代码中可以找到对它的注释:

    1
    2
    3
    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions

patch函数返回了与vnode对应的真实的DOM元素elm

何时会调用patch

将上面的例子放到浏览器中打断点调试,可以观察到调用过程如下图所示:

可以看到patch是在Vue._update中调用的,_update方法的定义位于文件core/instance/lifecycle.js中,其中调用patch的语句如下:

1
2
3
4
5
6
7
8
9
10
11
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
// 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)
}
}

在第一次渲染时还没有prevVnode,此时oldVnode的位置传的是vm.$el,也就是我们要去挂载的根元素,通常是<div id="app"></div>。之后再渲染时prevVnode就有值了。

patch的处理流程

patch函数的内容大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
function patch (oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}

// ...

if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} 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
oldVnode = emptyNodeAt(oldVnode)
}

// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

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

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

// destroy old node
if (isDef(parentElm)) {
// 会将 old node 从DOM中删除
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}

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

所以patch函数中主要分这么几种情况处理:

  • 如果vnode是空的,调用destroy钩子,销毁oldVnode,直接返回。一般发生在要移除自定义的组件时,oldVnode就是该组件的节点。

  • 如果oldVnode是空的,调用createElm函数创建新的DOM元素,最后调用insert钩子,返回节点对应的DOM元素。一般发生在创建组件的根元素时。

  • 如果oldVnodevnode都存在,这时又分两种情况,两个节点是否是相同的节点:

    • 如果是相同类型的节点,则调用patchVnode函数决定要如何更新节点。

    • 如果不是相同类型的节点,则调用createElm函数创建新的DOM元素,然后更新父节点中关联的DOM元素为刚创建的DOM元素,最后再将旧节点移除掉。

    1
    const isRealElement = isDef(oldVnode.nodeType)

    可以这样判断是否是真实的DOM元素,是因为虚拟节点没有nodeType属性,只有真实DOM元素才有。

这其中涉及到了一些辅助函数,等下我们一一看看,现在先举个例子看一下在每次的patch过程中oldVnodevnode都是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
let child = {
name: 'child',
props: {
items: {
type: Array,
default () { return [] }
}
},
render (h) {
if (this.items.length === 0) {
return h('p', 'No items found.')
} else {
return h('ul', this.items.map(item => {
return h('li', `${ item.name } is a ${ item.type }`)
}))
}
}
}

new Vue({
el: '#app',
data: { items: [] },
render (h) {
let items = [
{ name: 'alma', type: 'cat' },
{ name: 'ring', type: 'cat' },
{ name: 'winston', type: 'dog' }
]
let children = [
h(child, { props: { items: this.items } }), // child 组件
h('button', {
on: {
click: () => {
this.items = this.items.length > 0 ? [] : items
}
}
}, 'Click Me')
]
return h('p', children)
}
})

第一次进入patch函数中,此时打印oldVnodevnode的结果如下:

1
2
3
4
5
// oldVnode
<div id="app"></div>

// vnode 也是 h('p', children) 返回的vnode,vnode.parent 是 undefined
VNode {tag: "p", data: undefined, children: Array(2), text: undefined, elm: undefined, …}

createElm函数中也会创建vnode的子节点元素,因为它的子节点中有自定义的组件child,所以又会再一次触发patch。此时打印oldVnodevnode的结果如下:

1
2
3
4
5
// oldVnode
undefined

// vnode 也是 h('p', 'No items found.') 返回的vnode
VNode {tag: "p", data: undefined, children: Array(1), text: undefined, elm: undefined, …}

之后vnode的子节点中再没有自定义的组件了,所以不会再触发patch了。执行结束后页面中的元素从<div id="app"></div>变成了如下的DOM元素:

1
2
3
4
<p>
<p>No items found.</p>
<button>Click Me</button>
</p>

现在点击button,会将<p>No items found.</p>换成<ul>...</ul>,也会触发两次patch

第一次执行时oldVnodevnode打印的结果如下:

1
2
3
4
5
// oldVnode
VNode {tag: "p", data: undefined, children: Array(2), text: undefined, elm: p, …}

// vnode
VNode {tag: "p", data: undefined, children: Array(2), text: undefined, elm: undefined, …}

此时两个节点相同都是根元素p,所以会走进patchVnode函数,最终再次触发patch函数,这次的oldVnodevnode打印的结果如下:

1
2
3
4
5
// oldVnode,也就是<p>No items found.</p>对应的节点
VNode {tag: "p", data: undefined, children: Array(1), text: undefined, elm: p, …}

// vnode,也就是<ul>...</ul>对应的节点
VNode {tag: "ul", data: undefined, children: Array(3), text: undefined, elm: undefined, …}

invoke***Hook

createPatchFunction函数一开始就收集了传入的各模块的hook放在了变量cbs中,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
activate: [
ƒ _enter(_, vnode)
],
create: [
ƒ updateAttrs(oldVnode, vnode),
ƒ updateClass(oldVnode, vnode),
ƒ updateDOMListeners(oldVnode, vnode),
ƒ updateDOMProps(oldVnode, vnode),
ƒ updateStyle(oldVnode, vnode),
ƒ _enter(_, vnode),
ƒ create(_, vnode),
ƒ updateDirectives(oldVnode, vnode)
],
destroy: [
ƒ destroy(vnode),
ƒ unbindDirectives(vnode)
],
remove: [
ƒ remove(vnode, rm)
],
update: [
ƒ updateAttrs(oldVnode, vnode),
ƒ updateClass(oldVnode, vnode),
ƒ updateDOMListeners(oldVnode, vnode),
ƒ updateDOMProps(oldVnode, vnode),
ƒ updateStyle(oldVnode, vnode),
ƒ update(oldVnode, vnode),
ƒ updateDirectives(oldVnode, vnode)
]
}

除此之外,在Vue.js源码学习 —— createComponent这篇文章的最后我们说componentVNodeHooks中的钩子函数会在给组件的vnode打补丁(patch)期间会被调用。这些钩子函数是放在vnode.data.hook中的,主要包括:

1
2
3
4
5
6
7
// inline hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {/* ... */},
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {/* ... */},
insert (vnode: MountedComponentVNode) {/* ... */},
destroy (vnode: MountedComponentVNode) {/* ... */}
}

以上所有的钩子函数都在invokeCreateHooksinvokeInsertHookinvokeDestroyHookremoveAndInvokeRemoveHook等函数中对应地得到执行。当然,有的钩子函数可能不需要抽出一个单独的函数,比如prepatch,是在patchVnode函数中直接被调用的。

这里就可以为VNodeData中attrsclass等等字段的用途找到答案了,比如在触发create钩子时updateAttrs(oldVnode, vnode)会被执行,这个函数位于platforms/web/runtime/modules/attrs.js中,打开改文件后可以看到就是从vnode.data.attrs取的DOM元素的attributekeyvalue,然后再调用el.setAttribute(key, value)等方法去修改DOM元素的attribute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function updateAttrs (oldVnode: VNodeWithData, vnode: VNodeWithData) {
// ...
const elm = vnode.elm
const oldAttrs = oldVnode.data.attrs || {}
let attrs: any = vnode.data.attrs || {}
// ...
for (key in attrs) {
cur = attrs[key]
old = oldAttrs[key]
if (old !== cur) {
setAttr(elm, key, cur)
}
}
// ...
}

sameVnode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}

可以看到判断两个节点是否是相同类型的节点的条件:

  • 两个节点的key必须相同,否则会直接返回false

  • 如果是普通组件:tag相同,并且isComment值相同,有无data项相同,如果是input元素,input的类型也要相同。

  • 如果是异步组件:a节点的isAsyncPlaceholder属性为true,两个节点的asyncFactory指向同一个函数,并且b节点没有发生error

createElm

createElm函数的大致流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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)) {
// ...
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)
}
}

去掉了特殊情况和边界情况的处理后,createElm函数的大致流程还是比较清晰的:

  • 试图创建组件,如果成功了直接返回。

  • 如果tag有值,创建真实的DOM元素,然后设置给vnode.elm,接着调用setScope函数设置CSS作用域,这个方法一会具体看看它是什么实现的。再接着就调用createChildren函数创建子节点。如果data有值,执行invokeCreateHooks函数,触发各个create钩子函数的执行。然后将刚创建的DOM元素插入其父元素中。

  • 如果vnodeisComment属性为true,就创建一个comment元素,然后插入到父元素中。

  • 其他情况会创建文本节点,插入到父元素中。

createChildren

createChildren函数做的事情比较简单,分两种情况处理:

  • 如果children是数组,在开发环境下会判断是否存在重复的key,如果存在会给出警告信息。然后循环遍历children,再调用createElm函数依次创建对应的DOM元素。

  • 如果vnode.text是原始数据类型,则创建一个文本节点添加到vnode.elm中。

setScope

1
2
3
4
5
6
7
8
// set scope id attribute for scoped CSS.
// this is implemented as a special case to avoid the overhead
// of going through the normal attribute patching process.
function setScope (vnode) {
// ...
nodeOps.setStyleScope(vnode.elm, i)
// ...
}

在单文件(.vue)中我们都写过这样的代码:

1
<style scoped></style>

在浏览器中运行后可以发现DOM元素都多了一个标识值,这个值就是scopeId,在这里咱们可以看到它的实现,主要在nodeOps.setStyleScope函数中:

1
2
3
export function setStyleScope (node: Element, scopeId: string) {
node.setAttribute(scopeId, '')
}

createComponent

createComponent函数在vnode.data有值时才会继续处理,所以如果vnode.data没有值就创建组件失败了,上面的createElm函数会继续执行它后面的内容。

vnode.data有值时大致的流程如下:

  • 如果data.hook.init存在,就会调用该init钩子函数去创建组件的实例对象。

    如果是自定义的组件一定存在data.hook.init的,这个init钩子就是我们最前面说的componentVNodeHooks中的init函数,可以看到在这里被调用了。它的实现过程在除此之外,在Vue.js源码学习 —— createComponent中有说过。

  • 经过了init钩子函数的执行,此时如果vnode.componentInstance有值,说明是一个自定义的组件,这时可以返回true了。

  • 在执行init钩子的过程中,会去挂载组件,整个patch的过程又会被触发,所以vnode.componentInstance.$el现在是有值的,在initComponent函数中,直接将其赋值给vnode.elm就可以了。然后将vnode.elm插入父元素中。

  • 如果是被重新激活的组件,在reactivateComponent函数中,activate钩子函数会被触发。

removeVnodes

1
2
3
4
5
6
7
8
9
10
11
12
13
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else { // Text node
removeNode(ch.elm)
}
}
}
}

removeVnodes函数支持批量移除DOM元素,从vnodes中移除[startIdx, endIdx]区间的元素。

  • tag时,会去触发removedestroy钩子函数。

    remove钩子主要提高给transition.js模块中的用的,destroy主要提供给directives.jsref.js模块移除相关内容,同时componentVNodeHooks中的destroy也会得到执行,彻底将组件实例销毁掉。

  • 没有tag时,会被认为是简单的文本节点,就直接从DOM中移除了。

到此,patch.js中的函数我们就了解了大部分了,相信你会对patch的执行流程有一个大致的了解。我们还遗留了一个patchVnode函数没有具体看,这里涉及到了传说中的diff算法,内容比较多,所以咱们放下一篇文章详细说。

本文作者:意林
本文链接:http://shinancao.cn/2020/01/20/Vue-Source-Code-06/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!