前言
在上一篇文章中,我们实现了基础的响应式系统:通过 Proxy 拦截对象的读写操作,在 get 时自动收集依赖(track),在 set 时自动派发更新(trigger),完成了数据驱动视图的闭环。
然而这个系统仍有明显的缺陷:effect 是一个全局硬编码的变量,无法处理多个副作用函数共存的场景;每次数据变动都会立即执行更新,无法做到批量调度;也不支持 computed 和 watch 这些 Vue 中最常用的响应式 API。本文将逐一解决这些问题。
一、activeEffect ------ 摆脱全局硬编码
回顾上篇的代码,我们的 track 函数直接把一个全局的 effect 变量存入 dep 集合:
javascript
// 上篇的写法:硬编码全局 effect
function track(target, key) {
// ...
dep.add(effect) // ← 问题:effect 是谁?
}
这种写法只能处理一个副作用函数。如果存在两个 effect,第二个就会覆盖第一个,依赖收集就会出错。我们需要一种机制,让 track 知道"当前正在运行的是哪个副作用函数"。
解决方案是引入 activeEffect 变量,配合一个 effect 注册函数来管理它:
javascript
let activeEffect = null
function effect(fn) {
activeEffect = fn
fn() // 执行副作用函数,触发 Proxy 的 get → track 会读取 activeEffect
activeEffect = null
}
然后修改 track,用 activeEffect 替换硬编码的 effect:
javascript
function track(target, key) {
if (!activeEffect) return // 没有正在运行的 effect,无需收集
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(activeEffect) // ← 改为 activeEffect
}
来测试一下:
javascript
const product = reactive({ price: 5, quantity: 2 })
effect(() => {
console.log('副作用1:', product.price * product.quantity)
})
effect(() => {
console.log('副作用2:', product.quantity)
})
product.quantity = 3
// 输出:
// 副作用1: 10(首次执行)
// 副作用2: 2(首次执行)
// 副作用2: 3(quantity 变化触发)
// 副作用1: 15(quantity 变化触发)
两个副作用函数都被正确收集和触发了。
二、effectStack ------ 处理嵌套副作用
activeEffect 解决了单层 effect 的问题,但遇到了新场景:嵌套 effect。
javascript
effect(() => {
// 外层 effect
console.log('外层:', product.price)
effect(() => {
// 内层 effect
console.log('内层:', product.quantity)
})
})
执行流程是这样的:
- 外层
effect开始执行,activeEffect被设为外层函数 - 外层函数读取
product.price,track收集了外层函数 - 内层
effect开始执行,activeEffect被覆盖为内层函数 - 内层函数读取
product.quantity,track收集了内层函数 - 内层
effect执行完毕,activeEffect被设为null - 问题来了 :此时外层函数还没执行完,但
activeEffect已经是null,后续的依赖收集全部丢失
这和函数调用栈的道理是一样的------内层函数执行完毕后,需要回到外层继续执行。所以我们引入 effect 栈:
javascript
let activeEffect = null
const effectStack = []
function effect(fn) {
const effectFn = () => {
effectStack.push(effectFn) // 入栈
activeEffect = effectFn // 设为当前活跃 effect
fn() // 执行副作用
effectStack.pop() // 出栈
activeEffect = effectStack[effectStack.length - 1] // 恢复上一层
}
effectFn()
}
现在当内层 effect 执行完毕后,activeEffect 会自动恢复为外层 effect,确保外层的后续依赖收集不会丢失。
三、避免无限循环 ------ 同一个 effect 不重复触发
还有一个边界情况需要处理。考虑这段代码:
javascript
effect(() => {
product.count++ // 等价于 product.count = product.count + 1
})
在执行这个副作用函数时,先读取 product.count(触发 track,收集当前 effect),然后设置 product.count(触发 trigger,执行当前 effect),于是又读取又设置......无限循环。
解决方案很简单:在 trigger 执行副作用时,跳过当前正在执行的 effect 本身。
javascript
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (!dep) return
// 复制一份再遍历,避免遍历时修改 Set 导致的问题
const effectsToRun = new Set(dep)
effectsToRun.forEach(effectFn => {
if (effectFn !== activeEffect) { // ← 跳过自身
effectFn()
}
})
}
四、scheduler ------ 调度执行,控制时机
目前我们的响应式系统在数据变化时会同步执行所有副作用函数。但在实际场景中,这会导致性能问题:
javascript
product.price = 6
product.quantity = 3
// trigger 执行了两次副作用,但最终结果只需要最后一次
Vue 的做法是:数据变了不立即执行,而是把副作用函数放进一个队列,在"合适的时机"统一执行 。这个"合适的时机"通常是微任务(Promise.then)。
我们给 effect 增加一个 scheduler(调度器)选项:
javascript
function effect(fn, options = {}) {
const effectFn = () => {
effectStack.push(effectFn)
activeEffect = effectFn
const result = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return result
}
effectFn.scheduler = options.scheduler // 挂载调度器
effectFn()
return effectFn
}
然后修改 trigger,如果副作用函数有 scheduler,就调用调度器而不是直接执行:
javascript
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (!dep) return
const effectsToRun = new Set(dep)
effectsToRun.forEach(effectFn => {
if (effectFn !== activeEffect) {
if (effectFn.scheduler) {
effectFn.scheduler(effectFn) // 有调度器,交给调度器处理
} else {
effectFn() // 没有调度器,直接执行
}
}
})
}
有了 scheduler,我们就能实现任务队列去重------多次修改只执行一次:
javascript
const jobQueue = new Set()
const isFlushing = false
function flushJobs() {
if (isFlushing) return
isFlushing = true
Promise.resolve().then(() => {
jobQueue.forEach(job => job())
jobQueue.clear()
isFlushing = false
})
}
effect(() => {
console.log(product.price * product.quantity)
}, {
scheduler(effectFn) {
jobQueue.add(effectFn)
flushJobs()
}
})
product.price = 6
product.quantity = 3
// 虽然修改了两次,但副作用只执行一次,输出 18
Set 天然去重,Promise.then 保证在微任务中执行------这正是 Vue 组件异步更新的核心思路。
五、computed ------ 懒求值的响应式计算
Vue 中最常用的 API 之一是 computed,它具有两个特点:
- 懒求值:只有真正读取时才计算,没有读取就不执行
- 缓存:依赖不变时,多次读取返回同一个值,不重复计算
我们可以基于 effect + scheduler 来实现:
javascript
function computed(getter) {
let value
let dirty = true // "脏"标记:为 true 时需要重新计算
const effectFn = effect(getter, {
scheduler() {
if (!dirty) {
dirty = true // 依赖变化时标记为"脏"
trigger(obj, 'value') // 通知依赖 computed 的副作用
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn() // 重新计算
dirty = false // 标记为"干净"
}
track(obj, 'value') // 收集依赖 computed 的副作用
return value
}
}
return obj
}
来分析这个精巧的设计:
- 首次读取 :
dirty为true,执行effectFn()(即getter),计算值,标记为"干净" - 再次读取 (依赖没变):
dirty为false,直接返回缓存的value - 依赖变化 :
scheduler被触发,dirty重新设为true,但不立即计算 - 下次读取 :发现
dirty为true,才重新计算
这就是 computed 的精髓------惰性求值 + 缓存。
javascript
const product = reactive({ price: 5, quantity: 2 })
const total = computed(() => product.price * product.quantity)
console.log(total.value) // 10,首次读取时计算
console.log(total.value) // 10,直接返回缓存值,不重新计算
product.price = 10
// 此时不重新计算,dirty 被标记为 true
console.log(total.value) // 20,读取时发现 dirty 为 true,重新计算
六、watch ------ 侦听数据变化
watch 是另一个核心 API。它的本质是:观测一个数据源,当数据变化时执行回调函数。
最简单的实现,仍然是基于 effect + scheduler:
javascript
function watch(source, callback) {
let getter
if (typeof source === 'function') {
getter = source
} else {
// 如果传入的是 reactive 对象,递归读取所有属性来建立依赖
getter = () => traverse(source)
}
let oldValue, newValue
const effectFn = effect(
() => getter(),
{
scheduler() {
newValue = effectFn()
callback(newValue, oldValue)
oldValue = newValue
}
}
)
oldValue = effectFn() // 首次执行,获取初始值
}
traverse 函数递归遍历 reactive 对象的所有属性,确保深层属性的变化也能被侦测到:
javascript
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || value === null || seen.has(value)) return
seen.add(value)
for (const key of Object.keys(value)) {
traverse(value[key], seen) // 递归访问每个属性,触发 get → track
}
return value
}
使用示例:
javascript
const product = reactive({ price: 5, quantity: 2 })
watch(
() => product.price,
(newVal, oldVal) => {
console.log(`price 从 ${oldVal} 变为 ${newVal}`)
}
)
product.price = 10
// 输出:price 从 5 变为 10
七、ref ------ 让原始值也响应式
到目前为止,我们的 reactive 只能处理对象类型,因为它依赖 Proxy,而 Proxy 只能代理对象。对于原始值(数字、字符串、布尔值),需要另一种方案。
Vue 3 使用 ref 来解决这个问题。ref 的思路很朴素:把原始值包装成一个对象,通过 .value 读写 ,然后在 get value 和 set value 上做 track 和 trigger。
javascript
function ref(initialValue) {
const r = {
get value() {
track(r, 'value')
return initialValue
},
set value(newValue) {
if (initialValue !== newValue) {
initialValue = newValue
trigger(r, 'value')
}
}
}
return r
}
使用示例:
javascript
const count = ref(0)
effect(() => {
console.log('count =', count.value)
})
count.value++
// 输出:
// count = 0(首次执行)
// count = 1(变化触发)
不过上面的实现有个问题:initialValue 用闭包变量保存,如果 ref 的值本身是对象,需要用 reactive 包裹。Vue 3 的实际实现会判断传入值的类型:
javascript
function ref(initialValue) {
// 如果是对象,交给 reactive 处理
const value = isObject(initialValue) ? reactive(initialValue) : initialValue
const r = {
get value() {
track(r, 'value')
return value
},
set value(newValue) {
if (newValue !== value) {
// 如果新值是对象,也转为 reactive
value = isObject(newValue) ? reactive(newValue) : newValue
trigger(r, 'value')
}
}
}
return r
}
八、整体架构回顾
到这里,我们已经构建了一个完整的响应式系统。回顾整个架构:
┌─────────────────────┐
│ targetMap │
│ (WeakMap) │
│ target → depsMap │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ depsMap │
│ (Map) │
│ key → dep │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ dep │
│ (Set) │
│ effectFn 集合 │
└─────────────────────┘
核心函数之间的关系:
| API | 底层机制 | 核心特点 |
|---|---|---|
reactive |
Proxy 拦截 get/set |
对象类型响应式 |
ref |
对象的 get value/set value |
原始值响应式 |
effect |
activeEffect + effectStack |
副作用注册与执行 |
computed |
effect + lazy + dirty |
懒求值 + 缓存 |
watch |
effect + scheduler |
侦听变化 + 回调 |
数据流动的全链路:
读取数据 → Proxy.get / ref.value get → track(activeEffect)
修改数据 → Proxy.set / ref.value set → trigger → 执行 effectFn
↓
有 scheduler?
├── 是 → 调度器处理(异步/去重)
└── 否 → 直接同步执行
总结
从最简单的 Set 收集依赖,到 WeakMap + Map + Set 三层结构精确追踪,再到 Proxy 实现自动化,最后演进到 activeEffect、effectStack、scheduler、computed、watch、ref------这就是 Vue 3 响应式系统的核心脉络。
每一步的演进都不是凭空设计,而是为了解决前一步暴露的问题:
- 全局
effect无法多函数共存 → 引入activeEffect - 嵌套 effect 导致依赖丢失 → 引入
effectStack - 自增操作无限循环 →
trigger跳过当前activeEffect - 同步执行性能浪费 → 引入
scheduler异步调度 - 多次计算浪费 →
computed的dirty标记实现缓存 - 原始值无法 Proxy →
ref包装为对象的.value
Vue 的响应式系统看起来精巧复杂,但拆开来看,每一层都是在解决一个具体的问题。理解了问题,也就理解了设计。
完整代码
javascript
// ==================== 核心仓库 ====================
const targetMap = new WeakMap()
let activeEffect = null
const effectStack = []
// ==================== track & trigger ====================
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(activeEffect)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (!dep) return
const effectsToRun = new Set(dep)
effectsToRun.forEach(effectFn => {
if (effectFn !== activeEffect) {
if (effectFn.scheduler) {
effectFn.scheduler(effectFn)
} else {
effectFn()
}
}
})
}
// ==================== effect ====================
function effect(fn, options = {}) {
const effectFn = () => {
effectStack.push(effectFn)
activeEffect = effectFn
const result = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] || null
return result
}
effectFn.scheduler = options.scheduler
effectFn()
return effectFn
}
// ==================== reactive ====================
function reactive(target) {
const handler = {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (oldValue !== value) {
trigger(target, key)
}
return result
}
}
return new Proxy(target, handler)
}
// ==================== ref ====================
function ref(initialValue) {
const r = {
get value() {
track(r, 'value')
return initialValue
},
set value(newValue) {
if (initialValue !== newValue) {
initialValue = newValue
trigger(r, 'value')
}
}
}
return r
}
// ==================== computed ====================
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value')
return value
}
}
return obj
}
// ==================== watch ====================
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || value === null || seen.has(value)) return
seen.add(value)
for (const key of Object.keys(value)) {
traverse(value[key], seen)
}
return value
}
function watch(source, callback) {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
const effectFn = effect(() => getter(), {
scheduler() {
newValue = effectFn()
callback(newValue, oldValue)
oldValue = newValue
}
})
oldValue = effectFn()
}