保姆式地带你从0到1实现Vue3 Effect

345 阅读8分钟

一、什么是副作用函数

副作用函数,指的就是会产生副作用的函数 比如:

// 全局变量
let val = 1
function effect(){
    val = 2 // 修改全局变量,产生了副作用,就是将变更了全局变量
}

二、什么是响应式数据

就是修改一个数据,会影响到副作用函数自动重新执行 比如:

const obj  = {text: 'Hello World'}
function effect(){
    // effect 函数的执行会读取 obj.text
    document.body.innerText = obj.text
}

当修改 obj.text 值的时候 effect 副作用的函数会重新执行,obj就是响应式数据

三、实现一个简单的响应数据

1. 代码

// 最简单的响应式
const bucket = new Set();
// 原始数据
const data = { text: "Hello World" };

// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    //   将副作用函数 effect 添加到存储副作用函数的桶中
    bucket.add(effect);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用从桶中取出并执行
    bucket.forEach((fn) => fn());
    return true;
  },
});

function effect() {
  document.body.innerHTML = obj.text;
}
effect();

2. 效果

简单的响应.gif

3.图形解释

image.png

4. 文字解释

执行effect的时候,从obj中取出text,此时响应数据读取操作,将effect收集到桶中。
给obj.text 赋值,触发响应数据的设置操作,将桶中的effect取出并执行

四、设计完善的响应系统

1. 完善桶中的effect不能是写“死”的,需要“活”

image.png

添加一个effect注册副作用的函数,执行effect,将副作用函数赋值给 activeEffect 函数,并执行副作用函数,此时执行副作用函数的时候,obj.text 会触发 响应数据触发读取操作,将activeEffect 副作用函数收集到桶中,变更 obj.text,就会执行副作用函数

2. 在副作用函数与被操作的目标字段之间建立明确的联系

1)问题

我们先看下面的问题

effect(() => {
  console.log("effect 被执行了");
  document.body.innerHTML = obj.text;
});

副作用的影响.gif 我们其实指向obj.text这个值的时候,才会执行副作用函数,但是这里我向obj内添加ee这个变量,也会触发副作用,那我们就需要在副作用函数与被操作的目标字段之间建立明确的联系

2)分析关系

effect(() => {
  document.body.innerHTML = obj.text;
});

这段代码中存在三个角色:

  • 被操作(读取)的代理对象obj;
  • 被操作(读取)的字段名text;
  • 使用effect函数注册的副作用函数effectFn 用target表示代理对象的原始对象,key表示备操作的字段名称,effectFn表示被注册的副作用函数,三者关系:

image.png

effect(function effectFn1() {
  obj.text;
});
effect(function effectFn2() {
  obj.text;
});

上面的关系: image.png

effect(function effectFn1() {
  obj.text1;
  obj.text2;
});

上面的关系: image.png

effect(function effectFn1() {
  obj1.text1;
});

effect(function effectFn2() {
  obj2.text2;
});

上面的关系: image.png

3)设计桶

根据上面的关系,需要重新设计bucket桶 image.png

4)代码实现

image.png

5) 效果

关系.gif

6)功能封装

get拦截函数,单独封装到track函数中,表示追踪。set触发副作用,单独封装到trigger函数中 image.png

五、分支切换与cleanup

1. 性能问题

例如:

const data = {ok: true, text: 'Hello World'}
const obj = new Proxy(data, {...})
effect(()=> {
    document.body.innerText = obj.ok === true? obj.text : 'not'
})

关系图: image.png

当我们将 obj.ok 赋值为false,关系图理想的是 image.png

但是并不是,我们已经将text依赖的effectFn存储到桶中了,被没有清除,依然是 image.png

因此我们在执行的effect时候,需要删除与effect相关的关系图,重新再建立关系图

1)代码

image.png

2)效果

cleaup.gif

3)解释 trigger 为什么重新 new Set(effects) 再执行

主要是 Set 特性导致的。
语言规范中对此有明确说明:在调用forEach 遍历 Set 集合时,如果一个值已经被访问过了,但改值被删除并重新添加到集合中,如果此时forEact遍历没有结束,那么改制会重新被访问,因此会无限的循环。

// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
  // 最后将当前激活的副作用函数添加到桶里
  deps.add(activeEffect);
}

function trigger(target, key) {
  const effects = depsMap.get(key);
  effects && effects.forEach((fn) => fn())
}

// 用于注册副作用函数
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
  };
}

function cleanup(effectFn) {
  effectFn.deps.forEach((deps) => {
    deps.delete(effectFn);
  });
}

从上面简化的代码,可以看出,trigger的时候,effects.forEach执行,effects是Set集合,副作用函数会从 deps 中将 副作用函数给删除,然后在 track 在 将 副作用函数给添加到effects,就如果下面的操作

const set = new Set([1])
set.forEach(item, => {
    set.delete(1)
    set.add(1)
    console.log('遍历中...')
})

可以将上面的代码放到浏览器内置执行,浏览器不卡直接来捶我😁

六、嵌套的effect 与 effect 栈

1. 问题

image.png

Kapture 2022-05-15 at 01.01.24.gif 执行 重新赋值 obj.ok 的时候,effectFn2 嵌套在 effectFn1中,应该这两个都会执行

effectFn1 执行
effectFn2 执行

但是实际只执行了 effectFn2

2. 分析原因

image.png image.png effectFn1 嵌套 effectFn2,先执行effectFn1,将 effectFn1对应的副作用函数赋值在 activeEffect 中,遇到 effectFn2 又将 effectFn2 对应的副作用函数赋值在 activeEffect 中,activeEffect 函数只能有一个,obj.text 此时 将effectFn2 对应的副作用函数给收集在桶中,然后obj.ok activeEffect依然是 effectFn2 对应的副作用函数,因此,effectFn1对应的副作用函数压根就没有被收集到桶中。

3. 为了解决这个问题,需要引入一个副作用函数栈 effectStack

1)代码实现

image.png

2)效果

Kapture 2022-05-15 at 01.32.56.gif

七、避免无线递归循环

1. 问题

image.png image.png

2. 分析问题

这里就会死循环。
主要是因为,effect内 有同时触发了 getter 和 setter ,导致 track和trigger进入重复调用。

3. 在trigger动作发生时增加守卫条件

如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行

4. 代码

image.png

5.效果

image.png

八、调度执行

1. 什么是调度

指的是当 trigger 动作触发副作用函数重新执行,有能力决定副作用函数执行的时机,次数以及方式

2. 实现

effect添加一个属性scheduler调度器函数

effect(
  () => {
    obj.text;
  },
  // options
  {
    // 调度器 scheduler 是一个函数,effectFn 是副作用函数
    scheduler(effectFn) {
      effectFn();
    },
  }
);

image.png

3. 如何控制执行次数

image.png 目前执行结果是

1
2
3

想要控制执行次数,执行结果是

1
3

因此从调度器+队列+微任务入手,代码: image.png image.png

九、lazy

上面的代码,副作用函数式立即执行,但是有些场景,不需要要立即执行,而是需要的时候才会执行,比如后面结束计算属性

1. 代码

image.png

2.效果

Kapture 2022-05-15 at 12.41.44.gif

十、完整代码

// 存储副作用的桶
const bucket = new WeakMap();
// 原始数据
const data = { text: "Hello World" };

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

// effect 栈
const effectStack = [];

// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用从桶中取出并执行
    trigger(target, key);
    return true;
  },
});

// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return;
  // 根据 target 从 桶中取出 depsMap,它也是一个map 类型,key-->effects
  let depsMap = bucket.get(target);
  // 如果 depsMap 不存在,创建一个新的map,并与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }

  // 再根据 key 从depsMap 中取得 deps,它是一个 Set 集合
  // 里面存储着所有与当前 key 相关的副作用函数:effects
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到桶里
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 effectFn.deps 数组中
  activeEffect.deps.push(deps);
}

// 在 set 拦截函数内调用 trigger 函数 触发变化
function trigger(target, key) {
  // 从桶中取出 depsMap,它也是一个map 类型,key-->effects
  const depsMap = bucket.get(target);
  if (!depsMap) return true;
  // 再根据 key 从depsMap 中取得 副作用函数 effects
  const effects = depsMap.get(key);

  const effects2Run = new Set();
  effects &&
    effects.forEach((effectFn) => {
      // 如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行
      if (effectFn !== activeEffect) {
        effects2Run.add(effectFn);
      }
    });

  effects2Run.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      // 否则直接执行副作用函数
      effectFn();
    }
  });
}

// 用于注册副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    // 删除与 effectFn 相关的关系
    cleanup(effectFn);
    // 调用effect函数的时候,将 effectFn 赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前,将当前副作用函数添加到 effectStack 栈中
    effectStack.push(effectFn);
    // 执行副作用函数
    fn();
    // 在调用副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // 将 options 挂在到 effectFn 上
  effectFn.options = options;
  // effectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 只有非lazy的时候才会执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  effectFn.deps.forEach((deps) => {
    // 将 effectFn 从依赖集合中删除
    deps.delete(effectFn);
  });
  // 清空 effectFn.deps
  effectFn.deps.length = 0;
}

const effectFn = effect(
  () => {
    document.body.innerText = obj.text;
  },
  {
    lazy: true,
  }
);

十一、总结

Vue3核心是通过 Proxy 做了 getter 和 setter 的劫持,并且通过观察者模式,实现整套的响应式数据。并且处理了一些处理了一些细微的问题,比如:分支切换、嵌套effect问题,无限递归循环等问题,还认识到了 Set forEach 会 删除 & 添加同一个值的时候导致无限循环...
总的来说,会让你收获颇丰😊

十二、参考

《Vue.js 设计与实现》