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,全部重新创建。相关源码如下
    image
  • 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了。如下图所示

image


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)。

posted @ 2024-01-05 09:56  re大法好  阅读(20)  评论(0编辑  收藏  举报