likes
comments
collection
share

Vue3组件更新流程分析

作者站长头像
站长
· 阅读数 58

接上篇 Vue3组件初始化流程分析,本文主要来分析 vue3 组件的更新流程。

还是之前的例子:

App.vue

const App = {
  name: "App",
  setup() {
    const count = ref(0);
    const onClick = () => {
      count.value++
    };
    return {
      count,
      onClick
    }
  },
  render() {
    return h(
      "div", { id: "root" }, [
        h("div", {}, "count:" + this.count),
        h("button", { onClick: this.onClick }, "click")
      ]
    )
  }
}

最终页面渲染如下:

Vue3组件更新流程分析

当点击 click 按钮时,会执行 onClick 函数, count 的值会 +1,此时会触发组件的更新。由于 count 是响应式数据,会再次触发 setupRenderEffect 函数的执行,我们来看一下更新流程:

function setupRenderEffect(instance, initialVNode, container, anchor) {
  instance.update = effect(() => {
    if (!instance.isMounted) { // mounted
      ...
      instance.isMounted = true;
    } else { // updated
      const { proxy } = instance;
      const subTree = instance.render.call(proxy,proxy);
      const prevSubTree = instance.subTree;
      instance.subTree = subTree;

      patch(prevSubTree, subTree, container, instance, anchor);
    }
  })
}

由于组件初次渲染后会把 isMounted 置为 true,再次执行 setupRenderEffect 时会执行 else 逻辑。首先执行 render 函数生成新的 Vnode,然后取出上一次的 Vnode,并用新的 Vnode 覆盖老的 Vnode,最后执行 patch 函数将新老 Vnode 同时传入。新老 Vnode 对比如下:

Vue3组件更新流程分析

Vue3组件更新流程分析

patch

function patch(n1, n2, container, parentComponent, anchor) {
  const { type, shapeFlag } = n2;
  ...
  if (shapeFlag & ShapeFlags.ELEMENT) {
    processElement(n1, n2, container, parentComponent, anchor);
  } 
  ...
}

再次执行 patch 函数,由于我们传入的 n2.typediv, 此时会执行 processElement 逻辑,依次传入所有参数。

processElement

function processElement(n1, n2, container, parentComponent, anchor) {
  if (!n1) {
    mountElement(n2, container, parentComponent, anchor);
  } else {
    patchElement(n1, n2, container, parentComponent, anchor);
  }
}

由于 n1 存在,此时会执行 patchElement 函数,依次传入所有参数。

patchElement

function patchElement(n1, n2, container, parentComponent, anchor) {
  const oldProps = n1.props
  const newProps = n2.props
  const el = (n2.el = n1.el)

  patchChildren(n1, n2, el, parentComponent, anchor)
  patchProps(el, oldProps, newProps)
}

patchElement 函数会执行以下逻辑:

1、获取到新老 Vnodeprops 属性。

2、获取到老 Vnodeel 属性(el 可以映射到真实 DOM),并赋值给新 Vnodeel此步可以直接复用老的 DOM 元素

3、执行 patchChildren 逻辑, 并把 n1、n2、el 传入。

4、执行 patchProps 逻辑,并把新老 props 传入。

patchProps

export const EMPTY_OBJ = {};

function patchProps(el, oldProps, newProps) {
  if (oldProps !== newProps) {
    for (const key in newProps) {
      const prevProp = oldProps[key];
      const nextProp = newProps[key];

      if (prevProp !== nextProp) {
        hostPatchProp(el, key, prevProp, nextProp);
      }
    }

    if (oldProps !== EMPTY_OBJ) {
      for (const key in oldProps) {
        if (!(key in newProps)) {
          hostPatchProp(el, key, oldProps[key], null);
        }
      }
    }
  }
}

function hostPatchProp(el, key, prevVal, nextVal) {
  const isOn = key => /^on[A-Z]/.test(key); // 事件注册onClick
  if (isOn(key)) {
    const event = key.slice(2).toLowerCase();
    el.addEventListener(event, nextVal);
  } else {
    if (nextVal === undefined || nextVal === null) {
      el.removeAttribute(key);
    } else {
      el.setAttribute(key, nextVal);
    }
  }
}

patchChildren

对于子节点来说,只会有三种可能,分别是:文本节点、数组和空。所以这个方法里所有的if else分支就是在考虑新旧节点可能的全部情况,并进行相应的处理。

function patchChildren(n1, n2, container, parentComponent, anchor) {
  const prevShapeFlag = n1.shapeFlag;
  const c1 = n1.children;
  const { shapeFlag } = n2;
  const c2 = n2.children;

  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 新文本
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 老数组
      unmountChildren(n1.children);
    }
    if (c1 !== c2) { // 老文本
      hostSetElementText(container, c2);
    }
   } else { // 新数组
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) { // 老文本
      hostSetElementText(container, "");
      mountChildren(c2, container, parentComponent, anchor);
     } else { 
      // 老数组
      patchKeyedChildren(c1, c2, container, parentComponent, anchor);
     }
   }
}

情况1: 新子节点是文本,老子节点是数组。此时需要执行 unmountChildren 把老子节点卸载掉,执行 hostSetElementText 设置新文本。

function unmountChildren(children) {
  for (let i = 0; i < children.length; i++) {
    const el = children[i].el;
    hostRemove(el);
  }
}

情况2: 新子节点是文本,老子节点是文本。如果文本不一致,执行 hostSetElementText 设置新文本。

情况3: 新子节点是数组,老子节点是文本。此时需要执行 hostSetElementText 清空文本,执行 mountChildren 挂载新节点。

function mountChildren(children, container, parentComponent, anchor) {
  children.forEach((v) => {
    patch(null, v, container, parentComponent, anchor)
  })
}

情况4: 新子节点是数组,老子节点是数组。此时执行 patchKeyedChildren(核心 diff 算法)。

用一张图归纳如下:

Vue3组件更新流程分析

patchKeyedChildren

前提: 先假设所有元素都拥有一个独一无二的 key 值。

我们可以思考一下,新旧子节点都是数组会有哪几种变化情况?

Vue3组件更新流程分析

无论是哪种变化最后都是由更新、添加、删除、移动这四种操作的一种或者几种的组合。

patchKeyedChildren 函数首先获取到新老 children 的长度,用以计算出尾部索引 e1、e2,定义开始索引 i = 0。接着定义 isSameVNodeType 用于判断 vnode 节点的类型是否相同(type、key必须相等)。

 function isSameVNodeType(n1, n2) {
   return n1.type === n2.type && n1.key === n2.key;
 }
 function patchKeyedChildren(c1, c2, container, parentComponent, parentAnchor) {
  const l2 = c2.length;
  let i = 0;
  let e1 = c1.length - 1;
  let e2 = l2 - 1;
  ...
}

源码里在 diff 算法的最开始,会先从头部正序扫描从尾部倒序扫描,以便排除类型一样的干扰项,进一步的提高效率。

头部正序扫描(sync from start)

while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = c2[i]
  
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container, parentComponent, parentAnchor)
  } else {
    break
  }
  
  i++
}

按上述逻辑我们会先取新老 children 的第一个子元素通过 isSameVNodeType 函数来进行比对。按上述例子来看,第一个子元素 type 都是 divkey 值都没传默认是 undefined,此时会再次调用 patch 函数把新老 children 的第一个子元素传入。依次执行 processElement -> patchElement -> patchChildren 的逻辑。此时 n1children c1 为文本:count: 0n2children c2 也为文本:count: 1,因而会命中如下逻辑:

if (c1 !== c2) {
  hostSetElementText(container, c2);
}

此时就完成了第一个子元素的 patch 流程。第二个子元素 button 也是同样的对比逻辑,依次执行 processElement -> patchElement -> patchChildren。不过由于新老 button 的内容并没有变化,所以也不会有实际的 DOM 处理操作。

核心 diff

上面的例子比较简单,接下来我们用多个 li 元素来模拟整个 diff 流程。

头部正序扫描(sync from start)

 h('ul',[
   h('li', { key: 'a' }, 'a'),
   h('li', { key: 'b' }, 'b'),
   h('li', { key: 'c' }, 'c')
 ]) 
 h('ul',[
   h('li', { key: 'a' }, 'a'),
   h('li', { key: 'b' }, 'b'),
   h('li', { key: 'd' }, 'd'),
   h('li', { key: 'e' }, 'e')
 ])

图解: Vue3组件更新流程分析 代码:

 while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = c2[i]

  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container, parentComponent, parentAnchor)
  } else {
    break
  }

  i++
}

尾部倒序扫描(sync from end)

 h('ul',[
   h('li', { key: 'a' }, 'a'),
   h('li', { key: 'b' }, 'b'),
   h('li', { key: 'c' }, 'c')
 ]) 
 h('ul',[
   h('li', { key: 'd' }, 'd'),
   h('li', { key: 'e' }, 'e')
   h('li', { key: 'b' }, 'b'),
   h('li', { key: 'c' }, 'c')
 ])

图解: Vue3组件更新流程分析 代码:

while (i <= e1 && i <= e2) {
  const n1 = c1[e1]
  const n2 = c2[e2]

  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container, parentComponent, parentAnchor);
  } else {
    break
  }

  e1--
  e2--
}

同序列 + 挂载(common sequence + mount)

Vue3组件更新流程分析 Vue3组件更新流程分析 下面两种情况,新的孩子比老的孩子多,此时会满足 i > e1 && i <= e2。i -> e2之间是需要新增的元素。

插入位置判断: 通过判断 e2 + 1 和 l2 的关系,如果 e2 + 1 < l2 是前插,anchor 为 e2 + 1 索引对应的 el 元素,否则是向后插入,anchor 为 null。

if (i > e1) { 
  if (i <= e2) {
    const nextPos = e2 + 1;
    const anchor = nextPos < l2 ? c2[nextPos].el : null; // anchor用来标识:往前插、往后插
    while (i <= e2) {
      patch(null, c2[i], container, parentComponent, anchor);
      i++;
    }
  }
}

// 插入 api
function insert(child, parent, anchor = null) {
  parent.insertBefore(child, anchor);
}

同序列 + 卸载(common sequence + unmount)

下面两种情况,老的孩子比新的孩子多,此时会满足 i > e2 && i <= e1。i -> e1之间是需要移除的元素。 Vue3组件更新流程分析 Vue3组件更新流程分析

else if (i > e2) {
  while (i <= e1) {
    hostRemove(c1[i].el);
    i++;
  }
}

function hostRemove(child) {
  const parent = child.parentNode;
  if (parent) {
    parent.removeChild(child);
  }
}

乱序比对(unknown sequence)

Vue3组件更新流程分析 经过上面多步的比对,最终会形成图示的虚线框。此时 i = 2、e1 = 4、e2 = 5

let s1 = i // 2
let s2 = i // 2
const toBePatched = e2 - s2 + 1 // // 有多少节点需要比对: 4个
let patched = 0 // 用于标识比对过的次数,patch 1次加1次
const keyToNewIndexMap = new Map()
const newIndexToOldIndexMap = new Array(toBePatched).fill(0) // [0, 0, 0, 0]

for (let i = s2; i <= e2; i++) {
  const nextChild = c2[i]
  keyToNewIndexMap.set(nextChild.key, i) // 依据 key 值构成映射表
}

第1步: 定义了 keyToNewIndexMap,通过新元素的 key 和索引构制映射表。结构如下:

keyToNewIndexMap: { e: 2, c: 3, d: 4. h: 5 }
for (let i = s1; i <= e1; i++) {
  const prevChild = c1[i];

  if (patched >= toBePatched) { // 性能优化
    hostRemove(prevChild.el);
    continue;
  }
  // 拿老的节点去映射表里面找
  let newIndex;
  if (prevChild.key != null) { // 有key
    newIndex = keyToNewIndexMap.get(prevChild.key);
  } else {
    for (let j = s2; j <= e2; j++) { // 无 key
      if (isSameVNodeType(prevChild, c2[j])) {
        newIndex = j;

        break;
      }
    }
  }

  if (newIndex === undefined) { // 索引不存在,说明老的节点在c2中不存在,需要移除
    hostRemove(prevChild.el);
  } else { // 老的节点在c2中存在,进行patch
    newIndexToOldIndexMap[newIndex - s2] = i + 1;
    patch(prevChild, c2[newIndex], container, parentComponent, null);
    patched++;
  }
}

第2步: 从 s1 遍历至 e1,拿到每一个 children,寻找 children 是否存在于c2中(分有key 和 无key 的查找,存在的话会找到索引值)。

第3步: 如果找不到索引值,说明老的 children 在 c2 中不存在,调用 hostRemove 移除老的 children。如下图 q 需要被移除: Vue3组件更新流程分析 如果存在,会把老的 children 和 新的 children 进行 patch 比对,执行patched++ 表明 patch 过一次,然后更新 newIndexToOldIndexMap 数组的值。

更新流程如下: 先寻找 c,newIndex 为 3,newIndexToOldIndexMap 更新为[0, 3, 0, 0], i++ 后

再寻找 d,newIndex 为 4,newIndexToOldIndexMap 更新为[0, 3, 4, 0], i++ 后

再寻找 e,newIndex 为 2,newIndexToOldIndexMap 更新为[5, 3, 4, 0]。

for (let i = toBePatched - 1; i >= 0; i--) { // 从后向前插入
  const nextIndex = i + s2
  const nextChild = c2[nextIndex]
  const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null

  if (newIndexToOldIndexMap[i] === 0) { // 没有被patch过,说明需要新增
    patch(null, nextChild, container, parentComponent, anchor)
  } else {
    // 根据参照物 将节点直接移动过去 所有节点都要移动 (但是有些节点可以不动)
    hostInsert(nextChild.el, container, anchor)
  }
}

最长递增子序列优化

Vue3 采用最长递增子序列,求解不需要移动的元素有哪些

const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap) // 最长递增子序列 [1, 2]
let j = increasingNewIndexSequence.length - 1 // 1

for (let i = toBePatched - 1; i >= 0; i--) { // 从后向前插入
  const nextIndex = i + s2
  const nextChild = c2[nextIndex]
  const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null

  if (newIndexToOldIndexMap[i] === 0) { // 没有被patch过,说明需要新增
    patch(null, nextChild, container, parentComponent, anchor)
  } else {
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      hostInsert(nextChild.el, container, anchor)
    } else {
      j--
    }
  }
}

通过 getSequence 算法把 newIndexToOldIndexMap 传入,得到的最长递增子序列为[1, 2],此时 j = 1;然后由后向前遍历,先寻找插入位置 anchor。如果 newIndexToOldIndexMap 某一项为 0,说明没有被 patch 过,表明该元素需要创建。如果 i 和 increasingNewIndexSequence[j] 不相等,说明需要移动位置,否则 j--。

function getSequence(arr) { // [5, 3, 4, 0] -> [1, 2]
  const p = arr.slice();
  const result = [0];
  let i, j, u, v, c;
  const len = arr.length;
  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;
        result.push(i);
        continue;
      }
      u = 0;
      v = result.length - 1;
      while (u < v) {
        c = (u + v) >> 1;
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c;
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1];
        }
        result[u] = i;
      }
    }
  }
  u = result.length;
  v = result[u - 1];
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

完整 diff 算法

function patchKeyedChildren(c1, c2, container, parentComponent, parentAnchor) {
  const l2 = c2.length;
  let i = 0;
  let e1 = c1.length - 1;
  let e2 = l2 - 1;

  function isSameVNodeType(n1, n2) {
    return n1.type === n2.type && n1.key === n2.key;
  }
  
  // sync from start
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];

    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, parentComponent, parentAnchor);
    } else {
        break;
    }

    i++;
  }
  
  // sync from end
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];

    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, parentComponent, parentAnchor);
    } else {
      break;
    }

    e1--;
    e2--;
  }
  
  // common sequence + mount
  if (i > e1) { 
    if (i <= e2) {
      const nextPos = e2 + 1;
      const anchor = nextPos < l2 ? c2[nextPos].el : null; // anchor用来标识:往前插、往后插
      while (i <= e2) {
        patch(null, c2[i], container, parentComponent, anchor);
        i++;
      }
    }
  } else if (i > e2) { // common sequence + unmount
    while (i <= e1) {
      hostRemove(c1[i].el);
      i++;
    }
  } else { // 中间对比
    let s1 = i;
    let s2 = i;
    const toBePatched = e2 - s2 + 1;
    let patched = 0;
    const keyToNewIndexMap = new Map();
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
    let moved = false; // 优化考虑
    let maxNewIndexSoFar = 0; // 优化考虑

    for (let i = s2; i <= e2; i++) {
      const nextChild = c2[i];
      keyToNewIndexMap.set(nextChild.key, i); // 依据 key 值构成映射表
    }

    for (let i = s1; i <= e1; i++) {
      const prevChild = c1[i];

      if (patched >= toBePatched) { // 
        hostRemove(prevChild.el);
        continue;
      }
      // 拿老的节点去映射表里面找
      let newIndex;
      if (prevChild.key != null) { // 有key
        newIndex = keyToNewIndexMap.get(prevChild.key);
      } else {
        for (let j = s2; j <= e2; j++) { // 无 key
          if (isSameVNodeType(prevChild, c2[j])) {
            newIndex = j;
            break;
          }
        }
      }

      if (newIndex === undefined) { // 索引不存在,说明老的节点在c2中不存在,需要移除
        hostRemove(prevChild.el);
      } else { // 老的节点在c2中存在,进行patch
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex;
        } else {
          moved = true;
        }

        newIndexToOldIndexMap[newIndex - s2] = i + 1;
        patch(prevChild, c2[newIndex], container, parentComponent, null);
        patched++;
      }
    }

    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap) // 最长递增子序列
      : [];
    let j = increasingNewIndexSequence.length - 1;

    for (let i = toBePatched - 1; i >= 0; i--) { // 从后向前插入
      const nextIndex = i + s2;
      const nextChild = c2[nextIndex];
      const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null;

      if (newIndexToOldIndexMap[i] === 0) { // 没有被patch过,说明需要新增
        patch(null, nextChild, container, parentComponent, anchor);
      } else if (moved) {
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          hostInsert(nextChild.el, container, anchor);
        } else {
          j--;
        }
      }
    }
  }
}

总结

以上即为 Vue3 的更新流程,除了 diff 算法比对,另额外加入了最长递增子序列的算法来计算需要移动的元素,从而减少移动次数。

转载自:https://juejin.cn/post/7178038360890409019
评论
请登录