从零开始的手写vue3响应式 —— effect(1)

200 阅读6分钟

前言

本文章是基于《Vue.js 设计与实现》以及看 vue/core/reactivity实现的,记录下来的目的是主要是做笔记和理清思路,有问题欢迎各位大佬留言。

effect与响应式数据的关系

我们都知道,vue2 中实现响应式的关键是 Object.definePropertyWatcher。但是在 vue3 中,实现响应式的关键是 Proxyeffect

effect 翻译过来就是副作用函数,而副作用函数就是 会产生副作用的函数。与之对立的是 纯函数,对纯函数有兴趣的可以去了解 React 或者 Solid,这里就不细聊了。如果 effect函数 的执行能直接或间接影响了其他函数的执行,那么我们就说这个函数产生了副作用。

那么这个副作用跟响应式有什么关联,我们不妨这样设想一下:

const data = {
  text: "msg"
}

function effect() 
  //  执行effect的时候会读取到data.text
  doucument.body.textContent = data.text;
}

//  修改data.text的时候会重新执行effect
data.text = "text"

如果我们能做到上面这个操作,是不是就可以将 data 变成响应式数据。

响应式数据的粗糙实现

上面的代码就是很容易让我们联想到用 Proxy来实现。

const data = {
  text: "msg"
};

function effect() 
  //  执行effect的时候会读取到data.text
  doucument.body.textContent = data.text;
}

// 存储副作用函数
const dep = new Set();
const proxyData = new Proxy(data, {
  //  拦截读取操作
  get(target, key) {
    //  将副作用函数存到桶中
    dep.add(effect)
    return target[key]
  },
  //  拦截设置操作
  set(target, key, value) {
    target[key] = value;
    //  将副作用从桶里拿出来执行
    dep.forEach(fn => fn());
    return true;
  }
})

effect();
data.xxx = 1;
// effect 会被触发

这样我们就建造了一个十分粗糙的响应式数据。但是这里我们有很多问题需要处理,在这就先提出来:

  • 硬编码了副作用函数的名字 effect,实际上副作用函数可能是匿名函数
  • key副作用函数 之间没有对应关系,因此上面的程序如果执行data.xxx = 1xxxeffect没有一丁点关系,但是effect 也会再被执行。因此我们要重新设计数据结构。

改进响应式数据的实现

// 用一个全局变量存储被注册的副作用函数
let activeEffect;

//  effect函数用于注册副作用函数
function effect(fn) {
  //  记录副作用函数
  activeEffect = fn;
  fn();
}

//  改进1: 取消硬编码
effect(() => {
  doucument.body.textContent = data.text;
})

const data = {
  text: "msg"
};

const otherData = {
  foo: "foo"
}

//  改进点2: 重新设计数据结构
//  targetMap  ->  WeakMap<any, Map<any, Set<ReactiveEffect>>>
//    --data 对象  -> depsMap  Map<any, Set<ReactiveEffect>>
//      --text 对象的key   ->  Set<ReactiveEffect>
//        --[Anonymous Function]  key对象的副作用函数列表  -> ReactiveEffect
//    --otherData
//      --foo
//        --[]
const targetMap = new WeakMap();
const proxyData = new Proxy(data, {
  get(target, key) {
    //  没有副作用函数,就不用后续操作
    if (!activeEffect) return target[key];
    
    //  获取对象的key -> Set<ReactiveEffect> 的映射表
    let depsMap = targetMap.get(target);
    
    //  当前对象没有Map在就创建一个Map与target关联
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    
    //  获取key ->  Set<ReactiveEffect>
    let deps = depsMap.get(key);
    
    //  deps不存在就创建Set与key关联
    if(!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    //  将当前的副作用添加到桶里
    deps.add(activeEffect);
    
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    const depsMap = targetMap.get(target);
    if (!depsMap)  return;
    const effect = depsMap.get(key);
    effects && effects.forEach(fn => fn());
  }
})

为什么使用 WeakMap,其实是关系到一个垃圾回收的问题:

const map = new Map();
const weakMap = new WeakMap();

(function() {
  const foo = {foo: 1};
  const bar = {bar: 2};
  
  map.set(foo, 1);
  weakMap.set(bar, 2);
})()

匿名函数一旦执行完,照理来说,foobar 都会被清理掉。但是实际的情况是 foo 不会被清理,原因是 Map 中的 key 是强引用,而 WeakMap 中的 key 是弱引用,弱引用不阻止垃圾回收期的工作。所以匿名函数执行完后,bar 就会被清理掉,而里面保存的Map<key, Set<Effect>>也都被回收掉,不会发生内存泄漏。

然后我们再好好的封装一下,尽量跟 vue3 源码保持一致。

// effect.ts

class ReactiveEffect {
  private _fn: any;

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

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

type Dep = Set<ReactiveEffect>;
type KeyToDepMap = Map<any, Dep>;

const targetMap = new WeakMap<any, KeyToDepMap>();

export function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

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

  dep.add(activeEffect);
}

export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  dep && dep.forEach((e) => e.run());
}

let activeEffect: ReactiveEffect | null = null;

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

  _effect.run();
}

// reactive.ts

import { track, trigger } from "./effect";
export function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const res = Reflect.get(target, key);

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

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

测试用例

//  effect.spec.ts

import { effect } from "../effect";
import { reactive } from "../reactive";

describe("reactivity/effect", () => {
  it("should run the passed function once (wrapped by a effect)", () => {
    const fnSpy = jest.fn(() => {});
    effect(fnSpy);
    expect(fnSpy).toHaveBeenCalledTimes(1);
  });
  it("should observe basic properties", () => {
    let dummy;
    const counter = reactive({ num: 0 });
    effect(() => (dummy = counter.num));

    expect(dummy).toBe(0);
    counter.num = 7;
    expect(dummy).toBe(7);
  });
  it("should observe multiple properties", () => {
    let dummy;
    const counter = reactive({ num1: 0, num2: 0 });
    effect(() => (dummy = counter.num1 + counter.num1 + counter.num2));

    expect(dummy).toBe(0);
    counter.num1 = counter.num2 = 7;
    expect(dummy).toBe(21);
  });

  it("should handle multiple effects", () => {
    let dummy1, dummy2;
    const counter = reactive({ num: 0 });
    effect(() => (dummy1 = counter.num));
    effect(() => (dummy2 = counter.num));

    expect(dummy1).toBe(0);
    expect(dummy2).toBe(0);
    counter.num++;
    expect(dummy1).toBe(1);
    expect(dummy2).toBe(1);
  });
});

image.png

分支切换与cleanup

为了描述方便,我们暂且将 Set 数据结构所存储的作用域函数集合称为 依赖集合

如果你对 vue2 有所了解,应该知道 WatcherDep 是一个双向收集,即 N 对 N 的关系。同样地,在vue3 中,依赖集合effect 之间也是双向收集的。这个双向收集有什么必要,请接着往下看。

现在有这样一段代码:

const data = {
  ok: true,
  text: "hello, world",
};

const obj = new Proxy(data, {/* ... */});

effect(function () {
  document.body.textContent = obj.ok ? obj.text : "not";
});

effectFn 函数内部存在一个三元表达式,根据 obj.ok 来执行不同代码分支。当 obj.ok 的值发生变化的时候,代码执行的分支会跟着变化,这就是分支切换

分析下依赖情况:

data
  --> ok
    --> [effectFn]
  --> text
    --> [effectFn]

这样看好像没什么问题是吧,effectFn 分别被 oktext 收集触发。

但是,如果 obj.ok = false; 并触发了 effectFn 之后,我再设置 obj.text = "xxx"; 还是会触发 effectFn

但是根据上面的三元表达式,obj.okfalse 之后,只会返回 not,而不会读取 obj.text,也就是说在obj.okfalse 之后,obj.text 设置触发 effectFn 都是毫无意义的。

所以我们要把 effectFntext 那边移除掉。依赖情况要变成下面的:

data
  --> ok
    --> [effectFn]
  --> text
    --> []

如何解决这个情况呢,其实思路很简单。每次副作用函数执行时,先把它从全部相关联的依赖集合之中删除,当执行完毕后,再重新建立关联

执行完毕后的功能我们程序有了,我们需要再添加一个将副作用从全部和它相关联的依赖集合中删除的功能。这里我们就需要重新设计副作用函数的结构了,让它能做一个双向收集。

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

  active = true;

  //  用来存储所有与该副作用相关联的依赖集合
  deps: Dep[] = [];

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

  run() {
    // 新增
    cleanupEffect(this);
    activeEffect = this;
    this._fn();
  }
}

/**
 * @description 从副作用相关连的依赖集合中,删除副作用函数
 * @param effectFn
 */
function cleanupEffect(effectFn: ReactiveEffect) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const dep = effectFn.deps[i];
    dep.delete(effectFn);
  }
  // 重置掉effectFn.deps 数组
  effectFn.deps.length = 0;
}

export function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

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

  dep.add(activeEffect);
  
  // 新增
  // dep就是一个与当前副作用函数存在联系的依赖集合
  activeEffect.deps.push(dep);
}

export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  
  
  //  新增
  //  为什么需要新建一个Set
  //  因为用dep循环的话,run -> cleanEffect清除掉函数 -> fn又将函数加回来 -> 死循环
  const depToRun = new Set(dep);
  depToRun.forEach((e) => e.run());
  // dep && dep.forEach((e) => e.run());
}

测试用例

it("should not be triggered by mutating a property, which is used in an inactive branch", () => {
    let dummy;
    const obj = reactive({ prop: "value", run: true });

    const conditionalSpy = jest.fn(() => {
      dummy = obj.run ? obj.prop : "other";
    });
    effect(conditionalSpy);

    expect(dummy).toBe("value");
    expect(conditionalSpy).toHaveBeenCalledTimes(1);
    obj.run = false;
    expect(dummy).toBe("other");
    expect(conditionalSpy).toHaveBeenCalledTimes(2);
    obj.prop = "value2";
    expect(dummy).toBe("other");
    expect(conditionalSpy).toHaveBeenCalledTimes(2);
  });

image.png

总结

本文主要讲的内容:

  • effect 与 响应式数据的关系
  • effect 的一点点完善