跳到主要内容

Reactivity源码原理

如果只把 Vue 3 的响应式理解成“用了 Proxy”,这个答案是不够的。Proxy 只是入口,真正的核心是:

  • 谁在读取数据
  • 把这个读取关系记到哪里
  • 数据变化后怎么只通知相关副作用

所以从源码角度看,Reactivity 的主线不是 Proxy,而是:

track 收集依赖,trigger 触发依赖,effect 组织副作用执行。

0. 面试速答(30 秒版 TL;DR)

  • Vue 3 响应式核心包是 @vue/reactivity,核心链路是:代理拦截 -> 依赖收集 -> 依赖触发 -> 调度执行
  • 依赖关系通常可抽象成:WeakMap<Target, Map<Key, Dep>>
  • effect 表示“依赖这个数据的一段副作用逻辑”,组件 render、本地副作用、computed 底层都依赖它。
  • reactive 负责把对象接入依赖系统,ref 负责把单值包成可追踪对象,computed 负责懒执行与缓存。
  • Vue 3 的优势不只是 Proxy,还包括:精细依赖、cleanup、scheduler、集合类型特殊处理

先记主链路:effect 执行 -> 读取属性 -> track 建依赖;数据变更后走 trigger -> 调度 effect

1. 先立住心智模型:响应式系统到底在解决什么?

最本质的问题只有一句:

当一段逻辑依赖了某个数据,数据变化时,能不能只重新执行这段逻辑?

比如:

effect(() => {
console.log(state.count)
})

系统要做到两件事:

  1. 第一次执行时,知道这段 effect 读了 state.count
  2. 以后 state.count 变化时,只重新触发这段 effect

所以响应式系统的本质不是“自动刷新页面”,而是“自动维护读取关系”。

2. 源码里最关键的数据结构是什么?

面试里一定要把这句说出来:

WeakMap<Target, Map<Key, Dep>>

可以拆成三层理解:

  1. WeakMap 的 key 是目标对象 target
  2. Map 的 key 是目标对象上的属性 key
  3. Dep 是依赖这个属性的一组 effects

例如:

const state = reactive({ count: 0, name: 'vue' })

可能会形成这样的依赖图:

  • state.count -> {effectA, effectB}
  • state.name -> {effectC}

这意味着:

  • count 不会误触发只依赖 name 的逻辑
  • 响应式是按“对象 + 属性”精细追踪的

3. effect 是响应式系统的执行单元

effect(fn) 可以理解成:

  • 先把 fn 包装成一个响应式副作用对象
  • 执行时把它设成“当前激活副作用”
  • 这样后续所有属性读取都知道该把依赖记到谁身上

伪代码可理解为:

let activeEffect

function effect(fn) {
const reactiveEffect = () => {
activeEffect = reactiveEffect
fn()
activeEffect = undefined
}

reactiveEffect()
return reactiveEffect
}

真实实现比这复杂很多,还会处理:

  • effect 嵌套
  • cleanup
  • scheduler
  • 停止监听 stop
  • 避免递归自触发

但主线不变:执行 effect 时暴露 activeEffect,供依赖收集使用。

4. track 到底做了什么?

当你访问响应式对象属性时,会进入代理的 get 拦截。

例如:

const state = reactive({ count: 0 })

effect(() => {
console.log(state.count)
})

执行 state.count 时,流程大致是:

  1. 代理 get 被触发
  2. 调用 track(target, key)
  3. 发现当前存在 activeEffect
  4. targetMap 中找到 target 对应的依赖表
  5. 在依赖表里找到 key 对应的 dep
  6. activeEffect 放进 dep

伪代码:

function track(target, key) {
if (!activeEffect) return

let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}

let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}

dep.add(activeEffect)
}

重点不是背代码,而是理解:

  • track 的目标是建立“哪个属性被哪个 effect 用过”的关系

5. trigger 为什么能做到精准更新?

写入响应式属性时,会进入代理的 setdeleteProperty,或者集合类型的专门处理逻辑。

例如:

state.count++

流程通常是:

  1. 判断这次是新增、修改还是删除
  2. 根据 target + key 找到对应 dep
  3. 把相关 effects 收集出来
  4. 如果 effect 定义了 scheduler,交给它调度
  5. 否则直接执行

伪代码:

function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return

const effects = depsMap.get(key)
effects?.forEach((effect) => {
if (effect.scheduler) {
effect.scheduler()
} else {
effect()
}
})
}

这里的关键是:

  • 不是“对象一变,全量通知”
  • 而是 按具体 key 取出对应 effects

6. 为什么需要 effect 栈和 cleanup?

这是很多人回答不出来,但很容易被追问的点。

6.1 effect 栈

因为 effect 可能嵌套执行。

例如外层 effect 中又触发了内层 effect,如果只用一个全局变量保存当前 effect,就可能把依赖记错对象。

所以需要:

  • 入栈:当前 effect 开始执行
  • 出栈:当前 effect 结束执行
  • activeEffect 永远指向栈顶

6.2 cleanup

因为 effect 每次重新执行后,依赖集合可能变化。

例如:

effect(() => {
console.log(flag ? state.a : state.b)
})

第一次 flag = true 时依赖了 a,后来 flag = false 后应该依赖 b。如果不清理旧依赖,就会出现:

  • a 还会误触发 effect
  • 依赖集合越来越脏

所以 effect 在重新执行前,会把自己从旧 dep 中移除,再重新收集。

7. reactive 的本质不是 Proxy,而是“带协议的 Proxy”

reactive 返回的是代理对象,但关键不是“用了代理”,而是代理里藏着响应式协议。

最核心的 get / set 行为可以概括为:

new Proxy(target, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
trigger(target, key)
return result
},
})

真实源码还会继续处理:

  • 是否只读 readonly
  • 是否浅层 shallowReactive
  • 嵌套对象是否继续转响应式
  • 数组方法是否需要特殊拦截
  • Map / Set / WeakMap / WeakSet 的读写与遍历

所以源码难点不在语法,而在“边界条件非常多”。

8. ref 为什么也能接进同一套系统?

ref 没有直接把值暴露出去,而是包成了一个带 value 访问器的对象。

你可以把它想成:

class RefImpl {
get value() {
track(this, 'value')
return this._value
}

set value(nextValue) {
this._value = nextValue
trigger(this, 'value')
}
}

所以 refreactive 虽然入口不同,但最终都能落回:

  • 读取时 track
  • 写入时 trigger

换句话说,ref 不是另一套系统,而是同一套依赖协议的另一种包装形态。

9. computed 为什么既能缓存,又能自动更新?

computed 底层也是 effect,只不过是 lazy effect

它的关键点是:

  1. 不会一创建就立即重新计算到底
  2. 第一次读 .value 时才执行 getter
  3. 依赖变了后,不是立刻重算,而是先把自己标记为 dirty
  4. 下次再读 .value 时才重新计算

所以 computed 的本质是:

  • effect 提供依赖追踪能力
  • dirty 标记提供缓存失效能力

这也是它和普通方法最大的区别。

10. 数组、MapSet 为什么更难?

因为这类结构的“变化”不只是一条普通属性写入。

10.1 数组

数组会涉及:

  • length 变化
  • 按索引赋值
  • push / pop / splice
  • 遍历依赖

所以源码会特别处理:

  • 改某个索引是否会影响 length
  • length 是否要通知超出长度的索引依赖

10.2 Map / Set

集合类型的关键不是单个 key 读取,而是:

  • get
  • set
  • add
  • delete
  • clear
  • size
  • forEach
  • 迭代器遍历

其中“遍历相关副作用”并不绑定某个普通属性名,所以 Vue 会引入类似“迭代专用 key”的内部标记,用来表示:

  • 依赖的是结构变化
  • 不是依赖某个单独字段

面试里可以直接总结为:

  • 普通对象按属性追踪
  • 集合类型还要额外追踪结构性变化和遍历依赖

11. watch 属于 Reactivity 吗?

严格说:

  • watch 的能力建立在响应式 effect 之上
  • 但它的实现不完全属于 @vue/reactivity 的最底层核心
  • 它更像是基于响应式系统封装出来的高级 API

所以回答边界时可以这样说:

  • @vue/reactivity 负责底层依赖追踪与触发
  • watch、组件更新这些能力是在这套底层之上继续封装

12. 高频面试题怎么答

12.1 Vue 3 响应式为什么比 Vue 2 更强?

标准答法:

  • Vue 2 主要基于 Object.defineProperty,对新增属性、删除属性、数组下标和集合类型支持有限。
  • Vue 3 基于 Proxy,拦截能力更完整,能天然覆盖更多操作。
  • 但真正升级不只是 Proxy,还包括更精细的依赖结构、scheduler、cleanup 和集合类型处理。

12.2 为什么 computed 默认有缓存?

因为它底层是懒执行 effect,并且依赖变更时只先标记 dirty,不会立刻重算;再次读取时才重新求值。

12.3 为什么解构 reactive 会丢响应式?

因为解构后拿到的是当前值,不再经过原代理对象的 get / set,依赖收集链断了。

13. 常见误区

  • 误区 1:Vue 3 响应式核心就是 Proxy 不对。Proxy 只是拦截手段,真正核心是 targetMap + effect + track/trigger
  • 误区 2:依赖收集是按对象维度,不分属性 不对。普通对象通常是按 target + key 精细追踪。
  • 误区 3:effect 重跑不用清理旧依赖 不清理就会出现脏依赖和误触发。
  • 误区 4:refreactive 是两套完全独立系统 实际它们都接在同一套依赖协议上。

14. 速记要点

  • 一句话:Reactivity 本质上是在维护“属性 -> 副作用”的映射关系。
  • 一个结构WeakMap<Target, Map<Key, Dep>>
  • 两条主线track 收集,trigger 触发。
  • 三个高频 APIreactive 接对象,ref 接单值,computed 做懒计算缓存。
  • 一个高频追问点:为什么需要 cleanup 和 scheduler。