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

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

一个让人头秃的 bug

上周组里一个同事来问我:"为什么我给 reactive 对象加了个新属性,页面不更新?"

我看了一眼代码------Vue2 的写法,用的 Vue3 的 API。

ts 复制代码
const state = reactive({ name: '张三' })

// 他的操作:
state.age = 25 // 页面更新了 ✅(Vue3 没问题)

// 但他之前在 Vue2 项目里被坑过,条件反射写了:
Vue.set(state, 'age', 25) // Vue3 里根本没有 Vue.set 了

这件事让我意识到:很多人用了两三年 Vue3,但对响应式到底怎么工作的,还是停留在"Proxy 比 defineProperty 好"这句话上。

好在哪?为什么好?依赖怎么收集的?什么时候触发更新?

今天咱们把这事彻底说清楚,顺便手写一个能跑的迷你 reactivity。


Vue2 的 defineProperty 到底差在哪

先别急着夸 Proxy,得知道 Vue2 为啥被淘汰。

js 复制代码
// Vue2 的响应式核心:逐个属性拦截
Object.defineProperty(obj, 'name', {
  get() {
    // 收集依赖
    return value
  },
  set(newVal) {
    value = newVal
    // 通知更新
  }
})

// ❌ 问题1:新增属性拦截不到,必须用 Vue.set
obj.age = 25 // set 根本不会触发,页面不更新

// ❌ 问题2:数组下标修改拦截不到
arr[0] = 'new' // 没反应

// ❌ 问题3:初始化时要递归遍历整个对象,性能差
// 1000 个属性 → 1000 次 defineProperty

本质问题就一句话:defineProperty 是"属性级别"的拦截,你得提前知道有哪些属性。

这就像安检------defineProperty 是给每个人单独装一个安检门,来一个新人得现装;Proxy 是在大楼入口装一个,谁进来都得过。


Proxy:对象级别的拦截

js 复制代码
const raw = { name: '张三', age: 25 }

const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取了 ${key}`)  // 任何属性的读取都能拦截
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置了 ${key} = ${value}`)
    target[key] = value
    return true  // set 必须返回 true,不然严格模式报错
  }
})

proxy.name           // → "读取了 name"
proxy.hobby = '摸鱼'  // → "设置了 hobby = 摸鱼"  ✅ 新增属性也能拦截!
delete proxy.age     // 配合 deleteProperty trap,删除也能拦截

不用提前遍历,不用 Vue.set,天然支持新增/删除属性。这不是"好一点",这是降维打击。


光有 Proxy 还不够

拦截到读写只是第一步。关键问题是:谁在读?读了之后要通知谁?

这就是依赖收集。

想象一个场景:你在公司群里发了条消息"今晚团建",但不是所有人都需要知道------只有你组里的人需要收到通知。响应式系统干的就是这事:精准投递,别群发。

核心流程就三步:

  1. 读取(get) → 谁在读我?记住他(track)
  2. 修改(set) → 值变了,通知所有记住的人(trigger)
  3. effect → "那个在读的人"到底是谁?就是当前正在执行的副作用函数

手撸一个迷你 reactivity

别怕,核心代码不到 80 行。

第一步:全局变量------当前正在执行的 effect

ts 复制代码
// 全局指针:当前谁在执行?
// 这是整个系统的"指挥棒"
let activeEffect: Function | null = null

function effect(fn: Function) {
  activeEffect = fn  // 先把"当前执行者"挂上
  fn()               // 执行函数 → 函数内部会读取响应式数据 → 触发 get
  activeEffect = null // 执行完了,摘掉
}

这里有个精妙的设计:执行 fn() 的时候,fn 内部读取了什么属性,Proxy 的 get 就知道"当前是谁在读"。

时序上是这样的:

javascript 复制代码
effect(() => console.log(state.name))
│
├─ activeEffect = fn        ← 挂上
├─ fn()                     ← 开始执行
│   └─ 读取 state.name      ← 触发 Proxy get
│       └─ get 里发现 activeEffect 不为 null
│           └─ 记住:name 这个属性被 fn 依赖了!(track)
└─ activeEffect = null       ← 摘掉

第二步:依赖存储结构

ts 复制代码
// 依赖关系的存储:target → key → Set<effect>
// 用 WeakMap 是为了不阻止对象被垃圾回收
const targetMap = new WeakMap<object, Map<string | symbol, Set<Function>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return // 没人在执行 effect,不用收集

  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)
  }

  deps.add(activeEffect) // 把当前 effect 加进去
}

数据结构长这样:

javascript 复制代码
targetMap (WeakMap)
  └─ { name: '张三', age: 25 }  (Map)
       ├─ 'name' → Set [ effect1, effect2 ]
       └─ 'age'  → Set [ effect3 ]

为什么用三层结构? 因为一个应用里有多个响应式对象(target),每个对象有多个属性(key),每个属性可能被多个 effect 依赖。三层刚好,多了浪费,少了不够。

第三步:触发更新

ts 复制代码
function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)
  if (!deps) return

  // 遍历所有依赖了这个 key 的 effect,逐个执行
  deps.forEach(effect => effect())
}

简单粗暴:找到谁依赖了这个 key,挨个重新执行。

第四步:组装 reactive

ts 复制代码
function reactive<T extends object>(raw: T): T {
  return new Proxy(raw, {
    get(target, key, receiver) {
      track(target, key)                  // 读取时收集依赖
      const result = Reflect.get(target, key, receiver)

      // 如果值是对象,递归代理(懒代理,用到才包)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },

    set(target, key, value, receiver) {
      const oldValue = target[key as keyof T]
      const result = Reflect.set(target, key, value, receiver)

      if (oldValue !== value) {
        trigger(target, key)              // 值变了才触发,没变不浪费
      }
      return result
    }
  })
}

注意两个细节:

  • 懒代理:Vue3 不会在初始化时递归代理整个对象,只有 get 到嵌套对象时才代理。对比 Vue2 初始化就递归遍历,这就是性能差距。
  • Reflect.get/set :不直接用 target[key],因为 Reflect 能正确处理 this 指向和继承问题。你可能觉得"直接读不也行吗"------行,但在有 getter/继承的场景会出 bug。

跑一下看看

ts 复制代码
const state = reactive({ count: 0, msg: 'hello' })

// effect 1:只依赖 count
effect(() => {
  console.log('count changed:', state.count)
})
// 立即输出:count changed: 0

// effect 2:只依赖 msg
effect(() => {
  console.log('msg changed:', state.msg)
})
// 立即输出:msg changed: hello

state.count++
// → "count changed: 1"   ✅ effect1 触发
// → (effect2 没触发)      ✅ 精准更新,不是无脑全刷

state.msg = 'world'
// → "msg changed: world"  ✅ effect2 触发

70 多行代码,一个能跑的响应式系统就出来了。


设计权衡:Vue3 做了哪些取舍

为什么用 WeakMap 而不是 Map?

ts 复制代码
// WeakMap 的 key 是弱引用,对象没有其他引用时会被 GC 回收
// 如果用 Map → 响应式对象永远被 targetMap 引用 → 内存泄漏
const targetMap = new WeakMap() // ✅
const targetMap = new Map()     // ❌ 内存泄漏风险

为什么 effect 要立即执行一次?

因为不执行就收集不到依赖。依赖收集发生在 get 里,不读一遍属性,系统不知道你依赖了谁。

这也是 watchEffectwatch 的核心区别:

ts 复制代码
// watchEffect → 立即执行,自动收集依赖
watchEffect(() => {
  console.log(state.count) // 读了 count → 自动依赖 count
})

// watch → 你手动告诉它监听谁
watch(() => state.count, (newVal) => {
  console.log(newVal)
})

懒代理 vs 初始化全量代理

策略 初始化耗时 运行时耗时 适合场景
Vue2 全量递归 高(大对象很慢) 小型对象
Vue3 懒代理 几乎为零 首次访问有微小开销 大型 / 深层嵌套对象

Vue3 选了懒代理,因为实际项目中大部分属性不会在首帧全部读取------你一个 1000 行的 config 对象,首屏可能只用了 5 个字段,全量代理纯属浪费。


我们的迷你版漏了什么

写到这里你可能觉得"就这?挺简单啊"。别急,真实的 Vue3 reactivity 还处理了一堆你想不到的边界情况:

1. effect 嵌套

ts 复制代码
effect(() => {
  console.log('outer', state.a)
  effect(() => {
    console.log('inner', state.b) // 内层 effect 执行完,activeEffect 被置为 null
  })
  console.log(state.c) // ❌ 这里 activeEffect 已经是 null 了,c 的依赖收集不到!
})

Vue3 用 effectStack(栈结构)解决这个问题------进入 effect 时 push,退出时 pop,恢复上一层的 activeEffect。

2. 无限循环

ts 复制代码
effect(() => {
  state.count = state.count + 1 // 读 count → 触发 get → 收集依赖
                                 // 写 count → 触发 set → 执行 effect
                                 // effect 又读 count → 又触发 set → 💥 死循环
})

Vue3 的解法:如果当前正在执行的 effect 和要触发的 effect 是同一个,跳过。

3. ref 的存在意义

ts 复制代码
// reactive 只能代理对象
const count = reactive(0)  // ❌ Proxy 不能代理基本类型

// ref 用对象包一层,把基本类型变成对象
const count = ref(0)       // 内部:{ value: 0 } → 再用 reactive 代理
count.value++               // 通过 .value 触发 get/set

所以 ref.value 不是脱裤子放屁------是基本类型没法直接 Proxy 的无奈之举。

4. 集合类型的处理

ts 复制代码
const map = reactive(new Map())
map.set('key', 'value') // set 方法不是赋值操作,Proxy 的 set trap 拦不到

// Vue3 对 Map/Set/WeakMap/WeakSet 做了专门的 handler
// 拦截的是 get → 拿到 set 方法 → 返回一个包装后的 set 方法

这部分代码在 Vue3 源码里占了不少篇幅,也是最容易被忽略的。


可扩展性:这套模型能做什么

这套 track → trigger → effect 模型不只是给 Vue 用的,它本质上是一个自动依赖追踪的发布-订阅系统

你完全可以用它来做:

  • 状态管理:Pinia 的底层就是 reactive + 一些封装
  • 计算缓存:computed 就是一个带 dirty 标记的 effect
  • 跨组件通信:provide/inject + reactive = 自动响应式的上下文注入
  • 表单引擎:字段之间的联动关系,天然适合响应式依赖图

如果你的项目需要一套"数据变了自动通知"的机制,不一定要上 Vue,把这 70 行代码抄走改改就能用。


总结:一个通用模型

Vue3 响应式的本质,是解决一个古老的编程问题:状态同步。

A 变了,B 要跟着变。手动同步容易漏、容易错、容易忘。

Vue3 的解法是:

  1. Proxy 拦截读写------知道谁被读了、谁被改了
  2. effect + activeEffect------知道"谁在关心这个数据"
  3. track / trigger------自动建立和触发依赖关系
  4. WeakMap 三层结构------高效存储依赖映射

以后遇到类似的问题,不管是前端状态管理、后端事件驱动、还是 Excel 的单元格公式联动,底层模型都是一样的:观察者模式 + 自动依赖追踪。

区别只在于:谁来当观察者,怎么收集依赖,粒度做到多细。

想明白这一层,你看任何响应式框架(Solid、Svelte 5 runes、Preact signals)都会觉得------嗯,换了个壳,内核没跑出这个圈。

相关推荐
willow2 小时前
JavaScript数据类型整理1
javascript
LeeYaMaster2 小时前
20个例子掌握RxJS——第十一章实现 WebSocket 消息节流
javascript·angular.js
UIUV3 小时前
RAG技术学习笔记(含实操解析)
javascript·langchain·llm
颜酱4 小时前
理解二叉树最近公共祖先(LCA):从基础到变种解析
javascript·后端·算法
FansUnion5 小时前
我如何用 Next.js + Supabase + Cloudflare R2 搭建壁纸销售平台——月成本接近 $0
javascript
左夕6 小时前
分不清apply,bind,call?看这篇文章就够了
前端·javascript
滕青山7 小时前
文本行过滤/筛选 在线工具核心JS实现
前端·javascript·vue.js
时光不负努力7 小时前
编程常用模式集合
前端·javascript·typescript
大雨还洅下7 小时前
前端JS: 跨域解决
javascript