diff 基础
虚拟 dom 基础结构
// vnode.js
/**
* 产生虚拟节点
* 将传入的参数组合成对象返回
* @param {string} sel 选择器
* @param {object} data 属性、样式
* @param {Array} children 子元素
* @param {string|number} text 文本内容
* @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
* @returns
*/
// 将5个参数组合成对象返回
export default function (sel, data, children, text, elm) {
const key = data.key;
return { sel, data, children, text, elm, key };
}
// h.js
import vnode from "./vnode.js";
// 低配 h 函数,必须接收3个参数
// 类似重载
// h('div', {}, '文字')
// h('div', {}, [])
// h('div', {}, h())
export default function (sel, data, c) {
// 检查参数个数
if (arguments.length != 3) {
throw new Error('必须传入3个参数');
}
// 检查 c 的类型
if (typeof c === 'string' || typeof c === 'number') {
// 说明调用第一种
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 说明调用第二种
let children = [];
// 遍历 c,判断 c 的项是否合法
for (let i = 0; i < c.length; i++) {
if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) {
throw new Error('传入的数组参数某项不是h函数');
}
children.push(c[i]);
}
return vnode(sel, data, children, undefined, undefined);
} else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
// 说明调用第三种
//唯一的children
return vnode(sel, data, [c], undefined, undefined);
} else {
throw new Error('传入3个参数类型不对')
}
}
判断 oldVnode 是虚拟节点还是真实的 dom 节点
// 判断第一个参数是虚拟节点还是真实的 dom 节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
// 如果是真实的 dom 节点,则包装成虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}
判断同一个节点的规则
- 根据 key 和选择器 sel 唯一标识判断是否同一个节点
- 同一虚拟节点才进行精细比较
- 跨级不比较,直接删除,插入新的
// 判断 oldVnode 和 newVnode 是否为同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log('同一个节点');
patchVnode(oldVnode, newVnode);
} else {
console.log('不是同一个节点先插入,再删');
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
}
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
diff 算法流程
- 判断新节点是不是文本节点,是:则直接替换旧的节点;否:下一阶段;
- 判断旧节点是不是文本节点,是:则用新节点的 children 直接替换;否:下一阶段;
- 新旧节点都有 children,则进行子节点判断;
patchVnode 函数
import createElement from "./createElement";
import updateChildren from "./updateChildren";
export default function patchVnode (oldVnode, newVnode) {
// 判断新旧 vnode 是否是同一个对象
if (oldVnode === newVnode) {
return;
}
// 判断新 vnode 有没有 text 属性
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
console.log('新vnode有text属性');
if (newVnode.text !== oldVnode.text) {
// 如果新旧虚拟节点 text 不同,直接将新的 text 值写入旧的 elm中,即使旧的节点是 children 也会被覆盖
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性,有children
console.log('新vnode没有text属性');
// 判断旧的有没有 children
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 旧的有 children,新旧都有 children 情况最复杂
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
} else {
// 旧的没有,新的有
// 清空老节点内容
oldVnode.elm.innerHTML = '';
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i]);
oldVnode.elm.appendChild(dom);
}
}
}
}
createElement 函数
// 真正创建节点,创建真正的 dom,是孤儿节点,暂不进行插入
export default function createElement (vnode) {
// console.log('目的把虚拟节点插入', vnode, '变为真正的 dom 但不插入');
// 创建一个 dom 节点,此节点目前还是孤儿节点
let domNode = document.createElement(vnode.sel);
// 判断是子节点还是文本
if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
// 是文本
domNode.innerText = vnode.text;
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
// 内部是子节点,要递归创建子节点
for (let i = 0; i < vnode.children.length; i++) {
// 得到 children
let ch = vnode.children[i];
// 创建真实 dom
let chDom = createElement(ch);
// 将子节点添加进父节点
domNode.appendChild(chDom);
}
}
// 创建的真实 dom 对象指向新的 vnode 的 elm 属性
vnode.elm = domNode;
// 返回真实 dom 对象
return vnode.elm;
}
updateChildren() 函数
import createElement from "./createElement";
import patchVnode from "./patchVnode";
// 判断是否为同一个虚拟节点
function checkSameVnode (a, b) {
return a.sel === b.sel && a.key === b.key;
}
export default function updateChildren (parentElm, oldCh, newCh) {
console.log(oldCh, newCh)
// 旧前
let oldStartIdx = 0;
// 旧后
let oldEndIdx = oldCh.length - 1;
// 新前
let newStartIdx = 0;
// 新后
let newEndIdx = newCh.length - 1;
// 旧前节点
let oldStartVnode = oldCh[0];
// 旧后节点
let oldEndVnode = oldCh[oldEndIdx];
// 新前节点
let newStartVnode = newCh[0];
// 新后节点
let newEndVnode = newCh[newEndIdx];
let keyMap = null;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先应该不是判断四种命中,而是略过已经加了undefined标记的项
if (oldStartVnode === null || oldCh[oldStartIdx] === undefined) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode === null || oldCh[oldEndIdx] === undefined) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode === null || newCh[newStartIdx] === undefined) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode === null || newCh[newEndIdx] === undefined) {
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('一 旧前新前');
// 旧前新前
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('二 旧后新后');
// 旧后新后
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('三 旧前新后');
// 旧前新后
patchVnode(oldStartVnode, newEndVnode);
// 此种情况命中后,要移动真实 dom,将 新后 指向节点移动到 旧后 指向节点后面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('四 旧后新前');
// 旧后新前
patchVnode(oldEndVnode, newStartVnode);
// 此种情况命中后,要移动真实 dom,将 新前(旧后) 指向节点移动到 旧前 指向节点前面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 4种都没有命中
if (!keyMap) {
keyMap = {};
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key !== undefined) {
keyMap[key] = i
}
}
}
// 寻找 newStartIdx 这项在 keyMap 中的映射位置虚序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld === undefined) {
// 则表示是全新的项
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefind,则要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为 undefind,表示已处理
oldCh[idxInOld] = undefined;
// 移动,调用 insertBefore实现移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx];
}
}
// 继续处理剩余节点
if (newStartIdx <= newEndIdx) {
console.log('newCh 剩余节点没有处理完');
// 遍历新的newnewCh,添加到老的未处理之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore 方法可以自动识别 null,如果是 null 自动插入队尾
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('oldCh 剩余节点没有处理完');
// 批量删除 oldStart 和 oldEnd 之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
}
vue2
2和3的主要区别从 updateChildren 开始,在对比同一层子节点做了优化,vue2的判断规则是(旧新):头头、尾尾、前后、后前。指针名称:oldStart、oldEnd、newStart、newEnd
前前
- 比较旧前 oldStart 与新前 newStart;若命中,patch之后就移动头指针 ++oldStart、++newStart
if (checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('一 旧前新前');
// 旧前新前
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
后后
- 比较旧后 oldEnd 与新后 newEnd;若命中,patch之后就移动头指针 --oldEnd、--newEnd
if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('二 旧后新后');
// 旧后新后
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
前后
- 比较旧前 oldStrat 与新后 newEnd;若命中,将新后 newEnd 指向的节点移动到 旧后 oldEnd 指向节点之后,移动指针 --oldStart、++newEnd
if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('三 旧前新后');
// 旧前新后
patchVnode(oldStartVnode, newEndVnode);
// 此种情况命中后,要移动真实 dom,将 新后 指向节点移动到 旧后 指向节点后面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
后前
- 比较旧后 oldEnd 与新前 newStart;若命中,新前 newStart 指向的节点,移动到旧前 oldStart 之前,移动指针 --oldEnd、++newStart
if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('四 旧后新前');
// 旧后新前
patchVnode(oldEndVnode, newStartVnode);
// 此种情况命中后,要移动真实 dom,将 新前(旧后) 指向节点移动到 旧前 指向节点前面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
旧元素做 Map
- 前四种判断都没命种,则将
旧元素
做成 Map 结构,用 Map 判断当前 newStartVnode 是移动还是新增
if (!keyMap) {
keyMap = {};
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key !== undefined) {
keyMap[key] = i
}
}
}
// 寻找 newStartIdx 这项在 keyMap 中的映射位置虚序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld === undefined) {
// 则表示是全新的项
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefind,则要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为 undefind,表示已处理
oldCh[idxInOld] = undefined;
// 移动,调用 insertBefore实现移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx];
处理剩余节点
旧节点列表
可能有剩余节点:oldStartIdx <= oldEndIdx
if (oldStartIdx <= oldEndIdx) {
console.log('oldCh 剩余节点没有处理完');
// 批量删除 oldStart 和 oldEnd 之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
新节点列表
可能有剩余节点:newStartIdx <= newEndIdx
if (newStartIdx <= newEndIdx) {
console.log('newCh 剩余节点没有处理完');
// 遍历新的newnewCh,添加到老的未处理之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore 方法可以自动识别 null,如果是 null 自动插入队尾
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
}
涉及函数
Node.insertBefore()
var insertedNode = parentNode.insertBefore(newNode, referenceNode);
insertNode
:用于插入的节点(newNode)
parentNode
:新插入节点的父节点
newNode
:用于插入的节点
referenceNode
:newNode
将要插在这个节点之前,如果此节点为 null,则直接插入最后
如果插入的节点已存在于父节点,则移动此节点到指定位置
Node.appendChild()
parentNode.appendChild(childNode)
parentNode
:容器节点
childNode
:要插入节点
如果插入的节点已存在于父节点,则移动此节点到最后
Node.removeChild()
let delNode = node.removeChild(child)
delNode
:被删除节点
child
:要删除节点
vue3
原理
因为 vue 是同一层级对比,以数组对比为例: 旧:[a, b, c, d, e, f, g];新:[a, b, e, c, d, h, f, g]
基本步骤
- 首先对比头部,相同的则复用节点,并以指针记录。上述数组 a、b 可以复用,指针指向第三个不同的元素,记录下标为 3;
- 对比尾部,因为数组长度不一致需要双指针记录,f、g 可以复用,同理,旧元素的指针记录下标为 4,新元素为 5;
- 对比完头尾后,会有3种情况出现
3.1 老元素没有节点,新元素还有节点,要新增
节点;
3.2 老元素还有节点,新元素没有节点,要删除
节点;
3.3 新老节点都有节点,如上述,旧:[c, d, e];新:[e, c, d, h] - 处理 3.3 情况是 vue3 diff的核心
4.1 先是用 key-value 的数据结构保存新元素,在旧元素中找到可复用的旧元素(用pathc打补丁);
4.2 同时用一个数组对需要移动的元素做标记,标记数组的下标是新元素的相对下标:[e, c, d, h] 从0开始,值是老元素下标+1,e在老元素下标为4,即5,所以标记数组为[5,3,4,0];
4.3 然后用标记数组得到最长递增子序列
路径数组,最后进行移动和新增
代码解释
以下代码是伪代码
,获取最长子序列的算法还没弄懂
// vdom 虚拟dom
// old 老节点
// new 新节点
// old array [a, b, c, d, e, f, g]
// new array [a, b, e, c. d, h, f, g]
// 节点结构 node = { key: a }
// mountElement 新增元素
// patch 复用元素 a b c d e f g 注:源码无论 新增 还是 复用 元素都是用 patch
// unmount 删除元素
// todo
// move 元素移动
function diffArray(c1, c2, { mountElement, patch, unmount, move }) {
function isSameVnodeType(n1, n2) {
return n1.key === n2.key; // && n1.type === n2.type
}
let i = 0; // 头部记录指针
const l1 = c1.length;
const l2 = c2.length;
let e1 = l1 - 1; // 新元素尾部记录指针
let e2 = l2 - 1; // 旧元素尾部记录指针
// 1、从左边往右边遍历,如果节点可以复用就继续 go,反之停止
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVnodeType(n1, n2)) {
patch(n1.key);
} else {
break;
}
i++;
}
// 2、从右边往左边遍历,如果节点可以复用就继续 go,反之停止
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVnodeType(n1, n2)) {
patch(n1.key);
} else {
break;
}
e1--;
e2--;
}
// 3.1、老节点没了,新节点还有
if (i > e1) {
while (i <= e2) {
const n2 = c2[i];
mountElement(n2.key);
i++;
}
}
// 3.2、老节点还有,新节点没了
else if (i > e2) {
while (i <= e1) {
const n1 = c1[i];
unmount(n1.key);
i++;
}
}
// 4、新老节点都有,但是顺序不固定
else {
// 把新元素做成 Map,key:value(index),用于判断旧元素有没有复用 注:源码的 value 不是下标
const s1 = i;
const s2 = i;
const keyToNewIndexMap = new Map();
for (i = s2; i < e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
const toBePatched = e2 - s2 + 1; // 做完前后对比后,可以从新元素下标获取 需要更新的总节点数量
let patched = 0;
let moved = false; // 调用最长递增子序列算法标志
let maxNewIndexSofar = 0;
// 记录复用元素用于判断移动还是新增,下标是新元素的相对下标,初始值是0,如果节点复用了,值是老元素下标 +1
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
// 遍历老元素,判断老元素是否被复用,并且删除
for (i = s1; i < e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) {
//
unmount(prevChild.key); // 删除元素
continue;
}
const newIndex = keyToNewIndexMap.get(prevChild.key);
if (newIndex === undefined) {
// 节点没有复用
unmount(prevChild.key);
} else {
if (newIndex >= maxNewIndexSofar) {
// 判断是否需要获取 最长递增子序列 路径
maxNewIndexSofar = newIndex;
} else {
moved = true;
}
newIndexToOldIndexMap[newIndex - s2] = i + 1;
patch(prevChild.key);
patched++;
}
}
// 处理移动或者是新增元素:move mount
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: [];
const lastIndex = increasingNewIndexSequence.length - 1;
for (i = toBePatched - 1; i >= 0; i--) {
const nextChildIndex = s2 + i;
const nextChild = c2[nextChildIndex];
// 判断节点是不是 mount
if (newIndexToOldIndexMap[i] === 0) {
mountElement(nextChild.key);
} else {
if (lastIndex < 0 || i !== increasingNewIndexSequence[lastIndex]) {
move(nextChild.key);
} else {
lastIndex--;
}
}
}
}
}
// 获取最长递增子序列的路径,算法还没懂
// 依据例子传进来的 arr:[5, 3, 4, 0],返回的是3和4的下标[1, 2]
function getSequence(arr) {
// 返回LIS路径
const lis = [0];
const len = arr.length;
const record = arr.splice();
for (let i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
const last = lis[lis.length - 1];
if (arr[last] < arrI) {
record[i] = last;
lis.push(i);
continue;
}
// 二分插入
let left = 0,
right = lis.length - 1;
while (left < right) {
const mid = (left + right) >> 1;
if (arr[lis[mid]] < arrI) {
// 在右边
left = mid + 1;
} else {
right = mid;
}
}
// 从lis里找比arrI大的最小元素,并且替换
if (arrI < arr[lis[left]]) {
if (left > 0) {
record[i] = lis[left - 1];
}
lis[left] = i;
}
}
}
let i = lis.length;
let last = record[last];
while (i-- > 0) {
lis[i] = last;
last = record[last];
}
return lis;
}
优化方向文字描述
- 编译时优化:在模版编译阶段做
静态提升和分析
,将不会变的节点做静态标记,每次更新渲染时可以将节点提升至创建函数外,避免重新创建,减少虚拟 DOM 创建和销毁、及节点比较次数; - 补丁标记:对于文本节点的内容发生变化,为对应的虚拟 DOM 节点添加文本内容变化的补丁标记。在 diff 过程中,可以直接根据这个标记找到需要更新文本内容的节点,而不需要对整个组件的虚拟 DOM 树进行全面的比较;
- 双端比较优化:Vue3继续使用了双端比较算法,但是只做头头、尾尾比较,剩余以最长递增子序列做增删改。
后面两点不是很理解
- 动态节点处理的优化:支持了动态插槽,可以更好地处理动态节点,使得在处理包含动态节点的组件时,性能更好。
- 内存管理的优化:采用了更高效的虚拟DOM实现,使用 WeakMap 存储节点信息,减少了对DOM的操作,降低了内存占用。