在 Vue 3 的响应式系统中,当父组件的 props 发生变化时,如何精确、高效地触发子组件的更新,是一个涉及响应式核心、调度机制和渲染流程的复杂过程。这个过程从 trigger 开始,经过 queueJob,最终由 scheduler(调度器)协调完成。本节将深入剖析这一链条的每一个环节,揭示 Vue 如何实现组件间响应式的“精准打击”。
一、起点:props 变化与 trigger
当父组件中的响应式数据(如 ref 或 reactive 对象)被修改时,Vue 的响应式系统会启动更新流程。
1. 响应式数据的 set 拦截
const state = reactive({ count: 0 });
// 修改数据
state.count = 1;state是一个Proxy,修改count会触发set拦截器。- 拦截器会通知依赖系统:
count属性已被修改。
2. trigger:触发依赖
trigger 是响应式系统的核心函数之一。它的作用是:
- 查找依赖:根据被修改的
target和key,从依赖图(targetMap)中找到所有依赖于该数据的副作用函数(effect)。 - 调度执行:将这些
effect加入调度队列。
function trigger(target, key, type, newValue) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key); // 获取依赖于 target.key 的 effects
if (effects) {
// 将 effects 加入调度队列
effects.forEach(effect => {
if (effect !== activeEffect) { // 避免当前正在执行的 effect
queueJob(effect.run.bind(effect));
}
});
}
}二、中间层:queueJob 与异步队列
queueJob 是更新流程的“交通指挥官”,它负责管理待执行的任务队列,确保更新的高效和去重。
1. 为什么需要队列?
- 避免重复更新:如果同一组件在短时间内被多次触发,只需更新一次。
- 批量更新:多个状态变化可以合并为一次 DOM 更新,提升性能。
- 保证执行顺序:父组件先更新,子组件后更新。
2. queueJob 的实现
const queue = []; // 任务队列
const pending = false; // 是否正在等待 flush
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job);
queueFlush();
}
}- 去重:检查
job是否已在队列中,避免重复。 - 调度 flush:调用
queueFlush()安排队列的刷新。
3. queueFlush 与 nextTick
function queueFlush() {
if (!pending) {
pending = true;
nextTick(flushJobs);
}
}nextTick将flushJobs推入微任务队列。- 确保所有同步代码执行完毕后,再批量执行更新任务。
三、终点:scheduler 与 flushJobs
scheduler 是 Vue 更新流程的“总调度器”,它协调所有 effect 的执行。
1. flushJobs:执行队列
function flushJobs() {
pending = false;
// 对队列进行排序,确保父组件先于子组件更新
queue.sort((a, b) => a.id - b.id);
for (const job of queue) {
job(); // 执行 effect.run
}
queue.length = 0; // 清空队列
}关键点:
- 排序:
effect有id,按id升序执行,保证更新顺序。 - 批量执行:一次性执行所有任务。
四、从 props 变化到子组件更新的完整链条
现在,将上述环节串联起来,看 props 变化如何触发子组件更新。
1. 场景设定
<!-- 父组件 -->
<template>
<Child :count="state.count" />
</template>
<script setup>
import { reactive } from 'vue';
const state = reactive({ count: 0 });
setTimeout(() => state.count++, 1000); // 1秒后修改
</script>
<!-- 子组件 -->
<template>
<div>Count: {{ count }}</div>
</template>
<script setup>
defineProps(['count']);
</script>2. 执行流程
state.count++:- 触发
reactive对象的set拦截器。 - 调用
trigger(target: state, key: 'count')。
- 触发
查找依赖:
targetMap中,state.count的依赖列表包含:- 父组件的渲染
effect。 - 子组件的
props更新effect(因为count被传递)。
- 父组件的渲染
queueJob:- 将父组件和子组件的
effect.run加入更新队列。 - 由于异步队列,DOM 不会立即更新。
- 将父组件和子组件的
nextTick调度:flushJobs被安排在微任务队列中。
flushJobs执行:- 先执行父组件的
effect:- 重新执行父组件的
render函数。 - 生成新的
VNode树,其中Child组件的props已更新。
- 重新执行父组件的
- 再执行子组件的
effect:- 由于
props变化,子组件的render函数被重新执行。 - 生成新的
VNode。 patch过程对比新旧VNode,更新 DOM 文本。
- 由于
- 先执行父组件的
五、关键机制解析
1. 子组件的依赖是如何建立的?
在子组件初始化时:
- 解析 props:Vue 会为每个
prop创建响应式引用。 - 收集依赖:当子组件的
render函数访问props.count时,会触发get拦截器。 - 建立连接:子组件的渲染
effect被收集到state.count的依赖列表中。
因此,当 state.count 变化时,trigger 能直接找到子组件并触发更新。
2. 为什么父组件也要更新?
即使父组件没有直接使用 count,它的 render 函数也要重新执行,因为:
render函数中包含了<Child :count="state.count">。- 访问
state.count会触发依赖收集。 - 父组件的
effect也依赖于state.count。
但 Vue 会通过 patch 算法优化:父组件的 VNode 树结构未变,只会更新 Child 的 props,不会重新创建 DOM。
3. scheduler 的智能排序
scheduler 按 effect.id 排序,确保:
- 父组件(先创建,id 小)先更新。
- 子组件(后创建,id 大)后更新。
这保证了数据流的正确性,避免子组件在父组件更新前读取到旧数据。
六、性能优化考量
shallowRef/shallowReactive:对于大型对象,避免深层响应式,减少trigger范围。v-memo:缓存子树,跳过不必要的render执行。markRaw:标记不应被响应式化的对象,防止意外依赖收集。
七、总结
props 变化触发子组件更新的完整链条如下:
trigger:响应式数据变化,触发依赖系统,找出所有依赖的effect。queueJob:将effect.run加入异步队列,实现去重和批量更新。scheduler:通过nextTick调度flushJobs,按顺序执行队列中的任务。
这个过程体现了 Vue 3 响应式系统的设计哲学:
- 精准性:通过依赖收集,只更新真正受影响的组件。
- 高效性:通过异步队列和批量更新,最小化 DOM 操作。
- 有序性:通过
scheduler排序,保证组件更新的正确顺序。
理解这一链条,不仅有助于掌握 Vue 的工作原理,也能在开发中做出更优的性能决策。