前言
响应式系统对于前端开发者来说简直就是神,它使我们只需要关注数据,由数据驱动视图,使应用状态管理更加清晰可维护,极大的提高了开发效率,简化了代码结构。
得益于JS的发展,Vue3 使用了 ES6 的Proxy代替了 Vue2 中的Object.defineProperty,这使得Vue3的响应式系统在性能和功能上都有显著的提升。主要有以下几个当面的进步
Proxy可以监听对象属性的添加和删除,不需要对对象属性遍历进行监听Proxy更好的支持数组和对象的响应式,不需要重写数组原型方法来实现(如push)Proxy对嵌套对象处理性能更好,不需要递归遍历Proxy可以自动监听新增属性,不需要手动监听($set)Proxy支持更多拦截操作,不止get和set(还有deleteProperty、has等共13中)
响应式系统,关键在于以下几个部分,接下来深入了解这几部分
- Proxy代理:通过 Proxy 拦截对象操作
- 副作用函数: 使用 effec t函数管理副作用
- 依赖收集:使用 targetMap 和 depsMap 管理依赖
- 触发更新:通过 trigger 函数触发依赖更新
Proxy代理
Vue3基于Proxy对象对数据进行了拦截和追踪,实现了精准的依赖追踪机制,从而能在数据读取时收集依赖,在数据修改时触发更新。
如何代理各数据结构
所有响应式数据都通过 Proxy 拦截读写操作,在拦截器中统一插入 track(收集依赖)和 trigger(触发更新)逻辑。但不同的数据结构的操作方式不同,所以Vue3对此做了特化处理
| 数据类型 | 代理方式 | 关键拦截操作 |
|---|---|---|
| 普通对象 | new Proxy(obj, baseHandlers) |
get / set / deleteProperty / ownKeys |
| 数组 | new Proxy(arr, baseHandlers) |
同对象,但对索引、length做了特殊处理 |
| Map Set | new Proxy(obj, collectionHandlers) |
拦截get-> 重写get,set,add,delete,clear等方法 |
💡 注意:
ref虽然用于基础类型,但其内部仍是通过reactive+Proxy实现的。
普通对象(伪代码)
ts
const baseHandlers = {
get(target, key, receiver) {
track(target, TrackOpTypes.GET, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
return result
},
deleteProperty(target, key) {
const hadKey = hasOwn(target, key)
const result = Reflect.deleteProperty(target, key)
if (hadKey) {
trigger(target, TriggerOpTypes.DELETE, key)
}
return result
},
ownKeys(target) {
// 拦截 Object.keys()、for...in 等
track(target, TrackOpTypes.ITERATE, ITERATE_KEY,)
return Reflect.ownKeys(target)
}
}
数组(伪代码)
数组也是使用baseHandlers代理,但是需要特殊处理索引和length
ts
get(target, key, receiver) {
if (key === 'length' && isArray(target)) {
track(target, TrackOpTypes.GET, 'length') // ← 专门 track 'length'
return Reflect.get(target, key, receiver)
}
// ...
}
ITERATE_KEY 的触发是在 trigger 函数内部根据 type 判断的,逻辑是根据数组长度有没有发生变化来决定是否触发。
Map/Set(伪代码)
ts
const m = new Map()
m.set('key', 1)
// Proxy 无法拦截 m.set(),因为 set 是 Map 原型上的方法,Proxy.get 只能拦截属性读取
所以对于Map/Set,Vue不拦截属性访问,而是拦截方法调用
ts
const collectionHandlers = {
get(target, key, receiver) {
if (key === 'size') {
track(target, TrackOpTypes.ITERATE, 'size')
return Reflect.get(target, key, receiver)
}
// 重写关键方法
if (['get', 'has', 'add', 'set', 'delete', 'clear'].includes(key)) {
return (...args) => {
// 1. 执行原方法
const result = target[key](...args)
// 2. 根据操作类型 trigger
if (key === 'set' || key === 'add') {
trigger(target, TriggerOpTypes.ADD, args[0])
} else if (key === 'delete') {
trigger(target, TriggerOpTypes.DELETE, args[0])
} else if (key === 'clear') {
trigger(target, TriggerOpTypes.CLEAR)
}
return result
}
}
// 其他属性正常返回
return Reflect.get(target, key, receiver)
}
}
解决结构变化无法响应的问题
ITERATE_KEY用于解决"结构依赖"问题,指那些不依赖具体某个属性值,而是依赖"对象有哪些属性或数组有多少项"的逻辑
ts
// 场景1:遍历对象
for (const key in obj) { ... }
// 场景2:获取所有键
Object.keys(obj)
// 场景3:遍历数组(v-for)
arr.map(item => ...)
arr.forEach(...)
for (const item of arr) { ... }
// 场景4:读取数组长度
arr.length
这些操作的结果会因为"新增/删除属性或元素"而改变,但它们并没有读取那个"新属性"本身!
ts
const state = reactive({ a: 1 })
effect(() => {
console.log('keys:', Object.keys(state)) // 输出: ['a']
})
state.b = 2 // 新增属性
// 期望:effect 重新执行,输出 ['a', 'b'],
// 如果没有 `ITERATE_KEY` 依赖收集只记录了对 `a` 的依赖,当前 effect 不依赖 `b` 所以不会更新!这就是"结构变化无法响应"
虚拟的依赖键
ts
// 表示"可枚举属性集合"发生变化;
//`for...in`、`Object.keys()`、`Object.values()`、`Object.entries()`、`Reflect.ownKeys()`、数组/对象的 `for...of`
const ITERATE_KEY = Symbol('Object iterate')
// 表示 Map 的 keys 集合发生变化
//`map.keys()`、`for (const key of map.keys())`
const MAP_KEY_ITERATE_KEY = Symbol('Map keys iterate')
- 当执行结构依赖操作时(如
Object.keys、for...in),自动track(target, ITERATE_KEY) - 当发生结构变化时(新增/删除属性、数组扩容等),自动
trigger(target, ITERATE_KEY)`
Vue 3 基于 Proxy 构建了一个统一、强大、高性能的响应式内核,并以此为基础封装了完整的响应式 API 体系,支撑起整个框架的数据驱动能力。
副作用函数
在Vue3中,副作用函数式指依赖于响应式数据并在其变化时自动重新执行的函数,比如组件渲染函数、计算属性、watch回调函数等
js
import { reactive, effect } from '@vue/reactivity'
const state = reactive({ count: 0 })
effect(() => { console.log('count is:', state.count) }) // 这个回调就是一个副作用函数
state.count++ // 自动触发副作用函数重新执行
effect接收的函数就是副作用函数- Vue 的
watchEffect、computed、组件的render函数等,底层都是通过effect实现的
什么是effect
effect是一个用于包装副作用函数的函数,有以下几个功能
- 默认会立即执行传入的函数,但可以通过配置选项来改变(如
{lazy:true},computed的实现原理) - 在执行过程中自动追踪该函数所依赖的响应式数据(通过
track) - 当这些响应式数据变化时,自动重新执行该函数(通过
trigger)
有哪些特性?
- 调度器(scheduler) :允许自定义 trigger 是如何执行 effect (如
queueJob异步更新) - 懒执行(lazy) :不立即执行,用于
computed,只在访问.value时才计算 - 停止(stop):可手动停止 effect,清除依赖
- 依赖清理 (cleanup):支持动态依赖(如条件分支),每次执行前清除就依赖
- 嵌套 effect 支持:使用 effect 栈(effectStack) 管理依赖
依赖收集
当访问响应式对象的属性时,Vue3会收集当前正在执行的副作用函数,建立起"数据-副作用"的关系
关键前提:必须在
effect(或其派生的computed、组件渲染等)上下文中读取响应式数据,否则不会收集依赖。
即Vue 3 通过Proxy拦截读取操作,在effect上下文中自动构建"副作用函数 ↔ 响应式属性"的依赖图谱,为后续的精准更新提供基础。
依赖收集的核心数据结构
结构图示
javascript
targetMap (WeakMap)
│
├── target1: { count: 0, name: 'foo' }
│ │
│ ├── "count" → Dep1 = Set[ effectA, effectB ]
│ └── "name" → Dep2 = Set[ effectA ]
│
├── target2: [1, 2, 3]
│ │
│ ├── "0" → Dep3 = Set[ effectC ]
│ ├── "1" → Dep3
│ ├── "2" → Dep3
│ └── "length"→ Dep4 = Set[ effectC ]
│
└── target3: new Map([['key', 'val']])
│
└── "key" → Dep5 = Set[ effectD ] // 实际通过特殊 handler 处理
Map/Set 的依赖收集使用不同的 handler,但最终也存入 targetMap。
最外层 targetMap(WeakMap)
Ts
const targetMap = new WeakMap<object, KeyToDepMap>()
WeakMap的key都是弱引用(键必须是对象),当原始对象(target)在用户代码中不在被引用时,更好的被垃圾回收,避免因响应式系统持有强引用而导致内存泄漏问题
ts
let bigArray = new Array(1_000_000).fill('x')
reactive(bigArray)
const clear = () => {
bigArray = null
}
// 若obj=null,且无其他引用,则obj可被GC,不会因为 `reactive(obj)`Vue内部持有obj的引用而导致无法回收
ts
let bigArray = new Array(1_000_000).fill('x')
const targetMap = new Map()
targetMap.set(bigArray, 'test') // 使用map这样子是无法被回收的
const clear = () => {
bigArray = null
// targetMap.clear() // 如果把Map也清理,则可以正常回收
}
组件卸载时,Vue 不仅清理了 effect,还可能间接导致 obj 无引用,所以即使 targetMap 用 Map,obj 也能回收。但这依赖于组件代码"干净"(无外部引用),所有效应都被正确停止,开发者没有"丢弃"响应式对象等而 WeakMap 不依赖这些前提 ------ 它从内存模型层面保证安全。
中间层:KeyToDepMap (Map)
ts
type KeyToDepMap = Map<PropertyKey, Dep>
使用Map而不是普通对象是为了支持所有合法的PropertyKey类型(包括string、number、symbol)且拥有更好的查询性能
最内层:Dep(Set)
ts
type Dep = Set<ReactiveEffect>
使用Set而非数组是为了自动去重和高效删除
依赖收集的完整步骤(伪代码)
假设执行以下代码
ts
const state = reactive({ count: 0 })
effect(() => {
console.log(state.count)
})
准备阶段 -- effect 创建并执行
ts
// 1. 创建 ReactiveEffect 实例
const effectInstance = new ReactiveEffect(() => {
console.log(state.count)
})
// 2. 设置 activeEffect (全局变量,指向当前正在执行的effect)
activeEffect = effectInstance
// 3. 执行用户函数fn ()=> console.log(state.count)
fn() // 访问state.count
访问 state.count -> 触发 Proxy.get
ts
get(target, key, receiver) {
// 1. 收集依赖
track(target, key)
// 2. 返回值 (可能递归 reactive)
const res = Reflect.get(target, key, receiver)
return isObject(res) ? reactive(res) : res
}
执行 track(target, key)
ts
export function track(target: object, key: PropertyKey) {
// 1. 安全检查 必须在 effect 上下文中
if (!activeEffect) return
// 2. 获取或创建 target 对应的 KeyToDepMap
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
// 3. 获取或创建 key 对应的 Dep (Set<ReactiveEffect>)
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
// 4. 避免重复收集: 如果 dep 已经包含 activeEffect 则返回
if (dep.has(activeEffect)) return
// 5. 正向添加:将activeEffect 加入到dep
dep.add(activeEffect)
// 6. 反向记录: 让 effect 直到自己依赖了这个 dep.为后续 `cleanupEffect` 提供清理路径
activeEffect.deps.push(dep)
}
依赖关系创建完成
ts
targetMap = new WeakMap([
[
{ count: 0 }, // target
new Map([
['count', new Set([effectInstance])] // dep
])
]
])
effectInstance.deps = [ dep ] // 反向引用
依赖清理(Cleanup)
当 effect 重新执行前,必须清除就依赖,否则会导致:
- 无效更新(已经没有使用的属性变化仍会触发更新)
- 内存泄漏(effect 无法被GC)
清理时机: 在 effect.run() 开始时
ts
function cleanupEffect(effect: ReactiveEffect) {
const { deps } = effect
if(deps.length) {
for(let i = 0; i < deps.length; i++) {
deps[i].delete(effect) // 从每个 dep 中移除自己
}
deps.length = 0 // 清空反向引用
}
}
清理旧依赖 ≠ 删除 effect 函数本身 ,而是 清除 effect 与"旧属性"的绑定关系 。
effect 函数仍然会被执行 (由调度器调用),并在执行过程中重新收集新依赖 。
trigger触发的是当前最新依赖
ts
class ReactiveEffect {
run() {
// 1. 清理旧依赖
cleanupEffect(this)
// 2. 设置 activeEffect (建立上下文)
const prevEffect = activeEffect
activeEffect = this
try {
// 3. 执行用户函数 fn()
return this.fn()
} finally {
// 4. 恢复 activeEffect
activeEffect = prevEffect
}
}
}
cleanup 是为了"断旧连新"------先断开与旧属性的联系,再在执行副作用时连接到新属性,从而保证响应式系统的精准性。这正是Vue3响应式系统能支持复杂动态依赖的核心机制之一。
依赖收集特殊处理
如何收集对象/数组/Map/Set
- 当使用
for...in、Object.keys()、v-for遍历对象/数组时 - 当发生属性增删、数组长度变化等结构变更时
ts
const state = reactive({ a: 1 })
effect(() => {
console.log(Object.keys(state)) // ['a']
})
state.b = 2 // 新增属性
// 期望:effect 重新执行,输出 ['a', 'b'],但track没有对b的依赖,所以effect不会触发
解决方案: ITERATE_KEY 和 MAP_KEY_ITERATE_KEY
依赖收集的核心要点
| 维度 | 说明 |
|---|---|
| 数据结构 | WeakMap<object, Map<key, Set<effect>>> + effect.deps 反向引用 |
| 内存安全 | WeakMap 确保原始对象可 GC |
| 去重机制 | Set + dep.has() 避免重复依赖 |
| 动态依赖 | 每次执行前 cleanup,重新收集 |
| 触发时机 | Proxy.get 中调用 track |
| 上下文绑定 | 依赖 activeEffect 全局变量 |
| 嵌套支持 | 通过 effectStack 管理嵌套 effect |
触发更新
当响应式数据被修改的时候,Vue3会触发与修改属性相关的依赖,执行对应的副作用函数
大致流程
scss
用户修改数据
↓
Proxy.set / delete / 数组方法
↓
trigger(target, key, ...)
↓
从 targetMap 查找相关 effect(包括 ITERATE_KEY 等)
↓
去重合并 → effectsToRun
↓
对每个 effect:
└─ 若有 scheduler → scheduler(effect)
└─ 否则 → effect.run()
↓
scheduler 通常调用 queueJob(update)
↓
queueJob 将 job 加入异步队列(微任务)
↓
下一个 tick:flushJobs()
↓
执行所有 job(如组件 render)
↓
render 中重新 track 依赖
触发更新的起点:Proxy的set/delete/数组变异操作
在对应的拦截其中,Vue3会调用trigger(target, key, newValue, oldValue)触发副作用函数
trigger函数:找到并调度所有相关的effect
查找依赖
trigger函数首先会从 targetMap 中查找依赖,但不止这些。Vue还会收集其他可能收影响的effect。
| 触发类型 | 额外收集的依赖 |
|---|---|
| 对象属性新增/删除 | ITERATE_KEY(影响 for...in、Object.keys()) |
| 数组长度变化 | length + ITERATE_KEY(影响 arr.length、for...of) |
数组通过索引赋值(如 arr[100] = x) |
新增索引 + length |
这保证了像 v-for、Object.keys() 这类依赖"整体结构"的逻辑也能正确更新。 |
合并需要执行的effect
ts
const effectsToRun = new Set<ReactiveEffect>()
// 添加 key 对应的 effect
if (effects) {
effects.forEach(effect => effectsToRun.add(effect))
}
// 添加 ITERATE_KEY 的 effect(如果需要)
if (needsIterate) {
const iterateEffects = depsMap.get(ITERATE_KEY)
if (iterateEffects) {
iterateEffects.forEach(effect => effectsToRun.add(effect))
}
}
调度器(Scheduler)
ts
function effect(fn, options?) {
const _effect = new ReactiveEffect(fn, options?.scheduler)
// ...
return _effect
}
调度器用于控制effect的执行时机,每个 ReactiveEffect 可以带一个 scheduler
- 无schedule
- 立即执行
trigger-> 立即执行effect.run
- 有无schedule
trigger-> 调用scheduler(effect)
异步更新队列
queueJob
queueJob会将job去重后加入一个全局队列(queue),然后再触发一次异步flush(通过Promise.then、MutationObserver等)
ts
const queue: Job[] = []
let isFlushing = false
function queueJob(job: Job) {
if (!queue.includes(job)) {
queue.push(job)
if (!isFlushing) {
isFlushing = true
Promise.resolve().then(flushJobs)
}
}
}
flushJobs
flushJobs会遍历queue,按顺序执行所有job,执行完后清空队列,重置状态
ts
function flushJobs() {
for (let i = 0; i < queue.length; i++) {
const job = queue[i]
job() // ← 真正执行 render effect
}
queue.length = 0
isFlushing = false
}
异步队列将多次变更合并为一次更新从而性能得到提升,确保DOM只反映最终一致状态,与浏览器渲染节奏协同,微任务在JS执行完,下一次重绘(paint)之前执行,避免频繁 layout (重排)
nextTick
nextTick本质是注册一个microtask,排在flushJobs之后,保证nextTick是数据/DOM已更新
Vue 的异步更新不是"延迟",而是"智能调度"------它用 microtask 队列将多次数据变更合并为一次 DOM 更新,既保证了响应性,又避免了性能浪费。而调度器(scheduler)正是连接"响应式触发"与"异步更新"的桥梁。
结语
学习 Vue 3 响应式系统,远不止"会用 ref 和 reactive"使用这么简单。深入学习,看透"数据驱动视图"的底层实现,对自身的代码质量、编程能力、架构设计、思维方式和职业发展都有很多好处。记得学呀,面试必问的。