-
Notifications
You must be signed in to change notification settings - Fork 75
Description
作者:杨敬卓
转载请注明出处
目录
- 前言
- virtual dom
- 分析diff
- 总结
前言
vue2.0加入了virtual dom,有向react靠拢的意思。vue的diff位于patch.js文件中,我的一个小框架aoy也同样使用此算法,该算法来源于snabbdom,复杂度为O(n)。
了解diff过程可以让我们更高效的使用框架。
本文力求以图文并茂的方式来讲明这个diff的过程。
virtual dom
如果不了解virtual dom,要理解diff的过程是比较困难的。虚拟dom对应的是真实dom, 使用document.CreateElement
和 document.CreateTextNode
创建的就是真实节点。
我们可以做个试验。打印出一个空元素的第一层属性,可以看到标准让元素实现的东西太多了。如果每次都重新生成新的元素,对性能是巨大的浪费。
var mydiv = document.createElement('div');
for(var k in mydiv ){
console.log(k)
}
virtual dom就是解决这个问题的一个思路,到底什么是virtual dom呢?通俗易懂的来说就是用一个简单的对象去代替复杂的dom对象。
举个简单的例子,我们在body里插入一个class为a的div。
var mydiv = document.createElement('div');
mydiv.className = 'a';
document.body.appendChild(mydiv);
对于这个div我们可以用一个简单的对象mydivVirtual
代表它,它存储了对应dom的一些重要参数,在改变dom之前,会先比较相应虚拟dom的数据,如果需要改变,才会将改变应用到真实dom上。
//伪代码
var mydivVirtual = {
tagName: 'DIV',
className: 'a'
};
var newmydivVirtual = {
tagName: 'DIV',
className: 'b'
}
if(mydivVirtual.tagName !== newmydivVirtual.tagName || mydivVirtual.className !== newmydivVirtual.className){
change(mydiv)
}
// 会执行相应的修改 mydiv.className = 'b';
//最后 <div class='b'></div>
读到这里就会产生一个疑问,为什么不直接修改dom而需要加一层virtual dom呢?
很多时候手工优化dom确实会比virtual dom效率高,对于比较简单的dom结构用手工优化没有问题,但当页面结构很庞大,结构很复杂时,手工优化会花去大量时间,而且可维护性也不高,不能保证每个人都有手工优化的能力。至此,virtual dom的解决方案应运而生,virtual dom很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达平衡。
virtual dom 另一个重大意义就是提供一个中间层,js去写ui,ios安卓之类的负责渲染,就像reactNative一样。
分析diff
一篇相当经典的文章React’s diff algorithm中的图,react的diff其实和vue的diff大同小异。所以这张图能很好的解释过程。比较只会在同层级进行, 不会跨层级比较。
举个形象的例子。<!-- 之前 -->
<div> <!-- 层级1 -->
<p> <!-- 层级2 -->
<b> aoy </b> <!-- 层级3 -->
<span>diff</Span>
</P>
</div>
<!-- 之后 -->
<div> <!-- 层级1 -->
<p> <!-- 层级2 -->
<b> aoy </b> <!-- 层级3 -->
</p>
<span>diff</Span>
</div>
我们可能期望将<span>
直接移动到<p>
的后边,这是最优的操作。但是实际的diff操作是移除<p>
里的<span>
在创建一个新的<span>
插到<p>
的后边。
因为新加的<span>
在层级2,旧的在层级3,属于不同层级的比较。
源码分析
文中的代码位于aoy-diff中,已经精简了很多代码,留下最核心的部分。
diff的过程就是调用patch函数,就像打补丁一样修改真实dom。
function patch (oldVnode, vnode) {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
const oEl = oldVnode.el
let parentEle = api.parentNode(oEl)
createEle(vnode)
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
api.removeChild(parentEle, oldVnode.el)
oldVnode = null
}
}
return vnode
}
patch
函数有两个参数,vnode
和oldVnode
,也就是新旧两个虚拟节点。在这之前,我们先了解完整的vnode都有什么属性,举个一个简单的例子:
// body下的 <div id="v" class="classA"><div> 对应的 oldVnode 就是
{
el: div //对真实的节点的引用,本例中就是document.querySelector('#id.classA')
tagName: 'DIV', //节点的标签
sel: 'div#v.classA' //节点的选择器
data: null, // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style
children: [], //存储子节点的数组,每个子节点也是vnode结构
text: null, //如果是文本节点,对应文本节点的textContent,否则为null
}
需要注意的是,el属性引用的是此 virtual dom对应的真实dom,patch
的vnode
参数的el
最初是null,因为patch
之前它还没有对应的真实dom。
来到patch
的第一部分,
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
}
sameVnode
函数就是看这两个节点是否值得比较,代码相当简单:
function sameVnode(oldVnode, vnode){
return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}
两个vnode的key和sel相同才去比较它们,比如p
和span
,div.classA
和div.classB
都被认为是不同结构而不去比较它们。
如果值得比较会执行patchVnode(oldVnode, vnode)
,稍后会详细讲patchVnode
函数。
当节点不值得比较,进入else中
else {
const oEl = oldVnode.el
let parentEle = api.parentNode(oEl)
createEle(vnode)
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
api.removeChild(parentEle, oldVnode.el)
oldVnode = null
}
}
过程如下:
- 取得
oldvnode.el
的父节点,parentEle
是真实dom createEle(vnode)
会为vnode
创建它的真实dom,令vnode.el
=真实dom
parentEle
将新的dom插入,移除旧的dom
当不值得比较时,新节点直接把老节点整个替换了
最后
return vnode
patch最后会返回vnode,vnode和进入patch之前的不同在哪?
没错,就是vnode.el,唯一的改变就是之前vnode.el = null, 而现在它引用的是对应的真实dom。
var oldVnode = patch (oldVnode, vnode)
至此完成一个patch过程。
patchVnode
两个节点值得比较时,会调用patchVnode
函数
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
const el = vnode.el = oldVnode.el
这是很重要的一步,让vnode.el
引用到现在的真实dom,当el
修改时,vnode.el
会同步变化。
节点的比较有5种情况
-
if (oldVnode === vnode)
,他们的引用一致,可以认为没有变化。 -
if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text)
,文本节点的比较,需要修改,则会调用Node.textContent = vnode.text
。 -
if( oldCh && ch && oldCh !== ch )
, 两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren
函数比较子节点,这是diff的核心,后边会讲到。 -
else if (ch)
,只有新的节点有子节点,调用createEle(vnode)
,vnode.el
已经引用了老的dom节点,createEle
函数会在老dom节点上添加子节点。 -
else if (oldCh)
,新节点没有子节点,老节点有子节点,直接删除老节点。
updateChildren
updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0, 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
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { //对于vnode.key的比较,会把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
}else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
}else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
}else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
代码很密集,为了形象的描述这个过程,可以看看这张图。
过程可以概括为:oldCh
和newCh
各有两个头尾的变量StartIdx
和EndIdx
,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx
表明oldCh
和newCh
至少有一个已经遍历完了,就会结束比较。
具体的diff分析
设置key和不设置key的区别:
不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx
中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
diff的遍历过程中,只要是对dom进行的操作都调用api.insertBefore
,api.insertBefore
只是原生insertBefore
的简单封装。
比较分为两种,一种是有vnode.key
的,一种是没有的。但这两种比较对真实dom的操作是一致的。
对于与sameVnode(oldStartVnode, newStartVnode)
和sameVnode(oldEndVnode,newEndVnode)
为true的情况,不需要对dom进行移动。
总结遍历过程,有3种dom操作:
- 当
oldStartVnode
,newEndVnode
值得比较,说明oldStartVnode.el
跑到oldEndVnode.el
的后边了。
图中假设startIdx遍历到1。
- 当
oldEndVnode
,newStartVnode
值得比较,说明oldEndVnode.el
跑到了newStartVnode.el
的前边。(这里笔误,应该是“oldEndVnode.el跑到了oldStartVnode.el的前边”,准确的说应该是oldEndVnode.el需要移动到oldStartVnode.el的前边”)
- newCh中的节点oldCh里没有, 将新节点插入到
oldStartVnode.el
的前边。
在结束时,分为两种情况:
oldStartIdx > oldEndIdx
,可以认为oldCh
先遍历完。当然也有可能newCh
此时也正好完成了遍历,统一都归为此类。此时newStartIdx
和newEndIdx
之间的vnode是新增的,调用addVnodes
,把他们全部插进before
的后边,before
很多时候是为null的。addVnodes
调用的是insertBefore
操作dom节点,我们看看insertBefore
的文档:parentElement.insertBefore(newElement, referenceElement)
如果referenceElement为null则newElement将被插入到子节点的末尾。如果newElement已经在DOM树中,newElement首先会从DOM树中移除。所以before
为null,newElement将被插入到子节点的末尾。
newStartIdx > newEndIdx
,可以认为newCh
先遍历完。此时oldStartIdx
和oldEndIdx
之间的vnode在新的子节点里已经不存在了,调用removeVnodes
将它们从dom里删除。
下面举个例子,画出diff完整的过程,每一步dom的变化都用不同颜色的线标出。
- a,b,c,d,e假设是4个不同的元素,我们没有设置key时,b没有复用,而是直接创建新的,删除旧的。
- 当我们给4个元素加上唯一key时,b得到了的复用。
这个例子如果我们使用手工优化,只需要3步就可以达到。
总结
- 尽量不要跨层级的修改dom
- 设置key可以最大化的利用节点
- diff的效率并不是每种情况下都是最优的
Activity
userand commentedon Mar 23, 2017
@
fengmiaosen commentedon Mar 24, 2017
mark
cshenger commentedon Mar 24, 2017
配图好评,看代码绝对会晕的
ascoders commentedon Mar 24, 2017
和 react 的几乎一样
aooy commentedon Mar 24, 2017
@ascoders 是的
ghost commentedon Mar 25, 2017
React 的 diff 算法
mofengfly commentedon Apr 9, 2017
赞,mark
iterry commentedon Apr 12, 2017
详细,点赞
wangweida commentedon Apr 26, 2017
写的很好,点个赞。
当oldEndVnode,newStartVnode值得比较,说明 oldEndVnode.el跑到了newStartVnode.el的前边。
这句话估计你写笔误了吧...
aooy commentedon Apr 27, 2017
@wangweida 是的笔误了,谢谢提醒。
zengwenfu commentedon Apr 27, 2017
赞,有一处需要指出的是:
function sameVnode(oldVnode, vnode){
return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}
vue中sameVnode不会比较vnode.sel===oldVnode.sel,按照定义,这里的sel是选择器,如果选择器不一致就不值得比较的话,那么vue里面的v-bind:class动态绑定class就变得完全不提倡使用了,事实上vue并没有做这个比较:
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)
)
}
aooy commentedon Apr 27, 2017
@zengwenfu 谢谢指正,本文从原始算法去解读vue的diff,没有完全契合vue,vue实际情况会更复杂,它考虑到了class的动态绑定和input的type值,对sameVnode进行了修改。
dean5277 commentedon Jun 9, 2017
配图满分,很好理解
linzb93 commentedon Jun 27, 2017
有个疑问,假如一个节点是根节点的好几层子节点,那么它对应的vnode的el属性应该怎么表示?是从根节点一层层套下去吗?我看了你的代码还没明白。
41 remaining items
YeYongFen commentedon Jul 11, 2019
你好,我发现你的第一个例子,也就是 不带key的例子有点不合理
由于没有key ,那么肯定是走 findIdxInOld。
例如第一个点 b,通过 sameVnode 它还是可以找到 oldch 里面的 d 的啊,所以第一步不会是插入。
这是我的代码
xubaifuCode commentedon Aug 4, 2019
idxInOld
有可能是0吧?另外我结合大佬的文章和国外一个讲虚拟DOM的把两者结合实现了一个可直观感受且跑得通得DEMO。请看:https://github.com/xubaifuCode/virtual-dom-and-diff-implementation
hdulsh commentedon Aug 12, 2019
大佬 加上key的那张图 c是不是也不需要移动啊
haoolii commentedon Aug 15, 2019
一開始看圖真的看不懂 直接看源碼還清楚些 一直碎碎念為何C要移動
原來家都有這疑惑哈哈
GitHdu commentedon Aug 24, 2019
vue
版本不一样,楼主分析的是老版本,还没有这个函数Inakiz commentedon Nov 13, 2019
Mark
wolongfeitian commentedon Jan 30, 2020
为什么不设key,newCh和oldCh只会进行头尾两端的相互比较,这样怎么保证更新的dom正确呢?
xsfxtsxxr commentedon Jul 31, 2020
对于vnode.key的比较,会把oldVnode = null
请问这句话怎么理解啊?
uaoin commentedon Sep 15, 2022
newStartIdx
和newEndIdx
之间新增的节点为什么要插入在newEndIdx
之后呢?不应该是插入到它们之间?
仔细想了想 确实应该插入到newEndIdx之后,因为循环结束后 包括newStartIdx和newEndIdx都是新增的节点
RED523 commentedon Jan 13, 2024
看了评论有很多人提出来这个问题,其实是尤雨溪在解决 issues/6502 的时候添加的。文章作者写的时候这个问题还存在。