Skip to content

深入理解 Vue 的响应式系统

何为响应式

大部分人对「响应式」设计的初体验是 Excel —— 在表格内输入公式,当公式依赖的单元格的值发生变动时,公式产生的值也会实时更新。反映到前端中,响应式一般指:

  • 数据的更新会实时反映到用户界面(即 DOM)上,对应着 Vue 中的模板语法
  • 数据的更新会使得依赖它的数据跟着更新,对应着 Vue 中的 computed 计算属性
  • 数据的更新会触发一些依赖它的逻辑,对应着 Vue 中的 watchwatchEffect 侦听器

这三件事的核心都是数据的变动 —— 数据的变动会使得依赖它的内容一起变动。再进一步,这三件事其实是一件事:数据绑定了逻辑(函数

  • 模板的本质是 VNode,数据的更新导致生成 VNode 的函数重新调用
  • 数据的更新导致 computed 函数重新调用,产生了新的值
  • 数据更新触发的逻辑,本质上也是函数

NOTE

有关 Vue.js VNode 的更多内容,可参阅官方文档:渲染机制渲染函数与 JSX

侦听数据的变动

如何侦听数据的变动?首先要明确一个点:JS 没有提供任何方式追踪变量的读取和写入,但是 JS 可以追踪对象属性的读取和写入

所以有两种办法:

  1. 把一个值包装成对象,通过 obj.value 读取和写入值
  2. 干脆将值都写在对象属性中,直接监控这个对象

这两种方式分别对应了 Vue 的 refreactive API。

对于第一种情况,只有一个 value 属性需要侦听,我们可以通过 getter 与 setter 实现:

ts
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 实现:

ts
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

那么目前我们只是实现了 refreactive 两个存放数据的容器,还没有在读写属性时执行什么逻辑。

接下来我们需要一张表来保存每个响应式数据所对应的函数,这些函数需要在响应式数据修改时执行(简单起见,我们就不考虑这些函数的传参问题了建立一个 WeakMap effectMap 来存储:

ts
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

ts
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 们去记录读取情况:

ts
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:

ts
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

ts
function computed<T>(fn: () => T) {
  const refObj = ref(<T>null);
  watchEffect(() => (refObj.value = fn()));
  return refObj;
}

小结

至此,我们就实现了一个「猴版」Vue 响应式 API。当然,具体实现和 Vue 源码肯定还是有区别,很多边界情况也没有考虑,例如:

  • Vue 中对 reactive 对象的跟踪是更细粒度的:
    • 每个属性都有其独立的依赖集合,例如使用 obj.foowatchEffect 函数不会在 obj.bar 更新时触发。
    • Reactive 对象的跟踪是深层的,即不仅跟踪对象属性,还跟踪属性的属性、属性的属性的属性。这是通过递归 Proxy 代理实现的。
  • 在 reactive 的属性被赋值为 ref 对象时,ref 会自动解包
  • 示例中的 recording 标志在并发场景下会有问题,因为 watchEffect 函数执行过程中可能运行新的 watchEffect。实际上 Vue 会通过 activeEffect 和栈来管理当前运行的 Effect。
  • Vue2 中为了兼容性,采用 Object.defineProperty 实现响应式,且为了侦听数组修改重写了数组一些方法(即我们常说的 monkey patching

等等。此处不再列举。

但是作为一个用来理解响应式实现原理的示例,应该还是足够的。总结一下刚刚的内容就是:

  • 响应式的本质是数据与函数的绑定;
  • JS 中不能侦听变量的变动,只能侦听对象属性的变动;
  • 通过 getter & setter、Proxy 这些 JS 提供的方式建立数据与函数的关系。

这里也给出完整的示例代码,你可以自己玩一玩:

完整的示例代码
ts
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 之前的部分)才会被监控。因此,如果是这样一段代码:

ts
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 解构语法解构出来的时候,侦听会丢失。

ts
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

ts
const obj = reactive({ a: 1 });
// don't use:
const { a } = obj;
// use:
const { a } = toRefs(obj);

拓展阅读

你可以继续阅读 Vue 文档:深入响应式系统

此外,Vue 曾经有过类似 Svelte 的 响应式语法糖,在编译阶段将 ref 的调用转换为 ref.value,这样就不需要不断地写 .value 了。应当指出的是,这是一种对 JS 的「异化最终 被废弃