美文网首页
Vue 3.0组件的更新流程和diff算法详解

Vue 3.0组件的更新流程和diff算法详解

作者: chonglingliu | 来源:发表于2021-08-13 00:43 被阅读0次

    上篇文章我们介绍了组件的渲染流程,本篇文章我们来介绍响应式数据变化后组件的更新渲染流程。最后有不看文章的分析总结图。

    案例

    为了方便介绍流程,我们这里举一个例子:

    • App组件中有一个Hello组件,并且赋值msg这个prop值给Hello组件;
    • msgVue 3时,App组件中有li标签数组显示vue3.feature,即显示Vue 3的新特性,当msgVue 2时则不显示;
    • App组件中有一个按钮切换msg的值。
    App.vue
    <template>
      <HelloWorld :msg="msg" />
      <h1>App 组件显示:</h1>
      <ul>
        <li v-for="item in vue3.feature" v-bind:key="item">{{ item }}</li>
      </ul>
      <button @click="changeMsg">切换</button>
    </template>
    
    <script lang="ts">
    import { defineComponent, reactive, ref } from "vue";
    import HelloWorld from "./components/HelloWorld.vue";
    
    export default defineComponent({
      name: "App",
      components: {
        HelloWorld,
      },
      setup() {
        const msg = ref("Vue 2");
    
        const feature3: string[] = ["reactive", "composition api", "setup", "toRef", "Teleport"];
        const feature2: string[] = ["reactive", "option api"];
        const vue3 = reactive({ feature: feature2});
        let current = 0;
    
        const changeMsg = () => {
          if (current == 0) {
            msg.value = "Vue 3";
            vue3.feature = feature3;
            current = 1;
          } else {
            msg.value = "Vue 2";
            vue3.feature = feature2;
            current = 0;
          }
        };
    
        return {
          msg,
          vue3,
          changeMsg,
        };
      },
    });
    </script>
    
    
    Hello.vue
    <template>
      <h1>Hello 组件显示:{{ msg }}</h1>
    </template>
    
    <script lang="ts">
    import { ref, defineComponent } from "vue";
    export default defineComponent({
      name: "HelloWorld",
      props: {
        msg: {
          type: String,
          required: true,
        },
      },
      setup: () => {
    
      },
    });
    </script>
    
    效果图如下
    1.gif

    副作用渲染函数componentUpdateFn开启组件重新渲染

    我们上篇文章提到过组件挂载的时候会创建一个副作用渲染函数componentUpdateFn,这个函数在响应式数据变化后则会被调用。

    数据变化后为什么就会引发副作用渲染函数的调用?这是Vue 3.0响应式系统的相关内容,后续介绍。目前知道是这个逻辑就行。

    const componentUpdateFn = () => {
      // 1. 
      if (!instance.isMounted) {
        
        instance.isMounted = true
    
      } else {
        let { next, bu, u, parent, vnode } = instance
        let originNext = next
        let vnodeHook: VNodeHook | null | undefined
        
        // 2. 
        if (next) {
          next.el = vnode.el
          updateComponentPreRender(instance, next, optimized)
        } else {
          next = vnode
        }
        
        // 3
        const nextTree = renderComponentRoot(instance)
        
        const prevTree = instance.subTree
        instance.subTree = nextTree
        
        // 4
        patch(
          prevTree,
          nextTree,
          // parent may have changed if it's in a teleport
          hostParentNode(prevTree.el!)!,
          // anchor may have changed if it's in a fragment
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          isSVG
        )
      }
    }
    
    1. componentUpdateFn只有第一次执行的时候执行挂载逻辑,第一次执行后isMounted被置为true,后面都是执行更新的逻辑;
    2. 组件自己更新的场景下,next为空,将next指向组件对象自己的vnode;
    3. renderComponentRoot更新子树VNode,本例子中主要是将子树VNode的第一个和第三个子VNode的数据进行更新;
      差异
    4. patch用来对比新旧子树VNode,找到合适的方式更新DOM

    patch 更新组件的逻辑

    const patch: PatchFn = (
      n1,
      n2,
      container,
      anchor = null,
      parentComponent = null,
      parentSuspense = null,
      isSVG = false,
      slotScopeIds = null,
      optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
    ) => {
      // 1. 
      if (n1 === n2) {
        return
      }
    
      // 2. 
      if (n1 && !isSameVNodeType(n1, n2)) {
        anchor = getNextHostNode(n1)
        unmount(n1, parentComponent, parentSuspense, true)
        n1 = null
      }
    
      // 3. 
      const { type, ref, shapeFlag } = n2
      switch (type) {
        // 省略 ...
        default:
          if (shapeFlag & ShapeFlags.ELEMENT) {
            processElement(
              n1,
              n2,
              container,
              anchor,
              parentComponent,
              parentSuspense,
              isSVG,
              slotScopeIds,
              optimized
            )
          } else if (shapeFlag & ShapeFlags.COMPONENT) {
            processComponent(
              n1,
              n2,
              container,
              anchor,
              parentComponent,
              parentSuspense,
              isSVG,
              slotScopeIds,
              optimized
            )
          }
        // 省略 ...
      }
    
    }
    
    
    1. 如果新旧VNode节点是同一个,则直接返回不做处理;
    2. 如果新旧VNode节点的类型不同,那就将旧的VNode节点卸载,然后将旧的VNode节点置空,最后走挂载逻辑;
    3. 如果新旧VNode节点的类型相同,会根据不同的VNode类型走不同更新逻辑,譬如组件走processComponent流程, 普通DOM元素节点走processElement流程。
      处理逻辑
      本例中第一个子节点是组件VNode节点走processComponent,其他几个VNode节点走processElement流程。

    子组件更新流程updateComponent

    App组件对象的子树VNode的第一个子节点VNodeHello组件对象的VNode,其prop值变化了,所以Hello组件对象需要更新渲染,接下来我们就来看看Hello子组件的更新逻辑processComponent

    const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
      const instance = (n2.component = n1.component)!
      // 1.
      if (shouldUpdateComponent(n1, n2, optimized)) {
        // 2.   
        instance.next = n2
        // 3.
        invalidateJob(instance.update)
        // 4.
        instance.update()
      } else {
        // 2.
        n2.component = n1.component
        n2.el = n1.el
        instance.vnode = n2
      }
    }
    
    
    1. 首先使用shouldUpdateComponent判断组件是否需要重新渲染,因为有些VNode值的变化并不需要立即显示更新。更新的条件包括propchildren的变化等;
    2. 给组件对象设置了next值,也就是说如果是组件自己更新是没有设置next,如果是父组件触发更新,则子组件对象有设置这个next值;
      next赋值
    3. 更新队列中取消子组件对象的更新,避免重复更新;
    4. 子组件的副作用渲染函数componentUpdateFn被调用,进入了又一轮的递归调用;
    • 问题:为什么子组件对象重新渲染需要设置next值?
    • 答案:此时子组件对象不知道需要更新到的VNode, 所有需要赋值给子组件对象让其知道如何更新渲染。

    父组件触发的子组件的副作用渲染函数componentUpdateFn的和组件自身触发的区别

    let { next, bu, u, parent, vnode } = instance
    let originNext = next
            
    if (next) {
      next.el = vnode.el
      updateComponentPreRender(instance, next, optimized)
    } else {
      next = vnode
    }
    

    区别就在于父组件对象触发的子组件的VNodenext值,此时需要执行updateComponentPreRender,从而在渲染前完成propsslot等属性的赋值;

    • 问题:组件对象自身触发的渲染为什么不需要执行updateComponentPreRender方法?
    • 答案:组件对象在挂载的时候已经执行过了updateComponentPreRender方法,所以自身触发的情景下只需要更新一些属性值就行,要么通过updateComponentPreRender,要么直接给设置vnode属性值。

    普通元素节点更新入口patchElement

    const patchElement = (
      n1: VNode,
      n2: VNode,
      parentComponent: ComponentInternalInstance | null,
      parentSuspense: SuspenseBoundary | null,
      isSVG: boolean,
      slotScopeIds: string[] | null,
      optimized: boolean
    ) => {
      // 1.
      patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
      // 2. 
      patchChildren(
        n1,
        n2,
        el,
        null,
        parentComponent,
        parentSuspense,
        areChildrenSVG,
        slotScopeIds,
        false
      )
    }
    
    

    这个方法特别的长,功能是通过patchProps更新propsstyleclassevent等;通过patchChildren更新子节点。

    接下来我们就来重点介绍下子节点的更新逻辑。

    普通元素节点的子节点更新patchChildren

    const patchChildren: PatchChildrenFn = (
      n1,
      n2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized = false
    ) => {
        
      // 1.  
      const c1 = n1 && n1.children
      const prevShapeFlag = n1 ? n1.shapeFlag : 0
      const c2 = n2.children
    
      const { patchFlag, shapeFlag } = n2
      if (patchFlag > 0) {
        // 2.
        if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
          patchKeyedChildren(
            c1 as VNode[],
            c2 as VNodeArrayChildren,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
          return
        } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
          // unkeyed
          patchUnkeyedChildren(
            c1 as VNode[],
            c2 as VNodeArrayChildren,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
          return
        }
      }
    
      
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 3. 
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
        }
        if (c2 !== c1) {
          hostSetElementText(container, c2 as string)
        }
      } else {
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 4. 
          if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            patchKeyedChildren(
              c1 as VNode[],
              c2 as VNodeArrayChildren,
              container,
              anchor,
              parentComponent,
              parentSuspense,
              isSVG,
              slotScopeIds,
              optimized
            )
          } else {
            unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
          }
        } else {
          // 5.
          if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
            hostSetElementText(container, '')
          }
          if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            mountChildren(
              c2 as VNodeArrayChildren,
              container,
              anchor,
              parentComponent,
              parentSuspense,
              isSVG,
              slotScopeIds,
              optimized
            )
          }
        }
      }
    }
    
    

    普通元素节点的子节点有三种情况:

    子节点类型 例子
    数组子节点 <ul><li>1</li><li>1</li><li>1</li></ul>
    文本子节点 <div>文本</div>
    空子节点 <img />

    patchChildren针对这三种情况进行分别处理, 9种情况:

    行-旧节点,列-新节点 数组子节点 文本子节点 空子节点
    数组子节点 diff比对 卸载数组节点,设置文本 卸载数组节点
    文本子节点 将文本节点替换为数组节点 文本替换 去掉文本节点
    空子节点 挂载数组子节点 设置文本 不操作

    没有v-key数组子节点的比对patchUnkeyedChildren

    const patchUnkeyedChildren = (
      c1: VNode[],
      c2: VNodeArrayChildren,
      container: RendererElement,
      anchor: RendererNode | null,
      parentComponent: ComponentInternalInstance | null,
      parentSuspense: SuspenseBoundary | null,
      isSVG: boolean,
      slotScopeIds: string[] | null,
      optimized: boolean
    ) => {
      c1 = c1 || EMPTY_ARR
      c2 = c2 || EMPTY_ARR
      const oldLength = c1.length
      const newLength = c2.length
      const commonLength = Math.min(oldLength, newLength)
      let I
      for (i = 0; i < commonLength; i++) {
        const nextChild = (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[I]))
        patch(
          c1[I],
          nextChild,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      }
      if (oldLength > newLength) {
        // remove old
        unmountChildren(
          c1,
          parentComponent,
          parentSuspense,
          true,
          false,
          commonLength
        )
      } else {
        // mount new
        mountChildren(
          c2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized,
          commonLength
        )
      }
    }
    

    这个方法逻辑简单:先将两个数组从前往后逐个patch,当某个数组对比完成后,如果新的子节点数组还有元素就将剩下的节点进行mountChildren挂载,如果是旧节点有剩余的则unmountChildren卸载。

    这个方法简单,但是效率比较低,。我们接下来分析高效的比对方法。

    v-key数组子节点的高效比对patchKeyedChildren

    这个逻辑很长,我们分拆来分析:

    1. 同步头部节点

    旧节点 (a b) c

    新节点 (a b) d e

    先从两个数组的头部开始比对,如果节点是相同的VNode类型,执行patch更新节点,否则同步结束。
    上面例子中第三个节点的时 同步头部节点这一逻辑结束。

    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index
    
    // 1. sync from start
    // (a b) c
    // (a b) d e
    while (i <= e1 && i <= e2) {
      const n1 = c1[I]
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[I]))
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        break
      }
      I++
    }
    
    
    2. 同步尾部节点

    // a (b c)

    // d e (b c)

    先从两个数组的尾部开始比对,如果节点是相同的VNode类型,执行patch更新节点,否则同步尾部结束。
    上面例子中倒数第三个节点的时 同步尾部部节点这一逻辑结束。

    // 2. sync from end
    // a (b c)
    // d e (b c)
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1]
      const n2 = (c2[e2] = optimized
        ? cloneIfMounted(c2[e2] as VNode)
        : normalizeVNode(c2[e2]))
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        break
      }
      e1--
      e2--
    }
    
    3. 新子节点数组有需要添加的新子节点

    (a b)

    (a b) c

    if (i > e1) { // 旧子节点到了尾部
      if (i <= e2) { // 新子节点剩余节点
        const nextPos = e2 + 1
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        // 逐个挂载
        while (i <= e2) {
          patch(
            null,
            (c2[i] = optimized
              ? cloneIfMounted(c2[i] as VNode)
              : normalizeVNode(c2[i])),
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
          I++
        }
      }
    }
    
    4. 旧子节点数组有需要卸载子节点

    (a b) c (d e)

    (a b) (d e)

    else if (i > e2) {
      while (i <= e1) {
        unmount(c1[i], parentComponent, parentSuspense, true)
        I++
      }
    }
    
    5. 处理未知子序列

    // [i ... e1 + 1]: a b [c d j] f g

    // [i ... e2 + 1]: a b [e d c h] f g

    // i = 2, e1 = 4, e2 = 5

    • 1.建立新子序列的索引图---未知新子序列的每个节点在新子序列中对应的索引值
    // 5.1 build key:index map for newChildren
    const keyToNewIndexMap: Map<string | number, number> = new Map()
    for (i = s2; i <= e2; i++) {
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[I]))
      if (nextChild.key != null) {
        if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
          warn(
            `Duplicate keys found during update:`,
            JSON.stringify(nextChild.key),
            `Make sure keys are unique.`
          )
        }
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }
    
    

    结果:

    {
        {"e" => 2},
        {"d" => 3},
        {"c" => 4},
        {"h" => 5}
    }
    
    • 2.遍历旧子序列,有相同的key就执行patch更新,并且移除不在新子序列中的节点,并且确定序列是否有排列顺序的变化。
    • 建一个newIndexToOldIndexMap数组,数组长度是未知新子序列的长度,每个元素的初始值为0,当最后处理完还是0,那说明这个节点是新添加的节点;
    • 正序遍历旧子序列查找旧子序列节点在新子序列中的索引,如果找不到说明新子序列中没有该节点,这个节点需要卸载;如果找到了,就将其在旧子序列中的索引更新到newIndexToOldIndexMap`中,索引加了1;
    • 利用maxNewIndexSoFar来计算新子节点的顺序是否有更换,如果有更换将moved设置为true;
    • 如果新子节点序列已经遍历完成,旧子节点还有元素,直接卸载节点即可。
    let j
    let patched = 0
    const toBePatched = e2 - s2 + 1
    let moved = false
    // used to track whether any node has moved
    let maxNewIndexSoFar = 0
    // works as Map<newIndex, oldIndex>
    // Note that oldIndex is offset by +1
    // and oldIndex = 0 is a special value indicating the new node has
    // no corresponding old node.
    // used for determining longest stable subsequence
    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
    
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[I]
      if (patched >= toBePatched) {
        // all new children have been patched so this can only be a removal
        unmount(prevChild, parentComponent, parentSuspense, true)
        continue
      }
      let newIndex
      if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // key-less node, try to locate a key-less node of the same type
        for (j = s2; j <= e2; j++) {
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j] as VNode)
          ) {
            newIndex = j
            break
          }
        }
      }
      if (newIndex === undefined) {
        unmount(prevChild, parentComponent, parentSuspense, true)
      } else {
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex
        } else {
          moved = true
        }
        patch(
          prevChild,
          c2[newIndex] as VNode,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        patched++
      }
    }
    

    结果:

    newIndexToOldIndexMap: [0, 4, 3, 0] // d在旧子节点的索引是3,c在旧子节点的所以为2,e,h都是新增的节点 
    moved: true
    
    • 3.移动和挂载子节点
    • 如果movedtrue, 则求解最大递增子序列increasingNewIndexSequence,最大递增子序列能够让移动的次数最小化;
      本例子中得到的的值为[0, 2],表示newIndexToOldIndexMap对应的0, 3
    • 倒序遍历新子节点,如果newIndexToOldIndexMap对应的索引的值为0,说明新增的节点,进行挂载;
    • 倒序遍历新子节点,如果碰到了不是increasingNewIndexSequence中的对应索引下元素的值值则需要移动,否则不进行操作;

    我们用上面的例子解释下:

    循环次数 新子节点索引 新子节点 increasingNewIndexSequence的索引 increasingNewIndexSequence[索引] newIndexToOldIndexMap[循环次数] 进行的操作
    1 5 h 1 3 0 直接挂载h
    2 4 c 1 3 3 c不进行操作,将increasingNewIndexSequence的索引-1,变为0
    3 3 d 0 0 4 取到元素d,移动到c前面
    4 2 e 0 0 0 直接挂载e

    总结

    详解

    相关文章

      网友评论

          本文标题:Vue 3.0组件的更新流程和diff算法详解

          本文链接:https://www.haomeiwen.com/subject/aoarbltx.html