组件挂载与更新:mountComponent → patch 的完整执行链路
第16章:组件挂载与更新——mountComponent → patch 的完整执行链路
每一次
ref.value = newValue触发的组件更新,背后经历了至少 7 个函数调用层级,穿越了响应式系统、调度器、渲染器三个子系统——但整个过程发生在不到 1 毫秒之内。
本章核心问题:从 createApp().mount() 到第一个 DOM 节点出现,Vue 经历了哪些步骤?当响应式数据变化时,更新又是如何高效触发的?
读完本章你将理解:
- 从
createApp到第一次渲染的完整调用链 setupRenderEffect如何把响应式系统和渲染器连接起来- 组件更新的两种触发路径及其优化策略
- 异步更新队列的工作原理(为什么同一帧内多次修改只渲染一次)
patch()函数的分发逻辑和patchElement的精确更新机制
Level 1 · 你需要知道的(1-3年经验)
一句话理解 Vue 的更新机制
当你改变一个响应式数据时,Vue 不会立刻更新 DOM。它把更新操作放入一个队列,等当前 JavaScript 任务结束后(微任务时机),再一次性把所有积压的更新都执行完。
这就是为什么:
const count = ref(0)
count.value = 1
count.value = 2
count.value = 3
// DOM 只更新一次,不是三次
// 你看到的 DOM 里的值是 3,不是经历了 1→2→3 的三次变化
从 mount 到第一个 DOM 节点:6 个关键步骤
// 你写的代码
const app = createApp(App)
app.mount('#app')
// Vue 内部执行的关键步骤:
// 1. createApp() → 创建应用实例,配置全局插件/组件/指令
// 2. mount() → 找到 #app 元素,创建根 VNode,调用 render()
// 3. render() → 调用 patch(null, rootVNode, container)
// 4. patch() → 识别 VNode 类型,调用 mountComponent()
// 5. mountComponent() → 创建组件实例,执行 setup(),创建渲染副作用
// 6. setupRenderEffect() → 首次执行 render(),生成 subTree,递归 patch
理解异步更新队列的重要性
const { createApp, ref, nextTick } = Vue
const app = createApp({
setup() {
const count = ref(0)
const domText = ref('')
async function update() {
count.value = 100
// 此时 DOM 还没更新!
console.log(document.getElementById('num').textContent) // "0"
// 等待 DOM 更新完成
await nextTick()
// 现在 DOM 已更新
console.log(document.getElementById('num').textContent) // "100"
}
return { count, domText, update }
}
})
nextTick() 的本质是:在当前更新队列刷新完成后执行回调。它返回一个 Promise,你可以 await 它。
组件更新的两种触发方式
方式1:自身响应式数据变化
<script setup>
const count = ref(0)
function increment() {
count.value++ // 触发自身更新
}
</script>
方式2:父组件传入新 props
<!-- 父组件 -->
<Child :value="parentCount" />
<!-- 当 parentCount 变化时,父组件更新 → 对 Child VNode 执行 patch
→ 如果 props 真的变了,Child 组件会被通知更新 -->
shouldUpdateComponent:优化子组件更新
Vue 不会盲目地更新所有子组件。在父组件更新时,Vue 会对比子组件的新旧 VNode,判断是否真的需要更新子组件:
// 父组件模板中
<Child :name="user.name" :age="user.age" />
// 如果 user.name 变了,但 user.age 没变
// Vue 会检查:新 VNode 的 props 和旧 VNode 的 props 是否有差异
// 有差异 → 更新子组件
// 无差异 → 跳过子组件更新(即使父组件自身更新了)
这个检查叫做 shouldUpdateComponent,它是 Vue 3 重要的性能优化点之一。
Level 2 · 它是怎么运行的(3-5年经验)
完整执行链路 ASCII 图
createApp(App).mount('#app')
│
├─► createApp(App)
│ ├── 创建 app 实例(appContext:全局配置容器)
│ ├── 注册全局组件/指令/插件
│ └── 返回 { mount, use, component, directive, ... }
│
└─► mount('#app')
├── hostQuerySelector('#app') → 找到宿主 DOM 元素
├── createVNode(App, {}) → 创建根组件 VNode
└── render(vnode, container)
│
└─► patch(null, vnode, container)
│ n1=null 表示首次挂载
│ vnode.shapeFlag & COMPONENT → true
│
└─► mountComponent(vnode, container)
│
├── createComponentInstance(vnode, parent)
│ └── 创建 ComponentInternalInstance 对象
│ { uid, type, parent, appContext,
│ props, slots, setupContext,
│ isMounted: false,
│ subTree: null,
│ effect: null, ... }
│
├── setupComponent(instance)
│ ├── initProps() → 处理 props 定义
│ ├── initSlots() → 处理插槽
│ └── setupStatefulComponent()
│ ├── 设置 currentInstance = instance
│ ├── 调用 setup(props, setupContext)
│ │ → 用户的 setup() 函数在这里执行
│ │ → setup 返回值被处理
│ └── 清除 currentInstance = null
│
└── setupRenderEffect(instance, vnode, container)
│
└── 创建 ReactiveEffect(响应式副作用)
│
首次执行(同步):
├── 调用 componentUpdateFn()
│ ├── 调用 render() → 生成 subTree VNode
│ │ (在 render 执行期间,所有访问的
│ │ 响应式数据自动注册为依赖)
│ ├── patch(null, subTree, container)
│ │ → 递归挂载 subTree
│ ├── 调用 onMounted 钩子
│ └── instance.isMounted = true
│
后续执行(异步,由 scheduler 调度):
└── 同上,但 patch(prevTree, subTree, ...)
→ 执行 diff + 精确更新
setupRenderEffect 的响应式连接原理
// packages/runtime-core/src/renderer.ts(简化)
const setupRenderEffect = (instance, initialVNode, container, anchor) => {
const componentUpdateFn = () => {
if (!instance.isMounted) {
// ─── 首次挂载 ───
const { bm, m } = instance // beforeMount, mounted 钩子
// 调用 beforeMount 钩子
if (bm) invokeArrayFns(bm)
// 执行 render 函数,期间收集响应式依赖
const subTree = (instance.subTree = renderComponentRoot(instance))
// 递归挂载 subTree
patch(null, subTree, container, anchor, instance)
// 记录根 DOM 节点
initialVNode.el = subTree.el
instance.isMounted = true
// 调用 mounted 钩子(在 nextTick 后执行)
if (m) queuePostFlushCb(m)
} else {
// ─── 更新 ───
let { next, bu, u, vnode } = instance
// next 是父组件传来的新 VNode(props 更新场景)
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next)
// updateComponentPreRender 会更新 props 和 slots
}
// 调用 beforeUpdate 钩子
if (bu) invokeArrayFns(bu)
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
// diff:对比新旧 subTree
patch(prevTree, nextTree, container, anchor, instance)
// 调用 updated 钩子
if (u) queuePostFlushCb(u)
}
}
// 创建响应式副作用
// scheduler 控制副作用不立即重新执行,而是进入队列
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
EMPTY_FN,
() => queueJob(instance.update), // scheduler:触发更新时,把 update 推入队列
instance.scope // 副作用作用域,用于统一清理
))
const update = (instance.update = effect.run.bind(effect))
update() // 首次执行(同步),触发挂载
}
异步更新队列的完整实现
响应式数据变化 → 触发更新的完整路径:
count.value++
│
├── reactive setter 被触发
│
├── triggerEffects(dep)
│ 遍历所有订阅了 count 的 effect
│ 对于组件的 effect,不直接 run()
│ 而是调用 effect.scheduler()
│
└── effect.scheduler = () => queueJob(instance.update)
│
└── queueJob(job)
├── 检查 job 是否已在 queue 中(去重)
│ 使用 job.id 进行去重
├── 使用二分搜索按 id 排序插入
└── 如果当前不在刷新中:
flushJobs() 被调度到微任务(Promise.resolve().then)
同一 tick 内多次修改:
count.value = 1 → queueJob (job已在队列,跳过)
count.value = 2 → queueJob (job已在队列,跳过)
count.value = 3 → queueJob (job已在队列,跳过)
微任务时机到来:
flushJobs() 执行 queue 中的所有 job
此时 count.value = 3
只执行一次渲染
shouldUpdateComponent 的判断逻辑
// packages/runtime-core/src/componentRenderUtils.ts(简化)
export function shouldUpdateComponent(prevVNode, nextVNode, optimized?) {
const { props: prevProps, children: prevChildren } = prevVNode
const { props: nextProps, children: nextChildren, patchFlag } = nextVNode
// 有动态插槽,必须更新(插槽内容可能变化)
if (patchFlag === PatchFlags.DYNAMIC_SLOTS) return true
// props 引用相同(对象未变),不需要更新
if (prevProps === nextProps) return false
// 没有新 props,检查旧 props 是否为空
if (!nextProps) return !!prevProps
// 新旧 props 的 key 数量不同,必须更新
const nextKeys = Object.keys(nextProps)
if (nextKeys.length !== Object.keys(prevProps || {}).length) return true
// 逐 key 对比 props 值
for (let i = 0; i < nextKeys.length; i++) {
const key = nextKeys[i]
if (nextProps[key] !== prevProps![key] && !isEmitListener(prevVNode.type.emits, key)) {
return true // 发现 prop 变化,需要更新
}
}
// 检查插槽变化
if (prevChildren || nextChildren) {
if (!nextChildren || !(nextChildren as any).$stable) {
return true
}
}
return false // 所有 props/slots 都没变,跳过更新
}
patch 函数的分发逻辑
patch(n1, n2, container, anchor, parentComponent)
n2.type 判断:
┌──────────────────────────────────────────────────────────────────┐
│ switch(n2.type) │
│ case Text → processText() │
│ case Comment → processCommentNode() │
│ case Fragment → processFragment() │
│ default: │
│ if (shapeFlag & ELEMENT) → processElement() │
│ if (shapeFlag & COMPONENT) → processComponent() │
│ if (shapeFlag & TELEPORT) → type.process() │
│ if (shapeFlag & SUSPENSE) → type.process() │
└──────────────────────────────────────────────────────────────────┘
processElement(n1, n2, container):
if (n1 == null) → mountElement() (首次挂载)
else → patchElement() (更新)
processComponent(n1, n2, container):
if (n1 == null) → mountComponent() (首次挂载)
else → updateComponent() (更新)
updateComponent(n1, n2):
if (shouldUpdateComponent(n1, n2)):
n2.component = n1.component
instance.next = n2 ← 把新 VNode 挂到实例上
invalidateMount(instance) ← 标记需要更新
instance.update() ← 触发重渲染
else:
// 不需要更新,只复用旧 el
n2.component = n1.component
n2.el = n1.el
patchElement 的精确更新
// packages/runtime-core/src/renderer.ts(简化)
const patchElement = (n1, n2, ...) => {
const el = (n2.el = n1.el) // 复用真实 DOM 节点
const oldProps = n1.props || {}
const newProps = n2.props || {}
// 优化路径:根据 patchFlag 精确更新
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
// 需要全量 props diff(有动态 key)
patchProps(el, n2, oldProps, newProps, ...)
} else {
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class)
}
}
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style)
}
if (patchFlag & PatchFlags.PROPS) {
// 只对比 dynamicProps 中列出的 prop
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev || key === 'value') {
hostPatchProp(el, key, prev, next, ...)
}
}
}
}
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (!optimized) {
// 无 patchFlag,全量 props diff(动态 render 函数场景)
patchProps(el, n2, oldProps, newProps, ...)
}
// 递归处理子节点
patchChildren(n1, n2, el, null, ...)
}
Level 3 · 设计文档与源码(资深开发者)
ReactiveEffect 如何连接渲染器
// packages/reactivity/src/effect.ts(关键部分)
export class ReactiveEffect<T = any> {
active = true
deps: Dep[] = [] // 收集到的所有依赖(reactive 对象的属性)
parent: ReactiveEffect | undefined = undefined
constructor(
public fn: () => T, // 副作用函数(componentUpdateFn)
public trigger: () => void, // 触发时调用
public scheduler?: EffectScheduler, // 有 scheduler 则不立即 run
scope?: EffectScope
) {
// 注册到 scope 中,组件卸载时统一停止
recordEffectScope(this, scope)
}
run() {
if (!this.active) return this.fn()
let parent: ReactiveEffect | undefined = activeEffect
let lastShouldTrack = shouldTrack
// 建立 effect 栈,支持嵌套 effect
while (parent) {
if (parent === this) return // 防止循环
parent = parent.parent
}
try {
this.parent = activeEffect
activeEffect = this // ← 设置当前活跃 effect,所有响应式访问会注册到这里
shouldTrack = true
trackEffects(this.deps) // 准备重新收集依赖
return this.fn() // 执行副作用函数(render)
} finally {
cleanupEffect(this) // 清理旧依赖
activeEffect = this.parent
this.parent = undefined
shouldTrack = lastShouldTrack
}
}
stop() {
if (this.active) {
cleanupEffect(this)
this.active = false
}
}
}
组件的 render 函数执行时,所有 reactive/ref 的访问会触发 track(),把当前 activeEffect(即组件的渲染 effect)注册到依赖中。当这些数据变化时,trigger() 调用 effect.scheduler,把更新推入队列。
组件卸载的完整链路
// packages/runtime-core/src/renderer.ts(unmountComponent)
const unmountComponent = (instance, doRemove?) => {
const { bum, scope, update, subTree, um } = instance
// 1. 调用 onBeforeUnmount 钩子
if (bum) invokeArrayFns(bum)
// 2. 停止响应式作用域(scope.stop() 停止所有 effect)
// 包括组件自身的渲染 effect 和所有 watchEffect/watch
scope.stop()
// 3. 停止渲染 effect
if (update) {
update.active = false
// 不再触发重新渲染
}
// 4. 递归卸载 subTree(触发子组件的 unmount 链)
unmount(subTree, instance, ...)
// 5. 调用 onUnmounted 钩子(在 DOM 移除后)
if (um) queuePostFlushCb(um)
// 6. 清理组件实例引用(帮助 GC)
instance.isUnmounted = true
}
flushJobs 的排序策略:父组件先于子组件更新
// packages/runtime-core/src/scheduler.ts(关键逻辑)
function flushJobs(seen?) {
// 按 job.id 排序:父组件的 id 小于子组件的 id(uid 递增生成)
// 确保父组件先于子组件更新
queue.sort((a, b) => getId(a) - getId(b))
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
// 注意:job 执行期间可能产生新的 job(子组件更新)
// 这些新 job 会被追加到 queue 末尾,在当前循环中被处理
}
}
// 刷新 postFlushCbs(onMounted、onUpdated 等在这里执行)
flushPostFlushCbs(seen)
}
父组件先更新的原因:
- 父组件的 uid 是在子组件之前生成的(数值更小)
shouldUpdateComponent在父组件更新阶段就能决定是否需要更新子组件- 如果父组件更新后子组件的 props 没变,子组件的 update job 可以被跳过(job.active 被设为 false)
queueJob 的去重机制
// packages/runtime-core/src/scheduler.ts
export function queueJob(job: SchedulerJob) {
if (
!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)
) {
if (job.id == null) {
queue.push(job)
} else {
// 按 id 大小插入(保持父先于子的顺序)
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
// Promise.resolve() 实现微任务调度
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
queue.includes() 是去重的关键。同一个 job(同一个组件的 update 函数)在队列中只会存在一次。
Level 4 · 边界与陷阱(全体适用)
陷阱1:在 onMounted 中同步修改响应式数据引发双重渲染
// ❌ 问题写法
onMounted(() => {
count.value = 100 // 触发一次更新
title.value = 'Hello' // 再触发一次更新
// 两次修改各触发一次 queueJob
// 但 onMounted 在 postFlushCbs 中执行
// 此时 flushJobs 已经在执行中
// 这两个 job 会被追加到队列末尾,在当前循环结束前执行
// 结果:DOM 在 onMounted 结束后会再更新一次
})
// ✓ 不那么糟糕(同一个 tick,但仍然是额外一次渲染)
onMounted(() => {
// 如果必须在 mounted 时修改数据,考虑用 nextTick 推迟
nextTick(() => {
count.value = 100
})
})
// ✓ 最佳:在 setup 中用 ref 初始化正确的值,避免在 mounted 修改
陷阱2:watch 的 flush 时机与 DOM 状态不一致
// watch 默认 flush: 'pre'(在组件更新前执行)
watch(count, (newVal) => {
// 此时 DOM 还没更新!count 的值是新的,但 DOM 还是旧的
console.log(document.getElementById('num').textContent) // 旧值
})
// 如果需要在 DOM 更新后访问,使用 flush: 'post'
watch(count, (newVal) => {
// 此时 DOM 已更新
console.log(document.getElementById('num').textContent) // 新值
}, { flush: 'post' })
// 或者直接使用 watchEffect 的别名:watchPostEffect
watchPostEffect(() => {
console.log(document.getElementById('num').textContent)
})
陷阱3:update.active = false 导致的组件"假死"
// Vue 内部的错误处理有时会将 update.active 设为 false
// 如果组件在 render 过程中抛出异常(未被 errorCaptured 捕获)
// 组件的 effect 会被停止,组件进入"假死"状态
// 症状:响应式数据变化,但 DOM 不再更新
// 诊断:检查 instance.effect.active 是否为 false
// 正确处理异步错误:
const MyComp = defineComponent({
setup() {
// 这样写是错的:setup 里的 async 异常不会被捕获
const data = await fetch('/api').then(r => r.json()) // ❌
// 正确写法:用 ref + onMounted
const data = ref(null)
const error = ref(null)
onMounted(async () => {
try {
data.value = await fetch('/api').then(r => r.json())
} catch(e) {
error.value = e
}
})
return { data, error }
}
})
陷阱4:组件更新期间修改子组件 props 的时序问题
// 场景:父组件在 onUpdated 中修改会影响子组件的数据
const parentData = ref('initial')
// 父组件
onUpdated(() => {
// onUpdated 在子组件更新完成后才执行(postFlushCbs)
// 在这里修改 parentData 会触发父组件的再次更新
// 进而导致子组件再次更新
parentData.value = 'changed in onUpdated' // ⚠️ 可能引发无限循环
// 安全的做法:只在特定条件下修改
if (someCondition && parentData.value !== 'expected') {
parentData.value = 'expected'
}
})
陷阱5:nextTick 的 Promise 链时序
// nextTick 返回的 Promise 在 flushJobs 完成后 resolve
// 但 flushJobs 内部的 postFlushCbs(onMounted/onUpdated)是同步执行的
// 而 nextTick 的 then 回调是在 postFlushCbs 执行后的下一个微任务
// 时序:
// 1. 修改响应式数据
// 2. queueJob → isFlushPending = true
// 3. resolvedPromise.then(flushJobs) 注册微任务
// 4. 当前同步代码结束
// 5. 微任务:flushJobs() 执行(包括 postFlushCbs)
// 6. nextTick() 的 then 回调执行(在 flushJobs 内部的 Promise.resolve().then())
async function update() {
data.value = 'new'
await nextTick()
// ← 此时 onMounted/onUpdated 已经执行完毕,DOM 也已更新
}
陷阱6:大量子组件同时更新时的性能问题
// 场景:一个父组件有 200 个子组件,父组件的一个数据变化
// 理论上会触发 200 个子组件的 updateComponent 检查
// Vue 的 shouldUpdateComponent 在大多数情况下能快速拒绝不需要更新的子组件
// 但"检查"本身也有成本
// 优化策略1:使用 v-memo
// <ChildComp v-memo="[item.id, item.name]" v-for="item in list" />
// 只有 item.id 或 item.name 变化时才重新渲染子组件
// 优化策略2:使用 shallowRef 代替 ref 减少深度追踪
const list = shallowRef([...])
// 优化策略3:对于纯展示组件使用 Object.freeze() 冻结 props
// frozen 对象不会被响应式系统追踪
本章小结
-
挂载是同步执行的:
createApp().mount()触发的首次渲染是同步的,从mountComponent到 DOM 出现全在一个 JS 任务内完成;setupRenderEffect内部的update()首次执行是同步调用(不经过 scheduler)。 -
更新是异步批量的:数据变化后,update job 进入
queue,通过Promise.resolve().then(flushJobs)在当前 task 结束后统一执行;同一组件在一个 tick 内无论触发多少次数据变化,只执行一次渲染。 -
shouldUpdateComponent是子组件更新的守门员:父组件更新时,Vue 会逐 prop 对比新旧 VNode,只有当 props 或 slots 真正变化时才触发子组件重渲染;这是 Vue 3 比 Vue 2 更高效的关键原因之一。 -
patch 函数是渲染器的分发中心:它根据 VNode 的
shapeFlag和type将工作分发给processElement/processComponent/processFragment等函数;patchElement利用patchFlag跳过静态属性的对比,实现精确更新。 -
组件卸载需要停止所有副作用:
unmountComponent调用scope.stop()停止组件作用域内的所有 effect(包括 watch、watchEffect 和渲染 effect),防止内存泄漏;onBeforeUnmount/onUnmounted是在这个过程中按顺序调用的。