Vue源码探秘之 虚拟DOM和diff算法
请
扪心自问
:
你到底懂不懂虚拟
DOM
和
diff
算法??

先简单介绍一下虚拟
DOM
和
diff
算法
一、虚拟dom是什么
1.它是一个Object对象模型,用来模拟真实dom节点的结构
(虚拟dom其实是里面内存型对象(js内存对象) 属于内存数据 真实dom的一层映射)
2.提供一种方便的工具,使得开发效率得到保证
3.保证最小化的DOM操作,使得执行效率得到保证
二、虚拟dom的使用基本流程(前四步骤)
1.获取数据
2.创建vdom
3.将vdom渲染成真实dom
4.数据更改了
5.使用diff算法比对两次vdom,将之前的虚拟dom树结合新的数据生成一颗新的虚拟dom树
6.根据key将patch对象渲染到页面中改变的结构上,而其他没有改变的地方是不做任何修改的( 虚拟dom的惰性原则 )
三、diff算法是什么
用来做比对两次vdom结构
注意:vue是一个mvvm框架,Vue高性能的原因之一就是vdom
新虚拟
DOM
和老虚拟
DOM
进行
diff
(精细化比较),算出应该如何最小量更新,最后反映到真正的
DOM
上。
•
snabbdom
是瑞典语单词,单词原意“速度”;
•
snabbdom
是著名的虚拟
DOM
库,是
diff
算法的鼻祖,
Vue
源码借鉴了
snabbdom
;
•
官方
git
:
https://github.com/snabbdom/snabbdom

虚拟
DOM
:用
JavaScript
对象 描述DOM
的层次结构。
DOM 中的一切属性都在虚拟DOM
中 有对应的属性。
新虚拟
DOM
和老虚拟
DOM
进行
diff
(精细化比较),算出应该如何最小量更新,最后反映到真正的
DOM
上。
h
函数用来产生虚拟节点
比如这样调用h函数
h('a', { props: { href: 'http://www.baidu.com' }}, '百度');
将得到这样的虚拟节点:
{
"sel": "a",
"data": { props: { href: 'http://www.baidu.com' } },
"text": "百度"
}
它表示的真正的
DOM
节点:
<a href="http://www.baidu.com">百度</a>
一个虚拟节点有哪些属性?
{
children: undefined
data: {}
elm: undefined
key: undefined
sel: "div"
text: "我是一个盒子"
}
h
函数可以嵌套使用,从而得到虚拟
DOM
树(重要)
比如这样嵌套使用
h
函数:
h('ul', {}, [
h('li', {}, '牛奶'),
h('li', {}, '咖啡'),
h('li', {}, '可乐')
]);
将得到这样的虚拟
DOM
树:
{
"sel": "ul",
"data": {},
"children":
[
{ "sel": "li", "text": "牛奶" },
{ "sel": "li", "text": "咖啡" },
{ "sel": "li", "text": "可乐" }
]
}
感受diff算法的心得
•
最小量更新太厉害啦!真的是最小量更新!
当然,
key
很重要。
key
是这个节点的
唯一标识,告诉
diff
算法,在更改前后它们是同一个
DOM
节点。
•
只有是同一个虚拟节点,才进行精细化比较
,否则就是暴力删除旧的、插入新的。 延伸问题:如何定义是同一个虚拟节点?答:选择器相同且key
相同。
•
只进行同层比较,不会进行跨层比较。
即使是同一片虚拟节点,但是跨层了,对 不起,精细化比较不diff
你,而是暴力删除旧的、然后插入新的。

diff
并不是那么的“无微不至”啊!真的影响效率么??
答:上面
2
、
3
操作在实际
Vue
开发中,基本不会遇见,所以这是合理的优化
机制。
diff处理新旧节点不是同一个节点时

如何定义“同一个节点”这个事儿
旧节点的
key
要和新节点的
key
相同且 旧节点的选择器要和新节点的选择器相同

创建节点时,所有子节点需要递归创建的

diff处理新旧节点是同一个节点时

diff算法的子节点更新策略
四种命中查找:
① 新前与旧前
② 新后与旧后
③ 新后与旧前
(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧后之后)
④ 新前与旧后
(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧前之前)
命中一种就不再进行命中判断了
如果都没有命中,就需要用循环来寻找了。移动到
oldStartIdx
之前。
新增的情况


删除的情况

复杂的情况


更新函数韩信代码展示:
import patchVnode from './patchVnode.js';
import createElement from './createElement.js';
// 判断是否是同一个虚拟节点
function checkSameVnode(a, b) {
return a.sel == b.sel && a.key == b.key;
};
export default function updateChildren(parentElm, oldCh, newCh) {
console.log('我是updateChildren');
console.log(oldCh, newCh);
// 旧前
let oldStartIdx = 0;
// 新前
let newStartIdx = 0;
// 旧后
let oldEndIdx = oldCh.length - 1;
// 新后
let newEndIdx = newCh.length - 1;
// 旧前节点
let oldStartVnode = oldCh[0];
// 旧后节点
let oldEndVnode = oldCh[oldEndIdx];
// 新前节点
let newStartVnode = newCh[0];
// 新后节点
let newEndVnode = newCh[newEndIdx];
let keyMap = null;
// 开始大while了
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
console.log('★');
// 首先不是判断①②③④命中,而是要略过已经加undefined标记的东西
if (oldStartVnode == null || oldCh[oldStartIdx] == undefined) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null || oldCh[oldEndIdx] == undefined) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null || newCh[newStartIdx] == undefined) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null || newCh[newEndIdx] == undefined) {
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
// 新前和旧前
console.log('①新前和旧前命中');
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
// 新后和旧后
console.log('②新后和旧后命中');
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
// 新后和旧前
console.log('③新后和旧前命中');
patchVnode(oldStartVnode, newEndVnode);
// 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
// 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
// 新前和旧后
console.log('④新前和旧后命中');
patchVnode(oldEndVnode, newStartVnode);
// 当④新前和旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
// 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 四种命中都没有命中
// 制作keyMap一个映射对象,这样就不用每次都遍历老对象了。
if (!keyMap) {
keyMap = {};
// 从oldStartIdx开始,到oldEndIdx结束,创建keyMap映射对象
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key != undefined) {
keyMap[key] = i;
}
}
}
console.log(keyMap);
// 寻找当前这项(newStartIdx)这项在keyMap中的映射的位置序号
const idxInOld = keyMap[newStartVnode.key];
console.log(idxInOld);
if (idxInOld == undefined) {
// 判断,如果idxInOld是undefined表示它是全新的项
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefined,不是全新的项,而是要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示我已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动。
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx];
}
}
// 继续看看有没有剩余的。循环结束了start还是比old小
if (newStartIdx <= newEndIdx) {
console.log('new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStartIdx之前');
// 遍历新的newCh,添加到老的没有处理的之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
// newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('old还有剩余节点没有处理,要删除项');
// 批量删除oldStart和oldEnd指针之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
};
github源码地址:https://github.com/russ-gao/mysnabbdom