资讯详情

响应式系统核心:从 reactive 到 effect 的依赖收集原理

📅 2026/10/21 19:51:05 | 华诺云谱 👁 阅读
响应式系统核心:从 reactive 到 effect 的依赖收集原理
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 会抛错这是标准行为。所以拦截器返回值必须尊重底层操作的是否成功。我用个表格总结一下两种方案的核心差异对比维度definePropertyProxy拦截粒度属性级对象级所有属性操作统一拦截新增属性无法感知需要额外 API天然支持删除属性无法感知支持拦截数组下标操作需要特殊处理数组方法也能拦截性能开销初始化时遍历属性每次操作多一次反射开销3. effect 的职责把“副作用”变成可跟踪的任务3.1 什么是副作用函数学响应式时必须建立一个概念effect 就是副作用函数。什么叫副作用我给的通俗解释是一个函数执行时除了计算返回值还对外部产生了影响最常见的比如修改了外部变量、更新了 DOM、发起网络请求这些都叫副作用。在响应式系统里effect 特指一个包含了响应式数据读取逻辑的函数。这个函数执行时读取了响应式对象某属性 → 触发 get 拦截 → 收集当前 effect 作为该属性的依赖之后数据被修改 → 触发 set 拦截 → 找到依赖了这个属性的 effect → 重新执行这里的“当前 effect”是关键中的关键。系统必须知道“现在正在跑的是哪个 effect”才能把依赖关系准确登记到这个 effect 身上。3.2 ReactiveEffect 与执行栈用一段代码实现最基本的 effectlet 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 BB 执行完后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 3reactive 与 effect 在真实框架中的样子6.1 字段级依赖 vs 对象级依赖上面手写的简化版是属性级的依赖收集——不同属性的依赖放在不同集合。但 Vue 3 里实际用的是字段级依赖比属性级更进一步。属性级和字段级的区别属性级处理的是普通属性名字段级则能处理更复杂的依赖场景比如computed计算属性依赖多个字段、ref对象内的 value 字段的依赖等。这个设计直接让整个响应式系统的依赖收集更加精
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑