主题
实现组件更新流程
本节对标 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) ← 组件更新入口组件更新有两种触发方式:
- 外部触发 — 父组件 re-render,传给子组件的 props/slots 变化
- 内部触发 — 子组件自身的响应式数据变化,触发自己的 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——componentUpdateFn的else分支
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 3 | React |
|---|---|---|
| 是否需要更新 | shouldUpdateComponent(自动浅比较 props) | shouldComponentUpdate / React.memo(需手动) |
| 更新入口 | updateComponent → instance.update() | scheduleUpdateOnFiber |
| 状态更新 | 直接修改响应式数据,自动触发 | setState / dispatch,合并更新 |
| 批量更新 | queueJob + 微任务自动批量 | React 18 createRoot 自动批量 |
| 更新粒度 | 精确到组件(依赖收集知道哪些组件需要更新) | 从触发组件向下递归 reconcile |
| 跳过更新 | shouldUpdateComponent 自动判断 | 需要手动 memo / useMemo |
| 渲染与提交 | 同步 patch | Concurrent 模式可中断 |
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)
})
})本节小结
- 组件更新触发 — 父组件 re-render 传入新 props/slots,或组件自身响应式数据变化
- shouldUpdateComponent — 浅比较新旧 props,决定是否需要更新子组件
- updateComponent — 设置
instance.next = nextVNode,调用instance.update()触发 render effect - componentUpdateFn (update 分支) — updateComponentPreRender → render → patch(prevTree, nextTree)
- updateComponentPreRender — 在 render 之前更新 props 和 slots
- 异步批量更新 — queueJob + 微任务去重,多次状态变化只触发一次渲染
- 精确更新 — Vue 的响应式依赖收集 + shouldUpdateComponent 实现自动性能优化
下一节实现 provide/inject 跨层级通信。