Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

18 阅读8分钟

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

手撸开始:一个 200 行以内的迷你 reactivity

第一步:全局变量——追踪"当前正在执行的 effect"

在写任何具体逻辑之前,先定义两个模块级变量。它们是整个响应式系统的通信枢纽——track 通过读取它们知道"当前是谁在访问数据",effect 通过写入它们来"报到"。

运行// 当前正在执行的 effect 函数,track 会把它收集为依赖
let activeEffect: (() => void) | null = null

// effect 执行栈,处理嵌套 effect 的场景
const effectStack: (() => void)[] = []

为什么需要这两个变量?先看一个最简单的场景:

运行const state = reactive({ price: 10, quantity: 2 })

effect(() => {
  console.log(state.price * state.quantity)  // 输出 20
})

state.price = 20  // 自动输出 40

当 effect 执行传入的函数时,函数内部读取了 state.price 和 state.quantity,触发了 Proxy 的 get 拦截器。此时 get 里的 track 函数需要知道一件事: "是谁在读我?"  答案就在 activeEffect 里——effect 在执行 fn() 之前把自己赋值给了 activeEffecttrack 读取它就能建立依赖关系。

这种通过模块级变量传递上下文的方式,本质上和 React 的 currentlyRenderingFiber 是同一个思路——用一个全局指针标记"当前正在干活的是谁"。不算优雅,但在 JavaScript 单线程模型下,简单且可靠。

effectStack 的存在是为了处理嵌套场景,第二步会详细解释。这里只需要知道:activeEffect 标记"谁在执行",effectStack 保证嵌套时能正确恢复上一层。

第二步:effect——注册一个"关心数据变化"的函数

effect 的职责很单纯:接收一个函数,立即执行它,执行过程中自动收集这个函数访问了哪些响应式数据。下次这些数据变了,就重新执行这个函数。

function effect(fn: () => void) {
  const run = () => {
    activeEffect = run
    effectStack.push(run)
    fn()  // 执行 fn 的过程中会触发 get → 收集依赖
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] || null
  }
  run()  // 立即执行一次,触发首次依赖收集
}

这里有个关键设计:effectStack 这个栈结构。为什么需要它?看一个嵌套场景——外层 effect 读取 state.a,中间嵌套了一个内层 effect 读取 state.b,内层执行完之后外层继续读取 state.c。如果不用栈,内层 effect 执行结束后 activeEffect 直接变成 null,外层对 state.c 的访问就收集不到依赖了。

这不是什么刁钻的边界情况,嵌套 computed 就会触发这个场景。栈结构保证了无论嵌套多少层,每层 effect 结束后都能正确恢复到上一层的 activeEffect

第三步:依赖存储结构

依赖收集需要回答一个问题:**哪个对象的哪个属性,被哪些 effect 函数关心?

const targetMap = new WeakMap<object, Map<string | symbol, Set<() => void>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let deps = depsMap.get(key)
  if (!deps) depsMap.set(key, (deps = new Set()))
  deps.add(activeEffect)
}

function trigger(target: object, key: string | symbol) {
  const deps = targetMap.get(target)?.get(key)
  if (deps) deps.forEach(fn => fn())
}

track 在属性被读取时调用,把当前正在执行的 effect 存进 Set;trigger 在属性被写入时调用,遍历 Set 逐个重新执行。

这里用 WeakMap 作为第一级容器,是因为它对 key 的引用是弱引用。当 target 对象在业务代码中不再被使用,垃圾回收器可以直接回收它,不会因为 targetMap 里还存着引用就一直留在内存中。换成普通 Map 的话,所有被代理过的对象都会一直驻留内存,组件反复创建销毁的场景下内存会持续增长。

第四步:reactive——把普通对象变成响应式

有了 tracktrigger,reactive 只需要做一件事:给对象套上 Proxy,在 get 里调 track,在 set 里调 trigger

function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)
      const result = Reflect.get(obj, key, receiver)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)  // 懒代理:访问到才代理
      }
      return result
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key as keyof T]
      const result = Reflect.set(obj, key, value, receiver)
      if (oldValue !== value) trigger(obj, key)
      return result
    }
  })
}

注意 get 里对嵌套对象的处理方式——不是初始化时递归把所有层级的对象全代理一遍,而是访问到某个属性、发现它是对象时才递归代理。这就是所谓的懒代理。Vue2 的 observe 恰恰相反,初始化时全量递归遍历整个对象树做 defineProperty,数据层级深、属性多的时候,光初始化就有明显的卡顿。Vue3 把这个成本分摊到了实际访问时,没用到的深层数据根本不会被代理。


设计权衡:为什么 Vue3 这么设计

Proxy vs defineProperty

维度definePropertyProxy
新增属性拦不到,需要 $set自动拦截
数组变异需要 hack 7 个方法原生支持
初始化成本全量递归懒代理,按需
兼容性IE9+IE 完全不支持
性能属性多时慢整体更优

Vue3 放弃 IE 支持,不是拍脑袋的决定——Proxy 没法 polyfill。

为什么依赖收集在 get 里而不是手动声明

手动声明依赖意味着开发者自己维护"谁依赖谁"的关系。React 的 useEffect 就走了这条路——你得手写依赖数组,遗漏一个就会拿到过期的闭包值,这类 bug 排查起来相当头疼,因为代码不报错,只是行为不符合预期。

Vue 的自动依赖收集有运行时开销,但换来的开发体验提升是实打实的:你用到了哪些数据,框架自动知道,不需要人肉维护依赖列表。


边界与踩坑

解构丢失响应式

这是 Vue3 新手最常踩的坑之一。解构一个 reactive 对象时,基本类型的属性会被拷贝出来,和原对象完全断开。

const state = reactive({ count: 0 })

// 解构出来是个普通数字,和 state 断开了
const { count } = state  // count = 0,后续 state.count 变了它不会跟着变

// 用 toRefs 保持连接
const { count } = toRefs(state)  // count 是个 ref,.value 和 state.count 同步

toRefs 做的事情是把 reactive 对象的每个属性都转成一个 ref,ref 内部通过 getter/setter 维持和原对象的绑定关系。解构 ref 不会断开连接,因为你拿到的是一个引用类型的对象,不是一个原始值的拷贝。

reactive 只能用于对象

Proxy 的限制——它只能代理对象,基本类型(number、string、boolean)没法套 Proxy。

const count = reactive(0)  // 报错
const count = ref(0)       // 内部其实是 reactive({ value: 0 })

ref 的本质是把基本类型包了一层 { value: ... } 对象再做响应式代理。.value 不是 Vue 团队故意增加心智负担,而是 JavaScript 语言层面的限制——要拦截对一个变量的赋值操作,只能通过对象属性的 setter 来实现。

大数组 / 大对象的性能

响应式不是没有代价的。每次读取属性都会执行 track,每次写入都会执行 trigger。一个实际项目里有一个包含 5000+ 条记录的表格数据,用 reactive 包裹后初始渲染时间从 80ms 涨到了 300ms——因为表格组件遍历每一行每一列时都在触发 track

对于这类只读展示的大数据量场景,Vue3 提供了两个逃生舱:

// shallowRef:只有 .value 本身的替换是响应式的,内部属性变化不追踪
const bigList = shallowRef(fetchHugeList())

// markRaw:标记对象永远不会被代理
const rawData = markRaw(someHugeObject)

shallowRef 适合"整体替换"的场景——你不会修改列表里某一项的属性,而是直接换一个新列表。markRaw 更激进,被标记的对象即使被塞进 reactive 对象里也不会被代理。

循环引用

const a = reactive({} as any)
const b = reactive({} as any)
a.b = b
b.a = a  // 不会爆栈

为什么不会爆栈?还是因为懒代理。赋值 a.b = b 的时候只是把 b 存进去,不会递归展开。只有你真正访问 a.b.a.b.a... 的时候才会逐层代理,而实际业务代码不会无限层地访问下去。换成 Vue2 的全量递归就不一样了——observe(a) 会立即递归遍历 a 的所有属性,发现 a.b 指向 b,又去递归 b,发现 b.a 指向 a,再递归 a。 直接栈溢出。


响应式的通用思维模型

Vue3 的响应式不是什么独创发明,它是一个经典模式的精致实现:

拦截 → 收集 → 通知

Proxy 拦截对象的读和写;读的时候记下"谁在读";写的时候告诉所有读过的人"值变了"。三步,就这么简单。这个模型的适用范围远不止 UI 框架。数据库的触发器、Excel 的公式联动、消息队列的发布订阅,底层都是这个模式的变体。以后遇到类似的需求——"A 变了,B 要自动跟着变"——你就知道该怎么建模了:一个依赖图,读时收集,写时触发。

理解响应式最好的方式不是读文档,是自己实现一遍。200 行代码,核心逻辑就是 reactiveeffecttracktrigger 四个函数,你自己写一遍所获得的理解,比读十篇分析文章都深。