likes
comments
collection
share

【手写Vue3】实现effect & reactive 依赖收集与触发

作者站长头像
站长
· 阅读数 18

前言

这是「手写Vue3」系列第2篇,前面的系列直达链接如下:

在这一篇,我们将实现Vue3最经典依赖收集,依赖触发。

增加测试用例

在上一篇中,我们为了测试jest的功能加了一个index.spec.ts,现在可以删掉这个文件了,开始写真实的测试用例。

reactive测试

首先,我们创建一个reactive.spec.ts的单测文件,来测试reactive的功能,其实就是测试调用reactive能够创建一个响应式对象,代码如下:

// reactive.spec.ts
describe('reactive', () => {
  it('happy path', () => {
    // 创建一个初始对象
    const original = { foo: 1 };
    // 创建一个响应式对象
    const observed = reactive(original);
    // 断言二者并不相等
    expect(observed).not.toBe(original);
    // 断言响应式对象里的属性是正确的
    expect(observed.foo).toBe(1);
  });
});

effect测试

接着,我们创建一个effect.spec.ts的单测文件,来测试effect的功能,其实就是测试effect能够做到响应式更新,代码如下:

// effect.spec.ts
describe('effect', () => {
  it('happy path', () => {
    // 创建一个响应式对象
    const user = reactive({
      age: 23,
    });
    let nextAge;
    effect(() => {
      nextAge = user.age + 1;
    });
    // 断言nextAge值的初始化
    expect(nextAge).toBe(24);

    // 更新响应式对象
    user.age++;
    // 断言nextAge也随着更新了
    expect(nextAge).toBe(25);
  });
});

跑通测试用例

在上面的reactive.spec.ts中以及effect.spec.ts中,其实reactiveeffect是报错的,因为我们还没有实现和引入reactiveeffect。接下来,我们就来实现一下。

首先,我们知道,reactive是一个函数,它接收一个对象,返回一个Proxy,我们现在在src/reactivity下增加一个文件reactive.ts

export const reactive = row => {
  return new Proxy(row, {
    get(target, key) {
      const result = Reflect.get(target, key);
      
      // TODO: 依赖收集
      return result;
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value);

      // TODO: 触发依赖
      return result;
    }
  })
}

那么,我们就写了一个最基础版本的reactive的实现(其实还并没有实现),然后再reactive.spec.ts中引入:

// 增加引入
import { reactive } from './../reactive';
describe('reactive', () => {
  /// ...
});

这个时候,我们测试一下reactive.spec.ts,运行:

npm run test reactive

这个时候,测试用例应该是通过的。

接下来,我们创建一个effect.ts,我们知道,effect是一个函数,同时接收的参数也是一个函数,并且会马上执行这个参数函数。我们简单实现一下:

class ReactiveEffect {
  private _fn: any;

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

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

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

这个时候,我们再在effect.spec.ts中引入:

import { reactive } from "./../reactive";
import { effect } from './../effect';
describe('effect', () => {
  /// ...

});

这个时候,我们运行npm run test发现测试结果如下:

【手写Vue3】实现effect & reactive 依赖收集与触发

从结果可以看到,effect.spec.ts在最后失败了,原因是没有响应式更新,那是因为我们还没有彻底实现reactiveeffect,但是架子已经搭好了,接下来,我们去实现它们。

实现收集依赖

首先,我们理清思路:

所有的effect(cb)中的cb我们都转换成了一个ReactiveEffect对象,我们需要把这些对象收集起来,那在什么时候搜集呢,因为这个cb会立即执行,在执行cb的时候,当调用reactive构造的响应式对象的get的时候,我们就去收集。根据这个对象(targetkey)去「收集池」中找这个ReactiveEffect对象,如果没有,就收集起来,如果有,就跳过。

首先,我们改造一下reactive.ts:

import { track } from "./effect";
export const reactive = row => {
  return new Proxy(row, {
    get(target, key) {
      const result = Reflect.get(target, key);

      // 依赖收集
      track(target, key);
      return result;
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value);

      // TODO: 触发依赖
      return result;
    }
  })
}

接着,我们在effect.ts中增加track方法,来收集依赖:

class ReactiveEffect {
  private _fn: any;

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

  run() {
    // 在调用的时候,把activeEffect设置成当前的ReactiveEffect
    activeEffect = this;
    return this._fn();
  }
}

// 存储所有target的数据池
// target(这个对象的数据池) -> key(属于这个对象的某个属性的数据池)
const targetMap = new Map();

// 当前的ReactiveEffect对象
let activeEffect;

// 收集依赖
export function track(target, key) {
  // 根据target查找
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
  // 根据key在target的数据池中查找
  let dep = depsMap.get(key);
  if (!dep) {
    // 用set存储,因为不需要重复的
    dep = new Set();
    depsMap.set(key, dep);
  }
  // 把当前的执行的cb(也就是当前的ReactiveEffect)加入数据池
  dep.add(activeEffect);
}



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

那么现在,我们依赖就收集好了。

根据这个收集依赖的逻辑回顾一下测试用例:

  1. 在执行effect的时候,马上执行里面的cb,也就是nextAge = user.age + 1,执行之前先把activeEffect设置成了这个cb对应的ReactiveEffect
  2. 接着开始执行nextAge = user.age + 1,执行的过程中调用了user.ageuser是一个reactive,所以走到了userget方法,get方法触发了track去收集依赖。
  3. 收集依赖的时候,我们去targetMap数据池根据user这个对象和age这个属性去查找是否有对应的dep,因为是第一次进来,发现没有,所以我们dep = new Set()并且把这个cb对应的ReactiveEffect放入了这个set

至此,我们依赖就收集成功了。

实现依赖触发

我们还是先理清一下思路:

在什么时候去触发依赖呢?显然应该是在user.age++的时候,也就是在reactive对应的属性发生改变的时候,这个属性对应的依赖应该被触发。

现在,我们改造下reactive.ts

import { track, trigger } from "./effect";
export const reactive = row => {
  return new Proxy(row, {
    get(target, key) {
      /// ...
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value);

      // 触发依赖
      trigger(target, key);
      return result;
    }
  })
}

接着,我们在effect.ts中增加trigger方法,来触发依赖:

class ReactiveEffect {
  /// ...
}

/// ...

// 触发依赖
export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  const dep = depsMap.get(key);
  // 找到这个target->key对应的dep,它是一个ReactiveEffect的集合,遍历触发即可
  for(let effect of dep) {
    effect.run();
  }
};

export function effect(fn) {
  /// ...
}

触发依赖的逻辑很简单,就不多解释了,那么现在,我们整个的依赖收集和依赖触发就完全搞定了。

再跑测试用例

现在,我们再跑一次测试用例,运行npm run test,发现测试用例运行全部成功了。

实现effect返回值

其实,effect函数也是有返回值的,它的返回值就是callback函数return的数据,并且当为runner命名时,再次执行,会再次调用这个callback,所以我们为effect.spec.ts再增加一条测试用例如下:

import { reactive } from "./../reactive";
import { effect } from './../effect';
describe('effect', () => {
  it('happy path', () => {
    /// ...
  });

   it('effect should return runner', () => {
    let foo = 10;
    // 为effect定义变量
    const runner = effect(() => {
      foo++;
      return 'foo';
    });

    // 断言会立即执行 >> 已经实现了
    expect(foo).toBe(11);

    // 接受返回值
    const res = runner();

    // 断言运行了runner()会再次执行cb
    expect(foo).toBe(12);

    // 断言返回值
    expect(res).toBe('foo');
  });

});

现在,我们实现一下,其实很简单,只需要在effect函数把cb返回出去并且绑定this为当前的ReactiveEffect即可,修改effect.ts

/// ...

export function effect(fn) {
  /// ...
  return _effect.run.bind(_effect);
}

再次执行测试用例,发现成功了。

附上完整代码

完整代码如下:

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

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

  run() {
    // 在调用的时候,把activeEffect设置成当前的ReactiveEffect
    activeEffect = this;
    return this._fn();
  }
}

// 存储所有target的数据池
// target(这个对象的数据池) -> key(属于这个对象的某个属性的数据池)
const targetMap = new Map();

// 当前的ReactiveEffect对象
let activeEffect;

// 收集依赖
export function track(target, key) {
  // 根据target查找
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
  // 根据key在target的数据池中查找
  let dep = depsMap.get(key);
  if (!dep) {
    // 用set存储,因为不需要重复的
    dep = new Set();
    depsMap.set(key, dep);
  }
  // 把当前的执行的cb(也就是当前的ReactiveEffect)加入数据池
  dep.add(activeEffect);
}

// 触发依赖
export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  const dep = depsMap.get(key);
  // 找到这个target->key对应的dep,它是一个ReactiveEffect的集合,遍历触发即可
  for(let effect of dep) {
    effect.run();
  }
};


export function effect(fn) {
  const _effect = new ReactiveEffect(fn);
  _effect.run();
  return _effect.run.bind(_effect);
}
// reactive.ts
import { track, trigger } from "./effect";
export const reactive = row => {
  return new Proxy(row, {
    get(target, key) {
      const result = Reflect.get(target, key);

      // 依赖收集
      track(target, key);
      return result;
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value);

      // 触发依赖
      trigger(target, key);
      return result;
    }
  })
}

总结

自此,effect & reactive 依赖收集与触发都已经实现完成了,当然,effect还有schedulerstop等等功能,我们下期再聊!!!