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

737 阅读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. 组件自身的状态变化,触发了组件更新,此时 nextnull,如果是这种情况的话,直接把当前的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.elsubTree.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,并且是热更新触发,那就强制全量 diffclassstyleprops ),不采用优化的 patch 方案
  6. dynamicChildren (动态节点)只有使用 template 开发时会生成,手写的渲染函数不行,在 compilertemplate 转为 render 的过程中,compiler 会生成一些辅助的信息用于优化组件更新优化时根据辅助信息,只处理可能会变化的项。比如非优化模式组件更新时我们可能要遍历处理每一个节点,并且处理每一个节点 class 的变化、 style 的变化、 props 的变化等。但是有了辅助信息告诉我们哪些节点会变化、是节点的 class 变?还是 style 变?还是 props 变?就只处理可能会变的项,这样一来,大大加快了 patch 的速度。判断如果有 dynamicChildren ,那就调用 patchBlockChildren 处理子节点;否则就调用 patchChildren 处理子节点。
  7. 等子节点处理完了,再来处理该节点的 classstyleprops
  8. 如果 patchFlag 的存在意味着该节点的 render 是由编译器生成的(也就是用户是通过 template 开发),可以采用优化的 diff 。在这个分支中,旧节点和新节点保证具有相同的形状(即在源模板中的完全相同的位置)。
    1. 如果 patchFlag > 0, 再判断 patchFlag & PatchFlags.FULL_PROPS (当节点存在动态 keyprop 时,就需要全量的 diff ),否则再分别判断 patchFlag & PatchFlags.CLASSpatchFlag & PatchFlags.STYLEpatchFlag & PatchFlags.PROPS ,执行相应的操作。最后还会根据 patchFlag & PatchFlags.TEXT 执行文本更新操作。
    2. 如果!optimized && dynamicChildren == null ,那就直接执行和 patchFlag & PatchFlags.FULL_PROPS 相同的操作。
    3. 从代码中不难看出, FULL_PROPSCLASSSTYLEPROPS 是互斥的,因为 FULL_PROPSpatch ,包括了 CLASSSTYLEPROPS
  9. 调用 onVnodeUpdated hook

为了好理解,放上一个表格( FULL_PROPSCLASSSTYLEPROPS 场景以及对应的 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>

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

接着咱们分析一下如何实现 CLASS、STYLE、PROPSpatch,他们都是调用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 的不同调用不同的方法处理,比如:

  • keyclass 调用 patchClass
  • keystyle 调用 patchStyle
  • keyonXxx 调用 patchEvent
  • 其他调用 patchDOMProppatchAttr

看下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

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

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