【源码瘦身】Vue3 - 响应式 - 基础

996 阅读15分钟

项目采用迭代式方法循序渐进地添加内容,每篇文章都会对应一个主题,比如这篇文章就会添加响应式相关的基础内容。因此,将会产生许多版本的代码,我们使用 Git 来管理这些版本。

这里是我编写的已测试的 示例代码,你可以克隆仓库,切换到对应分支,在需要参考时使用。

准备工作

本项目尽量保证目录结构、模块划分与 Vue3 一致

创建并切换至分支 reactivity_base

删除起初创建的 index.tsindex.spec.ts,定义目录结构如下:

corex-vue3
└── packages
    ├── reactivity
    │   ├── __tests__
    │   └── src
    └── shared
        └── src

简单介绍一下各个目录的意义:

  • packagesVue3 采用 monorepo 的管理模式 对响应式等多个模块进行统一的依赖管理,它们都位于 packages 目录下,是项目的主要内容。
    • 本项目为了简单,不使用 monorepo,直接在一个项目中进行开发。
  • reactivity:包含响应式相关内容,如 reactive、ref 等。
  • shared:包含各模块通用的常量、函数,如 isObject(val) 等。
  • */src:包含源码。
  • */__tests__:包含测试代码。

基本思想

什么是响应式?在 Vue3 中,响应式是针对变量而言的,只要为变量赋予响应式,使用响应式变量的函数就会在响应式变量变化时自动执行。考虑如下场景:

// 假设 a 为响应式变量
let a = 1
// b 依赖 a
let b = 0;
function setB() { b = a - 1 }
// 自动执行 setB()
a = 2
// 希望得到 b = 1
console.log(b)

响应式渲染的原理也是一样:

// 假设 msg 为响应式变量
let msg = ''
// 渲染依赖 msg
let innerHTML = '<div></div>'
function render() { innerHTML = `<div>${msg}</div>` }
// 自动执行 render()
msg = 'hello'
// 希望渲染出 <div>hello</div>
console.log(innerHTML)

Vue3 实现响应式系统的基本思想非常简单:在函数 fn 执行时,如果使用了响应式变量 var,则记录 var=>fn 的依赖关系;在 var 更新时,根据依赖关系找到所有依赖 var 的 fn 自动执行。

编写测试

项目采用 TDD 的方式开发,在编写源码之前,我们必须明确所需功能、编写测试

在 Vue3 中,我们常用的响应式 API 及其主要功能如下:

  • reactive(target):赋予对象响应式。
    • 赋予嵌套对象响应式。
  • ref(value):赋予原始类型响应式,通过 ref.value 使用。
  • computed(getter):赋予计算属性响应式,通过 computed.value 使用。
    • 惰性更新:计算属性依赖的响应式变量更新时,计算属性并不会立即更新,而是在下次被使用时更新。

除此之外,还有一个隐性功能 effect(fn):它相当于一个“我要 fn 被响应式”的声明,只有通过 effect 执行的函数 fn 才会随着其依赖的响应式变量的变化而自动执行,而其它我们不关心的函数即使用了响应式变量,也不会为我们的响应式系统增加额外负担。

由于 Vue3 类型系统较为复杂,本项目中不会严格定义类型,仅为了加强代码提示

在 Vue3 中,这四个 API 恰好分属不同模块,为它们分别创建源文件、定义导出。

// reactivity/src/reactive.ts
export function reactive(target): any {}
// reactivity/src/ref.ts
export function ref(value): any {}
// reactivity/src/computed.ts
export function computed(getter): any {}
// reactivity/src/effect.ts
export function effect(fn): any {}

本章之后,测试代码不会贴出,你可以拷贝示例代码中的测试,也可以自行编写

根据以上功能定义,我们可以编写单元测试,这里以 reactive 为例。

在此再度强调本项目“源码瘦身”的核心理念,我们的测试包括后续的功能实现,只会包含主要的、最小可用的场景。因此,在编写测试时只需要囊括我们定义的主要功能,切勿面面俱到。

// reactivity/__tests__/reactive.spec.ts
import { effect } from '../src/effect';
import { reactive } from '../src/reactive';

// 对 reactive 模块的测试
describe('reactive', () => {
  // 测试一:赋予对象响应式
  test('shallow object', () => {
    let original = { a: 1 };
    let observed = reactive(original);
    // 响应式变量并非直接替换原变量
    expect(observed).not.toBe(original);
    // 响应式
    let b = 0;
    function setB() {
      b = observed.a - 1;
    }
    // 声明 setB 应被响应式
    effect(setB);
    // 更新 a,希望 setB 自动执行更新 b
    observed.a = 2;
    expect(b).toBe(1);
  });
  // 测试二:赋予嵌套对象响应式
  test('nested object', () => {
    let original = {
      obj: {
        a: 1
      }
    };
    let observed = reactive(original);
    // 嵌套变量也会被转为响应式变量
    expect(observed.obj).not.toBe(original.obj);
    // 嵌套变量响应式
    let b = 0;
    function setB() {
      b = observed.obj.a - 1;
    }
    effect(setB);
    observed.obj.a = 2;
    expect(b).toBe(1);
  });
});

编写源码

原理详解

在 Vue3 的模块划分中,effect 模块负责主要的响应式系统构建,其它模块都是与响应式系统的对接及使用。因此,我们第一步就要来实现 effect.ts

现在,回忆一下响应式的基本思想。假设有响应式对象 obj = { a: 1 },函数 fn1、fn2 都依赖 obj.a,那么我们就需要在某个地方保存 obj.a => fn1 和 obj.a => fn2 这两个依赖关系。首先,对于这种“映射关系”,很容易想到通过 Map 来实现,令 key 为 obj.a、value 为 [fn1, fn2] 即可;其次,如何定位 obj.a?我们可以使用两层 Map,第一层 targetMap 负责定位对象 obj,第二层 depsMap 负责定位属性 a;最后,如果 fn1 === fn2,我们不能够保存 [fn1, fn1],因为一次 obj.a 的更新只应该引起一次 fn1 的自动执行,所以我们不用数组而用 Set 来保存这些被响应式的函数。

// 映射结构大致如下
targetMap: {
  [obj1]: {
    [attr1]: Set(fn1, fn2),
    [attr2]: Set(fn4)
  },
  [obj2]: {
    [attr3]: Set(fn1, fn5)
  }
}

为了便于理解这个较为复杂的响应式系统,我们还需要定义一些词汇:

  • 依赖集合(Dep):Vue3 中的命名,等价于上文存储 fn 的 Set。
  • RE(ReactiveEffect):表示我们声明需要被响应式的函数。在 Dep 中不会直接保存 fn,而是保存 fn 包装后得到的 RE。
  • 当前 RE(activeEffect):表示正在执行的 RE。可能是 undefined,代表是普通的 fn 而非被响应式的 RE 在执行。
  • 收集(track):表示将当前 RE 保存到对应的 Dep 中。
  • 触发(trigger):表示自动执行对应的 Dep 中的全部 RE。

effect(fn)

主要的实现逻辑参考代码和注释,代码段前的文字将会解释一些较难说明的问题

首先,定义全局变量 targetMap。

这里还有一个小技巧,targetMap 使用 WeakMap 而非 Map 实现,出于两点考虑:首先,WeakMap 的键值恰好只能是对象,与我们第一层定位对象的需求一致;其次,如果对象 obj 是响应式变量,而现在因我们的业务代码而被清除了,如果使用的是 Map,则 Map 会继续引用 obj,导致它无法真正被清除,而我们已经永远不会再使用 obj 了,这就产生了内存泄露,而 WeakMap 就不存在这样的问题。

// reactivity/src/effect.ts
const targetMap = new WeakMap();

下一步,实现 RE 类:

先再定义两个全局变量 activeEffect 和 shouldTrack。在 Vue3 中,需要处理很多不应该收集的情况,因此需要定义一个全局变量 shouldTrack 来灵活控制。在我们的瘦身版本中,只有像上文提到的 fn1 === fn2,即重复收集同一个 RE 时才会用到。

此外,依赖关系其实不止 obj.a => fn,从 fn => obj.a 的映射也应该被保存,这里我们就在 RE 中定义了一个 deps,用于保存所有包含此 RE 的 Dep(不过暂时不会使用)。

// reactivity/src/effect.ts
export type EffectScheduler = (...args: any[]) => any;

export let activeEffect: ReactiveEffect | undefined;
export let shouldTrack = true;

export class ReactiveEffect {
  deps: Set<ReactiveEffect>[] = [];
  // scheduler 允许用户更灵活地定义 RE 被触发时的执行逻辑
  // 在 computed 中,我们将初次使用到它
  constructor(public fn, public scheduler: EffectScheduler | null = null) {}
  // 执行 fn 并收集 RE
  run() {
    try {
      // 声明自己应被收集
      activeEffect = this;
      shouldTrack = true;
      return this.fn();
    } finally {
      // 记得还原
      activeEffect = undefined;
      shouldTrack = false;
    }
  }
}

下一步,实现收集:

effect.ts 中,我们只实现收集和触发的功能,并将它们作为响应式系统的接口提供给外部,至于它们应该何时被调用是其它模块的事。

// reactivity/src/effect.ts
export function track(target, key) {
  // 如果有 RE 正在运行,才会收集
  // 否则说明使用响应式变量的函数无需响应式
  if (shouldTrack && activeEffect) {
    // 根据 target 和 key 定位 Dep
    // 如果不存在则新建
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    // 向 Dep中收集当前 RE
    trackEffects(dep);
  }
}

export function trackEffects(dep) {
  // v! 会向 TS 保证 v 不为 undefined/null
  // 如果已经收集过当前 RE,无需重复收集
  shouldTrack = !dep.has(activeEffect!);
  if (shouldTrack) {
    dep.add(activeEffect!);
    activeEffect!.deps.push(dep);
  }
}

下一步,实现触发:

这里我们将会用到一个判断是否是数组的工具函数,像这种与我们的核心逻辑无关,随便放到哪个电商项目、后台管理项目中都能复用的函数,我们就把它放在 packages/shared/src/index.ts 中。

// shared/src/index.ts
export const isArray = Array.isArray;
// reactivity/src/effect.ts
import { isArray } from '../../shared/src';

// 根据 target 和 key 定位 Dep并触发
export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  const dep = depsMap.get(key);
  triggerEffects(dep);
}

export function triggerEffects(dep) {
  // Vue3 原句:spread into array for stabilization
  const effects: ReactiveEffect[] = isArray(dep) ? dep : [...dep];
  // 触发所有 RE
  for (const effect of effects) {
    // 若定义了调度器则使用,而非直接执行
    if (effect.scheduler) {
      effect.scheduler();
    } else {
      effect.run();
    }
  }
}

最后,实现 effect(fn):

我们一开始定义功能的时候并没有考虑到 options.lazy 参数,但在实现中感觉有必要,所以加了上来,这并不会违反 TDD 的原则,只要补充好单元测试就行。

// reactivity/src/effect.ts
export function effect(fn, options?): any {
  // 将 fn 包装成 RE
  const _effect = new ReactiveEffect(fn);
  // 如果设置 options.lazy = true 则不立即执行 RE
  if (!options || !options.lazy) {
    _effect.run();
  }
  // 返回一个能够执行 RE 的函数,同时保留 RE 的引用
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner;
  runner.effect = _effect;
  return runner;
}

至此,我们已经完成了 effect(fn, options)。可惜的是,我们只是构建好了响应式系统,提供了收集和触发的 API,还没有真正调用它们,因此也无法进行测试。理论上来说,对 track/trigger 等功能也需要做单元测试,但我们一开始并不知道会有这些东西,只好偷懒等到完成 reactive 之后再进行测试了。

reactive(target)

reactive/ref/computed 等需要考虑的问题都是如何在合适的时机收集和触发依赖。

对于 reactive(target) 来说,它的目标是一个对象,我们应该在对象属性被使用时收集,在对象属性被更新时触发。恰好,ES6 为我们提供了 Proxy API,它允许我们基于原始对象创建一个“代理对象”。我们可以像使用原始对象一样访问代理对象的属性,而对属性的读取和修改会被创建时传入的第二个参数 handler 的 get/set 方法拦截。既然可以拦截读取和修改,那么我们就能在其中调用收集和触发,完成响应式的赋予。

在本文中,我们只考虑普通对象、只考虑 get & set,而不关心 Array, Map, Set、不关心 push, pop 等方法,因此 reactive.ts 的实现其实并不复杂。唯一需要注意的一点就是 Reflect.get/set 的使用,当对象的原型是一个代理对象时,使用 target[key] 取值可能会导致 一些问题

// shared/src/index.ts
export const isObject = (val: unknown) =>
  val !== null && typeof val === 'object';
export const hasChanged = (value: any, oldValue: any): boolean =>
  !Object.is(value, oldValue);
// reactivity/src/reactive.ts
import { hasChanged, isObject } from '../../shared/src';
import { track, trigger } from './effect';

const reactiveMap = new WeakMap();

export function reactive<T extends object>(target: T): T {
  // 一个对象只有一个代理对象
  // 如果已经创建,可以直接返回缓存中的代理对象
  const existingProxy = reactiveMap.get(target);
  if (existingProxy) {
    return existingProxy;
  }
  // 创建代理对象
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      // 如果不使用 Reflect.get,在取原型链属性
      // 即 target !== receiver 时可能出现 BUG
      const res = Reflect.get(target, key, receiver);
      // 使用对象属性,收集依赖
      track(target, key);
      // 如果对象属性还是对象,也要赋予其响应式
      if (isObject(res)) return reactive(res);
      return res;
    },
    set(target, key, value, receiver) {
      // 存储旧值
      let oldValue = target[key];
      // 和使用 Reflect.get 原因相同
      const result = Reflect.set(target, key, value, receiver);
      // 只有当值真正发生了变化才会触发依赖其的函数
      if (hasChanged(value, oldValue)) {
        trigger(target, key);
      }
      return result;
    }
  });
  // 缓存代理对象
  reactiveMap.set(target, proxy);
  return proxy;
}

进行测试

完成了 reactive(target),我们终于拥有了第一个可用的响应式功能,是时候进行测试了!你可以直接执行 pnpm test,但显然它会给你带来大段飘红的 failed,因为 ref 和 computed 还没有完成。

这里建议使用 VSCode,安装 Jest Runner 插件,安装完成后打开 reactive.spce.ts,你会发现 describe(...) 上方出现了 Run|Debug 的字样,点击 Run,即可单独运行这一模块的测试。

如果你乖乖地只使用 reactive 来编写 effect 的测试,那么它在这个阶段也已经可以测试,否则……建议重写吧!不然之后再发现 effect 存在问题可有罪受的。如果测试全部通过,我们可以认为实现代码无误;否则请对照 示例 检查是测试还是实现的问题,直到测试通过为止。

ref(value)

对于 ref(value) 来说,它的目标是原始类型值,无法通过 Proxy 拦截读写,这样一来就无法适时地进行触发和收集,怎么办呢?考虑到 ref.value 的使用方式,我们很容易想到可以把原始类型值包装成只有一个 value 属性的简单对象,这样就和之前的响应式系统接轨了。

但是,我们并不会直接复用 reactive({ value: primitiveValue }),主要是因为:我们只是为了给原始类型提供 get/set 的拦截,才将其包装为对象,并不希望它真的被当成对象操作,比如在 value 之外添加别的属性等。此外,针对原始类型单独实现,逻辑较为简单,专人专车效率也更高。

本文中实现的 ref(value) 只允许传入原始类型值,不支持传入对象,也不支持传入响应式对象。

// reactivity/src/ref.ts
import { hasChanged } from '../../shared/src';
import {
  activeEffect,
  ReactiveEffect,
  shouldTrack,
  trackEffects,
  triggerEffects
} from './effect';

export interface Ref<T = any> {
  value: T;
}

type RefBase<T> = {
  dep?: Set<ReactiveEffect>;
  value: T;
};

// Ref 实现类
class RefImpl<T> {
  // 用 _value 保存原始类型值
  private _value: T;
  // 无需 targetMap 两层定位,自己持有 Dep
  public dep?: Set<ReactiveEffect> = undefined;

  constructor(value: T) {
    this._value = value;
  }
  // 没有 target&key,所以不复用 track,而是自己实现
  get value() {
    trackRefValue(this);
    return this._value;
  }
  // 没有 target&key,所以不复用 trigger
  set value(newVal) {
    // 只有当值真正改变才会触发
    if (hasChanged(newVal, this._value)) {
      this._value = newVal;
      triggerRefValue(this);
    }
  }
}

// 如果符合收集条件,使用自己的 Dep 进行收集
export function trackRefValue(ref: RefBase<any>) {
  if (shouldTrack && activeEffect) {
    // 第一次收集的时候才会创建 Dep
    // 如果初始化时就创建可能导致过多的内存使用
    trackEffects(ref.dep || (ref.dep = new Set()));
  }
}
// 如果有 Dep,则将其触发
export function triggerRefValue(ref: RefBase<any>) {
  if (ref.dep) {
    triggerEffects(ref.dep);
  }
}

// ref 接口就是简单地返回一个 Ref 对象
export function ref<T>(value: T): Ref<T> {
  return new RefImpl(value);
}

至此,ref(value) 实现完毕,可以进行测试。

computed(getter)

对于 computed(getter) 来说,它的目标是计算属性,也就是一个返回值依赖其它响应式变量的函数。大部分情况下,计算属性也是一个原始类型值,因此我们对它做和 ref 类似的处理。需要注意的就是懒更新的实现,传入调度器的 RE 在被触发时执行 scheduler() 而非 run(),详见 triggerEffects 的实现。

本文中实现的 computed(getter) 只考虑返回原始类型值的 getter,不考虑 setter。

// reactivity/src/computed.ts
import { ReactiveEffect } from './effect';
import { trackRefValue, triggerRefValue } from './ref';

export interface ComputedRef<T = any> {
  readonly value: T;
}
export type ComputedGetter<T> = (...args: any[]) => T;

// Computed 实现类
export class ComputedRefImpl<T> {
  // 保存计算属性
  private _value!: T;
  // 自己持有 Dep
  public dep?: Set<ReactiveEffect> = undefined;
  // getter 将被包装为 RE
  public readonly effect: ReactiveEffect;
  // 脏位:标记计算属性是否应该更新
  public _dirty = true;

  constructor(getter: ComputedGetter<T>) {
    this.effect = new ReactiveEffect(getter, () => {
      // 在被触发时(计算属性依赖的响应式变量更新时)
      // 不会立即执行 getter 更新 _value,只是设置脏位
      if (!this._dirty) {
        this._dirty = true;
        // 当然,还是要进行触发,执行依赖计算属性的函数
        triggerRefValue(this);
      }
    });
  }

  get value() {
    // ComputedRef 与 Ref 相同,都持有 value 和 Dep
    // 因此 track/trigger 可以复用
    trackRefValue(this);
    // 只有在读取计算属性时才会执行 getter 更新 value
    if (this._dirty) {
      this._value = this.effect.run();
      // _value 已是最新,还原脏位
      this._dirty = false;
    }
    return this._value;
  }
  /* 目前不支持 set */
}

// computed 接口就是简单返回一个 ComputedRef 对象
export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T> {
  return new ComputedRefImpl(getter);
}

至此,computed(getter) 实现完毕,可以进行测试。但在编写测试时,我们会碰到这样一个问题:如何验证 computed 的懒更新特性?如果想要验证计算属性的值没有变化,必须读取 computed.value,但这样又会触发 getter 执行……我们可以换个思路,计算属性值没有变化,等价于 getter 没有执行,那么我们只要观察 getter 执行次数,就可以在不读取 computed.value 的基础上进行验证了。

在不知道如何编写测试时,可以参考 Vue3 对应测试的写法

// reactivity/__tests__/computed.spec.ts
test('computed lazy update', () => {
    let a = ref(1);
    const getter = jest.fn(() => a.value - 1);
    const b = computed(getter);
    a.value = 2;
    // 未使用 b,getter 未被调用
    expect(getter).toHaveBeenCalledTimes(0);
    // 使用 b,getter 被调用,b.value 更新
    expect(b.value).toBe(1);
    expect(getter).toHaveBeenCalledTimes(1);
  });

总结

这一章节,我们尝试了 TDD 的开发过程、了解了 Vue3 响应式系统的基本原理,实现了 effect(fn, options)reactive(target)ref(value)computed(getter) 等基础功能。