实现mini-vue -- reactivity模块(一)实现简易版reactivity

680 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

本专栏会讲述如何实现一个mini-vue,让你了解vue的底层原理,如果直接阅读vue源码,那会是一件非常头疼的事情,因为许多的代码是用于处理一些边界情况的,这就导致我们很难找到核心内容,而mini-vue实现了vue的核心功能,忽略边界条件的判断,旨在让我们能够抓住核心,了解vue的底层原理,并通过TDD的思想进行开发,让你感受到TDD带来的好处!

本节是该专栏的第一节,reactivity是实现mini-vue的基本,后续功能会依赖于reactivity,因此我们从reactivity开始,阅读本节之前,请确保你已经使用过vuereactivity相关功能,了解它的作用,本节不会介绍reactivity是什么,而是注重它的运行流程和实现原理

reactivity模块会分为几篇文章去讲解,本篇文章是reactivity模块的第一篇,主要讲解如何实现基本的reactiveeffect

reactive用于创建响应式对象

effect用于包裹副作用函数,收集响应式对象和副作用函数之间的依赖关系以及触发依赖

1. 项目搭建

首先需要创建我们的项目,需要用到的依赖有jestbabeltypescript

  1. 安装typescript
pnpm i typescript -D
npx tsc --init

修改tsconfig.json,将noImplicitAnyfalse,因为我们主要关注的是原理实现,而不关注类型,但又希望用到typescript的一些特性,所以要允许项目中使用any

  1. 安装jest
pnpm i jest @types/jest -D
  1. jest集成babel
pnpm i babel-jest @babel/core @babel/preset-env -D

创建babel.config.js

module.exports = {
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};
  1. jest集成typescript
pnpm i @babel/preset-typescript -D

修改babel.config.js

module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript',
  ],
};
  1. 修改package.json添加测试脚本
"scripts": {
  "test": "jest"
},

创建项目源码目录src/reactivity,并写一个简单的测试用例看看环境是否搭建成功,src/reactivity/tests/index.spec.ts

describe('index', () => {
  it('happy path', () => {
    console.log('hello reactivity');
  });
});

终端执行pnpm test,如果能够通过测试说明环境搭建完成


2. 实现简易版reactivity

首先我们实现一个简易版的reactivity,这也就意味着不考虑很多额外的功能,只考虑先把最基本的功能实现,那么reactivity最基本的功能有什么呢? 主要有两个模块:

  1. reactive,用于创建响应式对象,通过Proxy实现
  2. effect,管理副作用函数,最基本的功能包括依赖收集和触发依赖

2.1 effect测试用例

基于TDD(Test-Driven Development)的思想,我们先写一个简单的测试用例描述一下使用场景

// src/reactivity/tests/effect.spec.ts
describe('effect', () => {
  // it 和 test 是一样的
  // it.skip 表示暂时跳过该测试项 因为目前需要 reactive 和 effect 而我们希望先去实现 reactive
  // 但又不希望 effect.spec.ts 影响整个测试的进行 因此可以用 skip 暂时跳过 等 reactive 实现后再改回来
  it.skip('happy path', () => {
    const foo = reactive({
      name: 'foo',
      age: 20,
      isMale: true,
      friends: ['Mike', 'Tom', 'Bob'],
      info: {
        address: 'China',
        phone: 11011011000,
      },
    });

    let nextAge;
    effect(() => (nextAge = foo.age + 1));

    expect(nextAge).toBe(21);

    // update
    foo.age++;
    expect(nextAge).toBe(22);
  });
}

我们的需求很简单,就是利用reactive创建一个响应式对象,然后effect函数中会执行副作用函数fn,当fn所依赖的响应式对象的数据修改后,能够自动执行副作用函数fn去更新依赖

由于reactiveeffect函数目前都还没有实现,这个单元测试自然是无法通过的,而它们又是两个大模块,因此实现这两个模块也是有它们对应的单元测试的,可是目前这个happy path的单元测试会妨碍我们之后编写具体某一个模块的单元测试的运行

比如我想先实现reactive,那么我就需要先编写相应的单元测试,然后去运行单元测试,但是由于effect模块还没实现,因此会被happy path的这个单元测试干扰,导致无法通过所有测试用例,因此我们可以先将其标记为skip,等我们实现完了两个模块的基本功能后再回来将标记删除,来测试happy path是否可以通过

下面来理一下这个测试用例的流程

2.2 reactive测试用例

我们先来实现reactive模块,仍然是先创建测试用例,根据测试用例去开发代码,这是TDD的核心思想

describe('reactive', () => {
  it('happy path', () => {
    const foo = { bar: 1 };
    const observed = reactive(foo);

    // observed 代理 foo 对象
    expect(observed).not.toBe(foo);
    expect(observed.bar).toBe(1);
  });
});

接下来我们就需要去实现reacive函数


2.3 reactive基本实现

// src/reactivity/reactive.ts
export function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const res = Reflect.get(target, key);

      // TODO 依赖收集
      track(target, key);

      return res;
    },
    set(target, key, value) {
      const res = Reflect.set(target, key, value);

      // TODO 触发依赖
      trigger(target, key);

      return res;
    },
  });
}

就是返回了一个Proxy,代理传入的对象,并且getset也是基本的功能,没有做过多额外的处理

不过之后为了管理依赖,会在get中调用effect模块的track进行依赖收集,在set中调用effect模块的trigger触发依赖(目前还没实现,后面会讲),我们先看看能不能通过测试用例吧 image.png 测试用例通过,说明reactive实现基本的代理功能是没问题了,那么接下来我们就要开始处理依赖的问题了!


2.4 effect基本实现

考虑到effect既要负责执行副作用函数,又要管理依赖,有多个功能,因此适合将他们封装到一个类中,我们首先封装一个ReactiveEffect

// src/reactivity/effect.ts
class ReactiveEffect {
  private _fn: any;

  constructor(fn) {
    this._fn = fn;
  }

  run() {
    this._fn();
  }
}

export function effect(fn) {
  const _effect = new ReactiveEffect(fn);

  _effect.run();
}

只要调用effect函数,就会创建ReactiveEffect对象,并执行它的run方法,这样我们就将运行副作用函数的逻辑从effect中转移到了ReactiveEffectrun方法中了

接下来要编写track函数,用于收集依赖,trigger函数,用于触发依赖

2.4.1 track依赖收集

根据前面的流程图,track就是一个映射寻找的过程,首先是以依赖的对象作为key去寻找它的属性和副作用函数之间的映射,找到这个映射后,再以依赖对象的属性作为key去寻找副作用函数的集合,将当前激活的effect对象加入到该集合中即可

targetMap用一个全局变量去存储,可以使用Map或者WeakMap,为了让垃圾回收机制能够正常运作,建议使用WeakMap作为targetMap的实现,具体原因可自行了解MapWeakMap的区别

当前激活的effect对象用activeEffect全局变量存储,每当effct首次执行的时候,就会将activeEffect标记为当前在执行的函数 如果该函数内部有触发响应式对象的get拦截的话,就会执行track进行依赖收集,而track正是从actvieEffect中获取到需要收集的函数的,因此activeEffect算是建立起get拦截器和track之间沟通的桥梁

注意:加入到集合中的是**effect**对象,而不是副作用函数,因为我们是通过**effect**对象的**run**方法统一执行副作用函数的

/**
 * @description 依赖收集
 * @param target 依赖的对象
 * @param key 对象的属性
 */
export function track(target, key) {
  // target -> key -> deps
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }

  deps.add(activeEffect);
}

为了能够在track中通过activeEffect访问到正确的当前激活的effect对象,我们需要在effect对象执行run方法的时候修改一下activeEffect指向自己

class ReactiveEffect {
  private _fn: any;

  constructor(fn) {
    this._fn = fn;
  }

  run() {
    activeEffect = this;
    this._fn();
  }
}

2.4.2 trigger触发依赖

触发依赖也很简单,流程图中已经说明了,根据target拿到depsMap,再根据key拿到deps集合,遍历集合中每一个effect对象,调用它们的run方法即可将副作用函数执行

/**
 * @description 触发依赖
 * @param target 依赖的对象
 * @param key 对象的属性
 */
export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  const deps = depsMap.get(key);

  for (const effect of deps) {
    effect.run();
  }
}

tracktrigger都实现了以后,effect单元测试的happy path就可以通过了 image.png