diff

数据发生变化时,vue是怎么更新节点的

我们先根据真实DOM生成一颗virtual DOM,当virtual DOM某个节点的数据改变后会生成一个新的Vnode,然后Vnode和oldVnode作对比,发现有不一样的地方就直接修改在真实的DOM上,然后使oldVnode的值为Vnode。
diff的过程就是调用名为patch的函数,比较新旧节点,一边比较一边给真实的DOM打补丁。

分析Vue中 diff算法与虚拟dom

diff算法过程

vue中diff核心策略

平级比较,不考虑跨级比较节点的情况,内部采用深度优先递归 + 双指针(两端对比)策略进行比较
Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作,
展开来说

(观察主流的虚拟 DOM 库(snabbdom、virtual-dom),通常都有一个 h 函数,也就是 React 中的 React.createElement,以及 Vue 中的 render 方法中的 createElement,另外 React 是通过 babel 将 jsx 转换为 h 函数渲染的形式,而 Vue 是使用 vue-loader 将模版转为 h 函数渲染的形式(也可以通过 babel-plugin-transform-vue-jsx 插件在 vue 中使用 jsx,本质还是转换为 h 函数渲染形式)。)

可以看到,最终 HTML 代码会被转译成 h 函数的渲染形式。h 函数接受是三个参数,分别代表是 DOM 元素的标签名、属性、子节点,最终返回一个虚拟 DOM 的对象。

1
2
3
4
5
6
7
function h(tag, props, ...children) {
return {
tag,
props: props || {},
children: children.flat()
}
}

从 h 函数说起

vm.update

当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。

patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点

  • 判断两节点是否值得比较(基本属性是否相同,sameVnode),值得比较则执行patchVnode;
  • 不值得比较则创建新节点Vnode替换oldVnode;

patchVnode:当我们确定两个节点值得比较之后我们会对两个节点指定patchVnode方法;

  • 找到对应的真实dom,称为el;
  • 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return;
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点;
  • 如果oldVnode有子节点而Vnode没有,则删除el的子节点;
  • 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el;
  • 如果两者都有子节点且不相同,则执行updateChildren函数比较子节点,这一步很重要

updateChildren函数

现在分别对oldS、oldE、S、E两两做sameVnode比较,有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置

  • 如果是oldS和E匹配上了,那么真实dom中的第一个节点会移到最后
  • 如果是oldE和S匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
  • 如果四种匹配没有一对是成功的,分为两种情况
    • 如果新旧子节点都存在key,那么会根据oldChild的key生成一张hash表,用S的key与hash表做匹配,匹配成功就判断S和匹配节点是否为sameNode,如果是,就在真实dom中将成功的节点移到最前面,否则,将S生成对应的节点插入到dom中对应的oldS位置,S指针向中间移动,被匹配old中的节点置为null。
    • 如果没有key,则直接将S生成新的节点插入真实DOM(ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了,没做到)

vue中 diff算法是如何比对新旧虚拟dom树节点的

  • 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return
  • 进行文本节点的判断,若 oldVnode.text !== vnode.text,那么就会直接进行文本节点的替换;
  • 在vnode没有文本节点的情况下,进入子节点的 diff;
  • 当 oldCh 和 ch 都存在且不相同的情况下,调用updateChildren对子节点进行 diff;
  • 若 oldCh不存在,ch 存在,首先清空 oldVnode 的文本节点,同时调用 addVnodes 方法将 ch 添加到elm真实 dom 节点当中;
  • 若 oldCh存在,ch不存在,则删除 elm 真实节点下的 oldCh 子节点;
  • 若 oldVnode 有文本节点,而 vnode 没有,那么就清空这个文本节点。

Vue中的diff算法

看源码之前,先把俩个常用的工具函数贴一下

1
2
3
4
5
6
function isUndef(v) {
return v === undefined || v === null
}
function isDef(v) {
return v !== undefined && v !== null
}

Vue.js 源码的 diff 调用逻辑

diff

每个组件实例都会有相应的Watcher实,渲染组件的过程,会把属性记录为依赖,当我们操作数据的时候,依赖项的setter会被调用,从而通知Watcher重新计算,从而使得相关的组件得以更新。
完成视图的更新工作事实上就是调用了vm._update方法,这个方法接收的第一个参数是刚生成的Vnode,调用的vm._update方法定义在 src/core/instance/lifecycle.js中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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) {
// initial render 没有父节点 vm.$el真实的dom节点
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates 更新的时候走这里 老 新节点进行diff
vm.$el = vm.__patch__(prevVnode, vnode)
}
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
}
}

在这个方法当中最为关键的就是 vm.patch 方法,这也是整个 virtual-dom 当中最为核心的方法,主要完成了prevVnode 和 vnode 的 diff 过程并根据需要操作的 vdom 节点打 patch,最后生成新的真实 dom 节点并完成视图的更新工作。

接下来,让我们看下 vm.patch 的逻辑过程, vm.patch 方法定义在 src/core/vdom/patch.js 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function patch (oldVnode, vnode, hydrating, removeOnly) {
...
if (isUndef(oldVnode)) {
// 创建一个新节点 create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
// 对oldVnode和vnode进行diff,并对oldVnode打patch
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 如果新旧来个节点局部属性不一致则跳过diff 直接新建一个vNode
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
}
}

在 patch 方法中,我们看到会分为两种情况,
一种是当 oldVnode 不存在时,会创建新的节点
另一种则是已经存在 oldVnode ,那么会对 oldVnode 和 vnode 进行 diff 及 patch 的过程

其中 patch 过程中会调用 sameVnode 方法来对对传入的2个 vnode 进行基本属性的比较,只有当基本属性相同的情况下才认为新旧俩个vnode 只是局部发生了更新,然后才会对这新旧俩个 vnode 进行 diff
如果俩个vnode 的基本属性存在不一致的情况,那么就会直接跳过 diff 的过程,进而依据 vnode 新建一个真实的 dom,同时删除老的 dom 节点。

patchVnode和patch区别
pacth调用patchVnode,如果存在老节点 才会执行该方法,颗粒度更细

1
2
3
4
5
6
7
8
9
10
11
12
13
// 比较基本属性如tagName data属性等
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)
)
)
)
}

diff 过程中主要是通过调用 patchVnode 方法进行的:

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
function patchVnode (
oldVnode,
vnode
) {
if (oldVnode === vnode) {
return
}
const oldCh = oldVnode.children
const ch = vnode.children
// 如果vnode没有文本节点
if (isUndef(vnode.text)) {
// 如果oldVnode的children属性存在且vnode的children属性也存在
if (isDef(oldCh) && isDef(ch)) {
// 新老 子节点不一样 整个diff的核心
if (oldCh !== ch) {
// updateChildren,对子节点进行diff
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
// 如果oldVnode的text存在,那么首先清空text的内容,然后将vnode的children添加进去
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 删除elm下的oldchildren
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// oldVnode有子节点,而vnode没有,那么就清空这个节点
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 新老节点不一样 直接进行文本节点的替换
nodeOps.setTextContent(elm, vnode.text)
}
}

以上代码得知
diff 过程中又分了好几种情况,oldCh 为 oldVnode的子节点,ch 为 Vnode 的子节点:

  • 判断oldVnode和Vnode是否相同,相同直接退出
  • 如果他们都有文本节点并且不相等,那么将更新为Vnode的文本节点
  • 在 vnode 没有文本节点的情况下,进入子节点的 diff;
  • 当 oldCh 和 ch 都存在且不相同的情况下,调用 updateChildren 对子节点进行 diff;
  • 若 oldCh 不存在,ch 存在,首先清空 oldVnode 的文本节点,同时调用 addVnodes 方法将 ch 添加到elm真实dom节点当中;
  • 若 oldCh 存在,ch 不存在,则删除 elm 真实节点下的 oldCh 子节点;
  • 若 oldVnode 有文本节点,而 vnode 没有,那么就清空这个文本节点

子节点 diff 流程分析

这里着重分析下 updateChildren方法,它也是整个 diff 过程中最重要的环节,以下为 Vue.js 的源码过程,为了更形象理解 diff 过程,我们给出相关的示意图来讲解。

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
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
// 为oldCh和newCh分别建立索引,为之后遍历的依据
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
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
// 在老的节点找key 找不到就新加子节点
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}

updateChildren函数主要做了一下工作

  • 将Vnode的子节点Vch和oldVnode的子节点oldCh 开始和结束提取出来
    1
    2
    3
    4
    5
    6
    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] // 新的子节点 开始
  • oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx > EndIdx 表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。

patch过程中涉及的操作DOM api

diff 我们会看到 nodeOps 相关的方法对真实 DOM 结构进行操作,nodeOps 定义在 src/platforms/web/runtime/node-ops.js 中,其为基本 DOM 操作,有一个是关于setStyleScope设置属性scoped的

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
export function createElement (tagName: string, vnode: VNode): Element {
const elm = document.createElement(tagName)
if (tagName !== 'select') {
return elm
}
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)
}
export function createComment (text: string): Comment {
return document.createComment(text)
}
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
parentNode.insertBefore(newNode, referenceNode)
}
export function removeChild (node: Node, child: Node) {
node.removeChild(child)
}
export function appendChild (node: Node, child: Node) {
node.appendChild(child)
}
export function parentNode (node: Node): ?Node {
return node.parentNode
}
export function nextSibling (node: Node): ?Node {
return node.nextSibling
}
export function tagName (node: Element): string {
return node.tagName
}
export function setTextContent (node: Node, text: string) {
node.textContent = text
}
export function setStyleScope (node: Element, scopeId: string) {
node.setAttribute(scopeId, '')
}

图解子节点diff

diff

现在分别对oldS、oldE、S、E两两做sameVnode比较,有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置,这句话有点绕,打个比方

  • 如果是oldS和E匹配上了,那么真实dom中的第一个节点会移到最后
  • 如果是oldE和S匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
  • 如果四种匹配没有一对是成功的,分为两种情况
    • 如果新旧子节点都存在key,那么会根据oldChild的key生成一张hash表,用S的key与hash表做匹配,匹配成功就判断S和匹配节点是否为sameNode,如果是,就在真实dom中将成功的节点移到最前面,否则,将S生成对应的节点插入到dom中对应的oldS位置,S指针向中间移动,被匹配old中的节点置为null。
    • 如果没有key,则直接将S生成新的节点插入真实DOM(ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了,没做到)

(假设old和new中的子节点都有key)
diff

  • 第一步
    oldS: a, oldE: d
    S: a, E: b
    oldS和S匹配,则将dom中的a节点放到第一个,已经是第一个了就不管了,此时dom的位置为:a b d

  • 第二步
    oldS:b oldE:d
    S:c E:b
    oldS和E匹配上,将真实dom节点b移到最后,因为E中的b在最后位置,印证这句话:一旦能匹配上,则真实dom对应节点将会按照newE中的位置来,此时真实dom位置是 a d b

  • 第三步
    oldS: d oldE: d
    S: c E: d
    oldS和E匹配上,位置不变 此时还是 a d b

  • 第四步
    oldS++;
    oldE–;
    oldS > oldE;
    遍历结束,说明oldCh先遍历完。就将剩余的vCh节点根据自己的的index插入到真实dom中去,此时dom位置为:a c d b

这个匹配过程的结束有两个条件:

  • oldS > oldE表示oldCh先遍历完,新节点多余老节点,那么就将多余的vCh根据index添加到dom中去(如上图)
  • S > E表示vCh先遍历完,新节点少于老节点,那么就在真实dom中将区间为[oldS, oldE]的多余节点删掉

第二个例子模拟下diff过程
diff

  • 第一步
    oldS: b, oldE: e
    S: a, E: e
    oldE和E匹配,则将dom中的e节点放到最后一个,此时真实dom位置是 b a d f e

  • 第二步
    oldS:b oldE:f
    S:a E:b
    oldS和E匹配上,将真实dom节点b移到倒数第二位,此时真实dom位置是 a d f b e

  • 第三步
    oldS: a oldE: f
    S: a E: a
    oldS和S匹配上,位置不变 此时还是 a d f b e

  • 第四步
    S ++;
    E –;
    S > E;
    遍历结束,说明Ch先遍历完。S > E表示vCh先遍历完,新节点少于老节点,那么就在真实dom中将区间为[oldS, oldE]的多余节点删掉,此时真实dom位置就是 a b e

当这些节点sameVnode成功后就会紧接着执行patchVnode(又回到前面的patchVnode方法了,递归咯)了,可以看一下上面的代码
就这样层层递归下去,直到将oldVnode和Vnode中的所有子节点比对完。也将dom的所有补丁都打好啦.DomDiff的过程更像是俩棵树的比较,每找到相同的节点,都会层层往下比较子节点,这才是真正的深度递归遍历比较的过程

参考