vue3组件更新流程

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

整体的流程图如下:

vue3组件更新流程_第1张图片

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

vue3组件更新流程_第2张图片

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

vue3组件更新流程_第3张图片

  1. 触发 beforeUpdated hook
  2. 传入参数,调用patch,后续的流程是根据咋代码的修改count内容来走的
  3. 根据参数,进入path的的 processElement

vue3组件更新流程_第4张图片

  1. 更新流程,直接调用 updateElement
  2. 更新属性
  3. 更新children (diff算法)

属性更新

咋们来分析下 vue3 中属性变化的情况

第一种情况 属性增加

let oldProps = {a: 1}
let newProps = {a:1,b:2}
复制代码

对于这种情况,咋们怎么才能找出属性的变化,是不是就是应该遍历 newProps 如果里面的keyoldProps 中不存在,则标记为新增的属性 代码应该这么写:

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

右边比老的长

vue3组件更新流程_第5张图片

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

左边比老的长

vue3组件更新流程_第6张图片

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

新的右边比老的短

vue3组件更新流程_第7张图片

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

新的左边比老的短

vue3组件更新流程_第8张图片

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

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

vue3组件更新流程_第9张图片

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

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

vue3组件更新流程_第10张图片

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

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

vue3组件更新流程_第11张图片

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

复制代码

vue3组件更新流程_第12张图片

更多详情,请查看源码

你可能感兴趣的:(vue3源码分析,vue.js,前端,javascript)