ShawDubie

Vue 系列(三):初识响应式系统

在上一篇文章中,我们介绍了 Vue 的模板编译器,那么后面的文章我们就要讨论跟渲染器有关的内容了。在介绍渲染器之前,我们要先花好几篇文章来介绍 Vue 的响应式系统,因为渲染器的实现与响应式系统密不可分。本篇我们先简单认识一下响应式系统。

副作用


副作用这个名词大家一定不陌生,那么什么是副作用呢?这里不得不提到一个概念:引用透明性。

一个表达式在程序中可以被她等价的值替换,而不影响结果,那么我们就说这个表达式具有引用透明性。

如果一个函数的输入相同,对应的计算结果也相同,那么她也就具备引用透明性,她可被称为纯函数。举个例子:

const add(x: number, y: number) => x + y;

console.log(add(1, 2)) // 3
console.log(add(1, 2)) // 3

上面这段代码非常简单,就是对两个值进行求和。我们可以发现,如果把 add(1, 2) 替换成 3,对上面的结果不会有任何影响。那么我们说 add 函数是一个纯函数。与纯函数相对应的就是副作用函数,比如:

let a = 1;

const foo(x: number) => {
  a++;
  return x + a;
}

console.log(foo(1)) // 3
console.log(foo(1)) // 4

上面的例子我们可以发现,多次调用 foo 产生的结果并不相同,因为函数执行的结果受到了外部变量 a 的影响,我们称其为:副作用,该函数也可以成为副作用函数。副作用的产生往往跟 可变数据 和 状态共享 相关。那副作用跟响应式系统有什么关系呢?

const foo = { a: 1 };

let b = 0;

const effect = () => {
  b = foo.a;
  console.log(b);
}

上述代码中的 effect 方法就是一个副作用函数,她会设置外部变量 b 的值,然后再将其打印出来。我们可以发现,外部变量 b 的值是依赖对象 foo 中的属性 a 的。如果我们现在改变 a 的值:

foo.a = 2;

此时如果不执行 effect,那么 b 的值是不会有变化的。如果我们希望 foo.a 的值改变的时候,能重新执行 effect,那么我们说对象 foo 是响应式数据。很显然,我们上面的代码是无法做到这一点的,无论我们怎样去修改 foo.a,只要我们不主动执行 effect,那么 b 的值将不会发生改变。

实现响应式数据


很显然,我们上面的例子是无法做到响应式的,那么我们怎么才能实现响应式数据呢? 我们在前文已经说过,当 foo.a 的值改变的时候,如果能够重新执行 effect,那么我们就可以说对象 foo 是响应式数据。如下图所示:

通过分析上图,我们能够发现如下两点:

  • effect 方法执行的时候,foo.a 的值会被 读取(get)

  • 当我们执行 foo.a = 2 的时候,其实就是在 设置(set) foo.a,或者说,foo.a 的值会被 设置(set)

要想实现上述两点,只需要拦截对象的 get 和 set 操作即可。那要怎么做呢?我们知道,Object.defineProperty 和 Proxy 可以实现对原始数据的代理,在 Vue2 中,数据的响应就是采用 Object.defineProperty 实现的,在 Vue3 中则采用 Proxy 来实现。接下来我们就通过 Proxy 来实现响应式数据。

Proxy 可以拦截对象的 get 和 set,那么我们只需要:

  • 当对象被 get 的时候,我们将读取对象的副作用函数 收集(track) 起来,

  • 当对象被 set 的时候,我们将收集起来的副作用函数 触发(trigger) 执行即可

那么我们就可以写出如下代码:

查看代码
// 原始数据
const data = { a: 1 };

// 用来收集副作用对象的“桶”
// 因为一个对象可能会被多个副作用函数读取,所以这里用集合
const bucket: Set<Function> = new Set();

// 代理原始数据
const obj = new Proxy(data, {
  // 拦截 get
  get(target: any, key: string) {
    // 将副作用函数收集到“桶”中
    bucket.add(effect);
    // 返回要get的属性值
    return target[key];
  },
  // 拦截 set
  set(target: any, key: string, value: any) {
    // 设置属性值
    target[key] = value;
    // 触发副作用函数的执行
    bucket.forEach(fn => fn());
    // 属性值设置成功之后返回 true
    return true;
  }
})

// 外部变量
let b = 0;

// 副作用函数
const effect = () => {
  // 读取代理对象的属性a
  b = obj.a;
  console.log('b------>', b);
}

上述代码做了如下操作:

1. 定义了原始数据:data

2. 定义了一个 桶(bucket) 用来收集副作用函数,这里之所以用 Set 是因为一个对象可能会被多个副作用函数读取,那么每次对象被读取的时候都需要收集相应的副作用函数,且收集过的副作用函数不用再重复收集了

3. 使用 Proxy 对原始数据 data 进行了代理

这样 obj 就变成了一个响应式数据,我们可以通过如下代码来进行验证:

查看代码
// 执行副作用函数,触发 obj 的 get
effect()

// 打印如下信息
// b------>1

// 2 秒之后修改响应式数据 obj 的值
setTimeout(() => {
  obj.a = 2
}, 2000);

// 两秒之后打印如下
// b------>2

重新设计「桶」


在前面我们实现了一个比较简陋的响应式系统,她虽然能够满足我们上述的要求,但是还是存在着比较多的问题,比如我们现在要定义另一个读取响应式数据 obj 的副作用函数:

const effect2 = () => {
  const b = obj.a + 1;
  console.log('======>', b);
}

当该副作用函数被执行的时候,用上面的方法我们无法做到对副作用函数 effect2 的收集,因为在对副作用函数收集的时候,我们默认使用了 effect 的函数签名:

const obj = new Proxy(foo, {
  get(target: any, key: string) {
    // 这里写死了 effect,无法收集 effect2
    bucket.add(effect);
    ...
  },
  ...
});

为了解决上面的问题,我们作出如下改动:

  • 定义一个全局变量 activeEffect 用来存储当前正在执行的副作用函数

  • 定义一个 effect 方法,所有副作用函数都通过该方法进行注册

改动后的代码如下:

查看代码
// 全局变量,用来存储
let activeEffect: Function | null = null;

// 该方法用来注册副作用函数,所有副作用函数都通过该方法注册
export const effect = (effectFn: Function) => {
  activeEffect = effectFn;
  effectFn();
}

const obj = new Proxy(foo, {
  get(target: any, key: string) {
    // 添加当前正在执行的副作用函数到“桶”里面
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    // 省略部分代码
  },
  // 省略部分代码
});

有了上面的实现之后,我们要实现对上面 effect2 收集只需要:

effect(
  // 注册 effect2
  () => {
    const b = obj.a + 1;
    console.log('======>', b);
  }
)

重新实现后的完整代码如下:

查看完整代码
// 全局变量,用来存储
let activeEffect: Function | null = null;

// 用来收集副作用对象的“桶”
// 因为一个对象可能会被多个副作用函数读取,所以这里用集合
const bucket: Set<Function> = new Set();

// 原始数据
const data = { a: 1 };

// 代理原始数据
const obj = new Proxy(data, {
  // 拦截 get
  get(target: any, key: string) {
    // 添加当前正在执行的副作用函数到“桶”里面
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    // 返回要get的属性值
    return target[key];
  },
  // 拦截 set
  set(target: any, key: string, value: any) {
    // 设置属性值
    target[key] = value;
    // 触发副作用函数的执行
    bucket.forEach(fn => fn());
    // 属性值设置成功之后返回 true
    return true;
  }
});

// 该方法用来注册副作用函数,所有副作用函数都通过该方法注册
const effect = (effectFn: Function) => {
  activeEffect = effectFn;
  effectFn();
}

// 外部变量
let b = 0;

// 注册副作用函数
effect(
  () => {
    // 读取代理对象的属性a
    b = obj.a;
    console.log('b------>', b);
  }
);

// 打印如下信息
// b------>1

// 2 秒之后修改响应式数据 obj 的值
setTimeout(() => {
  obj.a = 2
}, 2000);

// 两秒之后打印如下
// b------>2

每次副作用函数都通过 effect 进行注册,就能实现对所有副作用函数的收集了。但是这个响应式系统还存在一些问题,比如我们在上面的代码后面再加下如下代码,然后重新执行:

setTimeout(() => {
  obj.c = 2
}, 3000);

// 三秒之后会打印:b------>2

可以发现会打印两次 b------>2,第一次是正常的,因为我们在 2 秒之后修改了 a 的值为 2。但是第二次我们并没有修改 a 的值,而且 obj 中没有 c 这个属性,但是还是打印了 b------>2。

理想情况下,如果没有修改 a 的值,或者修改了不存在的某个属性的值,我们是不希望去触发副作用函数执行的。通过研究上面的代码,我们的设计其实是有问题的,我们在对副作用函数收集的时候,是针对整个对象进行收集的,如下图所示:

可以看到副作用函数是与整个对象进行绑定的,也就是说,只要触发了 obj 的 set,就会触发副作用函数的执行。我们希望的情况是:触发 obj.a 的 set,才会执行副作用函数,为此我们需要重新设计一下 「桶」,要让副作用函数与某个属性绑定而不是和整个对象绑定,如下图所示:

我们重新设计后的 「桶」的数据结构就是这样:

const bucket: WeakMap<any, Map<string, Set<Function>>> = new WeakMap();

我们用 WeakMap 来实现「桶」,WeakMap 的 key 为对象,value 的数据结构是 Map,Map 的 key 为对象的属性名称,value 的数据结构为 Set,用来存储副作用函数,就如前文所说的那样。我们重新实现上面的响应式系统:

查看完整代码
// 全局变量,用来存储
let activeEffect: Function | null = null;

// 用来收集副作用对象的“桶”
const bucket: WeakMap<any, Map<string, Set<Function>>> = new WeakMap();

// 原始数据
const data = { a: 1 };

// 代理原始数据
const obj = new Proxy(data, {
  // 拦截 get
  get(target: any, key: string) {
    // 当前没有正在执行的副作用函数,直接返回属性值
    if (!activeEffect) return target[key];
    // 取出目标对象指向的 Map
    let depsMap = bucket.get(target);
    if (!depsMap) {
      // 如果不存在,就创建一个新的 Map
      depsMap = new Map()
      // 将新的 Map 添加到 「桶」中
      bucket.set(target, depsMap);
    }
    // 根据当前 key 取出 副作用函数的集合,她是一个 Set
    let deps = depsMap.get(key);
    if (!deps) {
      // 如果不存在就创建一个新的 Set
      deps = new Set();
      // 将新的 Set 添加到 Map 中去
      depsMap.set(key, deps);
    }
    // 收集副作用函数
    deps.add(activeEffect);
    return target[key];
  },
  // 拦截 set
  set(target: any, key: string, value: any) {
    // 设置目标对象属性值
    target[key] = value;
    // 从「桶」中取出当前对象绑定的 Map
    const depsMap = bucket.get(target);
    if (depsMap) {
      // 根据 key 取出与之绑定的副作用函数集合
      const deps = depsMap.get(key);
      // 遍历并执行这些副作用函数
      deps && deps.forEach(fn => fn());
    }
    return true;
  }
});

// 该方法用来注册副作用函数,所有副作用函数都通过该方法注册
const effect = (effectFn: Function) => {
  activeEffect = effectFn;
  effectFn();
}

在上面的代码中,我们重新实现了 bucket 的数据结构,并重写了响应式系统,主要就是改变了 bucket 的数据结构,然后根据新的数据结构做了调整,如下图所示:

可以看到:

  • bucket 的数据结构为 WeakMap,她的键是 target,值为 depsMap。可以这样去表示:target -> depsMap

  • despMap 的数据结构为 Map,她的键是 key,即原始对象的属性名,值为 deps。可以这样去表示:key -> deps

  • deps 的数据结构为 Set,为副作用函数的集合

为了使上述代码更加灵活:

  • 我们将 收集(track) 副作用函数的逻辑和触发 (trigger) 副作用函数执行的逻辑抽离出来

  • 实现一个名为 reactive 的方法来代理原始数据

查看完整代码
// 全局变量,用来存储
let activeEffect: Function | null = null;

// 用来收集副作用对象的“桶”
const bucket: WeakMap<any, Map<string, Set<Function>>> = new WeakMap();

// 定义 track 方法收集副作用函数
const track = (target: any, key: string) => {
  // 当前没有正在执行的副作用函数,直接返回
  if (!activeEffect) return;
  // 取出目标对象指向的 Map
  let depsMap = bucket.get(target);
  if (!depsMap) {
    // 如果不存在,就创建一个新的 Map
    depsMap = new Map()
    // 将新的 Map 添加到 「桶」中
    bucket.set(target, depsMap);
  }
  // 根据当前 key 取出 副作用函数的集合,她是一个 Set
  let deps = depsMap.get(key);
  if (!deps) {
    // 如果不存在就创建一个新的 Set
    deps = new Set();
    // 将新的 Set 添加到 Map 中去
    depsMap.set(key, deps);
  }
  // 收集副作用函数
  deps.add(activeEffect);
}

// 定义trigger方法用来触发副作用函数的执行
const trigger = (target: any, key: string) => {
  // 从「桶」中取出当前对象绑定的 Map
  const depsMap = bucket.get(target);
  if (depsMap) {
    // 根据 key 取出与之绑定的副作用函数集合
    const effects = depsMap.get(key);
    // 遍历并执行这些副作用函数
    effects && effects.forEach(fn => fn());
  }
}

// 使用reactive方法来代理原始数据
const reactive = (data: any) => {
  return new Proxy(data, {
    // 拦截 get
    get(target: any, key: string) {
      // 调用track方法收集副作用函数
      track(target, key);
      return target[key];
    },
    // 拦截 set
    set(target: any, key: string, value: any) {
      // 设置目标对象属性值
      target[key] = value;
      // 调用trigger方法触发副作用函数的执行
      trigger(target, key);
      return true;
    }
  });
}

然后我们重新运行一下上面的例子:

查看完整代码
// 通过 reactive 来初始化数据
const obj = reactive({ a: 1 });

// 外部变量
let b = 0;

// 注册副作用函数
effect(
  () => {
    // 读取代理对象的属性a
    b = obj.a;
    console.log('b------>', b);
  }
);

// 打印如下信息
// b------>1

// 2 秒之后修改响应式数据 obj 的值
setTimeout(() => {
  obj.a = 2
}, 2000);

// 两秒之后打印如下
// b------>2

setTimeout(() => {
  obj.b = 2
}, 3000);

// 不会再打印任何东西了

至此我们就实现了一个相对完善的响应式系统,但是该系统在处理一些特殊情况的时候还是会存在问题,接下来我们就一个个来解决这些问题。

处理分支切换


我们先来注册一个含有分支切换的副作用函数:

const data = reactive({
  show: true,
  a: 1
})

const effect1 = () => {
  const b = data.show ? data.a : -1;
  console.log('------>', b);
}

effect(effect1);

在上面的副作用函数中含有一个三元表达式,副作用函数打印的值 b,是随着 data.show 的值来变化的。或者说,当data.show 的值发生变化的时候,代码的执行分支就会发生变化。这就是分支切换。上面的代码也可以等价于:

const data = reactive({
  show: true,
  a: 1
})

const effect1 = () => {
  let b = -1;
  if (data.show) {
    b = data.a
  }
  console.log('------>', b)
}

effect(effect1);

那么上面的代码有什么问题呢?我们先来逐个走一下每个分支:

  • 当 data.show = true

当 data.show = true 的时候,副作用函数会执行 const b = data.show ? data.a,那我们可以画出上述副作用函数与 data 的绑定关系图:

当绑定关系处于上图的时候,只要 data.show 和 data.a 发生变化,都会触发副作用函数 effect1 的执行。这样是没有任何问题的,但是一旦我们设置 data.show = false,副作用函数将不会再执行 data.a 这个分支了。但是副作用函数的绑定关系还是跟上图一样,没有发生任何变化,如果我们这时设置 a = 2,还是会触发副作用函数的执行。这显然是不合理的,正常的绑定关系应该如下图所示:

很显然,我们之前实现的响应式系统产生了遗留副作用函数,那我们怎么才能做到像上图那样呢?其实很简单,只需要在副作用函数执行之前,把她从所有与之关联的集合中移除即可:

当副作用函数执行完之后,会再次建立联系,但是将不再会有遗留副作用函数了。要想将副作用函数从与之关联的集合中移除,我们得知道哪些集合关联了该副作用函数。其实做到这一点也不难,我们知道函数是可以有自定义属性的,我们可以给副作用函数定义一个属性 deps,当副作用函数被收集的时候,我们将收集她的集合存储到 deps 中就可以实现副作用函数与集合之间的绑定了,如下图所示:

根据上图我们先定义一下新的副作用函数的类型:

type EffectFunction = {
  (): void,
  // 用来存储跟副作用函数关联的集合
  deps: Array<Set<EffectFunction>>
}

我们给副作用函数增加了一个自定义属性:deps,该属性为一个数组,因为可能会有多个集合都存储了该副作用函数。我们需要重新实现一下 effect:

查看完整代码
// 定义一个新的类型
type EffectFunction = {
  (): void,
  // 用来存储跟副作用函数关联的集合
  deps: Array<Set<EffectFunction>>
}

// 当前正在执行的副作用函数,类型为 EffectFunction | null
let activeEffect: EffectFunction | null = null;

// 收集副作用函数集合的类型也改成成 Set<EffectFunction>
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();

const cleanup = (effectFn: EffectFunction) => {
  // 将当前副作用函数从关联她的集合中移除
  effectFn.deps.forEach(item => item.delete(effectFn));
  // 重置数组
  effectFn.deps.length = 0;
}

const effect = (fn: Function) => {
  // 将传入的副作用函数再次封装
  const effectFn = () => {
    // 清除与该副作用函数关联的集合
    cleanup(effectFn);
    // 当前正在执行的副作用函数
    activeEffect = effectFn;
    // 执行传入的副作用函数,完成依赖收集
    fn();
  }
  // 初始的deps为空数组
  effectFn.deps = [] as Array<Set<EffectFunction>>;
  // 执行一次副作用函数
  effectFn();
}

在上面的代码中,每当副作用函数重新执行的时候,都会先调用 cleanup 方法将她从与之关联的集合中移除。除此之外,要想重新收集副作用函数,我们还需要修改 track 方法:

查看完整代码
// 定义 track 方法收集副作用函数
const track = (target: any, key: string) => {
  // 当前没有正在执行的副作用函数,直接返回
  if (!activeEffect) return;
  // 取出目标对象指向的 Map
  let depsMap = bucket.get(target);
  if (!depsMap) {
    // 如果不存在,就创建一个新的 Map
    depsMap = new Map()
    // 将新的 Map 添加到 「桶」中
    bucket.set(target, depsMap);
  }
  // 根据当前 key 取出 副作用函数的集合,她是一个 Set
  let deps = depsMap.get(key);
  if (!deps) {
    // 如果不存在就创建一个新的 Set
    deps = new Set();
    // 将新的 Set 添加到 Map 中去
    depsMap.set(key, deps);
  }
  // 将收集副作用函数的集合添加到数组中
  activeEffect.deps.push(deps);
  // 收集副作用函数
  deps.add(activeEffect);
}

那么我们就解决了遗留副作用函数的问题了。但是如果你运行上面的代码,就会出现死循环:

const trigger = (target: any, key: string) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effects = depsMap.get(key);
    effects && effects.forEach(fn => fn());
  }
}

在上面的 trigger 方法中,我们最后会遍历 Set,然后将一个一个的副作用函数取出来执行。也就是执行如下显示高亮的代码:

const effectFn = () => {
  // 清除与该副作用函数关联的集合
  cleanup(effectFn);
  // 当前正在执行的副作用函数
  activeEffect = effectFn;
  // 执行传入的副作用函数,完成依赖收集
  fn();
}

我们分析一下上述代码的执行:

  • 副作用函数被执行前,先调用 cleanup。也就是说,将 fn 从 effects 中移除

  • 执行 fn(),这里又会将 fn 重新收集到 effects 中

由于 effects.forEach 还在执行中,上述两步操作就导致了死循环。要解决也非常简单,我们只需要再用一个 Set 重新构造一下就可以了:

const trigger = (target: any, key: string) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effects = depsMap.get(key);
    // 构造一个新的 Set
    const effectsToRun = new Set(effects)
    // effects && effects.forEach(fn => fn());
    effectsToRun.forEach(fn => fn());
  }
}

通过上面改造之后的完整代码如下:

查看完整代码
// 定义一个新的类型
type EffectFunction = {
  (): void,
  // 用来存储跟副作用函数关联的集合
  deps: Array<Set<EffectFunction>>
}

// 当前正在执行的副作用函数,类型为 EffectFunction | null
let activeEffect: EffectFunction | null = null;

// 收集副作用函数集合的类型也改成成 Set<EffectFunction>
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();

const cleanup = (effectFn: EffectFunction) => {
  // 将当前副作用函数从关联她的集合中移除
  effectFn.deps.forEach(item => item.delete(effectFn));
  // 重置数组
  effectFn.deps.length = 0;
}

const effect = (fn: Function) => {
  // 将传入的副作用函数再次封装
  const effectFn = () => {
    // 清除与该副作用函数关联的集合
    cleanup(effectFn);
    // 当前正在执行的副作用函数
    activeEffect = effectFn;
    // 执行传入的副作用函数,完成依赖收集
    fn();
  }
  // 初始的deps为空数组
  effectFn.deps = [] as Array<Set<EffectFunction>>;
  // 执行一次副作用函数
  effectFn();
}

// 定义 track 方法收集副作用函数
const track = (target: any, key: string) => {
  // 当前没有正在执行的副作用函数,直接返回
  if (!activeEffect) return;
  // 取出目标对象指向的 Map
  let depsMap = bucket.get(target);
  if (!depsMap) {
    // 如果不存在,就创建一个新的 Map
    depsMap = new Map()
    // 将新的 Map 添加到 「桶」中
    bucket.set(target, depsMap);
  }
  // 根据当前 key 取出 副作用函数的集合,她是一个 Set
  let deps = depsMap.get(key);
  if (!deps) {
    // 如果不存在就创建一个新的 Set
    deps = new Set();
    // 将新的 Set 添加到 Map 中去
    depsMap.set(key, deps);
  }
  // 将收集副作用函数的集合添加到数组中
  activeEffect.deps.push(deps);
  // 收集副作用函数
  deps.add(activeEffect);
}

const trigger = (target: any, key: string) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effects = depsMap.get(key);
    // 构造一个新的 Set
    const effectsToRun = new Set(effects)
    // effects && effects.forEach(fn => fn());
    effectsToRun.forEach(fn => fn());
  }
}

// 使用reactive方法来代理原始数据
const reactive = (data: any) => {
  return new Proxy(data, {
    // 拦截 get
    get(target: any, key: string) {
      // 调用track方法收集副作用函数
      track(target, key);
      return target[key];
    },
    // 拦截 set
    set(target: any, key: string, value: any) {
      // 设置目标对象属性值
      target[key] = value;
      // 调用trigger方法触发副作用函数的执行
      trigger(target, key);
      return true;
    }
  });
}

这样我们就解决了死循环的问题了。

副作用函数嵌套


在 Vue 中,我们通常会将一个一个的组件进行相互组合,比如:

<template>
  <Foo>
    <Bar />
  </Foo>
</template>

我们在渲染组件的时候,实际上就是调用 render 方法进行渲染。而 Vue 的 render 方法就是在 effect 中执行的,比如上面的代码我们可以简单理解为:

effect(() => {
  Foo.render();
  effect(() => {
    Bar.render();
  });
});

也就是说,我们实现的 effect 方法要支持嵌套,但是,我们当前的 effect 是不支持的。先来看一个简单的例子:

const data = reactive({ a: 1, b: 2 });

let x, y;

const effect2 = () => {
  console.log('run effect2')
  y = data.b;
};

const effect1 = () => {
  console.log('run effect1');
  effect(effect2)
  x = data.a;
}

effect(effect1);

可以看到,effect1 嵌套了 effect2 理想情况下,我们希望 data 与副作用函数之间的关联情况是这样的:

也就是说,理想情况下:

  • data.a 发生改变会执行 effect1,进而间接执行 effect2

  • data.b 发生改变只会执行 effect2

上面说的是理想情况,而实际情况呢?初始情况会打印如下信息:

run effect1
run effect2

上面的打印没有任何问题,当我们设置 a = 2 的时候,会打印如下信息:

run effect2

说明 effect2 执行了而 effect1 没有被执行,也就是说 data.a 绑定的副作用函数是 effect2,通过分析 effect 方法的代码,我们其实可以发现下面显示高亮的这行有问题:

const effect = (fn: Function) => {
  const effectFn = () => {
    cleanup(effectFn);
    // 当前正在执行的副作用函数
    activeEffect = effectFn;
    fn();
  }
  effectFn.deps = [] as Array<Set<EffectFunction>>;
  effectFn();
}

我们简单介绍一下上面嵌套副作用函数的执行情况:

  • 使用一个全局变量 activeEffect 来存储当前正在执行的副作用函数

  • 当 effect1 执行的时候,会触发 effect2 的执行

  • effect2 正在执行,这时会修改 activeEffect = effect2

  • effect2 执行完毕,继续执行 effect1 中的 x = data.a

  • 这时会进行副作用函数的收集:deps.add(activeEffect),而此时 activeEffect = effect2

这样 data.a 就与 effect2 相关联了。产生这样的原因就是我们用这种方式会导致同一时刻 activeEffect 只能存储一个副作用函数,要解决这样的问题也很简单,我们引入一个数组就可以了:

查看完整代码
type EffectFunction = {
  (): void,
  deps: Array<Set<EffectFunction>>
}

let activeEffect: EffectFunction | null = null;
// 引入了一个数组,副作用函数栈,用来存储当前在执行中的副作用函数
const effectStack: Array<EffectFunction> = [];
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();

export const effect = (fn: Function) => {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    // 将当前副作用函数压入栈中
    effectStack.push(effectFn);
    fn();
    // 副作用函数执行完之后再将其从栈中弹出
    effectStack.pop();
    // activeEffect 始终指向栈顶
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = [] as Array<Set<EffectFunction>>;
  effectFn();
}

在上面的代码中我们又引入了一个全局变量,她是一个数组,表示副作用函数栈,当副作用执行的时候,将该副作用压入栈中,当副作用执行完毕后,再将其从栈中弹出。然后再让 activeEffect 指向栈顶。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。上面代码运行过程中副作用函数栈的变化如下图所示:

无限递归


在日常编码中,我们写出下面的代码是很常见的:

const data = reactive({ a: 1, b: 2 })

const effect1 = () => {
  data.a++;
  console.log('---->', data.a)
};

effect(effect1);

可以发现,我们注册了一个副作用函数 effect1,在该副作用函数中执行了一个自增的操作,但是当我们执行上面代码的时候会发生如下错误:

index.ts:62 Uncaught (in promise) RangeError: Maximum call stack size exceeded

这是因为什么呢?我们可以先将上面的代码写成这样:

const data = reactive({ a: 1, b: 2 })

const effect1 = () => {
  data.a = data.a + 1
  console.log('---->', data.a)
};

effect(effect1);

这两段代码是等价的,接下来我们分析一下执行这段代码的时候发生了什么:

  • data.a 执行的时候会触发 track 方法的执行,会将 effect1 收集到 bucket 中

  • data.a + 1 执行的时候会触发 trigger 方法的执行,这时又会从 bucket 中取出 effect1 执行

  • 此时 effect1 还在执行中

这样就会导致 effect1 在执行的过程中一直在递归地调用自己,这样就会陷入无限递归,最终导致栈溢出。要解决这个问题我们只需要在 trigger 方法触发副作用函数执行的时候加一个判断就行了:

const trigger = (target: any, key: string) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effects = depsMap.get(key);
    if (effects) {
      const effectsToRun: Set<EffectFunction> = new Set();
      effects.forEach(fn => {
        // 增加判断
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
      effectsToRun.forEach(fn => fn());
    }
  }
};

如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。这样我们就能无限递归,从而避免栈溢出的情况发生了。

最后


虽然我们写了很多,但是一个响应式系统的核心流程就如上图所示:

  • 当某个对象被副作用函数(effect)读取(get)的时候,收集(track)该副作用函数

  • 当某个对象被改变(set)的时候,触发(trigger)副作用函数的执行

我们整个响应式系统就是围绕上述的核心流程来进行设计的。当然, Vue 的响应式系统是非常复杂的,本文只是介绍了冰山一角,在后面的文章中我们将逐渐深入,一步一步实现一个更为完善的响应式系统。本文响应式系统的完整代码如下:

最终版响应式系统
type EffectFunction = {
  (): void,
  deps: Array<Set<EffectFunction>>
}

let activeEffect: EffectFunction | null = null;
const effectStack: Array<EffectFunction> = [];
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();

export const reactive = (obj: any) => {
  return new Proxy(obj, {
    get(target: any, key: string) {
      track(target, key);
      return target[key];
    },
    set(target: any, key: string, value: any) {
      target[key] = value;
      trigger(target, key);
      return true;
    }
  });
};

const track = (target: any, key: string) => {
  if (!activeEffect) {
    return;
  }
  let depsMap = bucket.get(target);
  if (!depsMap) {
    depsMap = new Map();
    bucket.set(target, depsMap);
  }
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
  deps.add(activeEffect);
  activeEffect.deps.push(deps);
};

const trigger = (target: any, key: string) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effects = depsMap.get(key);
    if (effects) {
      const effectsToRun: Set<EffectFunction> = new Set();
      effects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
      effectsToRun.forEach(fn => fn());
    }
  }
};

const cleanup = (fn: EffectFunction) => {
  fn.deps.forEach(item => item.delete(fn));
  fn.deps.length = 0;
}

export const effect = (fn: Function) => {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = [] as Array<Set<EffectFunction>>;
  effectFn();
}