一步到位!简易复现 Vue3 中的 computed 计算属性

一、前言

上一篇文章我们基本实现了 Vue3 的响应式原理,代码如下:

js 复制代码
/** 存储副作用的桶 */
const bucket = new WeakMap();
const data = { text: "222" };

// 当前正在执行的副作用函数
let activeEffect;
// effect 栈
const effectStack = [];

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前将当前副作用函数压入栈中
    effectStack.push(effectFn);
    fn();
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

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);
  },
});

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  // 没有 activeEffect,直接 return
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

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

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 将 effectFn 从依赖集合中移除
    deps.delete(effectFn);
  }
  // 最后需要重置 effectFn.deps 数组
  effectFn.deps.length = 0;
}

effect(() => {
  console.log("text:", obj.text);
});

setTimeout(() => {
  obj.text = "hello world";
}, 1000);

但是基于目前的代码还是无法实现这 Computed 计算属性。接下来,我们一步步完善代码,从而实现 Vue3 的计算属性。

二、调度器 scheduler 与 lazy 配置

首先,我们需要改造一下 effect 函数,让它接受第二个参数 options,这个参数有两个属性:scheduler 调度器和 lazy 配置。接下来我们会分别介绍这两个属性。

1. scheduler 调度器

可调度性是响应系统非常重要的特性,它指的是当 trigger 动作触发副作用函数重新执行时,有能力决定该函数执行的时机、次数以及方式。

js 复制代码
effect(
  () => {
    console.log(obj.text);
  },
  // options
  {
    // 调度器 scheduler 是一个函数
    scheduler(fn) {
      // ...
    },
  },
);

如上所示,我们为 effect 函数传递第二个参数 options,这个参数是个对象,这个对象里面有个属性 scheduler ,这个属性就是我们的调度器,它是个函数,接收参数为当前的副作用函数,使得用户可以决定何时执行副作用函数。

我们会在 effect 函数中把 options 配置挂载到对应的副作用函数上:

js 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn);
    fn();
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // 将 options 挂载到 effectFn 上
  effectFn.options = options; // 新增
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

有个调度器后,我们在 trigger 函数触发副作用执行时,就可以把需要执行的副作用函数传递给调度器函数,从而把函数执行的控制权交给用户:

js 复制代码
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  const newEffectsToRun = new Set();
  effects &&
    effects.forEach((effectFn) => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn);
      }
    });
  newEffectsToRun.forEach((effectFn) => {
    // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
    if (effectFn.options.scheduler) {
      // 新增
      effectFn.options.scheduler(effectFn); // 新增
    } else {
      // 否则直接执行副作用函数(之前的默认行为)
      effectFn(); // 新增
    }
  });
}

下面我们来看一个实际的使用例子:

js 复制代码
const data = { age: 1 };
const obj = new Proxy(data, {
  /* ... */
});

effect(
  () => {
    console.log(obj.age);
  },
  // options
  {
    // 调度器 scheduler 是一个函数
    scheduler(fn) {
      // 将副作用函数放到宏任务队列中执行
      setTimeout(fn);
    },
  },
);

obj.age++;

console.log("结束了");


// 1
// 结束了
// 2

在这个例子中,首先会输出 1,接着我们修改 obj.age,此时会触发 trigger 重新执行副作用函数,由于我们在调度器函数中设置了定时器,延迟副作用的执行,因此会先输出 结束了,再输出 2

2. lazy 配置

一般情况下,当我们执行 effect 时,副作用函数会立即执行,而有些场景下我们并不希望它立即执行,而是在需要的时候才执行,如 computed 计算属性。由此我们在 options 中引入了 lazy 属性来达到这个目的,代码如下所示:

js 复制代码
effect(
  // 指定了 lazy 选项,这个函数不会立即执行
  () => {
    console.log(obj.text);
  },
  // options
  { lazy: true},
);

我们需要改一下 effect 的逻辑,当 lazy 为 true 时,则不立即执行副作用函数:

js 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  effectFn.options = options;
  effectFn.deps = [];
  // 只有非 lazy 的时候,才执行
  if (!options.lazy) {
    // 新增
    // 执行副作用函数
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn; // 新增
}

lazy 为 false 时,还是和之前一样,副作用函数会立即执行;当 lazy 为 true 时,副作用函数会作为返回值进行返回,这样用户就能自己去手动执行副作用函数:

js 复制代码
const effectFn = effect(
  () => {
    console.log(obj.text);
  },
  { lazy: true },
);

effectFn();

三、computed 实现

我们先分析一下 computed 函数的特点:

  • 接收 getter 函数作为参数;
  • 值缓存。

我们可以把传递给 effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值:

js 复制代码
const effectFn = effect(
  () => obj.foo + obj.bar,
  { lazy: true },
);

// value 是 getter 的返回值
const value = effectFn();

由于副作用函数执行时,是没有返回内容的,因此 valueundefined,为此我们要改造一下副作用函数执行的逻辑:

js 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    // 将 fn 的执行结果存储到 res 中
    const res = fn(); // 新增
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    // 将 res 作为 effectFn 的返回值
    return res; // 新增
  };
  effectFn.options = options;
  effectFn.deps = [];
  if (!options.lazy) {
    effectFn();
  }

  return effectFn;
}

此时我们的 computed 函数初步实现如下:

js 复制代码
function computed(getter) {
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
  });

  const obj = {
    // 当读取 value 时才执行 effectFn
    get value() {
      return effectFn();
    },
  };

  return obj;
}

我们使用 computed 函数来创建一个计算属性:

js 复制代码
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {
  /* ... */
});

const sumRes = computed(() => obj.foo + obj.bar);

console.log(sumRes.value); // 3

可以看到它能够正确地工作,但是每次访问 sumRes.value 时,都会重新执行 effectFn 函数,即使 obj.fooobj.bar 没有变化。即没有对值进行缓存。

为了实现值缓存,我们在 computed 函数内部新增两个变量 valuedirtyvalue 用来存储上一次计算的值, dirty 用于标识是否需要重新计算值,为 true 时则表示 value 是脏数据,需要重新获取新数据。代码如下:

js 复制代码
function computed(getter) {
  // value 用来缓存上一次计算的值
  let value;
  // dirty 标志,用来标识是否需要重新计算值,为 true 则意味着"脏",需要计算
  let dirty = true;

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      // 添加调度器,在调度器中将 dirty 重置为 true
      dirty = true
    }
  });

  const obj = {
    get value() {
      // 只有"脏"时才计算值,并将得到的值缓存到 value 中
      if (dirty) {
        value = effectFn();
        // 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
        dirty = false;
      }
      return value;
    },
  };

  return obj;
}

这时的计算属性已经很完善了,但是当我们在另外一个 effect 中读取计算属性时:

js 复制代码
const sumRes = computed(() => obj.foo + obj.bar);
effect(() => {
  // 在该副作用函数中读取 sumRes.value
  console.log(sumRes.value);
});

// 修改 obj.foo 的值
obj.foo++;

这时候修改 obj.foo,副作用函数并没有执行,这是因为计算属性内容有自己的 effect,且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把computed 内部的 effect 收集为依赖。而当把计算属性用于另外一个 effect 时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。

为了解决这个问题,我们需要在读取计算属性的值时,手动调用 track 函数进行依赖追踪,而计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应:

js 复制代码
function computed(getter) {
  let value;
  let dirty = true;

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true;
        // 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
        trigger(obj, "value");
      }
    },
  });

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      // 当读取 value 时,手动调用 track 函数进行追踪
      track(obj, "value");
      return value;
    },
  };

  return obj;
}

四、总结

我们为 effect 增加第二个参数 options,该参数是个对象,包含两个属性:schedulerlazyscheduler 调度器函数能够让用户自己控制副作用函数的执行时间,而 lazy 配置为 true 时,副作用函数在注册时不会立即调用,并把副作用函数返回,交由用户自己执行。基于此配置,我们实现了 Vue3 中的计算属性。

相关推荐
昨天;明天。今天。2 分钟前
案例-任务清单
前端·javascript·css
一丝晨光28 分钟前
C++、Ruby和JavaScript
java·开发语言·javascript·c++·python·c·ruby
Front思30 分钟前
vue使用高德地图
javascript·vue.js·ecmascript
zqx_71 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己1 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称2 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色2 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2342 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河2 小时前
CSS总结
前端·css
NiNg_1_2342 小时前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript