模块二 · 响应式与编译
响应式系统:Proxy、依赖图与调度
响应式不是"监听变化",而是"读写拦截 + 依赖图 + 调度"。这一节拆开 ref/reactive 的实现差异,并解释 computed 与 watch 的缓存逻辑。
读完这一节,你能做到
- 说清 Proxy 相比 defineProperty 解决了哪些问题。
- 区分 ref 与 reactive 的使用场景与陷阱。
- 用 track/trigger 解释依赖收集与派发。
- 说清 computed 的缓存与 watch 的调度差异。
一、为什么从 defineProperty 换成 Proxy
| 能力 | defineProperty | Proxy |
|---|---|---|
| 新增/删除属性 | 拦截不到 | 可以 |
| 数组下标/长度 | 需要重写数组方法 | 可以 |
| 嵌套对象 | 需要递归劫持 | 惰性代理,访问时才处理 |
| 拦截范围 | 只能拦截已有属性 | 支持 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:取出依赖集合,逐个调度执行
三个关键点:
- 依赖是"执行期间"收集的。条件分支里没走到的数据,不会建立依赖。
- 同一个 effect 多次读取同一数据,只记录一次(用 Set 去重)。
- 清理旧依赖:每次重新执行前,先把自己从上一轮的依赖集合里移除,避免无效触发。
四、调度器与 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,任何被它读取的数据变化都会让它重新执行。
调试时先看依赖,再看调度;先确认“谁依赖了它”,再确认“什么时候刷新”。依赖越细,更新越准,粒度决定性能。响应式不是魔法,是拦截与记录;理解依赖图,就理解了一半。
九、常见坑
- 解构
reactive后继续使用,响应式丢失。 - 把
ref当普通对象传给子组件,子组件忘记.value。 - 在
watch里修改被监听的数据,造成循环触发。 - 用
watchEffect处理需要旧值的逻辑。 - 以为
computed有副作用也没关系——它应该是纯函数。
十、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 手写一个 20 行的
reactive + effect,实现最基本的依赖收集。 - 用
watch监听一个对象,分别测试deep与直接监听.value的差异。 - 用
nextTick在修改数据后读取 DOM,验证更新时机。
② 让 AI 写同一题
把第 2 题(依赖收集实验)交给 AI,要求它解释:这个值为什么没触发更新。
③ 对比要盯的点
- 它把
reactive解构丢响应性解释成“Vue 的问题”还是“你拿到了原始值”? watch与watchEffect的差异,它是不是只说了“一个要写依赖一个不用”?- 深层监听
deep: true的开销它提了吗?大对象上开深监听会直接卡。
④ 一句话取舍
它给的示例能复现问题,还是只是把官方文档抄了一遍?
十一、自测
- Proxy 相比 defineProperty 解决了哪三类问题?
- ref 与 reactive 分别适合什么场景?
- 依赖清理为什么必要?
- 同一轮改三次为什么只渲染一次?
- computed 与 watch 的缓存差异是什么?
参考答案要点:新增删除、数组、嵌套;ref 任意类型、reactive 对象;避免旧依赖误触发;调度队列去重;computed 有缓存。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解