Vue3源码阅读——组件更新的流程是怎样的

1,094 阅读8分钟

前言

本文属于笔者Vue3源码阅读系列第六篇文章,往期精彩:

  1. 生成vnode到渲染vnode的过程是怎样的
  2. 组件创建及其初始化过程
  3. 响应式实现——reactive篇
  4. 响应式是如何实现的(ref + ReactiveEffect篇)
  5. 响应式是如何实现的(track + trigger篇)

本文主要内容为组件更新的大致流程(不会涉及到很具体的更新细节,比如diff算法)。废话不多说,接下来咱们就走进正文。

组件更新流程

同样的,还是以之前的例子来分析:

// Main.vue
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const visible = ref(true)
const count = ref(0)

function onClick() {
  visible.value = !visible.value
}

</script>

<template>
  <div>
    <div v-if="visible">{{ count }}</div>
    <button @click="onClick">click</button>
    <Child :visible="visible"></Child>
  </div>
</template>

// Child.vue
<template>
  <div>{{ props.visible }}</div>
</template>

<script setup>
const props = defineProps(['visible'])
</script>

初始化流程,依赖收集,以及trigger副作用函数重新执行重新收集依赖在之前的文章中都已详细分析过,本文的重点在于副作用函数执行的过程,而这就恰好是组件更新过程。

当点击了按钮,执行 visible.value = !visible.value,然后执行 ref 实例的 set 方法,通过 hasChanged 判断新旧值是否相等,如果值变化了就触发 triggerRefValue。

image.png

从上面几篇文章我们知道接下来就触发Main.vue对应的effect对象的run,在run中执行了组件对应的更新逻辑:

run() {
  // ....
  // 组件更新的逻辑
  return this.fn()
  //....
}

那这个 this.fn 是什么呢?

这个在初始化流程中写到过,在初始化过程中,调用setupRenderEffect:

image.png

所以执行this.fn就相当于在执行这个componentUpdateFn:

componentUpdateFn

const componentUpdateFn = () => {
      // 判断组件是否挂载
      if (!instance.isMounted) {
        // 初始化执行的逻辑
      } else {
        // 组件更新的逻辑,这里会有两种情况:
        // 1. 组件自身的状态变化,触发了组件更新,此时 next 为 null
        // 2. 父组件的更新,调用 processComponent 触发了子组件的更新,此时 next 为 VNode
        let { next, bu, u, parent, vnode } = instance
        let originNext = next
        let vnodeHook: VNodeHook | null | undefined
        // 省略一些非主要的逻辑...
        // Disallow component effect recursion during pre-lifecycle hooks.
        toggleRecurse(instance, false)
        if (next) {
          next.el = vnode.el
          updateComponentPreRender(instance, next, optimized)
        } else {
          next = vnode
        }
        // beforeUpdate钩子逻辑省略...
        toggleRecurse(instance, true)
        
        // 省略 render 函数执行时间统计逻辑
        
        // 执行render得到新的组件subTree
        const nextTree = renderComponentRoot(instance)
        // 缓存旧的组件subTree
        const prevTree = instance.subTree 
        // 更新组件subTree
        instance.subTree = nextTree

        // 省略 patch 过程时间统计逻辑
        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
        )
        // 当组件自身状态触发组件更新时,
        next.el = nextTree.el
        if (originNext === null) {
          // self-triggered update. In case of HOC, update parent component
          // vnode el. HOC is indicated by parent instance's subTree pointing
          // to child component's vnode
          updateHOCHostEl(instance, nextTree.el)
        }
        // 省略 updated 钩子的逻辑
      }
    }

初始化执行的逻辑在初始化流程中也分析过,本文主要关注else分支——组件更新的逻辑,大致逻辑如下:

  1. 先从当前组件实例上获取 next、beforeUpdate、updated、parent、vnode。

  2. 值得注意的是 next 属性用于区分组件的更新是如何触发的。触发组件更新可能会有两种情况: a. 组件自身的状态变化,触发了组件更新,此时 next 为 null,如果是这种情况的话,直接把当前的vnode 赋值给 next。

    b. 父组件的更新,调用 processComponent 触发了子组件的更新,此时 next 为 子组件新的vnode,新的 vnode 还没有 el,此时先把旧 vnode.el 赋值给新 vnode.el,然后调用updateComponentPreRender:

    const updateComponentPreRender = (
        instance: ComponentInternalInstance,
        nextVNode: VNode,
        optimized: boolean
      ) => {
        // 设置新vnode的组件实例
        nextVNode.component = instance
        // 得到旧的props
        const prevProps = instance.vnode.props
        // 更新组件vnode为新的
        instance.vnode = nextVNode
        // 将next设置为null
        instance.next = null
        // 更新组件的props
        updateProps(instance, nextVNode.props, prevProps, optimized)
        // 更新组件的slots
        updateSlots(instance, nextVNode.children, optimized)
        // 设置暂停track
        pauseTracking()
        // props更新可能会触发一些watcher,比如watch、computed,在执行render之前,先执行这些副作用
        flushPreFlushCbs()
        // 设置恢复track
        resetTracking()
     }
    
  3. 然后就是执行 beforeUpdate 钩子,如果是开发环境还会对执行 render、patch 计时,用于 vue-devtools。

  4. 执行 render 得到新的 subTree。

  5. 基于新旧 subTree 执行 patch,开始更新DOM。

  6. 更新 next.el 为 subTree.el。

  7. 然后就是执行 updated 钩子。

接下来看下本例patch的过程:

patch

在组件的初始化流程中,也是通过调用 patch 挂载 DOM,只不过初始化时没有旧的 subTree。

  const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
  ) => {
    if (n1 === n2) {
      return
    }
    
    // 如果有旧的vnode,并且新旧vnode不是同一种type,直接卸载旧vnode
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null
    }
    // 禁用优化patch
    if (n2.patchFlag === PatchFlags.BAIL) {
      optimized = false
      n2.dynamicChildren = null
    }
    // 根据新vnode节点的 type 和 shapeFlag 确定调用具体的处理逻辑,比如处理element节点、component、文本等
    const { type, ref, shapeFlag } = n2
    switch (type) {
      case Text:
        processText(n1, n2, container, anchor)
        break
      case Comment:
        processCommentNode(n1, n2, container, anchor)
        break
      case Static:
        if (n1 == null) {
          mountStaticNode(n2, container, anchor, isSVG)
        } else if (__DEV__) {
          patchStaticNode(n1, n2, container, isSVG)
        }
        break
      case Fragment:
        processFragment(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        break
      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
          )
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          ;(type as typeof TeleportImpl).process(
            n1 as TeleportVNode,
            n2 as TeleportVNode,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized,
            internals
          )
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          ;(type as typeof SuspenseImpl).process(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized,
            internals
          )
        } else if (__DEV__) {
          warn('Invalid VNode type:', type, `(${typeof type})`)
        }
    }

对于本例,调用 patch,由于根节点是 element,会调用 processElement:

image.png

然后 processElement 中根据判断有没有 旧vnode 决定调用 mountElement/patchElement:

const processElement = (//...) => {
    //...
    if (n1 == null) {
      mountElement(//...)
    } else {
      patchElement(//...)
    }
  }

那么接下来就走到 patchElement:

patchElement

截屏2023-07-19 11.00.00.png image.png
  1. n2.el 可能为 null,先赋值为 n1.el
  2. 确定 patchFlag
  3. 拿到新旧 props
  4. 调用 onVnodeBeforeUpdate hook
  5. 如果是 dev,并且是热更新触发,那就强制全量 diff( class 、 style 、 props ),不采用优化的 patch 方案
  6. dynamicChildren (动态节点)只有使用 template 开发时会生成,手写的渲染函数不行,在 compiler 将 template 转为 render 的过程中,compiler 会生成一些辅助的信息用于优化组件更新优化时根据辅助信息,只处理可能会变化的项。比如非优化模式组件更新时我们可能要遍历处理每一个节点,并且处理每一个节点 class 的变化、 style 的变化、 props 的变化等。但是有了辅助信息告诉我们哪些节点会变化、是节点的 class 变?还是 style 变?还是 props 变?就只处理可能会变的项,这样一来,大大加快了 patch 的速度。判断如果有 dynamicChildren ,那就调用 patchBlockChildren 处理子节点;否则就调用 patchChildren 处理子节点。
  7. 等子节点处理完了,再来处理该节点的 class 、 style 、 props 。
  8. 如果 patchFlag 的存在意味着该节点的 render 是由编译器生成的(也就是用户是通过 template 开发),可以采用优化的 diff 。在这个分支中,旧节点和新节点保证具有相同的形状(即在源模板中的完全相同的位置)。
    1. 如果 patchFlag > 0, 再判断 patchFlag & PatchFlags.FULL_PROPS (当节点存在动态 key 的 prop 时,就需要全量的 diff ),否则再分别判断 patchFlag & PatchFlags.CLASS 、 patchFlag & PatchFlags.STYLE 、 patchFlag & PatchFlags.PROPS ,执行相应的操作。最后还会根据 patchFlag & PatchFlags.TEXT 执行文本更新操作。
    2. 如果!optimized && dynamicChildren == null ,那就直接执行和 patchFlag & PatchFlags.FULL_PROPS 相同的操作。
    3. 从代码中不难看出, FULL_PROPS 和 CLASS 、 STYLE 、 PROPS 是互斥的,因为 FULL_PROPS 的 patch ,包括了 CLASS 、 STYLE 、 PROPS 。
  9. 调用 onVnodeUpdated hook 。

为了好理解,放上一个表格( FULL_PROPS 、 CLASS 、 STYLE 、 PROPS 场景以及对应的 patchFlag 值):

PatchFlagspatchFlagtemplate
TEXT(1)1<div >{{ count }}</div>
CLASS(2)2<div :class="cls">text</div>
CLASS(2)3 = 1 + 2<div :class="cls">{{ count }}</div>
STYLE(4)4<div :style="sty">text</div>
STYLE(4)5 = 1 + 4<div :style="sty">{{ count }}</div>
PROPS(8)15 = 2 + 4 + 8 + 1<div :class="cls" :style="sty" :value="visible">{{ count }}</div>
FULL_PROPS(16)16<div :[key]="visible">text</div>
FULL_PROPS(16)17<div :class="cls" :style="sty" :[key]="visible">{{ count }}</div>

下图是一张compiler将template生成render,并识别patchFlag的例子: image.png

接着咱们分析一下如何实现 CLASS、STYLE、PROPS 的patch,他们都是调用hostPatchProp:

PatchFlags
CLASShostPatchProp(el, 'class', null, newProps.class, isSVG)
STYLEhostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
PROPShostPatchProp(el, key, prev, next, isSVG, n1.children as VNode[], parentComponent, parentSuspense, unmountChildren)

hostPatchProp

截屏2023-07-21 14.02.53.png

在patchProp中,根据key 的不同调用不同的方法处理,比如:

  • key 为 class 调用 patchClass
  • key 为 style 调用 patchStyle
  • key 为 onXxx 调用 patchEvent
  • 其他调用 patchDOMProp 或 patchAttr

看下patchClass、patchStyle、patchEvent:

image.png 是我们所熟悉的`DOM`操作,`patchDOMProp、patchAttr`在此就不截图了,可到源码中阅读。

再来看下FULL_PROPS调用的 patchProps:

patchProps

image.png

根据newProps、oldProps,先去掉oldProps中包含,但是newProps中不包含,并且不在isReservedProp中的key;

export const isReservedProp = /*#__PURE__*/ makeMap(
  // the leading comma is intentional so empty string "" is also included
  ',key,ref,ref_for,ref_key,' +
    'onVnodeBeforeMount,onVnodeMounted,' +
    'onVnodeBeforeUpdate,onVnodeUpdated,' +
    'onVnodeBeforeUnmount,onVnodeUnmounted'
)

然后以newProps为准,遍历newProps更新prop。

对于本例,dynamicChildren中会有两个节点:

 <div v-if="visible">{{ count }}</div>
 <Child :visible="visible"></Child>

那么就会调用patchBlockChildren:

patchBlockChildren

image.png

patchBlockChildren中遍历children,然后确定vnode节点的DOM,调用patch。

第一个节点新旧vnode如下,新的vnode中是一个注释节点,旧的是一个文本节点:

image.png

由于节点类型不一致,调用patch,会先调用unmount将旧的节点DOM移除,然后调用 hostInsert 生成新的注释节点插入到container,接着就是Child组件的更新,Props发生变化,调用patch -> processComponent -> updateComponent:

updateComponent

image.png

在updateComponent中:

  • 如果组件是异步的并且没有加载完成,那就只需要更新组件的props、slots。
  • 设置 instance.next = n2,用于识别是父组件更新引起的子组件更新,然后调用instance.update(),然后就又回到componentUpdateFn啦,和初始化时一样,更新也是基于组件的subTree进行递归调用patch的过程。

总结

本文主要通过一个示例来分析组件更新的大致流程,如下图所示:

image.png

这是笔者第六篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!

如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞】都是我创作的最大动力 ^_^。