Skip to content

在 Vue 3 的响应式系统中,当父组件的 props 发生变化时,如何精确、高效地触发子组件的更新,是一个涉及响应式核心、调度机制和渲染流程的复杂过程。这个过程从 trigger 开始,经过 queueJob,最终由 scheduler(调度器)协调完成。本节将深入剖析这一链条的每一个环节,揭示 Vue 如何实现组件间响应式的“精准打击”。


一、起点:props 变化与 trigger

当父组件中的响应式数据(如 refreactive 对象)被修改时,Vue 的响应式系统会启动更新流程。

1. 响应式数据的 set 拦截

js
const state = reactive({ count: 0 });

// 修改数据
state.count = 1;
  • state 是一个 Proxy,修改 count 会触发 set 拦截器。
  • 拦截器会通知依赖系统:count 属性已被修改。

2. trigger:触发依赖

trigger 是响应式系统的核心函数之一。它的作用是:

  1. 查找依赖:根据被修改的 targetkey,从依赖图(targetMap)中找到所有依赖于该数据的副作用函数(effect)。
  2. 调度执行:将这些 effect 加入调度队列。
ts
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 的实现

ts
const queue = [];        // 任务队列
const pending = false;   // 是否正在等待 flush

function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job);
    queueFlush();
  }
}
  • 去重:检查 job 是否已在队列中,避免重复。
  • 调度 flush:调用 queueFlush() 安排队列的刷新。

3. queueFlush 与 nextTick

ts
function queueFlush() {
  if (!pending) {
    pending = true;
    nextTick(flushJobs);
  }
}
  • nextTickflushJobs 推入微任务队列。
  • 确保所有同步代码执行完毕后,再批量执行更新任务。

三、终点:scheduler 与 flushJobs

scheduler 是 Vue 更新流程的“总调度器”,它协调所有 effect 的执行。

1. flushJobs:执行队列

ts
function flushJobs() {
  pending = false;
  // 对队列进行排序,确保父组件先于子组件更新
  queue.sort((a, b) => a.id - b.id);

  for (const job of queue) {
    job(); // 执行 effect.run
  }

  queue.length = 0; // 清空队列
}

关键点:

  • 排序effectid,按 id 升序执行,保证更新顺序。
  • 批量执行:一次性执行所有任务。

四、从 props 变化到子组件更新的完整链条

现在,将上述环节串联起来,看 props 变化如何触发子组件更新。

1. 场景设定

vue
<!-- 父组件 -->
<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. 执行流程

  1. state.count++

    • 触发 reactive 对象的 set 拦截器。
    • 调用 trigger(target: state, key: 'count')
  2. 查找依赖

    • targetMap 中,state.count 的依赖列表包含:
      • 父组件的渲染 effect
      • 子组件的 props 更新 effect(因为 count 被传递)。
  3. queueJob

    • 将父组件和子组件的 effect.run 加入更新队列。
    • 由于异步队列,DOM 不会立即更新。
  4. nextTick 调度

    • flushJobs 被安排在微任务队列中。
  5. flushJobs 执行

    • 先执行父组件的 effect
      • 重新执行父组件的 render 函数。
      • 生成新的 VNode 树,其中 Child 组件的 props 已更新。
    • 再执行子组件的 effect
      • 由于 props 变化,子组件的 render 函数被重新执行。
      • 生成新的 VNode
      • patch 过程对比新旧 VNode,更新 DOM 文本。

五、关键机制解析

1. 子组件的依赖是如何建立的?

在子组件初始化时:

  1. 解析 props:Vue 会为每个 prop 创建响应式引用。
  2. 收集依赖:当子组件的 render 函数访问 props.count 时,会触发 get 拦截器。
  3. 建立连接:子组件的渲染 effect 被收集到 state.count 的依赖列表中。

因此,当 state.count 变化时,trigger 能直接找到子组件并触发更新。

2. 为什么父组件也要更新?

即使父组件没有直接使用 count,它的 render 函数也要重新执行,因为:

  • render 函数中包含了 <Child :count="state.count">
  • 访问 state.count 会触发依赖收集。
  • 父组件的 effect 也依赖于 state.count

但 Vue 会通过 patch 算法优化:父组件的 VNode 树结构未变,只会更新 Childprops,不会重新创建 DOM。

3. scheduler 的智能排序

schedulereffect.id 排序,确保:

  • 父组件(先创建,id 小)先更新。
  • 子组件(后创建,id 大)后更新。

这保证了数据流的正确性,避免子组件在父组件更新前读取到旧数据。


六、性能优化考量

  • shallowRef / shallowReactive:对于大型对象,避免深层响应式,减少 trigger 范围。
  • v-memo:缓存子树,跳过不必要的 render 执行。
  • markRaw:标记不应被响应式化的对象,防止意外依赖收集。

七、总结

props 变化触发子组件更新的完整链条如下:

  1. trigger:响应式数据变化,触发依赖系统,找出所有依赖的 effect
  2. queueJob:将 effect.run 加入异步队列,实现去重和批量更新。
  3. scheduler:通过 nextTick 调度 flushJobs,按顺序执行队列中的任务。

这个过程体现了 Vue 3 响应式系统的设计哲学:

  • 精准性:通过依赖收集,只更新真正受影响的组件。
  • 高效性:通过异步队列和批量更新,最小化 DOM 操作。
  • 有序性:通过 scheduler 排序,保证组件更新的正确顺序。

理解这一链条,不仅有助于掌握 Vue 的工作原理,也能在开发中做出更优的性能决策。