说明
当数据发生变化时,vue是如何更新页面的,是重新渲染一次页面,还是只需局部更新区域,显然,前者的性能花销太大了,后者更符合我们的需求。那么问题来了,vue是怎么精确做到局部页面更新的呢?将回流和重绘降到最低的?最小化dom操作的?
需求
当数据name发生改变时,只需更新.name的dom元素的文本节点,不影响其他元素,实现高效的复用dom元素。
<div id="app">
你好
<p>
<span>{{name}}</span>
</p>
</div>
<script>
const vm = new Vue({
el: '#app',
data(){
return{
name: 'lily',
}
},
})
setTimeout(() => {
vm.name = 'jusco';
}, 3000);
</script>
diff算法
定义
diff算法主要用来对两个虚拟树进行对比,并且是同层级的对比,不会跨层级比较。目的是获知哪些dom可以复用,哪些dom需要移除,尽可能的复用已有元素,而不是每次改变数据都要重新创建一个新的元素。
原理
diff比较方式
首先要知道diff在比对两个虚拟树的时候,只会同层级的比较。例如左边的A只会和右边的A进行比较,绝对不会和右边的B进行比较。
步骤
- 如果两个虚拟节点的标签不一致,那就直接使用新虚拟节点替换掉旧节点;
- 如果两个虚拟节点的标签一样,但是是两个文本元素,那就使用新虚拟节点的文本替换掉旧虚拟节点的文本;
- 如果不是前两种情况的话,那说明这是两个相同元素并且不是文本元素,那就复用原节点,更新节点属性,更新儿子节点; 更新儿子节点
- 如果旧虚拟节点有儿子,新虚拟节点没有儿子,则删除旧节点的儿子;
- 如果旧虚拟节点没有儿子,新虚拟节点有儿子,则旧节点加上新虚拟节点的儿子;
- 旧虚拟节点和新虚拟节点都有儿子; 对于旧虚拟节点和新虚拟节点都有儿子的情况,会采取一种双指针比较的方法。举个例子:
原代码:
<div id="app">
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>
<li key="d">D</li>
</div>
更新代码:
<div id="app">
<li key="a">A</li>
<li key="c">C</li>
<li key="b">B</li>
<li key="d">D</li>
</div>
旧虚拟节点的头指针为oldStartIndex,尾指针为oldEndIndex;新虚拟节点的头指针为newStartIndex,尾指针为newEndIndex。
-
oldStartIndex指向的节点会和newStartIndex指向的节点进行比较,判断是否为相同节点(主要根据两个节点的标签名和key值是否都相同),如果是相同节点的话,就会复用旧节点,oldStartIndex指针和newStartIndex指针往后移动一位。
-
同样对B节点和C节点进行比较,但是这两不是相同节点;接着就会oldEndIndex指向的节点会和newEndIndex指向的节点进行比较,这两是相同节点,复用旧节点,oldEndIndex指针和newEndIndex指针往前移动一位。
-
再次对B节点和C节点进行比较,但是这两不是相同节点;接着对C节点和B节点进行比较,同样不相等;接着对oldStartIndex指向的节点会和newEndIndex指向的节点进行比较,节点相同,复用旧节点,将oldStartIndex指向的dom节点移动到oldEndIndex指向节点的dom元素后面,oldStartIndex往后移动一位,newEndIndex往前移动一位。
-
最后剩下的c节点和c节点比对,就是复用了。
-
加一种复杂情况的分析,旧虚拟节点有E节点,复用E节点,但是该E节点位置在oldStartIndex和oldEndIndex之间,就会将E节点对应的dom元素移动到oldStartIndex指向节点的dom元素前面,并且将E节点置空。
-
再加一种复杂情况的分析,旧虚拟节点中没有E节点,就会新创建E节点dom元素放到oldStartIndex指向节点的dom元素前面。
-
最后如果新虚拟节点如果比旧虚拟节点多的话,就会将剩余的节点插入到dom里面;如果新虚拟节点如果比旧虚拟节点少的话,也会进行删除dom。
文字描述可能不太清楚,最好还是看代码。
实现
将虚拟节点转变为真实节点时做两个虚拟节点的比较
Vue.prototype._update = function(vnode){
const vm = this;
// 第一次初始化,第二次走diff算法
const prevVnode = vm._vnode;
vm._vnode = vnode; // 保存上一次的虚拟节点
if(!prevVnode){
vm.$el = patch(vm.$el, vnode);
}else{
vm.$el = patch(prevVnode, vnode);
}
}
export function patch(oldVnode, vnode){
if(!oldVnode){ // 1、组件
return createElm(vnode);
}
const isRealElement = oldVnode.nodeType; // 如果有nodeType说明是一个dom元素
if(isRealElement){ // 2、初次渲染
const oldEle = oldVnode;
// 需要获取父节点,将当前节点的下一个元素作为参照参照物,之后删除老节点
const parentNode = oldEle.parentNode;
const el = createElm(vnode);
parentNode.insertBefore(el, oldEle.nextSibling);
parentNode.removeChild(oldEle);
console.log('虚拟dom渲染出来的真实dom', el)
return el;
}else{ // 3、diff算法,两个虚拟节点比对
/**
* 1、如果两个虚拟节点的标签不一致,那就直接替换掉结束
* 2、标签一样,但是是两个文本元素
*/
if(oldVnode.tag !== vnode.tag){
return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
}
if(!oldVnode.tag){
if(oldVnode.text !== vnode.text){
return oldVnode.el.textContent = vnode.text;
}
}
/**
* 3、元素相同,复用老节点,并且更新属性
*/
let el = vnode.el = oldVnode.el;
updateProperties(vnode, oldVnode.data);
/* 4、更新儿子
* 1、老的有儿子 新的也有儿子 dom-diff
* 2、老的有儿子 新的没儿子 =》 删除老儿子
* 3、新的有儿子 老的没儿子 =》老节点增加儿子
*/
let oldChildren = oldVnode.children || [];
let newChildren = vnode.children || [];
if(oldChildren.length > 0 && newChildren.length > 0){
updateChildren(el, oldChildren, newChildren);
}else if(oldChildren.length > 0){
el.innerHTML = '';
}else if(newChildren.length > 0){
newChildren.forEach(child => el.appendChild(createElm(child)))
}
}
}
整个diff算法过程都是先考虑处理简单的场景,最后再处理复杂场景,这也是diff的一个优化方式吧。vue是通过标签名与key值来确定两个元素是否为相同节点的,在v-for添加key值也是为了更好地复用节点。同时应避免避免使用index下标作为key值,因为这和没设置key值是同一个效果,看代码吧不想写了。
function updateChildren(parent, oldChildren, newChildren){
let oldStartIndex = 0; // 老的头索引
let oldEndIndex = oldChildren.length - 1; // 老的尾索引
let oldStartVnode = oldChildren[oldStartIndex]; // 老的开始节点
let oldEndVnode = oldChildren[oldEndIndex]; // 老的结束节点
let newStartIndex = 0; // 新的头索引
let newEndIndex = newChildren.length - 1; // 新的尾索引
let newStartVnode = newChildren[newStartIndex]; // 新的开始节点
let newEndVnode = newChildren[newEndIndex]; // 新的结束节点
function makeIndexByKey(oldChildren){
let map = {};
oldChildren.forEach((item, index) => {
map[item.key] = index;
})
return map;
}
let map = makeIndexByKey(oldChildren);
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){
// 常见操作:尾部插入、头部插入、正序、反序
if(!oldStartVnode){
oldStartVnode = oldChildren[++oldStartIndex];
}else if(!oldEndVnode){
oldEndVnode = oldChildren[--oldEndIndex];
}else if(isSameVnode(oldStartVnode, newStartVnode)){ // 1)头头比较 向后插入的操作
patch(oldStartVnode, newStartVnode); // 递归比对节点
oldStartVnode = oldChildren[++oldStartIndex];
newStartVnode = newChildren[++newStartIndex];
}else if(isSameVnode(oldEndVnode, newEndVnode)){ // 2) 尾尾比较 向前插入的操作
patch(oldEndVnode, newEndVnode);
oldEndVnode = oldChildren[--oldEndIndex];
newEndVnode = newChildren[--newEndIndex];
}else if(isSameVnode(oldStartVnode, newEndVnode)){ // 3) 头尾比较 头移动到尾部
patch(oldStartVnode, newEndVnode);
parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
oldStartVnode = oldChildren[++oldStartIndex];
newEndVnode = newChildren[--newEndIndex];
}else if(isSameVnode(oldEndVnode, newStartVnode)){ // 4) 尾头比较 尾移动到头部
patch(oldEndVnode, newStartVnode);
parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
oldEndVnode = oldChildren[--oldEndIndex];
newStartVnode = newChildren[++newStartIndex];
}else{ // 5) 最终比较方法
let moveIndex = map[newStartVnode.key];
if(!moveIndex){
parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
}else{
let moveVnode = oldChildren[moveIndex];
oldChildren[moveIndex] = undefined;
patch(moveVnode, newStartVnode);
parent.insertBefore(moveVnode.el, oldStartVnode.el);
}
newStartVnode = newChildren[++newStartIndex];
}
}
if(newStartIndex <= newEndIndex){ // 新的比老的多,插入新节点
for(let i=newStartIndex; i<=newEndIndex; i++){
// 向前插入 向后插入
let nextEle = newChildren[newEndIndex+1] == null ? null : newChildren[newEndIndex+1].el;
// 如果newEndIndex的下一个元素是空的话,那就是尾部插入,反之,则是头部插入
parent.insertBefore(createElm(newChildren[i]), nextEle);
}
}
if(oldStartIndex <= oldEndIndex){ // 删除老的后面多余的节点
for(let i=oldStartIndex; i<=oldEndIndex; i++){
let child = oldChildren[i];
if(child != undefined){
parent.removeChild(child.el);
}
}
}
}
export function isSameVnode(oldVnode, newVnode){
return (oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key);
}