🎯 本节目标
Vue 3 响应式的核心魔法在于:当数据改变时,自动执行相关的函数。 为了实现这个目标,我们需要搞定两个狠角色:
-
Reactive : 使用
Proxy拦截数据的读取和修改。 -
Effect: 一个副作用函数,负责收集依赖并在数据变化时重新执行。
第一步:实现最基础的 reactive
我们先不考虑复杂的依赖收集,先定一个小目标:把一个普通对象变成 Proxy 代理对象。
1. 编写测试用例 (Red)
新建 src/reactivity/tests/reactive.spec.ts。 我们需要验证两点:
-
reactive返回的对象和原对象不一样(是被代理过的)。 -
reactive返回的对象依然能像原对象一样访问属性。
TypeScript
// src/reactivity/tests/reactive.spec.ts
import { reactive } from "../reactive";
describe("reactive", () => {
it("happy path", () => {
const original = { foo: 1 };
const observed = reactive(original);
// 1. 它们不应该是同一个对象
expect(observed).not.toBe(original);
// 2. 依然能访问属性
expect(observed.foo).toBe(1);
});
});
此时运行 npm run test,一定会报错,因为我们连 reactive.ts 都还没建。
2. 编写代码 (Green)
新建 src/reactivity/reactive.ts。 这里我们利用 ES6 的 Proxy 来拦截对象。
TypeScript
// src/reactivity/reactive.ts
export function reactive(raw) {
return new Proxy(raw, {
// 拦截读取操作
get(target, key) {
// Reflect.get 是规范的读取方式,等价于 target[key]
const res = Reflect.get(target, key);
// TODO: 这里以后要收集依赖 (track)
return res;
},
// 拦截设置操作
set(target, key, value) {
const res = Reflect.set(target, key, value);
// TODO: 这里以后要触发更新 (trigger)
return res;
}
});
}
再次运行 npm run test。 🎉 通过! 我们迈出了第一步,成功拦截了对象的读写。
第二步:实现 effect 与依赖收集 (核心!)
这是 Vue 3 最难理解的地方。我们用一个测试用例把逻辑串起来。
1. 编写测试用例 (Red)
新建 src/reactivity/tests/effect.spec.ts。 这是整个响应式系统的灵魂用例,请务必读懂它:
TypeScript
// src/reactivity/tests/effect.spec.ts
import { reactive } from "../reactive";
import { effect } from "../effect";
describe("effect", () => {
it("happy path", () => {
// 1. 创建一个响应式对象
const user = reactive({
age: 10,
});
let nextAge;
// 2. effect 函数会默认先执行一次
effect(() => {
nextAge = user.age + 1;
});
// 3. 验证首次执行结果
expect(nextAge).toBe(11);
// 4. update: 修改响应式对象的值
user.age++;
// 5. 核心目标:user.age 变了,effect 里的函数应该自动重新执行,nextAge 应该变成 12
expect(nextAge).toBe(12);
});
});
运行测试,会报错:
-
找不到
effect函数。 -
即使有了
effect,修改user.age后nextAge也不会变,因为我们还没把它们关联起来。
2. 实现 Effect 类的框架
新建 src/reactivity/effect.ts。 我们需要一个全局变量 activeEffect 来记录当前正在执行的副作用函数。
TypeScript
// src/reactivity/effect.ts
class ReactiveEffect {
private _fn: any;
constructor(fn) {
this._fn = fn;
}
run() {
// 1. 标记当前活动的 effect 是我自己
activeEffect = this;
// 2. 执行传入的 fn (这时候 fn 内部会读取 data.age,触发 reactive 的 get)
this._fn();
}
}
// 全局变量,用来暂存当前正在执行的 effect
let activeEffect;
export function effect(fn) {
const _effect = new ReactiveEffect(fn);
// effect 默认初始化执行一次
_effect.run();
}
3. 实现依赖收集 (Track)
现在 reactive 和 effect 还是割裂的。 我们需要在读取属性(get)时,把当前的 activeEffect 存起来。
数据结构设计(非常重要): 我们需要建立这样的映射关系: target (对象) -> key (属性) -> dep (依赖的 effect 集合)
(示意:WeakMap 存对象 -> Map 存 Key -> Set 存 Effect)
在 src/reactivity/effect.ts 中添加 track 函数:
TypeScript
// src/reactivity/effect.ts
// 仓库:用来存所有的依赖关系
// WeakMap 的 key 必须是对象,且弱引用(利于垃圾回收)
const targetMap = new WeakMap();
export function track(target, key) {
// 如果当前没有 activeEffect,说明不是在 effect 函数里读取的,不需要收集
if (!activeEffect) return;
// 1. 根据 target 找 depsMap
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// 2. 根据 key 找 dep (Set)
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
// 3. 把当前 effect 存进去
dep.add(activeEffect);
}
4. 实现触发更新 (Trigger)
在设置属性(set)时,把刚才收集到的 effect 拿出来运行。
在 src/reactivity/effect.ts 中添加 trigger 函数:
TypeScript
// src/reactivity/effect.ts
export function trigger(target, key) {
let depsMap = targetMap.get(target);
let dep = depsMap.get(key);
// 遍历所有收集到的 effect 并执行
if (dep) {
for (const effect of dep) {
effect.run();
}
}
}
5. 将 Track 和 Trigger 接入 Reactive
最后一步,回到 src/reactivity/reactive.ts,在 get 里调 track,在 set 里调 trigger。
TypeScript
// src/reactivity/reactive.ts
import { track, trigger } from "./effect"; // 引入刚才写的函数
export function reactive(raw) {
return new Proxy(raw, {
get(target, key) {
const res = Reflect.get(target, key);
// 核心:收集依赖!
track(target, key);
return res;
},
set(target, key, value) {
const res = Reflect.set(target, key, value);
// 核心:触发更新!
trigger(target, key);
return res;
}
});
}
第三步:见证奇迹
现在,回到终端,再次运行:
Bash
npm run test
如果代码没写错,你应该会看到令人激动的绿色: ✓ src/reactivity/tests/effect.spec.ts
这意味着:
-
effect(() => nextAge = user.age + 1)执行,触发user.age的 get。 -
get 调用
track,把这个 effect 存进了targetMap。 -
user.age++修改值,触发 set。 -
set 调用
trigger,找出了刚才存的 effect。 -
effect 重新运行,
nextAge更新为 12。
🧠 核心知识点总结 (Review)
-
Proxy: 是响应式的拦截器,它不需要像 Vue 2 那样递归遍历对象属性,而是懒拦截(访问到了才拦截)。
-
WeakMap -> Map -> Set: 这是 Vue 3 存储依赖的数据结构金字塔。
-
WeakMap: 存 Target 对象 (如果不被引用了,自动回收)。 -
Map: 存 Target 的 Key。 -
Set: 存 Key 对应的 Effect 集合 (Set 可以自动去重,防止重复添加同一个 effect)。
-
-
activeEffect: 一个全局指针,巧妙地解决了 "如何在 get 中获取当前正在执行的函数" 这个问题。
✅ 你的今日任务
-
代码实现 :按照教程完成
reactive.ts和effect.ts。 -
调试 :在
track和trigger里加上console.log,观察一下打印顺序,看看到底是什么时候收集、什么时候触发的。 -
思考 :现在的
effect很基础。如果我通过effect返回一个runner函数,手动调用它也能执行,该怎么做?(这将是下一节scheduler和stop功能的基础)。