深入理解 Vue 的响应式系统
何为响应式
大部分人对「响应式」设计的初体验是 Excel —— 在表格内输入公式,当公式依赖的单元格的值发生变动时,公式产生的值也会实时更新。反映到前端中,响应式一般指:
- 数据的更新会实时反映到用户界面(即 DOM)上,对应着 Vue 中的模板语法
- 数据的更新会使得依赖它的数据跟着更新,对应着 Vue 中的
computed
计算属性 - 数据的更新会触发一些依赖它的逻辑,对应着 Vue 中的
watch
、watchEffect
侦听器
这三件事的核心都是数据的变动 —— 数据的变动会使得依赖它的内容一起变动。再进一步,这三件事其实是一件事:数据绑定了逻辑(函数
- 模板的本质是 VNode,数据的更新导致生成 VNode 的函数重新调用
- 数据的更新导致
computed
函数重新调用,产生了新的值 - 数据更新触发的逻辑,本质上也是函数
侦听数据的变动
如何侦听数据的变动?首先要明确一个点:JS 没有提供任何方式追踪变量的读取和写入,但是 JS 可以追踪对象属性的读取和写入。
所以有两种办法:
- 把一个值包装成对象,通过
obj.value
读取和写入值 - 干脆将值都写在对象属性中,直接监控这个对象
这两种方式分别对应了 Vue 的 ref
和 reactive
API。
对于第一种情况,只有一个 value
属性需要侦听,我们可以通过 getter 与 setter 实现:
type Ref<T> = {
value: T;
};
function ref<T>(initial: T): Ref<T> {
const refObj = {
get value() {
console.log("get:", initial);
return initial;
},
set value(v) {
console.log("set:", v);
initial = v;
},
};
return refObj;
}
const a = ref(1);
console.log(a.value);
// get: 1
// 1
a.value = 2;
// set: 2
console.log(a.value);
// get: 2
// 2
对于第二种情况,侦听整个对象的属性,可以用 Proxy 实现:
type Reactive<T extends object> = T;
function reactive<T extends object>(obj: T): Reactive<T> {
const reactiveObj = new Proxy(obj, {
get(target: T, key: string) {
console.log("get:", key);
return key in target ? target[key] : undefined;
},
set(target: T, key: string, value: any) {
console.log("set:", key, value);
target[key] = value;
return true;
},
});
return reactiveObj;
}
const rObj = reactive({ foo: "foo!", bar: 2 });
console.log(rObj.foo);
// get: foo
// foo!
rObj.bar++;
// get: bar
// set: bar 3
那么目前我们只是实现了 ref
和 reactive
两个存放数据的容器,还没有在读写属性时执行什么逻辑。
接下来我们需要一张表来保存每个响应式数据所对应的函数,这些函数需要在响应式数据修改时执行(简单起见,我们就不考虑这些函数的传参问题了effectMap
来存储:
type Dependency = Ref<any> | Reactive<object>;
const effectMap = new WeakMap<Dependency, Set<Function>>();
function ref<T>(initial: T): Ref<T> {
const refObj = {
// ...
set value(v) {
// ...
if (effectMap.has(refObj))
effectMap.get(refObj)!.forEach((fn) => fn());
return v;
},
};
return refObj;
}
function reactive<T extends object>(obj: T): Reactive<T> {
const reactiveObj = new Proxy(obj, {
// ...
set(target: T, key: string, value: any) {
// ...
if (effectMap.has(reactiveObj))
effectMap.get(reactiveObj)!.forEach((fn) => fn());
return true;
},
});
return reactiveObj;
}
为什么使用 WeakMap?
相比于使用 Map,WeakMap 保存的是键的弱引用,因此不会干扰到 JS 的垃圾处理机制 —— 当 ref 或 reactive 对象的作用域被回收时,WeakMap 不会影响它们被回收。
有关 WeakMap 的更多内容,可参阅 WeakMap 与 Map 的区别 或 MDN: WeakMap。有关作用域的更多内容,可参阅 作用域、闭包与隐式块。
响应式 API 的实现
有了上面这些,实现响应式 API 就比较容易了 —— 只要按需将函数加入到 effectMap
即可。
watch
最简单的就是 watch
:
function watch(dep: Dependency | Dependency[], fn: () => void) {
const depArr = dep instanceof Array ? dep : [dep];
depArr.forEach((d) => {
if (!effectMap.has(d)) effectMap.set(d, new Set());
effectMap.get(d)!.add(fn);
});
}
watchEffect
watchEffect
会复杂一些,因为要侦听函数调用过程中哪些响应式数据被读取了。我们可以准备一个 flag,当需要「录制」数据读取情况时将 flag 设为 true,让 getter 们去记录读取情况:
const recording = {
isRecording: false,
refSet: new Set<Dependency>(),
};
function watchEffect(fn: () => void) {
recording.isRecording = true; // 启动「录制」
fn();
recording.isRecording = false; // 停止「录制」
recording.refSet.forEach((ref) => {
if (!effectMap.has(ref)) effectMap.set(ref, new Set());
effectMap.get(ref)!.add(fn);
});
recording.refSet.clear();
}
并修改刚刚的 ref 和 reactive:
function ref<T>(initial: T): Ref<T> {
const refObj = {
get value(v) {
console.log("get:", initial);
if (recording.isRecording) recording.refSet.add(refObj);
return initial;
},
// ...
};
return refObj;
}
function reactive<T extends object>(obj: T): Reactive<T> {
const reactiveObj = new Proxy(obj, {
get(target: T, key: string) {
console.log("get:", key);
if (recording.isRecording) recording.refSet.add(reactiveObj);
return key in target ? target[key] : undefined;
},
// ...
});
return reactiveObj;
}
computed
computed
API 可以基于刚刚的 watchEffect
:
function computed<T>(fn: () => T) {
const refObj = ref(<T>null);
watchEffect(() => (refObj.value = fn()));
return refObj;
}
小结
至此,我们就实现了一个「猴版」Vue 响应式 API。当然,具体实现和 Vue 源码肯定还是有区别,很多边界情况也没有考虑,例如:
- Vue 中对 reactive 对象的跟踪是更细粒度的:
- 每个属性都有其独立的依赖集合,例如使用
obj.foo
的watchEffect
函数不会在obj.bar
更新时触发。 - Reactive 对象的跟踪是深层的,即不仅跟踪对象属性,还跟踪属性的属性、属性的属性的属性。这是通过递归 Proxy 代理实现的。
- 每个属性都有其独立的依赖集合,例如使用
- 在 reactive 的属性被赋值为 ref 对象时,ref 会自动解包
- 示例中的
recording
标志在并发场景下会有问题,因为watchEffect
函数执行过程中可能运行新的watchEffect
。实际上 Vue 会通过activeEffect
和栈来管理当前运行的 Effect。 - Vue2 中为了兼容性,采用
Object.defineProperty
实现响应式,且为了侦听数组修改重写了数组一些方法(即我们常说的 monkey patching) 。
等等。此处不再列举。
但是作为一个用来理解响应式实现原理的示例,应该还是足够的。总结一下刚刚的内容就是:
- 响应式的本质是数据与函数的绑定;
- JS 中不能侦听变量的变动,只能侦听对象属性的变动;
- 通过 getter & setter、Proxy 这些 JS 提供的方式建立数据与函数的关系。
这里也给出完整的示例代码,你可以自己玩一玩:
完整的示例代码
type Ref<T> = {
value: T;
};
type Reactive<T extends object> = T;
type Dependency = Ref<any> | Reactive<object>;
const recording = {
isRecording: false,
refSet: new Set<Dependency>(),
};
const effectMap = new WeakMap<Dependency, Set<Function>>();
function ref<T>(initial: T): Ref<T> {
const refObj = {
get value() {
console.log("get:", initial);
if (recording.isRecording) recording.refSet.add(refObj);
return initial;
},
set value(v) {
console.log("set:", v);
initial = v;
if (effectMap.has(refObj))
effectMap.get(refObj)!.forEach((fn) => fn());
},
};
return refObj;
}
function reactive<T extends object>(obj: T): Reactive<T> {
const reactiveObj = new Proxy(obj, {
get(target: T, key: string) {
console.log("get:", key);
if (recording.isRecording) recording.refSet.add(reactiveObj);
return key in target ? target[key] : undefined;
},
set(target: T, key: string, value: any) {
console.log("set:", key, value);
target[key] = value;
if (effectMap.has(reactiveObj))
effectMap.get(reactiveObj)!.forEach((fn) => fn());
return true;
},
});
return reactiveObj;
}
function watch(dep: Dependency | Dependency[], fn: () => void) {
const depArr = dep instanceof Array ? dep : [dep];
depArr.forEach((d) => {
if (!effectMap.has(d)) effectMap.set(d, new Set());
effectMap.get(d)!.add(fn);
});
}
function watchEffect(fn: () => void) {
recording.isRecording = true;
fn();
recording.isRecording = false;
recording.refSet.forEach((ref) => {
if (!effectMap.has(ref)) effectMap.set(ref, new Set());
effectMap.get(ref)!.add(fn);
});
recording.refSet.clear();
}
function computed<T>(fn: () => T) {
const refObj = ref(<T>null);
watchEffect(() => (refObj.value = fn()));
return refObj;
}
响应式的常见问题
watchEffect
与异步
可以看到我们刚刚在 watchEffect
中对函数的侦听是同步的,也就是说只有函数的同步部分代码(同步函数、async
函数中第一个 await
之前的部分)才会被监控。因此,如果是这样一段代码:
const a = ref(0);
watchEffect(async () => {
await Promise.resolve();
console.log("effect:", a.value);
});
setTimeout(() => {
a.value = 1;
}, 1000);
// get: 0
// effect: 0
// [1s 过后]
// set: 1
watchEffect
中的函数只有第一次运行了,后续 a.value
的修改没有触发再次执行。这说明此例中 watchEffect
没有成功侦听到 a
。
Vue 提供了 effectScope
API 管理异步 Effect 的生命周期。相关内容可参阅 Vue 文档:effectScope。
响应式对象的解构
当我们把一个响应式对象的属性通过 ES6 解构语法解构出来的时候,侦听会丢失。
const foo = reactive({ bar: 1 });
const baz = computed(() => foo.bar * 2);
// get: bar
// set: 2
foo.bar = 3;
// set: bar 3
// get: bar
// set: 6
console.log(baz.value);
// get: 6
// 6
let { bar } = foo;
// get: bar
bar = 5;
// [无输出,侦听丢失]
console.log(baz.value);
// get: 6
// 6
// [值依然为 3*2,而不是 5*2]
这很好理解。因为响应式实现的核心是对对象属性的侦听。而解构只是把值取走,侦听就会丢失。所以你会看到一些库的 API 中,在使用响应式数据的时候要求传入 reactive 对象以及字符串形式的属性名 —— 因为如果直接以 obj.attr
这种形式书写参数,响应式就会丢失。
你可以使用 toRefs
将 reactive 对象解构为 ref 对象。相关内容可参阅 Vue 文档:toRefs。
const obj = reactive({ a: 1 });
// don't use:
const { a } = obj;
// use:
const { a } = toRefs(obj);
拓展阅读
你可以继续阅读 Vue 文档:深入响应式系统。
此外,Vue 曾经有过类似 Svelte 的 响应式语法糖,在编译阶段将 ref 的调用转换为 ref.value,这样就不需要不断地写 .value
了。应当指出的是,这是一种对 JS 的「异化