(四)手写 Vue2.0 源码 —— diff 算法原理

80 阅读5分钟

此篇是对渲染更新的优化,当模板发生变化之后,我们可以利用 diff 算法对比新旧 dom,看是否能节点复用

思考:

当我们初始渲染完成的 1 秒后,数据发生了变化, Vue 怎么处理显示最新的值呢?

  1. 把上次渲染的真实 dom 删除 然后重新渲染一个新的 dom 节点来应用最新的 a 的值
  2. 把老的 dom 进行复用 改变一下内部文本节点的 textContent 的值

很明显这里后者的性能开销最小,diff 算法就是采用的后者。

一、patch 核心渲染方法改写

  • oldVnode 是真实 dom ,就代表是初次渲染;
  • oldVnode 是虚拟 dom,就是更新过程,采用 diff 算法;
  • 如果新旧标签不一致 用新的替换旧的 oldVnode.el 代表的是真实 dom 节点--同级比较
新旧节点替换的 3 种情况:
  • 新旧标签不一致,则直接替换;
  • 新旧文本节点不一致,则将旧文本节点内容替换成新文本节点的 textContent 的值;
  • 如果标签一致,且不是文本节点,则进行节点复用,直接把旧的虚拟dom对应的真实dom赋值给新的虚拟dom的el属性

src/vdom/patch.js

image.png

image.png 我们直接看 else 分支 代表的是渲染更新过程 可以分为以下几步

1.diff 只进行同级比较

image.png

2.根据新老 vnode 子节点不同情况分别处理

image.png

二、 updateProperties 更新属性

  • 如果新的节点没有节点属性, 需要把老的节点属性移除;
  • 对style样式做特殊处理, 如果新的没有 需要把老的style值置为空;
  • 遍历新的属性, 进行增加操作;

src/vdom/patch.js

image.png 对比新老 vnode 进行属性更新

三、 updateChildren 更新子节点-diff 核心方法

  • 判断两个vnode的标签和key是否相同 如果相同 就可以认为是同一节点就地复用;
  • diff算法核心 采用双指针的方式 对比新老vnode的儿子节点;
  • 根据key来创建老的儿子的index映射表 类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置;

image.png

image.png

image.png

image.png

循环老节点,while函数
  • 只有当新老儿子的双指标的起始位置不大于结束位置的时候 才能循环 一方停止了就需要结束循环;
  • 因为暴力对比过程把移动的vnode置为 undefined 如果不存在vnode节点 直接跳过;
  • 头和头对比 依次向后追加;
  • 尾和尾对比 依次向前追加;
  • 老的头和新的尾相同 把老的头部移动到尾部;
  • 老的尾和新的头相同 把老的尾部移动到头部;
  • 上述四种情况都不满足 那么需要暴力对比;
  • 根据老的子节点的key和index的映射表 从新的开始子节点进行查找 如果可以找到就进行移动操作 如果找不到则直接进行插入;
// 只有当新老儿子的双指标的起始位置不大于结束位置的时候  才能循环 一方停止了就需要结束循环
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
	// 因为暴力对比过程把移动的vnode置为 undefined 如果不存在vnode节点 直接跳过
	if (!oldStartVnode) {
		oldStartVnode = oldCh[++oldStartIndex];
	} else if (!oldEndVnode) {
		oldEndVnode = oldCh[--oldEndIndex];
	} else if (isSameVnode(oldStartVnode, newStartVnode)) {
		// 头和头对比,依次向后追加
		patch(oldStartVnode, newStartVnode); // 递归比较他们的儿子及其子节点
		oldStartVnode = oldCh[++oldStartIndex];
		newStartVnode = newCh[++newStartIndex];
	} else if (isSameVnode(oldEndVnode, newEndVnode)) {
		// 尾和尾对比,依次向前追加
		patch(oldEndVnode, newEndVnode);
		oldEndVnode = oldCh[--oldEndIndex];
		newEndVnode = newCh[--newEndIndex];
	} else if (isSameVnode(oldStartVnode, newEndVnode)) {
		// 老的头和新的尾相同,则把老的头移动到尾部
		patch(oldStartVnode, newEndVnode);
		parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
		oldStartVnode = oldCh[++oldStartIndex];
		newEndVnode = newCh[--newEndIndex];
	} else if (isSameVnode(oldEndVnode, newStartVnode)) {
		// 老的尾和新的头相同,则把老的尾移动到头部
		patch(oldEndVnode, newStartVnode);
		parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
		oldEndVnode = oldCh[--oldEndIndex];
		newStartVnode = newCh[++newStartIndex];
	} else {
		// 以上情况都不符,则暴力对比
		// 根据老的子节点的key和index的映射表 从新的开始子节点进行查找 如果可以找到就进行移动操作 如果找不到则直接进行插入
		let moveIndex = map[newStartVnode.key];
		if (!moveIndex) { // 老节点找不到 直接插入
			parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
		} else { // 找得到就拿老的节点
			let moveVnode = oldCh[moveIndex];
			oldCh[moveIndex] = undefined;  //这个是占位操作 避免数组塌陷  防止老节点移动走了之后破坏了初始的映射表位置
			parent.insertBefore(moveVnode.el, oldStartVnode.el); //插在旧节点的最前面
			patch(moveVnode, newStartVnode);
		}
	}
}
  • 如果老节点循环完毕了 但是新节点还有 证明 新节点需要被添加到头部或者尾部;
  • 如果新节点循环完毕 老节点还有 证明老的节点需要直接被删除; image.png
对 updateChildren 函数的总结:

1.使用双指针移动来进行新老节点的对比 diff双指针.png

2.用 isSameVnode 来判断新老子节点的头头 尾尾 头尾 尾头 是否是同一节点 如果满足就进行相应的移动指针(头头 尾尾)或者移动 dom 节点(头尾 尾头)操作

3.如果全都不相等 进行暴力对比 如果找到了利用 key 和 index 的映射表来移动老的子节点到前面去 如果找不到就直接插入

diff暴力对比.png

4.对老的子节点进行递归 patch 处理

5.最后老的子节点有多的就删掉 新的子节点有多的就添加到相应的位置

总结一句就是,能复用就复用,能移动就移动,实在不行才选择新增或删除。

四、改造原型渲染更新方法 _update

  • 初次渲染 vm._vnode肯定不存在 要通过虚拟节点 渲染出真实的dom 赋值给$el属性;

  • 更新时把上次的vnode和这次更新的vnode穿进去 进行diff算法;

    src/lifecycle.js

image.png

改造_update 方法 在 Vue 实例的_vnode 保留上次的 vnode 节点 以供 patch 进行新老虚拟 dom 的对比

系列文章参考:

作者:前端鲨鱼哥
链接:juejin.cn/post/695343…
来源:稀土掘金