vue源代码学习-mvvm

Vue作为一个用于构建界面的框架,首要学习的当然是它的mvvm模块。结合Vue对象的生命周期,简单画个图说明mvvm的主要步骤分别是在哪个位置发生的

observe data

observe data的部分基于以下三个对象

  1. Observer core/observer/index.js
    利用Object.definePropery, 遍历data对象, 针对属性创建getter & setter
  2. Watcher core/observer/watcher.js
    接受一个表达式或者函数,在执行表达式或者函数的时候, 注册到被访问的属性对应的Dep对象,在dep.notify的时候执行watcher的回调
  3. Dep core/observer/dep.js
    链接Watcher和Observer之间的桥梁, 每一个被observe的对象、数组、以及它们的基本类型属性值, 都有一个dep实例. dep实例里注册着若干个watcher实例, 一旦setter被调用并且满足条件,则触发这些watcher执行回调

下面我们逐步来解析图1中observe data这个过程中的内容,如下图所示:

(1) observe(data)

这一步的主要流程中图上画的很清楚了, 这一步的作用就是为data创建Observer实例(当然也会有一些分支判断比如: data是否已经有Observer实例)

(2) new Observer(data)

  • 为这个Observer对象创建dep实例, 根据data类型(对象或者数组), 分别遍历它们的全部值
  • 若是对象的值则调用defineReactive
  • 若是数组的值则调用observe

(3) defineReactive(data, key, value)

为data创建响应式属性key

  • 创建dep实例
  • observe(value)
  • 为data[key]创建getter & setter
    • getter: 如果当前有watcher正在运行, 则将watcher注册到该属性的dep实例,以及注册到属性对象的Observer对象上(如果是数组则还要循环数组注册watcher)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// core/observer/index.js
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) { // 初始化watcher的时候,通过调用getter访问器来添加依赖(将watcher添加到dep里)
dep.depend()
if (childOb) { // 如果是对象属性并且成功observe, 同样收集依赖
childOb.dep.depend()
if (Array.isArray(value)) { // 数组
dependArray(value)
}
}
}
return value
}
  • setter: 设置新值, 若值变化, observe(newVal), 并且调用dep.notify()触发dep实例里注册的所有watcher
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// core/observer/index.js
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) { // 判断NaN ? Nan === NaN是false
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal) //如果新值是对象或者array,深度observe
dep.notify() //调用dep里所有watcher的update方法
}

生成render函数

这一步并不一定是必须的,有可能自定义了render函数,有可能使用如vue-loader等编译时使用的插件提前编译template创建了render函数,最后也可能传入template属性,让vue框架在run-time阶段生成render函数。

那么什么是render函数?用自己的一句话概括就是: render函数接受参数,生成对应的virtual dom tree的根结点vnode。每一次render函数依赖的变量改变时,vue会生成新的虚拟dom树,并且和旧的虚拟dom树进行diff,然后将变更的结果patch到真实的dom树中去。我们取examples/commits中的例子生成的render函数如下:

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
(function () {
with(this) {
return _c('div', {
attrs: {
"id": "demo"
}
}, [_c('h1', [_v("Latest Vue.js Commits")]), _v(" "), _l((branches), function (branch) {
return [_c('input', {
directives: [{
name: "model",
rawName: "v-model",
value: (currentBranch),
expression: "currentBranch"
}],
attrs: {
"type": "radio",
"id": branch,
"name": "branch"
},
domProps: {
"value": branch,
"checked": _q(currentBranch, branch)
},
on: {
"change": function ($event) {
currentBranch = branch
}
}
}), _v(" "), _c('label', {
attrs: {
"for": branch
}
}, [_v(_s(branch))])]
}), _v(" "), _c('p', [_v("vuejs/vue@" + _s(currentBranch))]), _v(" "), _c('ul', _l((commits), function (record) {
return _c('li', [_c('a', {
staticClass: "commit",
attrs: {
"href": record.html_url,
"target": "_blank"
}
}, [_v(_s(record.sha.slice(0, 7)))]), _v("\n - "), _c('span', {
staticClass: "message"
}, [_v(_s(_f("truncate")(record.commit.message)))]), _c('br'), _v("\n by "), _c('span', {
staticClass: "author"
}, [_c('a', {
attrs: {
"href": record.author.html_url,
"target": "_blank"
}
}, [_v(_s(record.commit.author.name))])]), _v("\n at "), _c('span', {
staticClass: "date"
}, [_v(_s(_f("formatDate")(record.commit.author.date)))])])
}))], 2)
}
})

上面的render函数中, this就是vm的实例, _c就是createElement的别名, _l就是渲染for list用的等等。具体这些函数定义可以查看core/instance/render-helpers/index.js。从上图代码中其实也不难看出,它返回的最终也是一个VNode节点(虚拟dom树的root节点),有了这个函数和虚拟树的diff算法,我们就可以通过响应式数据来动态更新视图了。

new watcher

在obseve data, 并且成功生成render函数后,vue会创建针对render函数的watcher:

1
2
3
4
5
// core/instance/lifecycle.js
updateComponent = () => { // 针对整个vm对象的watcher,里面会调用render方法创建虚拟vnode, 然后_update进行初次渲染(或者比较旧的虚拟树),顺便收集依赖
vm._update(vm._render(), hydrating)
}
vm._watcher = new Watcher(vm, updateComponent, noop) // 创建watcher

我们一直在强调收集依赖,那么这个依赖到底是怎么收集的呢?其实很简单,在watcher对象里,每一次被new(创建)或者update(依赖的属性值改变,重新收集依赖)时,都会进入watcher的get方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// core/observer/watcher.js
get () { // 通过这个方式来收集依赖,触发watcher回调
pushTarget(this) // 当前正在收集依赖的watcher, 储存中Dep的静态属性中 (Dep.target)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm) //进入getter, 收集依赖
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget() // 收集完毕
this.cleanupDeps()
}
return value
}

如上代码所示, 其实vue只是调用了一下watcher.get方法(比如上面的updateComponent方法, watcher构造函数的第二个参数),然后get方法里所有定义了getter的属性参数(render函数中),会将当前的watcher注册到对应的dep里(代码)。如此一来,当视图依赖值改变时,会再次重新调用render函数,生成新的虚拟树,然后根据diff算法patch到真实的dom树里。

diff算法

Vue的虚拟dom树diff, patch算法是基于Snabbdom的。源代码位于core/vdom/patch.js中。普遍来说,一个virtual dom的过程, 可以用以下的伪代码表示:

1
2
3
4
5
6
7
8
// 当依赖数据值改变后触发
var newVnode = render(vnode, state)
var diffs = diff(oldVnode, newVnode)
patch(diffs)

// 与上面不同, Snabbdom中直接将patch和diff合并到了一个过程中
var newVnode = render(vnode, state)
patch(oldVnode, newVnode)

其实这里也涉及到了一个两棵树最小差异比较的算法。但是考虑到复杂度,包括react在内的框架都只会将两颗树的同层级进行比较,因为实际应用过程中大部分的情况,也只会在同一个层级里进行节点修改。若父节点不同,那么子节点并不会做任何的比较,这颗子树会被直接替换。如何判断节点是否可复用呢, 当然要判断节点key属性,tag是否一致等,这样vue就可以直接改变节点attr属性, 改变事件绑定等,就不用再重复创建dom耗时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function sameVnode (a, b) { // 判断vnode是否相同,这样可以不用更新
return (
a.key === b.key && ( // key必须相同(非v-for情况下都是undefined也是相同)
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 毕竟input标签招式多
) || (
isTrue(a.isAsyncPlaceholder) && // 异步组件
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}

整个比较过程流程图如下所示

  • 如果oldVnode节点是html节点(第一次渲染时)或者不满足sameVnode(oldVnode, newVnode), 那么这种情况下,都应该抛弃oldVnode,直接使用newVnode创建新的dom树
  • 否则,获取新旧节点的children(oldCh, ch)
    • 如果oldCh && ch, 那么进行updateChildren(elm, oldCh, ch)
    • 如果只有oldCh, 说明新的树结构应该抛弃oldCh(即所有子节点)
    • 如果只有ch, 说明新的树结构应该直接添加整个ch

那么就还剩下一个最重要的问题,diff算法中核心的同层级节点比较,也就是updateChildren方法。该方法简要可以概括为: 为oldCh和newCh分别定义头部和尾部的游标索引, 在比较过程中,游标索引逐步移动,尽可能的寻找sameVnode,直到碰撞,整个updateChildren的过程也就结束了。

1
2
3
4
let oldStartIdx = 0 // oldCh头部索引
let newStartIdx = 0 // ch头部索引
let oldEndIdx = oldCh.length - 1 // oldCh尾部索引
let newEndIdx = newCh.length - 1 // ch尾部索引

整个比较流程如下:

  • while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
    • 若sameVnode(oldCh[oldStartIdx], newCh[newStartIdx])为true, 调用patchVnode,然后两个数组的头索引往后移动一位, 即++oldStartIdx, ++newStartIdx
    • 若sameVnode(oldCh[oldEndIdx], newCh[newEndIdx])为true, 调用patchVnode, 然后两个数组的头索引往前移动一位,即–oldEndIdx, --newEndIdx
    • 若sameVnode(oldCh[oldStartIdx], newCh[newEndIdx])为true, 调用patchVnode, 然后将oldCh[oldStartIdx]对应的dom直接插入到最后(复用dom), 然后++oldStartIdx, --newEndIdx
    • 若sameVnode(oldCh[oldEndIdx], newCh[newStartIdx])为true, 调用patchVnode, 然后将oldCh[oldEndIdx]对应的dom直接插入到最前面(复用dom), 然后–oldEndIdx, ++newStartIdx
    • 若以上都不满足, 针对newCh[newStartIdx]节点, 则要利用key属性(v-for)、sameVnode判断来尽量复用oldCh中的dom, 若无法复用则直接创建新的dom节点,最后++newStartIdx
  • while循环比较结束, 判断oldCh是否处理完毕
    • 若oldStartIdx > oldEndIdx (oldCh都处理了), 那么将newCh中newStartIdx到newEndIdx的vnode添加
    • 否则(newCh都处理好了), 那么将oldCh中oldStartIdx到oldEndIdx的vnode删除

源代码如下,已经添加注释:

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { // 当父节点一样,那么更新子节点
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm

// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 核心算法,同一个层级的vnode比较
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) { // start是同一个vnode
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) { // end是同一个vnode
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) // 把start插入到最后, 另外为什么第三个参数不直接传null?
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) // 把end插入到最前面
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else { // 最坏的情况, 需要利用定义的key或者逐个比较
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 做一个oldCh中key与索引的映射
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key] // 有key就直接取出旧索引
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 再去遍历一次,用sameVnode方法判断newStartVnode在oldCh中的索引
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
} else {
vnodeToMove = oldCh[idxInOld] // 能找到,那么就移动吧
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !vnodeToMove) { // 若不存在, 应该就是key重复了
warn(
'It seems there are duplicate keys that is causing an update error. ' +
'Make sure each v-for item has a unique key.'
)
}
if (sameVnode(vnodeToMove, newStartVnode)) { // 一样
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined // oldch已经用了
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
}
}
newStartVnode = newCh[++newStartIdx] // 位移一位
}
}
if (oldStartIdx > oldEndIdx) { // 说明old vnodes处理完了
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm // 若newEndIdx没变过, 那么新增的节点就放最后去, refElm为null
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) { // 说明new vnodes先处理完了, 删除剩余没处理的old vnodes
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}

为了更形象一些,下面举个例子来表示同层级节点diff的过程(尽量覆盖各种比较情况)。首先是初始化状态如下图所示:

oldStartIdx和newStartIdx的vnode节点可复用,如下图:

oldStartIdx和newEndIdx的vnode节点可复用,如下图:

oldEndIdx和newStartIdx的vnode节点可复用,如下图:

现在头尾已经没有可复用的节点了,那么遍历当前oldStartIdx和oldEndIdx之间的节点,发现有个div的key和newStartIdx相同并且满足sameVnode,那么复用

然后现在的newStartIdx对应的div虽然没有key,但是仍有一个符合sameVnode的节点复用

最后, h2节点找不到可复用的节点,那么直接创建新的dom。此时,newEndIdx已经小于newStartIdx, 循环结束,将oldCh中没有复用的vnode对应的dom对象移除,整个同层级的diff过程就结束了

到这里,整个diff过程就结束了。可以看出来,整个diff过程其实比较简单,并没有使用复杂的算法。

总结