知

模块二 · 响应式与编译

第 7 / 17 节17 分钟阅读型

响应式系统:Proxy、依赖图与调度

响应式不是"监听变化",而是"读写拦截 + 依赖图 + 调度"。这一节拆开 ref/reactive 的实现差异,并解释 computed 与 watch 的缓存逻辑。

读完这一节,你能做到

  1. 说清 Proxy 相比 defineProperty 解决了哪些问题。
  2. 区分 ref 与 reactive 的使用场景与陷阱。
  3. 用 track/trigger 解释依赖收集与派发。
  4. 说清 computed 的缓存与 watch 的调度差异。

一、为什么从 defineProperty 换成 Proxy

能力definePropertyProxy
新增/删除属性拦截不到可以
数组下标/长度需要重写数组方法可以
嵌套对象需要递归劫持惰性代理,访问时才处理
拦截范围只能拦截已有属性支持 get/set/has/delete 等

Proxy 的代价是兼容性(无法 polyfill)与性能开销(每次读写都走陷阱),换来的是完整语义。

二、ref 与 reactive

const count = ref(0); // { value: 0 },通过 .value 访问
const state = reactive({ a: 1 }); // 直接访问 state.a
  • ref 可以包装任意类型(包括基本类型),模板中自动解包。
  • reactive 只能用于对象/数组,返回 Proxy。
  • reactive 解构会丢失响应式,需要 toRefs。
  • ref 嵌套在对象里不会自动解包(除模板外)。

选择建议:组合式函数返回值用 ref(便于解构与传递),组件内部的大对象用 reactive(少写 .value)。

三、依赖收集与派发

effect 执行 → 读取 count.value → track:把当前 effect 记录到 count 的依赖集合
count.value = 1 → set 拦截 → trigger:取出依赖集合,逐个调度执行

三个关键点:

  1. 依赖是"执行期间"收集的。条件分支里没走到的数据,不会建立依赖。
  2. 同一个 effect 多次读取同一数据,只记录一次(用 Set 去重)。
  3. 清理旧依赖:每次重新执行前,先把自己从上一轮的依赖集合里移除,避免无效触发。

四、调度器与 nextTick

触发更新时不会立刻重新渲染,而是把任务放进队列:

let queue = new Set();
function queueJob(job) {
  queue.add(job);
  Promise.resolve().then(flushJobs);
}
  • 同一轮多次修改,job 只入队一次(Set 去重)。
  • flushJobs 按组件层级排序,先父后子,避免子组件重复渲染。
  • nextTick 返回一个 Promise,在队列刷新后 resolve,用来读取更新后的 DOM。

五、computed、watch、watchEffect

API是否缓存触发时机适用
computed是,依赖不变不重算读取时惰性求值派生状态
watch否依赖变化后,可配置 flush需要旧值、异步副作用
watchEffect否立即执行并自动收集依赖简单副作用
  • computed 返回的是 ref,模板中自动解包;它只有被读取时才计算,且依赖不变时复用缓存。
  • watch 默认在组件更新前触发(flush: 'pre'),需要操作 DOM 时用 flush: 'post'。
  • watchEffect 无法拿到旧值,依赖是运行时自动收集的,条件分支会导致依赖不完整。

六、effect 的作用域与嵌套

每个组件渲染都是一个 effect。effect 可以嵌套:父组件渲染时创建子组件,子组件的 effect 属于父级作用域。

  • 组件卸载时,会把自己作用域内的 effect 全部停止,避免内存泄漏。
  • effectScope 可以把一组 effect 聚合成一个作用域,统一停止,适合在组合式函数里管理副作用。
  • 嵌套 effect 会让依赖关系变复杂,调试时可以用 onTrack/onTrigger 观察是谁触发了更新。

七、数组与集合类型

Vue 3 的 Proxy 可以直接拦截数组索引与 length,但仍有几个细节:

  • push/splice 等方法会触发多次 get/set,属于正常现象。
  • Map/Set 需要专门的集合处理器,reactive(new Map()) 支持 get/set/has 的依赖追踪。
  • 直接替换整个数组会触发一次更新;频繁 push 会触发多次,必要时合并后再赋值。

八、响应式丢失的四种场景

场景原因修法
解构 reactive拿到的是普通值toRefs 或直接访问属性
传 prop 给函数参数是值拷贝传 ref 或 getter
存入普通对象代理未被引用保持原始代理引用
替换 ref.value 内部对象新对象未被代理用 reactive 包装或整体替换

判断方法:如果修改数据后视图不更新,先确认“你改的是不是那个被代理的对象”。调试技巧:在 onTrack/onTrigger 打日志,能看到某个属性被谁读取、被谁修改,比到处 console 更快定位重渲染来源。

computed 的缓存为什么可靠

computed 内部维护一个 dirty 标记:依赖变化时把标记置为 true,下次读取时若 dirty 才重新求值,否则直接返回缓存。因此它适合放“纯计算”,不适合放请求或 DOM 操作——副作用会让“什么时候该重算”变得不可预测。

watch 的 flush 三种模式

模式时机适用
pre组件更新前同步派生状态
post组件更新后读取/操作 DOM
sync立即同步调试,生产不建议

需要拿到旧值时用 watch,需要自动收集依赖时用 watchEffect;两者都不要在回调里修被监听的数据。

依赖图长什么样

依赖图可以理解为三层 Map:target → key → effect 集合。用 WeakMap 存 target,避免内存泄漏;用 Set 存 effect,天然去重。理解这三层,就能解释“为什么只改了一个属性,却触发了整个组件渲染”——组件是一个 effect,任何被它读取的数据变化都会让它重新执行。

调试时先看依赖,再看调度;先确认“谁依赖了它”,再确认“什么时候刷新”。依赖越细,更新越准,粒度决定性能。响应式不是魔法,是拦截与记录;理解依赖图,就理解了一半。

九、常见坑

  1. 解构 reactive 后继续使用,响应式丢失。
  2. 把 ref 当普通对象传给子组件,子组件忘记 .value。
  3. 在 watch 里修改被监听的数据,造成循环触发。
  4. 用 watchEffect 处理需要旧值的逻辑。
  5. 以为 computed 有副作用也没关系——它应该是纯函数。

十、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 手写一个 20 行的 reactive + effect,实现最基本的依赖收集。
  2. 用 watch 监听一个对象,分别测试 deep 与直接监听 .value 的差异。
  3. 用 nextTick 在修改数据后读取 DOM,验证更新时机。

② 让 AI 写同一题

把第 2 题(依赖收集实验)交给 AI,要求它解释:这个值为什么没触发更新。

③ 对比要盯的点

  • 它把 reactive 解构丢响应性解释成“Vue 的问题”还是“你拿到了原始值”?
  • watch 与 watchEffect 的差异,它是不是只说了“一个要写依赖一个不用”?
  • 深层监听 deep: true 的开销它提了吗?大对象上开深监听会直接卡。

④ 一句话取舍

它给的示例能复现问题,还是只是把官方文档抄了一遍?

十一、自测

  1. Proxy 相比 defineProperty 解决了哪三类问题?
  2. ref 与 reactive 分别适合什么场景?
  3. 依赖清理为什么必要?
  4. 同一轮改三次为什么只渲染一次?
  5. computed 与 watch 的缓存差异是什么?

参考答案要点:新增删除、数组、嵌套;ref 任意类型、reactive 对象;避免旧依赖误触发;调度队列去重;computed 有缓存。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解