一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 8 天,点击查看活动详情。
Virtual DOM
背景信息总结
DOM操作非常耗时耗性能(且现代前端框架要求不手动操作DOM,可以大大提高开发效率)---- 因为会引起页面的回流或重绘JS的执行很快(浏览器 V8 引擎的出现更加加快了JS的计算速度)- 实现更好的跨平台(如浏览器端渲染、 Node.js 实现 SSR 服务端渲染、安卓/IOS、小程序等)
推出 ==> Virtual DOM(虚拟 DOM)
将真实DOM抽象成一个以JS对象为节点的虚拟DOM树,DOM的变化过程中不需要操作真实DOM,只需要操作JS 对象(虚拟DOM),再与真实DOM比较差异,最后只对变化的DOM进行操作来更新真实DOM,减少了DOM操作,大大提升了性能。
VDOM(Virtual DOM 简写)是什么?
VDOM 是个啥?:用 JS 对象模拟的 DOM 结构(树形结构)
一个用JS对象表示DOM结构的简单例子:
<!-- DOM 结构 -->
<div id="cc" class="bigCc">
<p>DOM</p>
<ul style="font-size: 24px">
<li>huohuoit</li>
</ul>
</div>
<!-- JS 对象表示 -->
{
tag: 'div',
data: {
id: 'cc',
className: 'bigCc'
},
children: [
{
tag: 'p'
text: 'DOM'
},
{
tag: 'ul'
data: { style: 'font-size: 20px' }
children: [
{
tag: 'li',
text: 'huohuoit'
}
]
}
]
}
它的优势:
- 抽象了原本的渲染过程,实现了跨平台的能力
diff算法,减少JS操作真实DOM的带来的性能消耗
实现原理
用
JS对象 模拟DOM结构,然后比较出差异对象,再去操作DOM
来看看整体的流程:
- 虚拟
DOM发生变化时,比较两颗DOM树的差异,生成差异对象 - 根据差异对象更新真实
DOM - 把虚拟
DOM转换成真实DOM插入页面中 - 用
JS对象模拟真实DOM结构
Vue 对 VDOM 的源码实现
1、描述
关注:使用 VNode 这个 Class 来描述 VDOM(VNode 由 Vue.js 的 _createElement 方法创建)
这里先关注一些主要描述点:
elm:VNode对应的真实DOM节点key:VNode标记,diff过程可以提高效率data:VNode上的class/attribute/style/绑定事件等数据children:VNode的子节点text:文本属性tag:VNode的标签属性
再看看VNode的创建过程:
- 初始化
Vue:new Vue( ),调用this.\_init方法 Vue实例挂载:通过$mount方法挂载DOM=> 调用mountComponent方法(实例化一个渲染Watcher,并回调updateComponent方法) => 调用mountComponent方法(实例化一个渲染Watcher,并回调updateComponent方法)在updateComponent方法中调用vm.\_render方法先生成VNode,最终调用vm.\_update更新DOM- 创建
VNode:使用\_render方法(\_render方法把实例渲染成VNode,这里调用了\_createElement方法(即h函数))
2、使用h函数创建Virtual DOM Tree
3、diff算法(性能关键):比较新旧Virtual DOM Tree找出差异并更新
4、createElm函数将VNode转化为真实DOM
Diff 算法
简述理解
理解:比对新旧VDOM的变化,然后将变化的部分更新到视图(真实DOM)上。 一边比较新旧 VDOM,一边改变真实 Dom 节点
diff 的比较方式
比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较(时间复杂度 O(n))
diff 流程理解(Vue2.0 源码)
1、开始
数据发生改变时,被订阅者的
setter会调用Dep.notify通知所有订阅者Watcher,订阅者调用patch给真实DOM打补丁,更新视图
使用patch (oldVNode,VNode) 比较新旧节点差异:
function patch(oldVnode, vnode) {
// ...
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode);
} else {
const oEl = oldVnode.el; // 当前 oldVnode 对应的真实元素节点
let parentEle = api.parentNode(oEl); // 父元素
createEle(vnode); // 根据 Vnode 生成新元素
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)); // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el); // 移除以前的旧元素节点
oldVnode = null;
}
}
// ...
return vnode;
}
如何比较呢?
// 比较函数
function sameVnode(a, b) {
return (
a.key === b.key && // key 值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了 data,data 包含一些具体信息,例如 onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type 必须相同
);
}
针对比较出的结果分别做处理:
- 不同,则用新
VNode更新真实DOM,并替换旧节点oldVNode(如果子节点不一样就说明Vnode完全被改变了,就可以直接替换oldVnode) - 相同,则继续比较子节点/文本节点等情况,做出相应的处理
function patchVnode(oldVnode, vnode) {
// 当前 oldVnode 和 Vnode 对应的真实 DOM 节点
const el = (vnode.el = oldVnode.el);
let i,
oldCh = oldVnode.children,
ch = vnode.children;
// 判断 Vnode 和 oldVnode 是否指向同一个对象,如果是,那么直接 return
if (oldVnode === vnode) return;
// 都有文本节点且不相等,那么将 el 的文本节点设置为 Vnode 的文本节点
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 函数比较子节点
updateChildren(el, oldCh, ch);
} else if (ch) {
// oldVnode 没有子节点而 Vnode 有,则将 Vnode 的子节点真实化之后添加到 el
createEle(vnode);
} else if (oldCh) {
// oldVnode 有子节点而 Vnode 没有,则删除 el 的子节点
api.removeChildren(el);
}
}
}
2、updateChildren 函数(diff 算法核心)
注意到updateChildren函数 ,来看看它做了什么
它是怎么做的?
遍历Vnode的子节点vCh(源码中为参数newCh)和oldVnode的子节点oldCh,进行比较和更新
先来看看相关源码(Vue2.0)
// Vue2.0 diff 算法核心的理解
function updateChildren(
parentElm,
oldCh,
newCh,
insertedVnodeQueue,
removeOnly
) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1; // 以下简化一下
let oldStartVnode = oldCh[0]; // oldS:子节点 oldCh 的头部指针
let oldEndVnode = oldCh[oldEndIdx]; // oldE:子节点 oldCh 的尾部指针
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0]; // S:子节点 vCh 的头部指针
let newEndVnode = newCh[newEndIdx]; // E:子节点 vCh 的尾部指针
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;
if (process.env.NODE_ENV !== "production") {
checkDuplicateKeys(newCh);
}
// 遍历 Vnode 的子节点 vCh(这里记为 newCh 方便理解比较) 和 oldVnode 的子节点 oldCh,进行比较和更新
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 循环条件:oldS <= oldE && S <= E
// 1、检查 oldS oldE 非空
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // oldS 向左移动
} else if (isUndef(oldEndVnode)) {
// oldE 向右移动
oldEndVnode = oldCh[--oldEndIdx];
}
// 2、两两比较 oldS、oldE、S、E(4种情况)
// 2.1 oldS = S
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
); // 执行 patch,不需要移动 dom
oldStartVnode = oldCh[++oldStartIdx]; // 匹配上的两个指针都向中间移动(向左)
newStartVnode = newCh[++newStartIdx];
}
// 2.2 oldE = E
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
); // 执行 patch,不需要移动 dom
oldEndVnode = oldCh[--oldEndIdx]; // 匹配上的两个指针向中间移动(向右)
newEndVnode = newCh[--newEndIdx];
}
// 2.3 oldS = E
else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(
oldStartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
// 把获得更新后的 (oldS/E) 的 DOM 右移,移动到oldE 对应的 DOM 的右边
// 注意一下这里的 DOM 移动
// 2.3.1 oldS = E,显然是 vnode 右移了
// 2.3.2 第一轮 while 循环:移到 oldE(oldEndVnode.elm)右边就是最右边
// 2.3.3 非第一轮 while 循环:比较过程是两头向中间移动,两头比较过的位置(也即真实DOM位置)是正确的,这次 DOM 是移动到 oldE(oldEndVnode.elm)右边
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx]; // 匹配上的两个指针向中间移动
newEndVnode = newCh[--newEndIdx];
}
// 2.4 oldE = S
else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
// 同上,左移更新后的 DOM
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
// 3、上面四种情况都不存在,通过 key 值查找对应 vnode 进行对比
else {
// 3.1 oldKeyToIdx不存在,创建 oldCh 中 vnode 的 key 到 index 的映射(即根据oldCh的key生成一张hash表),方便我们之后通过 key 去拿下标
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
// 3.2 拿到下标,用 S 的 key 与 hash表做匹配
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 3.2.1 下标不存在(匹配失败),说明 S(newStartVnode) 是原来没有(新)的 vnode
if (isUndef(idxInOld)) {
// 为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
}
// 3.2.2 下标存在(匹配成功),说明 oldCh 中有相同 key 的 vnode
else {
vnodeToMove = oldCh[idxInOld];
// (1) type 相同(且key相同),说明是相同的 vnode,执行 patch
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(
vnodeToMove,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldCh[idxInOld] = undefined; // 被匹配oldCh中的节点置为 undefined
canMove &&
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm); // 直接将S生成新的节点插入真实DOM
} else {
// (2) type 不同(且key相同),作为新元素处理。为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
// 比较的过程中,指针会往中间靠,直到达到匹配完成条件(即退出循环条件)oldS <= oldE && S <= E
if (oldStartIdx > oldEndIdx) {
// oldS > oldE:oldCh 先遍历完
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
refElm,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
); // 将多余的 vCh 节点根据 index 添加到 DOM 中去
} else if (newStartIdx > newEndIdx) {
// S > E:vCh 先遍历完
removeVnodes(oldCh, oldStartIdx, oldEndIdx); // 在真实 DOM 中将未遍历(区间为[oldS, oldE])的多余节点删掉
}
}
详细的比较更新过程
代码有点多,我们一步步来理解它...
首先在 updateChildren 函数的开始定义了一些辅助变量,这里我将几个重点变量名简化表示:
oldCh:旧虚拟DOM的子节点vCh:新虚拟DOM的子节点oldS:子节点oldCh的头部指针oldE:子节点oldCh的尾部指针S:子节点vCh的头部指针E:子节点vCh的尾部指针
我们通过上面的头尾指针遍历Vnode的子节点vCh(源码中为newCh) 和oldVnode的子节点oldCh,进行两两比较和更新
这里头尾指针一共有4 种比较匹配方式(只要其中两个能匹配上,真实 Dom 中的相应节点就移到 Vnode 相应的位置)
第一、二种:头尾对应匹配(执行 patch,不需要移动 dom)
oldS=S:匹配上的两个指针向中间移动oldE =E:匹配上的两个指针向中间移动
第三、四种:头尾交叉匹配
oldS=E:把获得更新后的 (oldS/E) 的DOM右移,移动到oldE对应的DOM的右边。匹配上的两个指针向中间移动oldE=S:同上,左移更新后的DOM
这里关于DOM移动注意点(oldS = E 为例)
oldS=E,显然是vnode右移了- 第一轮
while循环:移到oldE(oldEndVnode.elm)右边就是最右边 - 非第一轮
while循环:比较过程是两头向中间移动,两头比较过的位置(也即真实DOM位置)是正确的,这次DOM是移动到oldE(oldEndVnode.elm)右边
比较的过程中,指针会往中间靠,直到达到匹配完成条件(退出循环)
oldS>oldE:oldCh先遍历完,将多余的vCh节点根据index添加到DOM中去S>E:vCh先遍历完,在真实DOM中将未遍历(区间为[oldS, oldE])的多余节点删掉
如果不匹配以上 4 种比较方式,如果设置了 key,就会用 key 进行比较
情况一:oldCh 和 vCh 都存在 key
根据oldCh的key生成一张hash表,用S的key与hash表做匹配。匹配成功就判断S和匹配的旧节点是否为相同节点。同时被匹配oldCh中的节点置为undefined。
- 是相同节点,则直接将
S生成新的节点插入真实DOM, - 不是相同节点,则将
S生成对应的节点插入到DOM中对应的oldS的前面的位置(作新元素处理)
情况二:不存在 key
直接为S创建DOM节点 并插入到oldS的前面的位置
note1:为什么 v-for 的时候需要设置 key ?
如果没有key那么就只会做四种匹配,匹配不上就直接创建新节点插入DOM,这样指针中间有可复用的节点都不能被复用了
note2:Vue3.0 对这里的优化
如果都没有key,新的节点会到旧的children队列里通过samenode对比剩下的节点看看是否可以有重用的节点。不是直接插入