vue2-diff算法手写一步步增加功能
前面
vue2 diff算法虽然只有50行,但4个指针思想很难理解,因此便有了这篇文章。
我的想法一步步实现这个diff,然后发现问题,解决问题,慢慢向源码实现靠拢。
vue2 diff算法思想
- 尽可能复用原来的老节点
- 比如老节点头尾跟新节点头尾看看是不是同一个节点,是的话打个补丁, 没有找到可复用的就重新创建)。
vue patch工作流程简单版本
示例代码
<div class="app" @click="count++">{{count}}</div>
new Vue({
data(){
return {
count: 0
}
}
})
1. 首次$mount
- 首次$mount的时候, vue会根据模板生成对应的VNode
- 发现第一次渲染,直接根据vnode全量生成dom即可
// 第一次渲染的VNode
{
tag: "div",
attrs: {'class': 'app'},
children: [
{tag: undefined, text: 0}
]
}
2. count变化的时候
- 当count变化,会重新生成一次VNode
- 与上一份的VNode进行比较,发现不一样的就去修改对应的dom,这时候一个优秀的diff算法就很重要了,优秀的diff算法可以最小化减少dom性能的消耗。
// count变化时候渲染的VNode
{
tag: "div",
attrs: {'class': 'app'},
children: [
{tag: undefined, text: 1}
]
}
3. vue2 diff算法原则
- 以组件为一个整体,如果发现根标签不是同一个元素, 会销毁所有的dom,全部重新创建。相关源码如下
- diff算法是只同层比较, 深度优先。先创建完元素,然后最后再删除不需要的。
开始实现
1. 先实现一个h函数,专门生成VNode的。(类似于vue2的render函数)
function h(tag, attrs, children){
// h('div', {}, [h(undefined, {}, '111')])
if (tag){
const node = {
tag, attrs,
children
}
return node;
}
return {
tag,
attrs,
text: children
}
}
// <div>111</div>
h('div', {}, [h(undefined, {}, '111')])
- 这样就可以生成我们想要的Vnode了。如下图所示
2. 实现第一次渲染
第一次渲染根据vnode直接全量生成vnode即可。
// 这是dom
<div class="app">占位</div>
function h(tag, attrs, children){
// h('div', {}, [h(undefined, {}, '111')])
if (tag){
const node = {
tag, attrs,
children
}
return node;
}
return {
tag,
attrs,
text: children
}
}
function createElm(parentDom, vnode){
const {tag, attrs, children, text} = vnode;
let dom;
if (tag){
// 元素节点
dom = document.createElement(tag);
Object.keys(attrs).forEach(key=>{
dom.setAttribute(key, attrs[key]);
});
// 子元素
children && children.forEach(child=>createElm(dom, child));
}else{
// 文本节点
dom = document.createTextNode(text);
}
parentDom.appendChild(dom);
}
function patch(vnode){
const elm = document.querySelector('.app');
// dom挂载点
const container = elm.parentNode;
createElm(container, vnode);
// 删除占位的
elm.remove();
}
patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world')]));
3. patch vnode
如果数据变化了,vue会重新生成一个vnode,然后那这个vnode跟之前的老的vnode对比。在对比的过程中对dom进行修改。
// 实现目标
patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world')]));
patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world-changed')])); // dom修改为 hello world-changed
3.1. 直接暴力修改,删掉原来的,然后根据最新的vnode生成。
<div class="app">占位</div>
function h(tag, attrs, children){
// h('div', {}, [h(undefined, {}, '111')])
if (tag){
const node = {
tag, attrs,
children
}
return node;
}
return {
tag,
attrs,
text: children
}
}
function createElm(parentDom, vnode){
const {tag, attrs, children, text} = vnode;
let dom;
if (tag){
// 元素节点
dom = document.createElement(tag);
Object.keys(attrs).forEach(key=>{
dom.setAttribute(key, attrs[key]);
});
// 子元素
children && children.forEach(child=>createElm(dom, child));
}else{
// 文本节点
dom = document.createTextNode(text);
}
// 这里是为了后面diff的时候可以直接对该vnode进行dom操作。
vnode.elm = dom;
parentDom.appendChild(dom);
}
// 判断两个节点是否是一样的,因为每次的虚拟节点都是直接生成的,看着对象是一样的,但其实并不是一个对象。(对象引用)
function sameNode(a, b){
// 这里的策略是
return a.tag === b.tag && a.tag !== undefined;
}
function updateChildren(parentDom, oldCh, ch){
for (let i=0; i<ch.length; i++){
// 暴力根据新节点进行递归创建
createElm(parentDom, ch[i]);
}
// 删除所有的老节点
for (let i=0; i<oldCh.length; i++){
oldCh[i].elm.remove();
}
}
function patchVnode(old, vnode){
// 一样的,就不用对比了。
if (old === vnode) return;
// 本节点的处理,更新attrs,props,class之类的,这边先不实现此功能。
const ch = vnode.children;
const oldCh = old.children;
vnode.elm = old.elm;
const elm = vnode.elm;
if (ch && oldCh){
if (ch !== oldCh){
// 都有子节点,可以进行下一步diff操作,其他的情况下直接删除对应dom然后重新创建;
updateChildren(elm, oldCh, ch);
}
}
}
// 保存新老节点
let prev, current;
function patch(vnode){
if(!prev || !sameNode(prev, vnode)){
// 第一次挂载或者发现根节点的元素都不一样了,那就全量生成
prev = vnode;
const elm = document.querySelector('.app');
// dom挂载点
const container = elm.parentNode;
createElm(container, vnode);
elm.remove();
return;
}
const old = prev;
current = vnode;
patchVnode(old, current);
// patch完毕后,这里的vnode就成为老节点了,等待下一次新的vnode进行对比
prev = current;
}
patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world')]));
patch(h('div', {'class':'app'}, [h(undefined, {}, 'hello world -changed')]));
3.2. 优化-复用老节点
考虑如下情况
patch('div', {'class':'app'}, [h('a', {}, [h(undefined, {}, 'hello world')])]);
patch('div', {'class':'app'}, [h('a', {}, [h(undefined, {}, 'hello world-changed-a')])]);
/*
像这里的 a标签之前老节点是有的,因此不需要删除重建整个a标签.
我们只需要给a标签新建一个 文本节点 hello world-changed-a,然后 删除之前的文本节点 hello world 即可。
这样可比上面那种暴力删除a标签,然后重新创建a标签节省很多性能。
*/
具体实现
function createElm(parentDom, vnode, ref){
const {tag, attrs, children, text} = vnode;
let dom;
if (tag){
// 元素节点
dom = document.createElement(tag);
Object.keys(attrs).forEach(key=>{
dom.setAttribute(key, attrs[key]);
});
// 子元素递归创建
children && children.forEach(child=>createElm(dom, child));
}else{
// 文本节点
dom = document.createTextNode(text);
}
// 这里是为了后面diff的时候可以直接对该vnode进行dom操作。
vnode.elm = dom;
if (ref){
parentDom.insertBefore(dom, ref);
}else{
parentDom.appendChild(dom);
}
}
// 从老节点进行搜索,看看tag是否一样,是一样的tag,可以直接返回该老节点了。
function getOldIndex(oldCh, vnode){
for (let i=0; i<oldCh.length; i++){
if(oldCh[i]&&sameNode(oldCh[i], vnode)){
return i;
}
}
}
function updateChildren(parentDom, oldCh, ch){
// 这个值是为了确定从哪里位置开始删oldCh
let oldStart = 0;
// 这个值是确定从哪个位置增加元素
let newStart = 0;
let oldStartNode = oldCh[oldStart]; // oldCh与当前的dom是完全对应的。
for (let i=0; i<ch.length; i++){
// 遇到之前已经被复用过的节点了。那就跳到下一个呗。
// 为啥while循环,因为可能可能会出现连续undefined的情况。
while(oldStartNode === undefined){
oldStart++;
oldStartNode = oldCh[oldStart]
}
// 暴力根据新节点进行递归创建
const oldIndex = getOldIndex(oldCh, ch[i]);
if (oldIndex === undefined){
// 没有在老的vnode找到,只能重新创建该节点了。
createElm(parentDom, ch[i], oldStartNode.elm);
}else{
const vnodeToRemove = oldCh[oldIndex];
/*
patch('div', {'class':'app'}, [
h('a', {}, [h(undefined, {}, 'hello world')
]);
patch('div', {'class':'app'}, [
h('li', {}, [h(undefined, {}, 'lilili')]),
h('a', {}, [h(undefined, {}, 'hello world-changed-a')])
]);
当执行到 updateChildren(parentDom, [h('a'...)], [h('li'...), h('a')]) 时
发现 'li'标签在老的节点上找不到,所以重新创建了下li元素,不过这里需要注意的是,li元素应该从头部开始插入
因为都得按照最新的vnode的顺序来,第一次插入0号位置,第二次就是插入1号位置,以此类推
*/
// 把这个老 dom更新成最新的状态了
patchVnode(vnodeToRemove, ch[i]);
// 移动老dom位置挪到正确的位置。
parentDom.insertBefore(vnodeToRemove, oldStartNode.elm);
// 这个老dom不应该再次被复用了(下次遍历时候 getOldIndex 函数不会再次扫到该vnode)
oldCh[oldIndex] = undefined;
}
newStart++;
}
// 如果新的子节点大于旧的说明需要创建
/*
旧 [h(undefined, {}, '111'), h(undefined, {}, '222')]
新 [h(undefined, {}, '111-1'), h(undefined, {}, '2222'), h(undefined, {}, '3333')]
*/
const oldLength = oldCh.length;
const newLength = ch.length;
if (newLength>oldLength){
// 直接appendChild即可
for(let i=oldLength; i<newLength; i++){
createElm(parentDom, ch[i].elm);
}
}else{
// 旧的大于新的,那就要移除不需要的。
for(let i=newLength; i<oldLength; i++){
oldCh[i].elm.remove();
}
}
}
3.3. 增加循环停止条件
- 测试上面的代码, 会发现当新的vnode子节点小于老节点时候正常,但是当新的vnode子节点大于老节点时,会出现异常情况。
- 考虑如下情况
// 老节点
[h(undefined, {}, '111')]
// 新节点
[h(undefined, {}, '111'), h(undefined, {}, '222'), h(undefined, {}, '222')]
- 这种情况就应该看看 oldStartNode 是否已经超出 oldCh
- 不能通过undefined方式判断,因为patch的时候会将 oldch里的vnode置为 undefined
- 应该判断 oldStart是否大于等于老节点的长度即可。若大于,停止循环。
3.3.1 代码实现
// 从老节点进行搜索,看看tag是否一样,是一样的tag,可以直接返回该老节点了。
function getOldIndex(oldCh, vnode){
for (let i=0; i<oldCh.length; i++){
if(oldCh[i]&&sameNode(oldCh[i], vnode)){
return i;
}
}
}
function updateChildren(parentDom, oldCh, ch){
// 这个值是为了确定从哪里位置开始删oldCh
let oldStart = 0;
// 这个值是确定从哪个位置增加元素
let newStart = 0;
let oldStartNode = oldCh[oldStart]; // oldCh与当前的dom是完全对应的。
foo:for (let i=0; i<ch.length; i++){
// 遇到之前已经被复用过的节点了。那就跳到下一个呗。
// 为啥while循环,因为可能可能会出现连续undefined的情况。
while(oldStartNode === undefined){
oldStart++;
if (oldStart>=oldCh.length) break foo;
oldStartNode = oldCh[oldStart]
}
// 暴力根据新节点进行递归创建
const oldIndex = getOldIndex(oldCh, ch[i]);
if (oldIndex === undefined){
// 没有在老的vnode找到,只能重新创建该节点了。
createElm(parentDom, ch[i], oldStartNode.elm);
}else{
const vnodeToRemove = oldCh[oldIndex];
/*
patch('div', {'class':'app'}, [
h('a', {}, [h(undefined, {}, 'hello world')
]);
patch('div', {'class':'app'}, [
h('li', {}, [h(undefined, {}, 'lilili')]),
h('a', {}, [h(undefined, {}, 'hello world-changed-a')])
]);
当执行到 updateChildren(parentDom, [h('a'...)], [h('li'...), h('a')]) 时
发现 'li'标签在老的节点上找不到,所以重新创建了下li元素,不过这里需要注意的是,li元素应该从头部开始插入
因为都得按照最新的vnode的顺序来,第一次插入0号位置,第二次就是插入1号位置,以此类推
*/
// 把这个老 dom更新成最新的状态了
patchVnode(vnodeToRemove, ch[i]);
// 移动老dom位置挪到正确的位置。
parentDom.insertBefore(vnodeToRemove, oldStartNode.elm);
// 这个老dom不应该再次被复用了(下次遍历时候 getOldIndex 函数不会再次扫到该vnode)
oldCh[oldIndex] = undefined;
}
newStart++;
}
// oldStart 从哪里开始删, 要是等于oldCh的长度,那说明也不用删了
// newStart 从哪里开始增加,要是等于ch的长度,那就不用增加了
// 如果新的子节点大于旧的说明需要创建
/*
旧 [h(undefined, {}, '111'), h(undefined, {}, '222')]
新 [h(undefined, {}, '111-1'), h(undefined, {}, '2222'), h(undefined, {}, '3333')]
*/
for(let i=oldStart; i<oldCh.length; i++){
oldCh[i].elm.remove();
}
for (let i=newStart; i<ch.length; i++){
createElm(parentDom, ch[i].elm);
}
}
3.4 增加预判
- 上面的实现代码时间复杂度还是比较高的, 为 n^2。
- 这是因为遍历了 ch的新节点时,拿着当前的vnode在所有的老节点进行查找。运气好的会在老节点第一个vnode找到,运气差的话会遍历整个老节点。
如果我们在遍历新节点的时候先尝试看看以下方式
- 老节点第一个元素跟新节点第一个元素是否相同
- 老节点第一个元素跟新节点最后一个元素是否相同
- 老节点最后一个元素跟新节点第一个元素是否相同
- 老节点最后一个元素跟新节点最后一个元素是否相同
- 以上不符合才走遍历的方式(也就是我们上面的代码实现方式)
3.4.1 先实现第一个预判, 也就是老头与新头是否相同。
function updateChildren(parentDom, oldCh, ch){
let oldStart = 0;
let newStart = 0;
let oldStartNode = oldCh[oldStart];
foo:for (let i=0; i<ch.length; i++){
while(oldStartNode === undefined){
oldStart++;
if (oldStart>=oldCh.length) break foo;
oldStartNode = oldCh[oldStart]
}
if (sameNode(oldStartNode, ch[i])){
// patch下,因为都是同一个位置,因此无需移动dom
patchVnode(oldStartNode, ch[i]);
oldStart++;
if (oldStart>=oldCh.length) break foo;
oldStartNode = oldCh[oldStart];
}else{
const oldIndex = getOldIndex(oldCh, ch[i]);
if (oldIndex === undefined){
createElm(parentDom, ch[i], oldStartNode.elm);
}else{
const vnodeToRemove = oldCh[oldIndex];
patchVnode(vnodeToRemove, ch[i]);
parentDom.insertBefore(vnodeToRemove, oldStartNode.elm);
oldCh[oldIndex] = undefined;
}
}
newStart++;
}
for(let i=oldStart; i<oldCh.length; i++){
oldCh[i].elm.remove();
}
for (let i=newStart; i<ch.length; i++){
createElm(parentDom, ch[i].elm);
}
}
3.4.2 全部实现
function updateChildren(parentDom, oldCh, ch){
// 这个值是为了确定从哪里位置开始删oldCh
let oldStart = 0;
// 这个值是确定从哪个位置增加元素
let newStart = 0;
let oldStartNode = oldCh[oldStart]; // oldCh与当前的dom是完全对应的。
let oldEnd = oldCh.length - 1;
let oldEndNode = oldCh[oldEnd];
let newStartNode = ch[newStart];
let newEnd = ch.length - 1;
let newEndNode = ch[newEnd];
while(newStart<=newEnd && oldStart <=oldEnd){
// // 遇到之前已经被复用过的节点了。那就跳到下一个呗。
// // 为啥while循环,因为可能可能会出现连续undefined的情况。
// while(oldStartNode === undefined){
// oldStart++;
// if (oldStart>=oldCh.length) break foo;
// oldStartNode = oldCh[oldStart]
// }
if (oldStartNode === undefined){
oldStart++;
oldStartNode = oldCh[oldStart];
}
// 新头与老头对比,位置一样,不用变
else if (sameNode(newStartNode, oldStartNode)){
patchVnode(oldStartNode, newStartNode);
newStart++;
oldStart++;
oldStartNode = oldCh[oldStart];
newStartNode = ch[newStart];
}else if (sameNode(newEndNode, oldEndNode)){
// 新尾与老尾对比,位置一样,不用变
patchVnode(oldEndNode, newEndNode);
oldEnd --;
newEnd --;
oldEndNode = oldCh[oldEnd];
newEndNode = ch[newEnd];
}else if (sameNode(newStartNode, oldEndNode)){
// 新头与老尾对比,位置不同 左移
patchVnode(oldEndNode, newStartNode);
newStart ++;
oldEnd --;
newStartNode = ch[newStart];
oldEndNode = oldCh[oldEnd];
parentDom.insertBefore(newStartNode.elm, oldStartNode.elm)
}else if (sameNode(newEndNode, oldStartNode)){
// 右移
/*
[div-1, aa-1, bb-1, cc-1]
[div-1, div-2, aa-1]
*/
patchVnode(oldStartNode, newEndNode);
oldStart ++;
newEnd --;
newEndNode = ch[newEnd];
oldStart = oldCh[oldStart];
parentDom.insertBefore(newStartNode.elm, oldStartNode.elm.nextSibling)
}else{
const oldIndex = getOldIndex(oldCh, newStartNode);
if (oldIndex === undefined){
// 没有在老的vnode找到,只能重新创建该节点了。
createElm(parentDom, newStartNode, oldStartNode.elm);
}else{
const vnodeToRemove = oldCh[oldIndex];
/*
patch('div', {'class':'app'}, [
h('a', {}, [h(undefined, {}, 'hello world')
]);
patch('div', {'class':'app'}, [
h('li', {}, [h(undefined, {}, 'lilili')]),
h('a', {}, [h(undefined, {}, 'hello world-changed-a')])
]);
当执行到 updateChildren(parentDom, [h('a'...)], [h('li'...), h('a')]) 时
发现 'li'标签在老的节点上找不到,所以重新创建了下li元素,不过这里需要注意的是,li元素应该从头部开始插入
因为都得按照最新的vnode的顺序来,第一次插入0号位置,第二次就是插入1号位置,以此类推
*/
// 把这个老 dom更新成最新的状态了
patchVnode(vnodeToRemove,newStartNode);
// 移动老dom位置挪到正确的位置。
parentDom.insertBefore(vnodeToRemove, oldStartNode.elm);
// 这个老dom不应该再次被复用了(下次遍历时候 getOldIndex 函数不会再次扫到该vnode)
oldCh[oldIndex] = undefined;
}
newStart++;
newStartNode = ch[newStart];
}
}
// oldStart 从哪里开始删, 要是等于oldCh的长度,那说明也不用删了
// newStart 从哪里开始增加,要是等于ch的长度,那就不用增加了
// 如果新的子节点大于旧的说明需要创建
/*
旧 [h(undefined, {}, '111'), h(undefined, {}, '222')]
新 [h(undefined, {}, '111-1'), h(undefined, {}, '2222'), h(undefined, {}, '3333')]
*/
if (oldStart > oldEnd){
for (let i=newStart; i<=newEnd; i++){
createElm(parentDom, ch[i].elm);
}
}else if (newStart > newEnd){
// oldEnd 初始值为 oldCh.length 因此是 <=
for(let i=oldStart; i<=oldEnd; i++){
oldCh[i].elm.remove();
}
}
}
总结
- 这样的实现已经与vue2实现的diff算法差不多了
- v-for的key并没有实现哈。不过可以看到key的重要性(只获取一次并缓存起来, 下次查询key时间复杂度均为1)。