在上一篇文章中,咋们介绍了vue3组件的初始化流程,接下来咋们来一起分析下vue3组件的更新流程是咋样的
先写一个组件,App.js, 然后咋们来执行更新的流程
import { h, ref } from "vue"; export default { name: 'App', setup() { const count = ref(0); // 把count赋值给window,然后在控制台中来改变数据,看看流程是咋样变化的 window.count = count return { count }; }, render() { return h('div', { pId: '"helloWorld"' }, [ h('p', {}, 'hello world'), h('p', {}, `count的值是: ${this.count}`), ]) } } 复制代码
mount
mount阶段就是上篇文章,这里直接跳过,咋们来走更新流程
update
还记得组件挂载阶段中的 setupRenderEffect么? 在这里的时候会进行依赖收集,会在实例instance上挂载一个方法
instance.update = effect(componentUpdateFn, { scheduler: () => { queueJob(instance.update); }, }); 复制代码
当数据发送变化的时候,就会触发 componentUpdateFn函数, 不清楚响应式系统的可以查看这里
整体的流程图如下:
1.第一步肯定就是执行 componentUpdateFn,由于组件已经挂载完成,直接走更新操作
2.判断属性是否有变化,如果有变化的话需要更新属性,咋们这里没有属性发生变化,直接调用normalizeVNode(instance.render.call(proxyToUse, proxyToUse)
)获取children
3.触发 beforeUpdated hook
4.传入参数,调用patch,后续的流程是根据咋代码的修改count内容来走的
5.根据参数,进入path的的 processElement
6.更新流程,直接调用 updateElement
7.更新属性
8.更新children (diff算法)
属性更新
咋们来分析下 vue3 中属性变化的情况
第一种情况 属性增加
let oldProps = {a: 1} let newProps = {a:1,b:2} 复制代码
对于这种情况,咋们怎么才能找出属性的变化,是不是就是应该遍历 newProps 如果里面的key 在 oldProps 中不存在,则标记为新增的属性 代码应该这么写:
for (const key in newProps) { const prevProp = oldProps[key]; const nextProp = newProps[key]; if (prevProp !== nextProp) { // 新增属性 } } 复制代码
第二种情况 属性减少
let oldProps = {a: 1, c: 4} let newProps = {a:1} 复制代码
对于这种情况,咋们要找出属性的变化,直接遍历 oldProps 既即可,和上面的方式是一样的
第三种情况 属性变化
let oldProps = {a: 2} let newProps = {a:1} 复制代码
对于这种情况,咋们是不是还需要一个 对比属性的函数来,循环遍历依次来对比属性的变化呢?针对上面的情况一和情况二,都可以用同一个方法来新增,修改,删除属性,vue3 只不过把处理的都映射给每一个dom了
/** * * @param el 更新的真实dom * @param key 属性的key * @param preValue 旧的值 * @param nextValue 新的值 */ function hostPatchProp(el, key, preValue, nextValue){ // 传入的key,是不是事件处理函数 if (isOn(key)) { // 添加事件处理函数的时候需要注意一下 // 1. 添加的和删除的必须是一个函数,不然的话 删除不掉 // 那么就需要把之前 add 的函数给存起来,后面删除的时候需要用到 // 2. nextValue 有可能是匿名函数,当对比发现不一样的时候也可以通过缓存的机制来避免注册多次 // 缓存所有的事件函数 const invokers = el._vei || (el._vei = {}); const existingInvoker = invokers[key]; // 属性存在,直接修改 if (nextValue && existingInvoker) { existingInvoker.value = nextValue; } else { // 属性不存在,进行新增或者删除事件 const eventName = key.slice(2).toLowerCase(); // 注册事件 if (nextValue) { const invoker = (invokers[key] = nextValue); el.addEventListener(eventName, invoker); } else { // 移除事件 el.removeEventListener(eventName, existingInvoker); invokers[key] = undefined; } } } else { // 新的值不存在,直接删除操作 if (nextValue === null || nextValue === "") { el.removeAttribute(key); } else { // 反之存在则进行添加新的属性 el.setAttribute(key, nextValue); } } } 复制代码
更新children
更新children,这里有一个条件,如果新的children和old children 则触发diff 算法,其实diff 算法也没有想象中的那么复杂,是一点点根据边界情况和性能优化写出来的,下面咋们就一起来写一个简单版的diff算法吧
在处理 children 更新的过程中,采用的是一种双端对比的模式,这样就可以缩小对比的范围
左侧对比
通过左侧对比获取起始位置
/** * 是否相同 * @param {*} n1 * @param {*} n2 * @returns */ const isSame = (n1, n2) => { return n1.value === n2.value && n1.key === n2.key } // 咋们的新老节点分别为n1, n2 const n1 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }] const n2 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'E', key: 'E' }, { value: 'D', key: 'D' }] // 从左侧开始查找,看看左侧有哪些是相同的,那么在更新的时候就可以跳过相同的节点,节约性能 const diff = (n1, n2) => { // 为了方便演示,咋们就只操作 n1来完成diff的操作 const copyN1 = JSON.parse(JSON.stringify(n1)) let i = 0; let e1 = n1.length - 1 let e2 = n2.length - 1 // 确定起始的位置i while (i <= e1 && i <= e2) { if (isSame(n1[i], n2[i])) { i++ } else { break } } } 复制代码
从上面的代码,咋们可以获取到i的值,起始位置就获取好了
右侧对比
通过右侧对比,获取结束位置,用来锁定中间有问题的部分
// 咋们的新老节点分别为n1, n2 const n1 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }] const n2 = [{ value: 'D', key: 'D' }, { value: 'E', key: 'E' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }] // 上面咋们知道了,左侧id的位置,那么接下来咋们来确定右侧的位置 while (i <= e1 && i <= e2) { if (isSame(n1[e1], n2[e2])) { e1-- e2-- } else { break } } 复制代码
这样咋们就确定了结束位置了,接下来就是判断边界条件了
新的比老的长———创建新的
在新的比老的长里面,分为两种情况,
1.新的右边比老的长
2.新的左边比老的长
右边比老的长
// 咋们的新老节点分别为n1, n2 const n1 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }] const n2 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }] // ... 获取i e1, e2 // 在本种情况种, i = 2, e1 = 1 , e2 = 2 // 当 i > e1 时候,并且 i <= e2 的时候,咋们就可以确定新节点的右侧比老节点长 if (i > e1 && i <= e2) { // 增加新的节点i copyN1.splice(i, 0, ...n2.slice(i)) } return copyN1 复制代码
左边比老的长
// 咋们的新老节点分别为n1, n2 const n1 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }] const n2 = [ { value: 'C', key: 'C' },{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }] // ... 省略其他逻辑 // 在这种情况下, i = 0, e1 = -1, e2 = 0, 所以条件还是 i > e1 && i <= e2, // 但是上面的 copyN1.splice(i, 0, ...n2.slice(i)) 这个方法是否适合这里呢,显然不适合 if (i > e1 && i <= e2) { while (i <= e2) { // 增加新的节点i,这里与dom操作是不一样的,在dom种没有插入指定位置的api, copyN1.splice(i, 0, n2[i]) i++ } } 复制代码
新的比老的短———删除老的
在新的比老的短里面,分为两种情况,
1.新的右边比老的短
2.新的左边比老的短
新的右边比老的短
// 咋们的新老节点分别为n1, n2 const n1 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }] const n2 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }] // ... 省略其他逻辑 // 在这种情况种,咋们的 i = 2, e1 = 2 , e2 = 1 所以满足新节点比老节点短的条件是 i <= e1 && i > e2 else if (i <= e1 && i > e2) { // 新的节点比老的节点短,进行删除老的节点 while (i <= e1) { copyN1.splice(i, 1) i++ } } 复制代码
新的左边比老的短
// 咋们的新老节点分别为n1, n2 const n1 = [{ value: 'C', key: 'C' },{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }] const n2 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }] // ... 省略其他逻辑 // 在这种情况种,咋们的 i = 2, e1 = 2 , e2 = 1 所以满足新节点比老节点短的条件是 i <= e1 && i > e2, 这里会发现和我们右侧的是一样的 else if (i <= e1 && i > e2) { // 新的节点比老的节点短,进行删除老的节点 while (i <= e1) { copyN1.splice(i, 1) i++ } } 复制代码
中间对比
通过上面的左右对比,咋们就可以得出一个新的区域对于n2的范围在 【i,e2】 而n1的范围是【i, e1】 在中间对比的时候咋们有一种很直接的方法—— 直接双重for循环来暴力破解😀😀😀,但是这么做肯定是有点费性能的,vue3肯定不是这么做的,人家在里面用了个 最长递增子序列算法来查找尽可能多的节点是不用变化的. 不熟悉最长递增子序列算法请参考这里
在比较中间部分的时候,又会有以下几种情况:
1.剩余部分的节点都存在于老的和新的,只是顺序发生变化
2.剩余部分只存在于新的,需要增加节点
3.剩余部分只存在于老的,需要删除节点
中间部分只存在于老的————删除
// 咋们的新老节点分别为n1, n2 const n51 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, { value: 'D', key: 'D' }, { value: 'E', key: 'E' }] const n52 = [{ value: 'A', key: 'A' },{ value: 'B', key: 'B' }, { value: 'C', key: 'C' }, { value: 'E', key: 'E' }] // 在这里咋们可以看到,老节点中间是多了一个D节点,那咋们就需要把这个节点找出来 ... 省略其其他逻辑 else { //处理中间节点 let s1 = i, s2 = i; // 对新节点建立索引,给缓存起来, const keyToNewIndexMap = new Map(); // 缓存新几点 for (let i = s2; i <= e2; i++) { keyToNewIndexMap.set(n2[i].key, i) } // 需要处理新节点的数量 const toBePatched = e2 - s2; // 遍历老节点,需要把老节点有的,而新节点没有的给删除 for (let i = s1; i <= e1; i++) { let newIndex; // 存在key,从缓存中取出新节点的索引 if (n1[i].key && n1[i].key == null) { newIndex = keyToNewIndexMap.get(n1[i].key) } else { // 不存在key,遍历新节点,看看能不能在新节点中找到老节点 for (let j = s2; j <= e2; j++) { if (isSame(n1[i], n2[j])) { newIndex = j break } } } // 如果newIndex 不存在,则是老节点中有的,而新节点没有,删除 if (newIndex === undefined) { copyN1.splice(i, 1) } } 复制代码
在这里咋们可以看错,在v-for的时候,key的作用了吧😄😄😄,不写的话就会再来一遍循环,造成性能的浪费。
中间部分的节点新节点有,老节点无————新增节点
// 咋们的新老节点分别为n1, n2 const n51 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, { value: 'E', key: 'E' }] const n52 = [{ value: 'A', key: 'A' },{ value: 'B', key: 'B' }, { value: 'C', key: 'C' }, { value: 'D', key: 'D' },{ value: 'E', key: 'E' }] // 省略其他逻辑 // 在这里咋们是知道D节点是新增的节点,为了让代码知道D节点是新增的节点,咋们需要做一个新老节点的映射 // 对老节点建立索引映射, 初始化为 0 , 后面处理的时候 如果发现是 0 的话,那么就说明新值在老的里面不存在 const newIndexToOldIndexMap = new Array(toBePatched).fill(0) 在newIndex 存在的时候,来更新老节点的 // 把老节点的索引记录下来, +1 的原因是怕,i 恰好为0 newIndexToOldIndexMap[newIndex - s2] = i + 1 // 遍历新节点, for (let i = s2; i <= toBePatched; i++) { // 如果新节点在老节点中不存在,则创建 if (newIndexToOldIndexMap[i] === 0) { copyN1.splice(i + s2, 0, n2[i + s2]) } } 复制代码
中间部分节点都存在,移动位置
// 咋们的新老节点分别为n1, n2 const n71 = [{ value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, { value: 'D', key: 'D' }, { value: 'E', key: 'E' }] const n72 = [{ value: 'A', key: 'A' }, { value: 'C', key: 'C' }, { value: 'D', key: 'D' }, , { value: 'B', key: 'B' }, { value: 'E', key: 'E' }] // 在这种情况下,节点C和节点D的位置是没有变化的,之哟节点B是变化了的,所以咋们只要移动节点B // 我们人知道需要移动节点B呢? 移动的条件: 如果从老节点的newIndex 一直都是升序的话,机不需要移动,反之则移动,使用最长子序列来规定最小的移动范围 const diff = (n1, n2) => { const copyN1 = JSON.parse(JSON.stringify(n1)) let i = 0; let e1 = n1.length - 1 let e2 = n2.length - 1 // 确定起始的位置i while (i <= e1 && i <= e2) { if (isSame(n1[i], n2[i])) { i++ } else { break } } // 确定结束位置 while (i <= e1 && i <= e2) { if (isSame(n1[e1], n2[e2])) { e1-- e2-- } else { break } } // 条件一, 新节点比老节点长 // 条件1.1 新节点的右侧比老节点长 // 当 i > e1 时候,并且 i <= e2 的时候,咋们就可以确定新节点的右侧比老节点长 if (i > e1 && i <= e2) { while (i <= e2) { // 增加新的节点i copyN1.splice(i, 0, n2[i]) i++ } } else if (i <= e1 && i > e2) { // 新的节点比老的节点短,进行删除老的节点 while (i <= e1) { copyN1.splice(i, 1) i++ } } else { //处理中间节点 let s1 = i, s2 = i; // 对新节点建立索引,给缓存起来, const keyToNewIndexMap = new Map(); // 是否需要移动 let moved = false; // 最大新节点索引 let maxNewIndexSoFar = 0; // 收集新节点的key for (let i = s2; i <= e2; i++) { keyToNewIndexMap.set(n2[i].key, i) } // 需要处理新节点的数量 const toBePatched = e2 - s2 + 1; // 对老节点建立索引映射, 初始化为 0 , 后面处理的时候 如果发现是 0 的话,那么就说明新值在老的里面不存在 const newIndexToOldIndexMap = new Array(toBePatched).fill(0) // 遍历老节点,需要把老节点有的,而新节点没有的给删除 for (let i = s1; i <= e1; i++) { let newIndex; // 存在key,从缓存中取出新节点的索引 if (n1[i].key && n1[i].key == null) { newIndex = keyToNewIndexMap.get(n1[i].key) } else { // 不存在key,遍历新节点,看看能不能在新节点中找到老节点 for (let j = s2; j <= e2; j++) { if (isSame(n1[i], n2[j])) { newIndex = j break } } } // 如果newIndex 不存在,则是老节点中有的,而新节点没有,删除 if (newIndex === undefined) { copyN1.splice(i, 1) } else { // 老节点在新节点中存在 // 把老节点的索引记录下来, +1 的原因是怕,i 恰好为0 newIndexToOldIndexMap[newIndex - s2] = i + 1 // 新的 newIndex 如果一直是升序的话,那么就说明没有移动 if (newIndex >= maxNewIndexSoFar) { maxNewIndexSoFar = newIndex } else { moved = true } } } // 利用最长递增子序列来优化移动逻辑 // 因为元素是升序的话,那么这些元素就是不需要移动的 // 而我们就可以通过最长递增子序列来获取到升序的列表 // 在移动的时候我们去对比这个列表,如果对比上的话,就说明当前元素不需要移动 const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : []; // increasingNewIndexSequence 返回的是最长递增子序列的索引 let j = 0; // 遍历新节点, for (let i = 0; i < toBePatched; i++) { // 如果新节点在老节点中不存在,则创建 if (newIndexToOldIndexMap[i] === 0) { copyN1.splice(i + s2, 0, n2[i + s2]) } else if (moved) { // 新老节点都存在,需要进行移动位置 if (j > increasingNewIndexSequence.length - 1 || i !== increasingNewIndexSequence[j]) { // 先删掉节点,然后插入 即是移动 copyN1.splice(newIndexToOldIndexMap[i] - 1, 1) copyN1.splice(i + s2, 0, n2[i + s2]) } else { j++ } } } } 复制代码
自此,整个diff算法的核心就在这里了,文章里面采用的是diff数组,而vue里面是diff的是真实的dom
测试
const oldNode = [ { value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, { value: 'E', key: 'E' }, { value: 'F', key: 'F' }, { value: 'G', key: 'G' }] const newNode = [ { value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'D', key: 'D' }, { value: 'C', key: 'C' }, { value: 'E', key: 'E' }, { value: 'F', key: 'F' }, { value: 'G', key: 'G' }] console.log('oldNode', oldNode, 'newNode', newNode, '新节点和老节点都存在,位置发生变化', diff(oldNode, newNode)) 复制代码
更多详情,请查看源码