vue3组件更新流程

简介: 还记得组件挂载阶段中的 setupRenderEffect么? 在这里的时候会进行依赖收集,会在实例instance上挂载一个方法

在上一篇文章中,咋们介绍了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函数, 不清楚响应式系统的可以查看这里


整体的流程图如下:


8e0d5e5230386e76566e3bc6719d9125.png


1.第一步肯定就是执行 componentUpdateFn,由于组件已经挂载完成,直接走更新操作


9bfe451882f655e8eba3cf2d07b0962f.png


2.判断属性是否有变化,如果有变化的话需要更新属性,咋们这里没有属性发生变化,直接调用normalizeVNode(instance.render.call(proxyToUse, proxyToUse))获取children


73aa4cb10b2d3677a4a175a96ececd0a.png


3.触发 beforeUpdated hook


4.传入参数,调用patch,后续的流程是根据咋代码的修改count内容来走的


5.根据参数,进入path的的 processElement


4bc4b3bddf5770f393817ee2316b685e.png


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.新的左边比老的长


右边比老的长


c321796697660ef6e100ffb49214d6b4.png


// 咋们的新老节点分别为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
复制代码


左边比老的长


5702d411149b7c77fb9c64f900a4b618.png


// 咋们的新老节点分别为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.新的左边比老的短

新的右边比老的短


7adaf11b20aea00a8835a09bfaee7d17.png


// 咋们的新老节点分别为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++
    }
  }
复制代码


新的左边比老的短


0c8eaacb7efcc7852cc0b3296dfd2d23.png


// 咋们的新老节点分别为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.剩余部分只存在于老的,需要删除节点


中间部分只存在于老的————删除


8110e9c9fed68d0110524f597b603e6c.png


// 咋们的新老节点分别为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的作用了吧😄😄😄,不写的话就会再来一遍循环,造成性能的浪费。


中间部分的节点新节点有,老节点无————新增节点


79efd86f597e24e99e2561b05da2af9a.png


// 咋们的新老节点分别为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])
      }
    }
复制代码


中间部分节点都存在,移动位置


864aef9ce5c81fbe79d6e373830af12e.png


// 咋们的新老节点分别为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))
复制代码


af24fd772f96fb0faf8143136da2c897.png


更多详情,请查看源码

相关文章
|
13天前
|
缓存 JavaScript UED
Vue3中v-model在处理自定义组件双向数据绑定时有哪些注意事项?
在使用`v-model`处理自定义组件双向数据绑定时,要仔细考虑各种因素,确保数据的准确传递和更新,同时提供良好的用户体验和代码可维护性。通过合理的设计和注意事项的遵循,能够更好地发挥`v-model`的优势,实现高效的双向数据绑定效果。
116 64
|
13天前
|
JavaScript 前端开发 API
Vue 3 中 v-model 与 Vue 2 中 v-model 的区别是什么?
总的来说,Vue 3 中的 `v-model` 在灵活性、与组合式 API 的结合、对自定义组件的支持等方面都有了明显的提升和改进,使其更适应现代前端开发的需求和趋势。但需要注意的是,在迁移过程中可能需要对一些代码进行调整和适配。
|
13天前
|
前端开发 JavaScript 测试技术
Vue3中v-model在处理自定义组件双向数据绑定时,如何避免循环引用?
Web 组件化是一种有效的开发方法,可以提高项目的质量、效率和可维护性。在实际项目中,要结合项目的具体情况,合理应用 Web 组件化的理念和技术,实现项目的成功实施和交付。通过不断地探索和实践,将 Web 组件化的优势充分发挥出来,为前端开发领域的发展做出贡献。
23 8
|
13天前
|
JavaScript
在 Vue 3 中,如何使用 v-model 来处理自定义组件的双向数据绑定?
需要注意的是,在实际开发中,根据具体的业务需求和组件设计,可能需要对上述步骤进行适当的调整和优化,以确保双向数据绑定的正确性和稳定性。同时,深入理解 Vue 3 的响应式机制和组件通信原理,将有助于更好地运用 `v-model` 实现自定义组件的双向数据绑定。
|
26天前
|
存储 JavaScript 开发者
Vue 组件间通信的最佳实践
本文总结了 Vue.js 中组件间通信的多种方法,包括 props、事件、Vuex 状态管理等,帮助开发者选择最适合项目需求的通信方式,提高开发效率和代码可维护性。
|
26天前
|
存储 JavaScript
Vue 组件间如何通信
Vue组件间通信是指在Vue应用中,不同组件之间传递数据和事件的方法。常用的方式有:props、自定义事件、$emit、$attrs、$refs、provide/inject、Vuex等。掌握这些方法可以实现父子组件、兄弟组件及跨级组件间的高效通信。
|
JavaScript
Vue的非父子组件之间传值
全局事件总线 一种组件间通信的方式,适用于任意组件间通信
|
缓存 JavaScript 前端开发
Vue Props、Slot、v-once、非父子组件间的传值....
Vue Props、Slot、v-once、非父子组件间的传值....
86 0
|
JavaScript
Vue中父子组件传值
先在⽗组件中给⼦组件的⾃定义属性绑定⼀个⽗组件的变量
|
JavaScript
vue 组件传值
vue 组件传值
84 0