Skip to content

实现组件更新流程

本节对标 Vue 3 源码 @vue/runtime-core 中的 renderer.ts —— updateComponent、componentUpdateFn 部分

组件更新的触发时机

组件更新发生在父组件 re-render 时。当父组件的响应式数据变化导致重新渲染,patch 过程中遇到子组件 VNode,就需要判断子组件是否需要更新:

父组件 state 变化


父组件 render effect 重新执行


生成新的 VNode 树(包含新的子组件 VNode)


patch(oldVNode, newVNode)
    │  遇到组件类型 VNode

processComponent(n1, n2)
    │  n1 !== null(已挂载)

updateComponent(n1, n2)  ← 组件更新入口

组件更新有两种触发方式:

  1. 外部触发 — 父组件 re-render,传给子组件的 props/slots 变化
  2. 内部触发 — 子组件自身的响应式数据变化,触发自己的 render effect

shouldUpdateComponent —— 判断是否需要更新

对标 packages/runtime-core/src/componentRenderUtils.ts —— shouldUpdateComponent

并非每次父组件 re-render 都需要更新子组件。通过对比新旧 props,可以跳过不必要的更新:

ts
// 判断组件是否需要更新:通过对比新旧 VNode 的 props 和 children 决定
export function shouldUpdateComponent(
  prevVNode: VNode,   // 旧的组件 VNode
  nextVNode: VNode,   // 新的组件 VNode
): boolean {
  const { props: prevProps, children: prevChildren } = prevVNode
  const { props: nextProps, children: nextChildren } = nextVNode

  // 如果有 slots children,保守地总是更新
  // 因为插槽是函数,无法通过简单比较判断其输出是否变化
  if (prevChildren || nextChildren) {
    if (!(nextChildren && (nextChildren as any).$stable)) {
      return true  // 非 $stable 的 slots 总是需要更新
    }
  }

  // props 引用完全相同 → 没有任何变化,跳过更新
  if (prevProps === nextProps) {
    return false
  }

  // 旧的没有 props,判断新的是否有 → 有则需要更新
  if (!prevProps) {
    return !!nextProps
  }
  // 旧的有 props,新的没有 → 需要更新(props 被移除了)
  if (!nextProps) {
    return true
  }

  // 逐个对比 props 的 key 和值
  return hasPropsChanged(prevProps, nextProps)
}

// 浅比较两个 props 对象是否发生变化
function hasPropsChanged(
  prevProps: Record<string, any>,
  nextProps: Record<string, any>,
): boolean {
  const nextKeys = Object.keys(nextProps)

  // key 数量不同 → 一定有变化
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }

  // 逐个对比每个 key 的值(浅比较,使用 !==)
  for (const key of nextKeys) {
    if (nextProps[key] !== prevProps[key]) {
      return true
    }
  }

  return false  // 所有 key 和值都相同
}

判断逻辑解析

shouldUpdateComponent(n1, n2)

    ├── 有 slots children? → true(保守策略,总是更新)

    ├── prevProps === nextProps? → false(引用相同,无变化)

    ├── 一方没有 props? → 比较是否都无 props

    └── hasPropsChanged → 逐 key 浅比较

注意这里使用的是浅比较!==),和 React 的 shallowEqual 策略一致。如果 props 中传了新的对象引用(即使内容相同),也会触发更新。

updateComponent —— 组件更新入口

ts
// 组件更新入口:在 patch 中遇到已挂载的组件 VNode 时调用
function updateComponent(n1: VNode, n2: VNode) {
  // 复用旧 VNode 上的组件实例(组件实例在整个生命周期内只创建一次)
  const instance = (n2.component = n1.component!)

  if (shouldUpdateComponent(n1, n2)) {
    // 需要更新:将新 VNode 存入 instance.next,作为更新的数据来源
    instance.next = n2
    // 手动调用 instance.update() 触发 render effect 重新执行
    // 这会进入 componentUpdateFn 的 update 分支
    instance.update()
  } else {
    // 不需要更新:仅同步 VNode 引用,跳过 render 过程
    n2.el = n1.el              // 复用 DOM 元素
    n2.component = n1.component // 保持组件实例引用
    instance.vnode = n2         // 更新实例的 vnode 为最新的
  }
}

关键设计:instance.next

instance.next 是组件更新机制的核心:

ts
instance.next = n2  // 保存新的组件 VNode
instance.update()   // 触发 render effect

为什么不直接更新 props/slots,而是先存到 next?

因为 instance.update() 会重新执行 componentUpdateFn,在那里统一处理更新前的准备工作(更新 props、slots 等)。这样可以将"外部触发的更新"和"内部触发的更新"统一到同一个代码路径:

外部触发(父组件 re-render):
    updateComponent → instance.next = n2 → instance.update()

内部触发(自身 state 变化):                      │
    trigger → scheduler → queueJob(update)       │

                    都走这里 ─────────────────────▼
                    componentUpdateFn(update 分支)

componentUpdateFn 中的更新分支

对标 packages/runtime-core/src/renderer.ts —— componentUpdateFnelse 分支

ts
// 组件的核心更新函数:由 ReactiveEffect 管理,根据 isMounted 区分挂载和更新
const componentUpdateFn = () => {
  if (!instance.isMounted) {
    // === MOUNT 分支 ===(第 13 节已实现)
    // ...
  } else {
    // === UPDATE 分支 ===
    let { next, vnode } = instance

    // 判断更新的触发来源
    if (next) {
      // next 存在说明是外部触发的更新(父组件 re-render 传入了新的 VNode)
      next.el = vnode.el  // 复用旧的 DOM 元素引用
      // 在执行 render 之前,先更新 props 和 slots 到最新值
      updateComponentPreRender(instance, next)
    } else {
      // next 为 null 说明是内部触发的更新(自身响应式数据变化)
      // 不需要更新 props/slots,直接用当前 vnode
      next = vnode
    }

    // 执行 beforeUpdate 生命周期钩子
    const { bu, u } = instance
    if (bu) {
      invokeArrayFns(bu)
    }

    // 重新执行 render 函数,生成新的子树 VNode
    const nextTree = renderComponentRoot(instance)
    const prevTree = instance.subTree  // 获取上一次渲染的子树
    instance.subTree = nextTree        // 保存新的子树,供下次更新时对比

    // 对新旧子树进行 patch(Diff + DOM 更新)
    // hostParentNode 获取旧子树根节点的父 DOM 元素
    // getNextHostNode 获取旧子树的下一个兄弟 DOM 节点(用于正确的插入位置)
    patch(
      prevTree,
      nextTree,
      hostParentNode(prevTree.el!)!,
      getNextHostNode(prevTree),
      instance,
    )

    // 更新组件 VNode 的 el 引用为新子树的根 DOM 元素
    next.el = nextTree.el

    // 执行 updated 生命周期钩子(放入 post 队列,确保 DOM 已更新)
    if (u) {
      queuePostFlushCb(u)
    }
  }
}

更新分支的完整流程

componentUpdateFn(isMounted = true)

    ├── instance.next 存在?
    │       │ YES(外部触发)
    │       ▼
    │   updateComponentPreRender(instance, next)
    │       ├── 更新 instance.vnode
    │       ├── 更新 instance.props
    │       └── 更新 instance.slots

    ├── invokeArrayFns(bu)  ← beforeUpdate hooks

    ├── renderComponentRoot(instance)  ← 重新执行 render
    │       └── nextTree = render.call(proxy)

    ├── patch(prevTree, nextTree, ...)  ← Diff 新旧子树
    │       └── 递归更新 DOM

    └── queuePostFlushCb(u)  ← updated hooks(异步执行)

updateComponentPreRender —— 更新前的准备

ts
// 组件更新前的预处理:在重新执行 render 之前,先将 props 和 slots 更新到最新
function updateComponentPreRender(
  instance: ComponentInternalInstance,
  nextVNode: VNode,  // 新的组件 VNode(来自父组件的最新渲染结果)
) {
  // 将实例的 vnode 更新为最新的 VNode
  instance.vnode = nextVNode
  // 清除 next 标记,表示预处理已完成
  instance.next = null

  // 用新 VNode 的 props 更新实例的 props(触发响应式更新)
  updateProps(instance, nextVNode.props)

  // 用新 VNode 的 children(slots)更新实例的 slots
  updateSlots(instance, nextVNode.children)
}

这个函数在 render effect 执行之前调用,确保 render 函数可以访问到最新的 props 和 slots。

updateProps 的细节

ts
// 更新 props:逐个对比新旧值,只在变化时才赋值(避免不必要的响应式触发)
function updateProps(
  instance: ComponentInternalInstance,
  rawNextProps: Record<string, any> | null,
) {
  const { props } = instance  // instance.props 是 shallowReactive 对象

  if (rawNextProps) {
    for (const key in rawNextProps) {
      const next = rawNextProps[key]
      if (props[key] !== next) {
        // 只有值真正变化时才赋值,因为 shallowReactive 的赋值会触发 trigger
        props[key] = next
      }
    }
  }

  // 删除不再存在的 props:旧 props 中有但新 props 中没有的 key
  for (const key in props) {
    if (!rawNextProps || !(key in rawNextProps)) {
      delete props[key]  // 删除操作同样会触发响应式更新
    }
  }
}

updateSlots 的细节

ts
// 更新 slots:用新的 children 中的插槽函数替换旧的
function updateSlots(
  instance: ComponentInternalInstance,
  children: any,  // 新 VNode 的 children(包含最新的插槽函数定义)
) {
  const { slots } = instance

  // 只有 SLOTS_CHILDREN 类型的组件才需要更新插槽
  if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    const rawSlots = children as Record<string, Function>
    // 遍历新的 slots,逐个替换
    for (const key in rawSlots) {
      slots[key] = rawSlots[key]
    }
    // 删除不再存在的 slots:旧 slots 中有但新 slots 中没有的
    for (const key in slots) {
      if (!(key in rawSlots)) {
        delete slots[key]
      }
    }
  }
}

组件的 subTree patch

组件更新的本质是对比新旧 subTree 并 patch

ts
const nextTree = renderComponentRoot(instance) // 新的渲染结果
const prevTree = instance.subTree              // 上一次的渲染结果
instance.subTree = nextTree                    // 保存新的 subTree

patch(prevTree, nextTree, container, anchor, instance)

这里的 patch 就是之前实现的 DOM Diff 流程。组件更新最终还是落到 Element 级别的 DOM 操作上。

组件更新的层次

组件级别:shouldUpdateComponent → updateComponentPreRender → render()


VNode 级别:patch(prevTree, nextTree)

    ├── patchElement → patchProps + patchChildren
    │                       │
    │                       ├── 文本 → setText
    │                       ├── 数组 → patchKeyedChildren (Diff)
    │                       └── 空 → unmount

    └── processComponent → 递归处理子组件

异步更新队列

组件更新通过 queueJob 放入异步队列,避免同一个 tick 内多次更新:

ts
const effect = new ReactiveEffect(
  componentUpdateFn,
  () => queueJob(update),  // scheduler
)
ts
// 更新任务队列:存放待执行的组件更新函数
const queue: Function[] = []
// 标记是否正在刷新队列,防止重复调度
let isFlushing = false

// 将更新任务推入队列:去重机制确保同一个 update 函数不会重复入队
export function queueJob(job: Function) {
  if (!queue.includes(job)) {
    queue.push(job)  // 只有队列中不存在该任务时才添加
  }
  queueFlush()  // 安排队列刷新
}

// 安排异步刷新:利用 Promise 微任务,在当前同步代码执行完后统一处理更新
function queueFlush() {
  if (!isFlushing) {
    isFlushing = true  // 标记正在刷新,避免重复创建微任务
    // 通过 Promise.then 将 flushJobs 推入微任务队列
    Promise.resolve().then(flushJobs)
  }
}

// 刷新队列:依次执行所有排队的更新任务
function flushJobs() {
  try {
    for (let i = 0; i < queue.length; i++) {
      queue[i]()  // 执行每个组件的 update 函数
    }
  } finally {
    // 无论是否出错,都要重置状态,确保下一轮更新能正常调度
    isFlushing = false
    queue.length = 0  // 清空队列
  }
}

// nextTick:返回一个 Promise,在当前更新批次完成后执行回调
// 常用于在修改数据后等待 DOM 更新完成
export function nextTick(fn?: () => void): Promise<void> {
  return fn ? Promise.resolve().then(fn) : Promise.resolve()
}

批量更新的效果

ts
// 同一个 tick 内多次修改
count.value = 1
count.value = 2
count.value = 3

// 不会触发 3 次更新
// scheduler 将 update 推入队列(去重),只执行一次
// 最终 render 时 count 已经是 3

组件更新的完整时序

时间轴:

同步阶段:
  ├── count.value = 1  → trigger → scheduler → queueJob(update)
  ├── count.value = 2  → trigger → scheduler → queueJob(update)  [去重,不重复入队]
  └── count.value = 3  → trigger → scheduler → queueJob(update)  [去重]

微任务阶段(Promise.then):
  └── flushJobs()
        └── update()
              └── componentUpdateFn()
                    ├── updateComponentPreRender (如果有 next)
                    ├── beforeUpdate hooks
                    ├── render() → 新 subTree
                    ├── patch(prevTree, nextTree)
                    └── queuePostFlushCb(updated hooks)

Post 阶段:
  └── flushPostFlushCbs()
        └── updated hooks 执行

对比 React

维度Vue 3React
是否需要更新shouldUpdateComponent(自动浅比较 props)shouldComponentUpdate / React.memo(需手动)
更新入口updateComponentinstance.update()scheduleUpdateOnFiber
状态更新直接修改响应式数据,自动触发setState / dispatch,合并更新
批量更新queueJob + 微任务自动批量React 18 createRoot 自动批量
更新粒度精确到组件(依赖收集知道哪些组件需要更新)从触发组件向下递归 reconcile
跳过更新shouldUpdateComponent 自动判断需要手动 memo / useMemo
渲染与提交同步 patchConcurrent 模式可中断

Vue 的精确更新 vs React 的递归协调

Vue 3 的一个重要优势是精确的更新定位

Vue 3:
  state 变化 → 依赖收集知道哪个 effect(哪个组件)需要更新 → 只更新该组件
  父组件更新 → shouldUpdateComponent 判断子组件是否需要更新 → 跳过无变化的子组件

React:
  setState → 从当前组件开始向下递归 reconcile
  需要 React.memo / useMemo / useCallback 来手动优化

Vue 的响应式系统 + shouldUpdateComponent 提供了自动的性能优化,而 React 需要开发者手动添加 memo 等优化。

测试用例

ts
describe('component update', () => {
  // 测试 props 变化触发子组件更新
  it('should update when props change', async () => {
    const Child = {
      props: ['count'],
      setup(props: any) {
        return () => h('span', {}, String(props.count))  // 渲染 props.count
      },
    }

    const count = ref(0)  // 父组件的响应式状态
    const App = {
      setup() {
        // 将 count.value 作为 props 传给子组件
        return () => h(Child, { count: count.value })
      },
    }

    const root = document.createElement('div')
    render(h(App), root)
    expect(root.innerHTML).toBe('<span>0</span>')  // 初始渲染

    count.value = 10      // 修改父组件状态,触发父组件 re-render
    await nextTick()       // 等待异步更新完成
    // 断言子组件因 props 变化而重新渲染
    expect(root.innerHTML).toBe('<span>10</span>')
  })

  // 测试 props 未变化时跳过子组件更新(shouldUpdateComponent 优化)
  it('should skip update when props are the same', async () => {
    let childRenderCount = 0  // 追踪子组件 render 执行次数

    const Child = {
      props: ['msg'],
      setup(props: any) {
        return () => {
          childRenderCount++  // 每次 render 都递增计数器
          return h('span', {}, props.msg)
        }
      },
    }

    const trigger = ref(0)  // 用于触发父组件更新的响应式数据
    const App = {
      setup() {
        return () => {
          // 读取 trigger 建立依赖,使 trigger 变化时 App 会 re-render
          void trigger.value
          // 但传给 Child 的 props 始终不变(msg 固定为 'static')
          return h(Child, { msg: 'static' })
        }
      },
    }

    const root = document.createElement('div')
    render(h(App), root)
    expect(childRenderCount).toBe(1)  // 初始渲染执行一次

    trigger.value++     // 触发 App re-render,但 Child 的 props 没变
    await nextTick()
    // shouldUpdateComponent 判断 props 未变化,跳过 Child 更新
    // 所以 childRenderCount 仍然是 1
    expect(childRenderCount).toBe(1)
  })

  // 测试批量更新:同一 tick 内多次修改状态只触发一次渲染
  it('should batch multiple state changes', async () => {
    let renderCount = 0  // 追踪 render 执行次数

    const Comp = {
      setup() {
        const count = ref(0)
        const increment = () => {
          count.value++  // 连续修改三次
          count.value++
          count.value++
        }
        return { count, increment }
      },
      render() {
        renderCount++  // 每次 render 递增
        return h('div', {}, String(this.count))
      },
    }

    const root = document.createElement('div')
    render(h(Comp), root)
    expect(renderCount).toBe(1)  // 初始 mount 执行一次
    expect(root.innerHTML).toBe('<div>0</div>')

    const div = root.querySelector('div')!
    // 获取组件实例并调用 increment(连续修改 count 三次)
    const instance = root._vnode.component
    instance.setupState.increment()

    await nextTick()
    // 关键断言:虽然 count 被修改了三次,但 queueJob 去重只触发一次更新
    expect(renderCount).toBe(2)
    // 最终 count 值为 3
    expect(root.innerHTML).toBe('<div>3</div>')
  })

  // 测试 slots 更新:父组件数据变化导致传给子组件的 slot 内容变化
  it('should update slots', async () => {
    const Child = {
      setup(_: any, { slots }: any) {
        return () => h('div', {}, renderSlots(slots, 'default'))
      },
    }

    const toggle = ref(true)  // 控制 slot 内容的响应式数据
    const App = {
      setup() {
        return () =>
          h(Child, null, {
            // 根据 toggle 值动态切换 slot 内容
            default: () =>
              toggle.value
                ? h('span', {}, 'ON')
                : h('span', {}, 'OFF'),
          })
      },
    }

    const root = document.createElement('div')
    render(h(App), root)
    expect(root.innerHTML).toBe('<div><span>ON</span></div>')  // 初始:toggle=true

    toggle.value = false  // 切换状态
    await nextTick()
    // 断言 slot 内容已更新
    expect(root.innerHTML).toBe('<div><span>OFF</span></div>')
  })

  // 测试生命周期钩子在更新时的调用
  it('should call lifecycle hooks on update', async () => {
    const beforeUpdateFn = vi.fn()  // mock beforeUpdate 钩子
    const updatedFn = vi.fn()       // mock updated 钩子

    const Comp = {
      setup() {
        const count = ref(0)
        onBeforeUpdate(beforeUpdateFn)  // 注册 beforeUpdate 钩子
        onUpdated(updatedFn)            // 注册 updated 钩子
        return { count }
      },
      render() {
        return h('div', {}, String(this.count))
      },
    }

    const root = document.createElement('div')
    render(h(Comp), root)

    // 修改组件内部状态触发更新
    const instance = root._vnode.component
    instance.setupState.count++

    await nextTick()
    // beforeUpdate 在 render 之前同步调用
    expect(beforeUpdateFn).toHaveBeenCalledTimes(1)

    // updated 在 post 队列中异步执行,需要再等一个 tick
    await nextTick()
    expect(updatedFn).toHaveBeenCalledTimes(1)
  })
})

本节小结

  1. 组件更新触发 — 父组件 re-render 传入新 props/slots,或组件自身响应式数据变化
  2. shouldUpdateComponent — 浅比较新旧 props,决定是否需要更新子组件
  3. updateComponent — 设置 instance.next = nextVNode,调用 instance.update() 触发 render effect
  4. componentUpdateFn (update 分支) — updateComponentPreRender → render → patch(prevTree, nextTree)
  5. updateComponentPreRender — 在 render 之前更新 props 和 slots
  6. 异步批量更新 — queueJob + 微任务去重,多次状态变化只触发一次渲染
  7. 精确更新 — Vue 的响应式依赖收集 + shouldUpdateComponent 实现自动性能优化

下一节实现 provide/inject 跨层级通信。

用心学习,用代码说话 💻