1. 一个核心问题:数据变了,界面凭什么自动更新?
1.1 前端开发者的经典困惑
先回想一下你最早写前端时的状态。无论是 jQuery 时代手动操作 DOM,还是 React/Vue 早期版本里手动调用 setState、手动触发视图刷新,我们都在做同一件事:把数据变化同步到界面。
这一步看起来理所当然,但一旦业务复杂起来,问题就暴露了——A 处改了数据,B 处要更新,C 处也要更新,改完数据还要记得调用对应模块的刷新方法。漏掉一个,界面就停留在旧状态,bug 隐藏得毫无规律。
我最早接触这套机制时,心里冒出来的第一个念头特别朴素:能不能有一个东西,让我只改数据,界面自己就知道该更新了?不需要手动通知,不需要到处派发事件。
这个需求听起来像魔法,但实际上就是响应式系统在解决的问题。而它的两个地基,正是reactive和effect。
1.2 响应式系统需要回答的三个问题
把响应式的需求拆开,其实就三个问题:
- 数据什么时候变了——需要一种机制感知对象属性的读取和修改。
- 哪些代码依赖了这份数据——需要登记依赖关系,避免每次变更都全量刷新。
- 数据变更时如何通知这些代码——需要一种调度方式触发依赖重新执行。
任何一个响应式框架,无论表面 API 怎么包装,底层都在解决这三件事。reactive 负责第一件事,effect 负责第二和第三件事。
打个比方:reactive 是传感器,安装在数据的各个属性上;effect 是工单中心,记录谁对这份数据感兴趣。数据一变,传感器上报,工单中心立刻派单,关联任务重新执行。整个链路听起来不复杂,但真正实现起来,细节远比想象中多。
2. reactive 的底层:Proxy 如何让普通对象拥有超能力
2.1 为什么是 Proxy 而不是别的方案
先说结论:现代框架的响应式核心几乎都建立在 JavaScript 的Proxy之上。在 Proxy 之前,主流方案是用Object.defineProperty对属性做 getter/setter 改写,也就是 Vue 2 使用的方案。
defineProperty方案有一个致命问题:它只能拦截已有属性的读写。如果你往对象上新增一个属性,getter/setter 根本不存在,数据变更无法被感知。所以 Vue 2 才需要Vue.set这个 API 来补丁式地解决动态添加属性的问题。
Proxy 完全不同。它拦截的是对对象本身的操作,不管访问哪个属性、新增哪个属性、删除哪个属性,都会被同一套拦截逻辑接管。不需要提前遍历属性,不需要额外补丁,天然支持动态场景。
另外,Proxy 还能拦截in操作符、delete操作符、Object.keys遍历等更多操作,这让响应式系统能覆盖更多边界情况。
我刚开始学这块时有个误区:以为 Proxy 是把对象“复制”一份再做手脚。其实不是。Proxy 只是给原对象加了一层拦截器,原对象依然存在,所有读写都先经过拦截逻辑,再决定如何处理。这也解释了为什么响应式对象和原对象在===比较时不相等——用 Proxy 包出来的本身就是个新对象引用。
2.2 一个最小可用的 reactive 实现
理论铺垫够了,直接动手写。下面这段代码实现了最小可用的 reactive 核心:
const reactive = (target) => { return new Proxy(target, { get(target, key, receiver) { // 在这里触发依赖收集(track) track(target, key) return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver) // 在这里触发依赖更新(trigger) trigger(target, key) return result } }) }代码很短,但注意几个关键点。
get拦截里我用了Reflect.get,而不是直接target[key]。原因是Reflect.get会把正确的this绑定传给原始对象。比如对象里有 getter 属性,这个 getter 内部又访问了其他响应式属性,用Reflect.get才能保证这类场景的依赖收集正确。这是一个很容易被忽略、但实际影响不小的细节。
set拦截里的逻辑顺序也很重要:先改真实值,再触发更新。如果先触发更新再改值,effect 里读到的数据还是旧值,产生奇怪的行为。
这里出现了两个尚未定义的函数——track和trigger,它们就是整个依赖机制的核心,下一章详细拆解。
2.3 setter 拦截与触发信号
有人可能会问:既然set拦截里能拿到新旧值,为什么不判断一下是否真的变了再触发?
确实,很多框架会做这个优化,逻辑上也是合理的。比如数据从 1 改成 1,值没有变化,触发依赖更新就没必要。但这里有个容易踩坑的点:引用类型的数据,=== 只能用来看引用是否变化,无法判断深层内容是否变化。
比如obj.list.push(item),set拦截根本不会触发,因为 push 走的是数组内部方法,不经过对象的 set 拦截。这个问题在数组那一节会详细说明。
还有一个细节:如果set拦截里直接return Reflect.set(...),那么当使用严格模式时,如果操作的是不可写属性,返回 false 会抛错,这是标准行为。所以拦截器返回值必须尊重底层操作的是否成功。
我用个表格总结一下两种方案的核心差异:
| 对比维度 | defineProperty | Proxy |
|---|---|---|
| 拦截粒度 | 属性级 | 对象级,所有属性操作统一拦截 |
| 新增属性 | 无法感知,需要额外 API | 天然支持 |
| 删除属性 | 无法感知 | 支持拦截 |
| 数组下标操作 | 需要特殊处理 | 数组方法也能拦截 |
| 性能开销 | 初始化时遍历属性 | 每次操作多一次反射开销 |
3. effect 的职责:把“副作用”变成可跟踪的任务
3.1 什么是副作用函数
学响应式时必须建立一个概念:effect 就是副作用函数。
什么叫副作用?我给的通俗解释是:一个函数执行时,除了计算返回值还对外部产生了影响,最常见的比如修改了外部变量、更新了 DOM、发起网络请求,这些都叫副作用。
在响应式系统里,effect 特指一个包含了响应式数据读取逻辑的函数。这个函数执行时:
- 读取了响应式对象某属性 → 触发 get 拦截 → 收集当前 effect 作为该属性的依赖
- 之后数据被修改 → 触发 set 拦截 → 找到依赖了这个属性的 effect → 重新执行
这里的“当前 effect”是关键中的关键。系统必须知道“现在正在跑的是哪个 effect”,才能把依赖关系准确登记到这个 effect 身上。
3.2 ReactiveEffect 与执行栈
用一段代码实现最基本的 effect:
let activeEffect = null class ReactiveEffect { constructor(fn) { this.fn = fn this.deps = [] // 记录这个 effect 依赖过的所有属性集合 } run() { activeEffect = this this.fn() activeEffect = null } } const effect = (fn) => { const _effect = new ReactiveEffect(fn) _effect.run() return _effect }activeEffect是一个全局变量,表示当前正在执行的 effect。effect 执行时把自身赋值给activeEffect,这样数据 get 拦截发生时,就知道该把依赖登记到谁头上。
为什么用全局变量而不是把 effect 作为参数传进 track?核心原因是调用链深度问题。track 被调用时,代码里不会显式传 effect 参数,但中间隔着 Proxy 拦截器,层层调用下来,把 effect 挂在全局是最简洁可靠的做法。
但只有一个全局变量也有问题:effect 嵌套时如何处理?比如 effect A 执行到一半调用了 effect B,B 执行完后activeEffect变成 null,然后 A 继续执行,后续的属性读取就收集不到依赖了。
解决办法是维护一个 effect 栈:
const effectStack = [] class ReactiveEffect { run() { try { effectStack.push(this) this.fn() } finally { effectStack.pop() } } } const track = (target, key) => { const effect = effectStack[effectStack.length - 1] if (!effect) return // ... 记录依赖 }try/finally保证即使 fn 执行抛错,栈也能正确弹出,不至于把整个系统状态搞坏。
3.3 trigger 时的执行顺序与防抖
有了收集,就该有触发。trigger 的责任是:根据目标对象和目标属性,找到所有关联的 effect,让它们重新执行:
const trigger = (target, key) => { const depsMap = targetMap.get(target) if (!depsMap) return const effects = depsMap.get(key) if (effects) { effects.forEach(effect => effect.run()) } }这里有个很隐蔽的问题:trigger 遍历 effect 时,effect 内部执行可能再次修改数据,导致新的 trigger 触发,如果互相修改同一个属性,就会无限循环。
另外,每个 effect 重新执行时会重新收集依赖,旧的依赖集合和新的依赖集合可能不同,这就涉及到清理依赖的问题。假设一个 effect 之前依赖了 A 和 B 两个属性,执行体改成只依赖 A,如果不清理 B 的依赖记录,下次 B 变更时这个 effect 已经不需要重新执行了,会造成无效更新。
完整实现需要 track 时先清理旧依赖,再收集新依赖,每个依赖集合都要维护一份该 effect 的引用,这样清理才能做到双向删除。这块细节很多,但逻辑链是闭环的。
4. 从 effect 到 reactive 的完整链路:依赖收集的精髓
4.1 用一段代码走通全过程
把上面几段代码拼起来,加上数据结构,就得到了一个可以运行的响应式系统:
const targetMap = new WeakMap() const track = (target, key) => { const effect = effectStack[effectStack.length - 1] if (!effect) return let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let deps = depsMap.get(key) if (!deps) { deps = new Set() depsMap.set(key, deps) } if (!deps.has(effect)) { deps.add(effect) effect.deps.push(deps) } }数据结构是一个嵌套结构:
targetMap:以目标对象为 key,值为一个 Map- 这个 Map 以属性名为 key,值为一个 Set
- 这个 Set 里存放的就是所有依赖该属性的 effect
这样设计的原因很清晰:同一对象不同属性的依赖集合要分开,因为修改 A 属性不应该触发依赖 B 属性的 effect。
跟踪一下完整链路。假设有这样一个场景:
const state = reactive({ count: 0 }) effect(() => { console.log('count is', state.count) }) state.count = 1执行过程如下:
- 创建
reactive,返回一个 Proxy 对象。 - 执行 effect 内部函数,
activeEffect指向该 effect。 - 读取
state.count,触发 get 拦截。 track(state, 'count')把当前 effect 加入 targetMap 中对应的 Set。- 函数执行完毕,
activeEffect置空。 - 执行
state.count = 1,触发 set 拦截。 trigger(state, 'count')找到该属性对应的 effect Set,逐个执行。- 控制台打印出新的 count,界面更新完成。
这八步就是响应式工作的完整闭环。
4.2 依赖收集的时机:为什么是“读取时收集”
很多人刚开始学响应式时会困惑:为什么依赖收集偏偏发生在 get 拦截里,而不是在 effect 创建时?
原因很简单:你只有在真正读取数据时,才知道这个 effect 到底依赖了哪些属性。effect 的函数体是动态的,可能有条件分支,可能调用其他函数,也可能什么都没有读取。想要准确知道依赖关系,只能在函数执行过程中实时标记。
这个设计有个天然的好处:依赖关系始终和实际执行的内容保持一致。如果 effect 内部有if (condition) state.a = ... else state.b = ...,不同条件下依赖的属性不同,每次执行时 track 都重新收一遍,依赖集合自动更新到最新状态。
所以,响应式系统本质上是“按需收集、按需更新”的,它不做任何预判,一切以实际运行轨迹为准。
4.3 数据结构选型:为什么用 WeakMap
第一个容器选 WeakMap 而不是普通 Map,是个深思熟虑的设计。
WeakMap 的 key 是弱引用,也就是说,如果没有其他地方引用原始对象,这个对象可以被垃圾回收,WeakMap 里的记录也随之清除。这防止了为响应式对象创建依赖关系后产生内存泄漏——你可以创建一个临时对象,给某个 effect 用一下,用完丢弃该对象,系统不会因为依赖记录而强制保留它。
这在真实应用里很重要,特别是组件卸载场景。一个组件持有的响应式状态如果还挂在广播型数据结构里,整个 Map 就一直存活。用 WeakMap 后,组件销毁,对象失去引用,它的依赖记录也自动蒸发,无需手动清理。
第二个容器是 Map,它负责把属性名映射到依赖集合。Map 的 key 可以是任意类型,字符串没问题,Symbol 也没问题,非常契合动态场景。
第三个容器 Set 用来存放 effect,保证同一属性被同一个 effect 依赖多次时不会重复收集。比如 effect 里读了两次state.count,第二次 track 时发现 Set 里已经有了,直接忽略。
5. 真实场景中的边界情况与常见陷阱
5.1 嵌套响应式对象的问题
reactive 返回的 Proxy 只代理了传入对象那一层,但如果属性值本身是个对象,这个“子对象”并不会自动变成响应式的。实际使用中,绝大多数框架会做懒递归处理——第一次访问子对象属性时才把子对象也包成响应式,而不是在初始化时一次性递归全部包装。
懒递归的好处是性能:不需要提前递归整个对象树,而是按需包装,访问到哪里就包装到哪里。这种设计特别适合大型对象结构,因为不是所有深层属性都会在业务里被访问。
需要注意的坑是:如果你把state.list传到组件外面,然后直接给list.push(...),这个 list 对象如果不是响应式的,视图不会更新。解决方法是访问它时总是通过state.list这个路径再操作,或者用reactive显式包装后再使用。
5.2 effect 默认执行一次引发的意外
第一次跑 effect,函数立即执行了一次。这是响应式系统的默认行为,是因为:不在初始化时跑一次,系统根本不知道这个 effect 依赖了哪些数据,依赖收集无从谈起。
但这个立即执行会带来一个反直觉的问题。假设 effect 里做了一次网络请求,写的是:
effect(() => { fetchData(state.userId) })初始化时会无关发一次请求,如果这个 userId 初始值不该发请求,就要手动判断。我在实际项目里遇到过一个让人困惑的问题:某个页面刚打开就发了一次不该发的请求,排查了很久才发现源头就是 effect 的默认执行时机。
解决方案一般是加条件把首次执行过滤掉,比如用一个标记变量判断是否为第一次运行。真实框架实现的watchAPI 就是在这里做了文章——它比 effect 多了一个immediate选项,默认 false 时不执行第一次,这本质上就是对 effect 默认行为的封装。
5.3 数组与新增属性的坑
数组是最容易出问题的场景。arr.push(...)走的是数组内部方法,不会经过 set 拦截。所以如果在 effect 里依赖了arr.length,然后执行arr.push(1),是能正常触发的——因为 push 会修改 length 属性,而 length 的 set 可以被拦截到。
反过来,如果 effect 依赖的是arr[0],然后arr.push(1),index 0 的值没有变化,不会触发更新。这个一致性需要自己留意。
新增属性也一样。用 Proxy 方案后新增属性其实能被拦截到,但如果你从一个非响应式对象派生响应式数据,然后给原对象加属性,响应式对象那边是不会有感应的。记住一个原则:要么全程使用响应式对象,要么在数据变化前先重新包装,不要混着操作原对象。
6. 从手写实现回到 Vue 3:reactive 与 effect 在真实框架中的样子
6.1 字段级依赖 vs 对象级依赖
上面手写的简化版是属性级的依赖收集——不同属性的依赖放在不同集合。但 Vue 3 里实际用的是字段级依赖,比属性级更进一步。
属性级和字段级的区别:属性级处理的是普通属性名,字段级则能处理更复杂的依赖场景,比如computed计算属性依赖多个字段、ref对象内的 value 字段的依赖等。这个设计直接让整个响应式系统的依赖收集更加精