Vue3 响应式系统源码深度解析
之前读 Vue3 响应式源码的时候,把几个核心文件的逻辑整理了一遍。这篇文章会按照源码的调用链路,从枚举定义到 Proxy 拦截器,把整个响应式系统串起来讲清楚。如果你正在准备面试或者想深入理解 Vue3 的底层设计,希望这篇文章能帮到你。
前置知识:枚举常量
Vue3 响应式系统里有一份常量定义文件 constants.ts,用枚举把所有操作类型和标志位都统一定义好了。先花一分钟过一遍,后面会反复用到。
TrackOpTypes —— 读取操作类型
export enum TrackOpTypes {
GET = 'get', // 读取属性
HAS = 'has', // in 操作符检查
ITERATE = 'iterate' // 遍历操作(for...in / Object.keys)
}
这三个值描述的是"你怎么读数据"。当你在代码里访问响应式对象的属性时,Proxy 的拦截器会根据具体的操作类型调用 track() 来收集依赖。比如 user.name 触发 GET,'name' in user 触发 HAS,for (let key in user) 触发 ITERATE。
TriggerOpTypes —— 写入操作类型
export enum TriggerOpTypes {
SET = 'set', // 修改已有属性
ADD = 'add', // 新增属性
DELETE = 'delete', // 删除属性
CLEAR = 'clear' // 清空集合(Map/Set.clear)
}
这四个值描述的是"你怎么改数据"。当你修改响应式数据时,Proxy 的拦截器会根据操作类型调用 trigger() 来通知相关的副作用重新执行。比如 user.name = '张三' 触发 SET,user.age = 18(之前没有 age)触发 ADD,delete user.age 触发 DELETE。
ReactiveFlags —— 内部标志位
export enum ReactiveFlags {
SKIP = '__v_skip', // 跳过代理(markRaw)
IS_REACTIVE = '__v_isReactive', // 是否是 reactive 对象
IS_READONLY = '__v_isReadonly', // 是否是 readonly 对象
IS_SHALLOW = '__v_isShallow', // 是否是浅响应式
RAW = '__v_raw', // 获取原始对象
IS_REF = '__v_isRef', // 是否是 Ref 对象
}
这些标志位是 Vue 内部用来判断对象类型的。比如 toRaw(reactiveObj) 实际上就是读取 __v_raw 属性来拿到原始对象,markRaw(obj) 则是给对象打上 __v_skip 标记让它永远不被转成响应式。
依赖管理:Dep 和 targetMap
搞清楚枚举之后,接下来看 Vue 是怎么存储和管理依赖关系的。
Dep 类
Vue3 里每个响应式属性都对应一个 Dep 实例,可以把它理解成一份"订阅名单"。Dep 内部用链表结构存储所有依赖这个属性的 effect(副作用函数),主要做两件事:
track()—— 把当前正在执行的 effect 加入订阅名单trigger()—— 通知名单里所有 effect 重新执行
用链表而不是数组来存 effect,是因为依赖关系会频繁增删,链表在这方面的性能更好。
class Dep {
subs = null // 订阅者链表
version = 0 // 版本号,后面 computed 缓存会用到
track() {
if (activeSub) {
// 把当前 activeSub 挂到链表上
}
}
trigger() {
this.version++ // 版本号 +1
globalVersion++ // 全局版本 +1
this.notify() // 通知所有订阅者
}
notify() {
startBatch() // 开启批量更新
try {
// 从后往前遍历 effect 链表
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) {
// 如果是 computed,递归通知它的订阅者
link.sub.dep.notify()
}
}
} finally {
endBatch() // 结束批量更新
}
}
}
targetMap —— 全局依赖注册表
export const targetMap: WeakMap<object, Map<any, Dep>> = new WeakMap()
targetMap 是一个三层嵌套结构,存储关系是:原始对象 → 属性名 → Dep 实例。
targetMap (WeakMap)
└── key: 原始对象(如 user)
└── value: depsMap (Map)
├── key: 'name' → Dep 实例
├── key: 'age' → Dep 实例
└── key: ITERATE_KEY → Dep 实例(遍历专用)
用 WeakMap 的好处是:当原始对象被销毁后,对应的依赖存储会被自动回收,不用担心内存泄漏。
遍历操作的三个特殊 Key
export const ITERATE_KEY: unique symbol = Symbol('Object iterate')
export const MAP_KEY_ITERATE_KEY: unique symbol = Symbol('Map keys iterate')
export const ARRAY_ITERATE_KEY: unique symbol = Symbol('Array iterate')
遍历操作(for...in、Object.keys、数组遍历)不对应某个具体属性,所以 Vue 用 Symbol 创建了几个"虚拟 key" 来专门处理这类依赖。当你新增或删除属性时,除了触发属性本身的更新,还要触发这些虚拟 key 对应的 Dep,这样遍历操作才能正确响应变化。
依赖收集与派发更新
track() —— 依赖收集
当你读取响应式数据时,Proxy 的 get 拦截器会调用 track():
export function track(target: object, type: TrackOpTypes, key: unknown): void {
if (shouldTrack && activeSub) {
// 1. 获取或创建 对象 → depsMap
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
// 2. 获取或创建 属性名 → Dep
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Dep()))
// 3. 把当前 effect 加入 Dep
dep.track()
}
}
整个过程很直接:先从 targetMap 找到对象对应的 depsMap,再从 depsMap 找到属性对应的 Dep,最后把当前正在运行的 effect 加进去。
trigger() —— 派发更新
当你修改响应式数据时,Proxy 的 set/delete 拦截器会调用 trigger()。这里是 Vue 响应式里逻辑最复杂的地方,因为不同操作需要触发不同的依赖:
export function trigger(...) {
const depsMap = targetMap.get(target)
if (!depsMap) return
startBatch()
if (type === TriggerOpTypes.CLEAR) {
// 清空集合 → 所有属性都要更新
depsMap.forEach(dep => dep.trigger())
} else if (targetIsArray && key === 'length') {
// 修改数组长度 → length + 迭代器 + 超出新长度的索引都要更新
...
} else {
// 1. 触发当前属性的更新
run(depsMap.get(key))
// 2. 数组索引修改 → 触发数组遍历依赖
if (isArrayIndex) run(depsMap.get(ARRAY_ITERATE_KEY))
// 3. 新增/删除属性 → 触发 for...in 遍历依赖
if (type === ADD || type === DELETE) {
run(depsMap.get(ITERATE_KEY))
}
}
endBatch()
}
为什么要这么精细?因为 Vue 追求的是精准更新——改了 name 就只更新用到 name 的地方,不会牵连其他无关的渲染。数组新增元素时,除了更新对应索引,还要更新依赖数组遍历的副作用;新增或删除属性时,for...in 的结果也会变,所以遍历依赖也要触发。
批量更新机制
如果你连续修改了三个属性,Vue 不会每次修改都立即触发渲染。它用了一套批量更新的机制,把所有需要执行的 effect 先收集起来,最后一次性跑完。
let batchDepth = 0
let batchedSub: Subscriber | undefined // 普通 effect 队列
let batchedComputed: Subscriber | undefined // computed 队列
export function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED
if (isComputed) {
sub.next = batchedComputed
batchedComputed = sub
} else {
sub.next = batchedSub
batchedSub = sub
}
}
export function startBatch(): void {
batchDepth++
}
export function endBatch(): void {
if (--batchDepth > 0) return
// 先处理 computed
while (batchedComputed) { ... }
// 再执行普通 effect
while (batchedSub) {
let e = batchedSub
batchedSub = undefined
while (e) {
e.flags &= ~EffectFlags.NOTIFIED
if (e.ACTIVE) (e as ReactiveEffect).trigger()
e = e.next
}
}
}
batchDepth 支持嵌套批量操作,只有最外层的 endBatch() 才会真正执行队列。执行顺序是先处理 computed,再处理普通的渲染 effect 和 watch,因为 computed 的值可能被其他 effect 依赖。
依赖清理:自动追踪与自动删除
这是 Vue3 相比 Vue2 的一个重要改进——依赖可以自动清理。
prepareDeps —— 执行前标记
function prepareDeps(sub: Subscriber) {
for (let link = sub.deps; link; link = link.nextDep) {
link.version = -1 // 标记为"待验证"
link.dep.activeLink = link
}
}
effect 重新执行之前,先把所有旧依赖的版本号标记为 -1。
cleanupDeps —— 执行后清理
function cleanupDeps(sub: Subscriber) {
let link = sub.depsTail
while (link) {
const prev = link.prevDep
if (link.version === -1) {
// 版本号还是 -1,说明这次执行没用到 → 移除
removeSub(link)
removeDep(link)
}
link = prev
}
}
effect 执行完之后,如果某个旧依赖的版本号仍然是 -1,说明这次执行过程中没有访问到它,就会自动从订阅列表中删除。
举个实际的例子:
effect(() => {
if (user.age > 18) {
console.log(user.name)
} else {
console.log(user.address)
}
})
当 age 从 17 变成 20 时,effect 重新执行,走了 if 分支,只访问了 name。这时 cleanupDeps 发现 address 的版本号还是 -1,就会把它从依赖列表中移除。以后修改 address 就不会再触发这个 effect 了。
脏检测与 computed 缓存
isDirty —— 判断是否需要重新计算
function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
if (link.dep.version !== link.version) return true
}
return false
}
每个 Dep 都有一个 version,每次触发更新时会 version++。effect 记录的是依赖当时的版本号,如果两者不一致,说明数据变了,computed 需要重新计算。
refreshComputed —— computed 的刷新逻辑
export function refreshComputed(computed: ComputedRefImpl): undefined {
if (computed.globalVersion === globalVersion) return
computed.globalVersion = globalVersion
if (!isDirty(computed)) return // 不脏,直接用缓存
prepareDeps(computed)
const value = computed.fn()
computed._value = value
computed.dep.version++ // 值变了,通知订阅者
cleanupDeps(computed)
}
computed 的设计有三个关键特性:
- 惰性计算 —— 不访问就不执行,不会浪费算力
- 强缓存 —— 依赖没变就永远用缓存值
- 级联更新 —— 自身值变了会通知依赖它的 effect
这也是为什么 Vue 官方推荐用 computed 代替复杂的模板表达式——它的缓存机制能避免大量重复计算。
effect —— 响应式副作用的入口
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
const e = new ReactiveEffect(fn)
extend(e, options)
e.run() // 立即执行一次,收集依赖
const runner = e.run.bind(e)
runner.effect = e
return runner
}
effect 是 Vue 响应式系统的统一入口。组件的 render 函数、computed、watch,底层都是通过 effect 来实现的。创建 effect 时会立即执行一次 run(),执行过程中访问到的响应式数据会自动收集依赖。之后当这些数据变化时,effect 就会被自动触发重新执行。
reactive —— 创建响应式对象
四个 API 和四个缓存
reactive.ts 导出了四个常用的响应式 API:
| API | 深度 | 可写 |
|---|---|---|
reactive() | 深层 | 是 |
shallowReactive() | 浅层 | 是 |
readonly() | 深层 | 否 |
shallowReadonly() | 浅层 | 否 |
每个 API 都有对应的 WeakMap 缓存,作用是保证同一个对象不会被重复代理:
export const reactiveMap: WeakMap<Target, any> = new WeakMap()
export const shallowReactiveMap: WeakMap<Target, any> = new WeakMap()
export const readonlyMap: WeakMap<Target, any> = new WeakMap()
export const shallowReadonlyMap: WeakMap<Target, any> = new WeakMap()
createReactiveObject —— 工厂函数
四个 API 最终都走 createReactiveObject 这个工厂函数,逻辑大致是:
- 不是对象 → 直接返回
- 已经是代理对象 → 直接返回
- 被标记为跳过(
markRaw)→ 直接返回 - 查缓存,已有代理 → 直接返回
- 根据目标类型选择 handler:普通对象用
baseHandlers,Map/Set 用collectionHandlers - 创建 Proxy,存入缓存,返回
三个工具函数
// 拿到原始对象(去掉代理层)
export function toRaw(observed) {
const raw = observed[ReactiveFlags.RAW]
return raw ? toRaw(raw) : observed
}
// 标记对象永远不做响应式
export function markRaw(value) {
def(value, ReactiveFlags.SKIP, true)
return value
}
// 是对象就转 reactive,不是就原样返回
export const toReactive = (value) =>
isObject(value) ? reactive(value) : value
Proxy 拦截器 —— 响应式的真正心脏
前面讲了依赖收集和派发更新的机制,但它们是怎么被触发的?答案就在 Proxy 拦截器里。
BaseReactiveHandler —— 读取拦截
所有响应式对象共享的基础拦截器,核心是 get:
class BaseReactiveHandler {
get(target, key, receiver) {
// 1. 处理内部标志位
if (key === ReactiveFlags.IS_REACTIVE) return !this._isReadonly
if (key === ReactiveFlags.IS_READONLY) return this._isReadonly
if (key === ReactiveFlags.RAW) return target
// 2. 读取真实值
const res = Reflect.get(target, key, receiver)
// 3. 依赖收集(只读对象不需要)
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
// 4. 浅层响应式直接返回
if (isShallow) return res
// 5. ref 自动解包
if (isRef(res)) return res.value
// 6. 深层响应式:值是对象则递归代理
if (isObject(res)) return reactive(res)
return res
}
}
这里有个细节值得注意:reactive 的深层响应式是懒加载的。不是一开始就把所有嵌套对象都变成响应式,而是当你访问到某个属性、发现它的值是对象时,才递归调用 reactive()。这样既节省了初始化开销,也避免了不必要的代理创建。
另外,在 reactive 对象里使用 ref 时不需要写 .value,就是因为第 5 步的自动解包逻辑。
MutableReactiveHandler —— 写入拦截
继承基础拦截器,增加了 set、deleteProperty、has、ownKeys 四个拦截:
set 拦截器(最核心的一个):
set(target, key, value, receiver) {
let oldValue = target[key]
const hadKey = hasOwn(target, key)
const result = Reflect.set(target, key, value)
if (target === toRaw(receiver)) {
if (!hadKey) {
// 新增属性
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
// 修改属性(值确实变了才触发)
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}
注意这里有个 hasChanged 判断——如果新值和旧值相同,就不会触发更新。这个细节能避免很多无意义的渲染。
deleteProperty 拦截器:
deleteProperty(target, key) {
const hadKey = hasOwn(target, key)
const result = Reflect.deleteProperty(target, key)
if (result && hadKey) {
trigger(target, TriggerOpTypes.DELETE, key)
}
return result
}
has 拦截器(in 操作符):
has(target, key) {
track(target, TrackOpTypes.HAS, key)
return Reflect.has(target, key)
}
ownKeys 拦截器(遍历操作):
ownKeys(target) {
track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
return Reflect.ownKeys(target)
}
ReadonlyReactiveHandler —— 只读拦截
class ReadonlyReactiveHandler extends BaseReactiveHandler {
set() {
console.warn('Set operation on key failed: target is readonly.')
return true
}
deleteProperty() {
console.warn('Delete operation on key failed: target is readonly.')
return true
}
}
只读拦截器继承了读取逻辑,但把写入和删除操作都拦截掉了,只给一个警告提示。
ref —— 基本类型的响应式方案
reactive 只能处理对象,对于数字、字符串这类基本类型就需要用 ref 了。
RefImpl —— ref 的核心实现
class RefImpl {
_value // 响应式值(对象会自动转 reactive)
_rawValue // 原始值
dep = new Dep() // 依赖管理器
constructor(value, isShallow) {
this._rawValue = value
this._value = toReactive(value)
}
get value() {
this.dep.track() // 收集依赖
return this._value
}
set value(newValue) {
if (hasChanged(newValue, this._rawValue)) {
this._rawValue = newValue
this._value = toReactive(newValue)
this.dep.trigger() // 触发更新
}
}
}
ref 的原理比 reactive 简单得多——就是用 getter/setter 包了一个 .value。读的时候收集依赖,写的时候触发更新。如果传入的是对象,会自动调用 toReactive() 转成 reactive。
ref 家族的几个 API
| API | 说明 |
|---|---|
ref(value) | 创建深层响应式 ref |
shallowRef(value) | 浅层 ref,只有 .value 的替换会触发更新 |
triggerRef(ref) | 手动触发 shallowRef 的更新 |
toRef(object, key) | 将 reactive 对象的某个属性转为 ref,和源属性保持同步 |
toRefs(object) | 将 reactive 对象的所有属性转为 ref,解构时不丢失响应式 |
toRef 和 toRefs 在实际开发中很常用。当你需要把 reactive 对象的属性传给子组件或者解构使用时,直接解构会丢失响应式,用 toRefs 包一层就行:
const state = reactive({ name: '张三', age: 18 })
const { name, age } = toRefs(state)
// 现在 name 和 age 都是 ref,解构后仍然有响应式
computed —— 带缓存的计算属性
ComputedRefImpl
export class ComputedRefImpl {
_value: any // 缓存的计算结果
dep: Dep = new Dep() // 谁用了我
deps?: Link // 我用了谁
flags: DIRTY // 脏标记
notify() {
this.flags |= DIRTY // 只标记脏,不马上算
batch(this, true) // 加入 computed 队列
return true
}
get value() {
this.dep.track() // 收集依赖
refreshComputed(this) // 脏了就重算
return this._value // 返回缓存
}
set value(newValue) {
if (this.setter) {
this.setter(newValue)
} else {
console.warn('Write operation failed: computed value is readonly')
}
}
}
computed 有一个"双重身份":它既被别人依赖(通过 dep 记录),也依赖别人(通过 deps 记录)。当它依赖的数据变化时,notify() 只是把脏标记置位,不会立即重新计算。等到有人访问 .value 时,才通过 refreshComputed() 判断是否需要重算。
computed 的完整生命周期
用一个例子来说明:
const fullName = computed(() => firstName.value + lastName.value)
- 创建时 —— 不会执行计算函数,只是创建了一个
ComputedRefImpl实例 - 第一次访问
fullName.value—— 发现是脏的,执行计算函数,收集firstName和lastName作为依赖,缓存结果 - 再次访问 —— 不脏,直接返回缓存,不执行计算函数
firstName变了 —— computed 收到通知,只标记脏了,不计算- 再次访问 —— 脏了,重新计算,更新缓存
这个"标记脏但不立即计算"的设计,是 computed 性能好的关键。如果一个 computed 依赖的数据变了但没人用到这个 computed 的值,那计算函数根本不会执行。
把整个流程串起来
到这里,Vue3 响应式系统的核心模块都过了一遍。最后用一段代码的执行过程把所有环节串起来:
const user = reactive({ name: '张三', age: 18 })
effect(() => {
document.getElementById('app').textContent = user.name
})
user.name = '李四'
reactive({ name: '张三', age: 18 })—— 通过createReactiveObject创建 Proxy 代理,存入reactiveMap缓存effect(fn)—— 创建ReactiveEffect,立即执行一次fn- 执行
fn时访问user.name—— 触发 Proxy 的get拦截器 get拦截器调用track(user, GET, 'name')—— 在targetMap中建立user → name → Dep的关系,把当前 effect 加入 Depuser.name = '李四'—— 触发 Proxy 的set拦截器set拦截器调用trigger(user, SET, 'name')—— 找到name对应的 Dep- Dep 调用
trigger()→notify()→ 把 effect 加入批量队列 endBatch()执行队列中的 effect —— effect 重新执行,DOM 更新
整个过程就是:读数据时收集依赖,改数据时触发更新,批量执行避免重复渲染。
写在最后
Vue3 的响应式系统相比 Vue2 有几个明显的优势:
- 用 Proxy 替代
Object.defineProperty,天然支持属性的新增和删除 - 依赖自动清理,不会像 Vue2 那样需要开发者手动处理
- 批量更新机制,多次数据修改只触发一次渲染
- computed 的惰性计算和缓存机制,避免无意义的重复计算
- WeakMap 存储依赖关系,对象销毁后自动回收,没有内存泄漏
如果你对某个模块想深入了解,建议直接看 Vue3 源码的 reactivity 目录,代码量其实不大,核心逻辑都在这篇文章涉及到的几个文件里。