前言
在前一篇文章学习了Vue3的createApp和h函数实现,但是只出了vnode渲染成真实dom,这里再来介绍一下dom的更新操作。
dom更新
从createApp和h函数实现的我们知道dom的挂载和更新主要是在执行effect方法,instance.isMounted判断元素是否被挂载,挂载过就执行更新逻辑,未挂载执行创建逻辑
- 更新dom时,先取到旧的vnode
- 执行render,获得新的vnode
- patch方法 执行,传入新旧vnode,diff开始
setupRenderEffect方法
js
const setupRenderEffect = (instance, contanier) => {
effect(() => {
if (!instance.isMounted) {
// 创建dom
const proxy = instance.proxy;
const subTree = instance.subTree = instance.render.call(proxy, proxy);
patch(null, subTree, contanier)
instance.isMounted = true
} else {
// 更新dom
const proxy = instance.proxy;
const prevTree = instance.subTree; // 记录旧的vnode
// 执行render方法,获取到新的vnode
const subTree = instance.subTree = instance.render.call(proxy, proxy);
// patch方法比较新旧vnode,diff开始的地方
patch(prevTree, subTree, contanier)
}
})
}
patch方法
- 新旧的vnode存在,并且vnode标签不同,删除旧的元素,创建新的元素
- 新旧的vnode存在,标签相同,继续processElement方法,下一步的diff
- 旧的不存在,直接创建新的元素
js
// 判断元素是否相同
const isSameVnode = (vnode1, vnode2) => {
return vnode1.type === vnode2.type && vnode1.key === vnode2.key;
}
// 移除vnode对应的真实dom
const unmount = (vnode) => {
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el);
}
}
const patch = (n1, n2, contanier, ancher = null) => {
// 旧的vnode存在,新
if (n1 && !isSameVnode(n1, n2)) {
// 元素不同,直接删除,下面再创建新的
unmount(n1);
n1 = null;
}
const { shapeFlag, type } = n2;
if (type === TEXT) {
processText(n1, n2, contanier)
} else if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, contanier, ancher)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, contanier)
}
}
processElement方法
相同标签的文本可能不同,可以通过patchChildren更新标签文本;
如果相同标签的还有子标签,就是递归的patch过程
- 新旧vnode都存在,再去patchChildren,进一步diff
js
const processElement = (n1, n2, contanier, ancher) => {
if (n1 == null) {
mountElement(n2, contanier, ancher)
} else {
// 更新元素
patchElement(n1, n2, contanier, ancher);
}
}
const patchElement = (n1, n2, contanier) => {
let el = n2.el = n1.el;
const oldProps = n1.props || {};
const newProps = n2.props || {};
// 省略比对属性...
// 比对儿子
patchChildren(n1, n2, el);
}
patchChildren方法
- 新旧vnode的children是文本,直接文本覆盖
- 新的vnode的children是文本,旧的children是数组,直接文本覆盖
- 新的vnode的children是数组,旧的children是文本,删除文本,新的children挂载
- 新旧vnode的children都是数组,进一步**patchKeyChildren方法,diff核心**
js
const patchChildren = (n1, n2, el) => {
const c1 = n1.children;
const c2 = n2.children;
const prevShapeFlage = n1.shapeFlag;
const newShapeFlage = n2.shapeFlag;
if (newShapeFlage & ShapeFlags.TEXT_CHILDREN) {
el.textContent = c2;
} else {
// 新旧都是数组
if (prevShapeFlage & ShapeFlags.ARRAY_CHIDLREN) {
patchKeyChildren(c1, c2, el)
} else { // 旧的是文本
// 删除文本
el.textContent = '';
mountChildren(el, c2)
}
}
}
const mountChildren = (el, children) => {
for (const child of children) {
let n2 = child;
// 不是vnode生成vnode,具体实现参考上一篇文章
if (!child._v_isVnode) {
n2 = createVnode(TEXT, null, child)
}
patch(null, n2, el);
}
}
patchKeyChildren方法
新旧vnode的children都是数组时,分三种方式进行逐步diff比较,分别是1.新旧子vnode从前往后开始比较;2.新旧子vnode从后往前开始比较;3.剩余的新旧子vnode点建立索引,删除旧的,增加新的,新旧都存在,通过最长递增子序列,计算出做少移动dom的步骤
从前往后比较
新旧childVnode相同,继续递归patch;新旧childVnode不同,跳出循环
js
// c1,c2对应的旧children和新的children
let i = 0;
let e1 = c1.length - 1;
let e2 = c2.length - 1;
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVnode(n1, n2)) {
patch(n1, n2, el)
} else {
break
}
i++;
}
从后往前比较
新旧childVnode相同,继续递归patch;新旧childVnode不同,跳出循环
js
// c1,c2对应的旧children和新的children
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVnode(n1, n2)) {
patch(n1, n2, el)
} else {
break
}
e1--;
e2--;
}
删除不存在,增加新的vnode
根据从前往后的比较或者从后往前的vnode比较,删除多余的vnode,新增不存在的vnode
新增
新的比旧的多,新增,i > e1
删除
新的比旧的少
js
// 旧的子vnode比新的子vnode少
if (i > e1) {
const nextProps = e2 + 1;
// 前追加
const ancher = nextProps < c2.length ? c2[nextProps].el : null;
while (i <= e2) {
patch(null, c2[i++], el, ancher)
}
} else if (i > e2) {
// 旧的比新的多
while (i <= e1) {
unmount(c1[i++])
}
}
首尾不相等,建立索引
- 计算出乱序的个数,创建数组,用于存储索引,初始值为0
- 创建map对象,记录vnode的key和新vnode索引之间的关系
- 循环旧的vode,发现在map对象上找不到索引,删除旧vnode,否则执行patch,同时将旧的索引位置记录在创建的数组上
- 最后执行完上一步后,会得到一个旧的子vnode的位置的数组
- 通过getSequence方法,传入上一步的数组,得到一个最长递增子序列;最长递增子序列中的元素是可以不移动位置的,这样保证了dom的最少操作次数。实现细节看另一篇文章
- 最后,为了保证正确顺序,从后往前遍历,遇到数组旧vnode值为0,就新建vnode;遇到索引在最长递增子序列里面,无需处理,否则移动元素到对应
js
let s1 = i;
let s2 = i;
const toBePathced = e2 - s2 + 1; // 乱序的个数
const newIndexToPatchMap = Array(toBePathced).fill(0);
// 建立起新的vnode的key和索引
const keyIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
const childVnode = c2[i];
keyIndexMap.set(childVnode.key, i);
}
// 处理旧的dom列表
for (let i = s1; i <= e1; i++) {
const oldChildVnode = c1[i];
let newIndex = keyIndexMap.get(oldChildVnode.key);
// 删除不存在元素
if (newIndex === undefined) {
unmount(oldChildVnode)
} else {
// 新旧关系
newIndexToPatchMap[newIndex - s2] = i + 1;
patch(oldChildVnode, c2[newIndex], el)
}
}
const increasingNewIndexSequence = getSequence(newIndexToPatchMap);
let j = increasingNewIndexSequence.length - 1;
// 移动节点,添加新增的元素,从后往前开始
for (let i = toBePathced - 1; i >= 0; i--) {
let curruentIndex = i + s2; // 新的索引
let child = c2[curruentIndex]; // 新的vnode
// 新元素后一个位置作为参考
let ancher = curruentIndex + 1 < c2.length ? c2[curruentIndex + 1].el : null
if (newIndexToPatchMap[i] === 0) {
// 新增元素
patch(null, child, el, ancher)
} else {
// 元素位置调整
if (i !== increasingNewIndexSequence[j]) {
el.insertBefore(child.el, ancher)
} else {
j--;
}
}
}
完整patchKeyChildren代码
js
// 儿子都是数组
const patchKeyChildren = (c1, c2, el) => {
let i = 0;
let e1 = c1.length - 1;
let e2 = c2.length - 1;
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVnode(n1, n2)) {
patch(n1, n2, el)
} else {
break
}
i++;
}
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVnode(n1, n2)) {
patch(n1, n2, el)
} else {
break
}
e1--;
e2--;
}
// 旧少,新多
if (i > e1) {
const nextProps = e2 + 1;
// 追加
const ancher = nextProps < c2.length ? c2[nextProps].el : null;
while (i <= e2) {
patch(null, c2[i++], el, ancher)
}
} else if (i > e2) {
// 旧的比新的多
while (i <= e1) {
unmount(c1[i++])
}
} else {
let s1 = i;
let s2 = i;
const toBePathced = e2 - s2 + 1; // 乱序的个数
const newIndexToPatchMap = Array(toBePathced).fill(0);
// key记录的新元素的索引
const keyIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
const childVnode = c2[i];
keyIndexMap.set(childVnode.key, i);
}
// 处理旧的dom列表
for (let i = s1; i <= e1; i++) {
const oldChildVnode = c1[i];
let newIndex = keyIndexMap.get(oldChildVnode.key);
// 删除不存在元素
if (newIndex === undefined) {
unmount(oldChildVnode)
} else {
// 新旧关系
newIndexToPatchMap[newIndex - s2] = i + 1;
patch(oldChildVnode, c2[newIndex], el)
}
}
const increasingNewIndexSequence = getSequence(newIndexToPatchMap);
let j = increasingNewIndexSequence.length - 1;
// 移动节点,添加新增的元素,从后往前开始
for (let i = toBePathced - 1; i >= 0; i--) {
let curruentIndex = i + s2; // 新的索引
let child = c2[curruentIndex]; // 新的vnode
// 新元素后一个位置作为参考
let ancher = curruentIndex + 1 < c2.length ? c2[curruentIndex + 1].el : null
if (newIndexToPatchMap[i] === 0) {
// 新增元素
patch(null, child, el, ancher)
} else {
// 元素位置调整
if (i !== increasingNewIndexSequence[j]) {
el.insertBefore(child.el, ancher)
} else {
j--;
}
}
}
}
}
// 最大递增子序列
const getSequence = (arr) => {
const p = [] // 修正结果的数组
const result = [0];
const len = arr.length;
let i, j, start, end, mid;
for (i = 0; i < len; i++) {
const arrI = arr[i]; // 第一个元素的索引
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j; // push时,最后一个元素是前一项
result.push(i)
continue
}
// 二分法
start = 0
end = result.length - 1
while (start < end) {
mid = start
if (arr[mid] < arrI) {
start = mid + 1
} else {
end = mid;
}
}
// 替换result的第start个元素,它的值可能大于现有值
if (arrI < arr[result[start]]) {
if (start > 0) {
p[i] = result[start - 1]
}
result[start] = i;
}
}
}
// 通过数组p,修正最长递增子序列对应的值
start = result.length
end = result[start - 1]
while (start-- > 0) {
result[start] = end
end = p[end];
}
return result
}
最后
终于完成了vue3的diff的简单实现,更多细节还是需要大家阅读源码,不过对应Vue3面试,还是会有点作用。感兴趣的朋友还可以阅读前面2遍文章,对Vue3有个全面了解。