02-reactivity-reactive-ref-computed
第 2 章 · reactivity(下):reactive、ref、computed
第 2 章 · reactivity(下):reactive、ref、computed
第 1 章解决了"记录谁读了、通知谁重跑"。本章解决剩下的问题:怎么拦截读写(reactive/ref),以及在 effect 之上的第一个高级封装(computed)。 读完第 1 章后,本章的每个文件都会显得"薄"——这是正常的,说明你抓对了重心。
2.1 reactive.ts + baseHandlers.ts:Proxy 只是耳目
失败用例
it('reactive happy path', () => {
const original = { foo: 1 }
const observed = reactive(original)
expect(observed).not.toBe(original) // 返回的是代理,不是原对象
expect(observed.foo).toBe(1) // 读写行为透明
})
it('nested reactive', () => {
const original = { nested: { foo: 1 }, array: [{ bar: 2 }] }
const observed = reactive(original)
expect(isReactive(observed.nested)).toBe(true) // ★ 嵌套对象也是响应式
expect(isReactive(observed.array[0])).toBe(true)
})
it('readonly cannot set', () => {
const user = readonly({ age: 10 })
user.age = 11 // 写失败 + 警告
expect(user.age).toBe(10)
})实现:reactive.ts 是薄壳
// reactive.ts
import { mutableHandlers, readonlyHandlers, shallowReadonlyHandlers } from './baseHandlers'
export function reactive(raw) { return createActiveObject(raw, mutableHandlers) }
export function readonly(raw) { return createActiveObject(raw, readonlyHandlers) }
export function shallowReadonly(raw) { return createActiveObject(raw, shallowReadonlyHandlers) }
function createActiveObject(raw, handlers) {
return new Proxy(raw, handlers)
}整个文件的信息量是:reactive/readonly/shallowReadonly 的差别只在 handlers。真正的内容全在 baseHandlers.ts:
// baseHandlers.ts
import { track, trigger } from './effect'
import { reactive, readonly, ReactiveFlags } from './reactive'
import { isObject } from '../shared'
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key) {
// ① isReactive/isReadonly 的实现藏在这里
if (key === ReactiveFlags.IS_REACTIVE) return !isReadonly
if (key === ReactiveFlags.IS_READONLY) return isReadonly
const res = Reflect.get(target, key)
if (shallow) return res // ② shallow:不递归,到此为止
if (isObject(res)) { // ③ 惰性递归:读到对象才包
return isReadonly ? readonly(res) : reactive(res)
}
if (!isReadonly) track(target, key) // ④ readonly 不会被写 → 不必收集依赖
return res
}
}
function createSetter() {
return function set(target, key, value) {
const res = Reflect.set(target, key, value)
trigger(target, key) // 先改值,再通知(顺序不能反)
return res
}
}
// ⑤ 顶层执行一次,缓存住,而不是每次 createActiveObject 时新建
const get = createGetter()
const set = createSetter()
const readonlyGet = createGetter(true)
const shallowReadonlyGet = createGetter(true, true)
export const mutableHandlers = { get, set }
export const readonlyHandlers = {
get: readonlyGet,
set(target, key) {
console.warn(`key "${String(key)}" set 失败,因为 target 是 readonly`, target)
return true // 返回 true 骗过 Proxy,避免抛 TypeError
},
}
export const shallowReadonlyHandlers = {
...readonlyHandlers, // 复用 readonly 的 set(同样禁止写)
get: shallowReadonlyGet,
}逐段解释
① 用"读一个不存在的特殊 key"实现类型判断。 isReactive(obj) 的实现是:
export function isReactive(value) {
return !!value[ReactiveFlags.IS_REACTIVE] // 读这个 key
}普通对象读 __v_isReactive 得到 undefined,!! 后为 false;代理对象的 get 拦截器截住这个 key 返回 true/false。对象本身没有这个属性,答案完全由拦截器"现场编造"。 这招同时解释了为什么参数是 isReadonly 取反——readonly 代理不是 reactive。第一次读会觉得绕,认清后你会在 Vue 源码里看到一整族这种 flag(__v_raw、__v_skip...),全是同一机制。
③ 惰性递归是 Vue 3 对 Vue 2 的关键胜利。 对比:
- Vue 2:
new Vue({data})时立刻深度遍历整个 data,对每个属性 defineProperty。数据有 1 万个叶子节点就要做 1 万次拦截定义,哪怕你只用其中 3 个; - Vue 3:只代理最外层。读到
observed.nested这一刻,发现值是对象,才对它调reactive()。没读过的分支永远保持原始对象,零开销。
代价是每次读嵌套对象都新建一个 Proxy?真源码里有一层 reactiveMap 缓存(同一个 raw 永远返回同一个代理),mini-vue 略去了——这是又一个"mini 版故意不做,读真源码时验证"的点。
④ 为什么 readonly 不 track:track 的唯一用途是"将来 trigger 时找到你",而 readonly 对象永远不会被合法地写、永远不会 trigger,收集了也是白白占内存。一行 if 背后是"这条数据通路的下游是否存在"的推理——读源码时看到"少做了一步",先想下游,往往不是漏了而是省了。
⑤ 工厂函数 + 顶层缓存。 四种 getter 的逻辑 90% 相同,差两个布尔值。三种写法对比:复制四份(改 bug 要改四处);每次 new Proxy 时现场 createGetter(每个响应式对象一份闭包,浪费);顶层 create 一次、模块级缓存、所有代理共享——mini-vue 选的第三种。createGetter(isReadonly, shallow) 这种用高阶函数把"变体差异"参数化的手法,会在 compiler 章的 createTransformXxx 再次出现,是源码级 DRY 的标准形态。
Reflect.get 而不是 target[key]:教学层面可以理解为等价写法;真实原因与 receiver、原型链上的 getter 里 this 指向有关(Reflect.get(target, key, receiver) 能让原型 getter 里的 this 指向代理,保证嵌套依赖也被收集)。mini-vue 没传 receiver,读 vuejs/core 时注意对比这个参数。
2.2 ref.ts:为什么已有 reactive 还要发明 ref
问题
Proxy 的能力边界是对象。let count = 0 是基本值,没有属性访问,无从拦截。而"一个响应式的数字/字符串/布尔"是刚需(计数器、开关、输入框内容)。
解法与失败用例
解法朴素:包一层对象。约定属性名叫 value:
it('ref happy path', () => {
const a = ref(1)
let dummy, calls = 0
effect(() => { calls++; dummy = a.value })
expect(dummy).toBe(1)
a.value = 2
expect(dummy).toBe(2)
a.value = 2 // ★ 设相同值
expect(calls).toBe(2) // 不应触发更新
})
it('ref with object', () => {
const a = ref({ count: 1 })
a.value.count = 2 // ★ value 是对象时,内部也要响应式
expect(a.value.count).toBe(2)
})实现
// ref.ts
import { trackEffects, triggerEffects, isTracking } from './effect'
import { reactive } from './reactive'
import { hasChanged, isObject } from '../shared'
class RefImpl {
private _value
private _rawValue // 存未代理的原始值,用于比较
public dep = new Set() // ★ 只有一个 value,不需要 targetMap,自带一个 dep
public __v_isRef = true // isRef 的判断依据
constructor(value) {
this._rawValue = value
this._value = convert(value) // 对象 → reactive,基本值 → 原样
}
get value() {
if (isTracking()) trackEffects(this.dep) // 复用第 1 章拆出来的后半段
return this._value
}
set value(newValue) {
if (!hasChanged(newValue, this._rawValue)) return // ★ 值没变,静默
this._rawValue = newValue
this._value = convert(newValue)
triggerEffects(this.dep) // 先赋值再触发,顺序同 Proxy set
}
}
function convert(value) {
return isObject(value) ? reactive(value) : value
}
export function ref(value) { return new RefImpl(value) }
export function isRef(r) { return !!r?.__v_isRef }
export function unRef(r) { return isRef(r) ? r.value : r }逐段解释
ref 用 class 的 get/set 而不是 Proxy——因为 key 是写死的 value,用 accessor 属性就够了,比 Proxy 轻。同一个"拦截读写"的需求,key 固定时用 getter/setter,key 任意时才需要 Proxy。拦截手段的选择由拦截面的大小决定,这也是 Vue 2(defineProperty,key 固定于初始化时已知的集合)与 Vue 3(Proxy,任意 key 包括新增)的分界。
dep = new Set() 直接长在实例上:第 1 章 targetMap 三层查找的意义是"从 (target, key) 定位到 dep",ref 的 (target, key) 恒为 (自己, 'value'),一步到位。这正是当初把 trackEffects/triggerEffects 拆出来的原因——ref 跳过定位,直接用后半段。回看第 1 章的拆分,你现在拥有了"作者视角":拆分不是审美,是被第二个调用方逼出来的。
_rawValue 为什么要单独存:hasChanged 比较必须用原始值。若 value 是对象,_value 已被 convert 成 reactive 代理,拿代理和新来的原始对象比永远不相等,"设相同对象"就会误触发。一个字段专门为"正确比较"而存在——源码里冗余存储常常是为某个比较/回退服务的,找到那个使用点才算读懂。
proxyRefs:模板里不写 .value 的真相
setup 返回 { count: ref(0) },模板里却直接写 {{ count }}。魔法在这 15 行:
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {
return unRef(Reflect.get(target, key)) // 读:自动拆包
},
set(target, key, value) {
if (isRef(target[key]) && !isRef(value)) {
target[key].value = value // 写:旧值是 ref、新值不是 → 写进 .value
return true
}
return Reflect.set(target, key, value) // 否则整个替换
},
})
}第 4 章你会看到组件初始化时对 setup 的返回值套了这一层。"框架的便利性魔法 = 某处多包的一层代理",在 Vue 里这个等式几乎处处成立。
2.3 computed.ts:scheduler 的第一个客户
失败用例(一字一句都要读)
it('should compute lazily', () => {
const value = reactive({ foo: 1 })
const getter = vi.fn(() => value.foo)
const cValue = computed(getter)
expect(getter).not.toHaveBeenCalled() // ① 不读就不算(懒)
expect(cValue.value).toBe(1) // ② 读了才算
cValue.value
expect(getter).toHaveBeenCalledTimes(1) // ③ 再读不重算(缓存)
value.foo = 2
expect(getter).toHaveBeenCalledTimes(1) // ④ ★ 依赖变了,仍然不算!
expect(cValue.value).toBe(2) // ⑤ 直到下次读,才重算
expect(getter).toHaveBeenCalledTimes(2)
cValue.value
expect(getter).toHaveBeenCalledTimes(2) // ⑥ 又进入缓存态
})第④行是全测试的灵魂:依赖变化时 computed 什么都不计算。它只是默默记下"我脏了"。这个行为普通 effect 做不到(普通 effect 变化即重跑),唯一的实现通道就是 scheduler。
实现(完整体只有 25 行)
// computed.ts
import { ReactiveEffect } from './effect'
class ComputedRefImpl {
private _value
private _dirty = true // ★ 唯一的状态:缓存是否失效
private _effect: ReactiveEffect
constructor(getter) {
this._effect = new ReactiveEffect(getter, () => {
// scheduler:依赖变化时执行的不是 getter,而是这两行
if (!this._dirty) this._dirty = true
})
}
get value() {
if (this._dirty) {
this._dirty = false
this._value = this._effect.run() // run 一次:算出新值 + 重新收集依赖
}
return this._value
}
}
export function computed(getter) {
return new ComputedRefImpl(getter)
}逐段解释
为什么 getter 必须包成 ReactiveEffect,而不是普通函数? 试着用普通函数写:get value() { return getter() }——懒和缓存都能做,但 value.foo = 2 之后没有任何机制把 _dirty 置回 true,缓存永远不失效。getter 里读了 value.foo,就必须有人被收集进 foo 的 dep,这个"人"只能是一个 ReactiveEffect。所以结构是:
value.foo 的 dep ──收集──> computed 内部的 effect
└─ scheduler: _dirty = true (被通知时只改标记)
cValue.value 被读 ── _dirty? ──> effect.run() 重算并缓存状态机视角:computed 是一个两态机——clean(缓存有效,读直接返回)和 dirty(缓存失效,读触发重算)。依赖变化把它打到 dirty,读把它打回 clean。整个实现就是这个状态机的直译。把"懒计算 + 缓存失效"压缩成一个布尔 + 一个 scheduler,这 25 行是控制反转的教学标本。
mini-vue 故意没做的一层:cValue 本身也可能被 effect 依赖(组件渲染读了 computed)。依赖链变成 foo → computed → 渲染 effect,foo 变化时应该把"脏"沿链传播、并触发渲染 effect。真源码在 scheduler 里加了 triggerRefValue(this)(把依赖自己的 effect 也通知一遍)。带着这个问题去读 vuejs/core 的 computed.ts,还会遇到 Vue 3.4 重写的"版本计数"优化——那是理解现代响应式系统(脏检查 vs 推拉结合)的门票。
2.4 本章的三个可迁移结论
- 拦截手段跟着拦截面走:key 固定 → getter/setter(ref);key 任意 → Proxy(reactive)。
- 薄壳文件是好信号:reactive.ts 三行一个函数,说明复杂度被推到了 handlers——读源码先找"复杂度真正堆积的文件",壳可以秒过。
- 每个"魔法"对应一层代理:
.value自动拆包 = proxyRefs;this.xxx直通 setup 返回值 = 第 4 章的 PublicInstanceProxy;模板里{{ count }}不用前缀 = codegen 生成的_ctx+ 上面两层。祛魅路径是统一的。
自测
isReactive读的那个 key 在对象上存在吗?答案从哪来的?- 惰性递归相比 Vue 2 的全量递归省在哪?代价是什么,真源码怎么补?
- ref 为什么不用 Proxy?
_rawValue为谁而存在? - 不看书画出 computed 的两态状态机,标出谁把它打脏、谁把它打回干净。
- (手撕)实现 computed 并通过 2.3 的测试;然后思考:如果渲染 effect 依赖了 computed,你的实现缺了哪一环?