Vue3 - computed

74 阅读5分钟

本文为《Vue.js设计与实现》的笔记。

1. 调度执行

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

我们为effect设计选项参数options,允许用户指定调度器:

effect(() => {...}, {
    scheduler(fn){
        //...
    }
})

通过sheduler,我们在trigger函数中触发副作用函数重新执行时,直接调用用户传递的调度器函数,从而把控制权交给用户。

首先修改effect函数:

function effect(fn, options = {}) {
  const effectFn = () => {
      ...
  };
  // 将options挂载在effectFn上
  effectFn.options = options;
  effectFn.deps = [];
  effectFn();
}

修改trigger函数:

function trigger(target, key) {
    //...
  effectToRun &&
    effectToRun.forEach((effectFn) => {
    // 优先执行调度器函数
      if (effectFn.options.scheduler) {
        effectFn.options.scheduler(effectFn);
      } else {
        effectFn();
      }
    });
}

测试:

effect(
  () => {
    console.log(obj.a);
  },
  {
    scheduler(fn) {
      setTimeout(fn);
    },
  }
);

obj.a = 123;
console.log("test");

通过在调度器函数中使用setTimeout将副作用函数放到宏任务队列中执行,实现在test打印后再执行副作用函数。结果为:

1
test
123

以上代码成功控制了副作用函数的执行顺序,下面我们来控制它的执行次数。

看一段代码:

effect(
  () => {
    console.log(obj.a);
  }
);

obj.a++;
obj.a++;

在没有使用调度器的情况下,该段代码的结果为:

1
2
3

如果我们只关心最终结果,而不希望了解中间过程,我们希望的输出是:

1
3

为实现这一目标,我们可以考虑以下思路:

  • 使用任务队列,来将副作用函数放入
  • 副作用函数多次调用,但最后只执行一次,考虑去重
  • 副作用函数应推迟执行,考虑异步

添加一个任务队列:

// 任务队列
const jobQueue = new Set();
// Promise实例,resolved
const p = Promise.resolve();

let isFlushing = false; // 是否正在刷新队列
function flushJob() {
  if (isFlushing) return; // 正在刷新,则直接退出
  isFlushing = true;
  p.then(() => {
    jobQueue.forEach((job) => job()); // 微任务,遍历任务队列并执行
  }).finally(() => {
    isFlushing = false;
  });
}

定义调度器:

effect(
  () => {
    console.log(obj.a);
  },
  {
    scheduler(fn) {
      jobQueue.add(fn);
      flushJob();
    },
  }
);

obj.a++;
obj.a++;

分析以上代码执行过程:

  • 使用调度器时,将副作用函数添加至jobQueue,jobQueue为Set,可自动去重
  • 调用flushJob进行刷新队列,该函数往微任务队列中添加了一个 遍历jobQueue并执行 的任务
  • 通过isFlushing作为flag,多次调用flushJob时也只会添加一个遍历的微任务

2. computed与lazy

在原先的实现中,effect接收的函数参数会立即执行。

effect(
  () => {
    console.log(obj.a);
  }
);

以上代码会立即执行并打印。某些场景下我们不希望它理解执行,所以我们在options参数中增加一个lazy选项:

effect(
  () => {
    console.log(obj.a);
  },
  {
    lazy: true,
  }
);

修改effect:

function effect(fn, options = {}) {
  const effectFn = () => {
      // ...
    fn();
    // ...
  };
  //...
  // 新增
  if (!options.lazy) {
    effectFn();
  }
  return effectFn;
}

以上代码中判断是否lazy,若为lazy则不立即执行,且effect最终会返回一个effectFn,在外部调用该返回的函数,可执行副作用函数。

单纯地手动执行副作用函数意义不大,但如果将传递给effect的函数看作一个getter,执行后有返回值,那么我们手动执行后便可取得其返回值。

effect(() => obj.a + obj.b, {
  lazy: true,
});

之前的effect仅能控制执行时机,但未将执行结果返回。修改effect:

function effect(fn, options = {}) {
  const effectFn = () => {
    // ... 
    const res = fn();
    // ...
    return res;
  };
  // ...
  return effectFn;
}

通过以上修改,我们已经可以实现懒执行副作用函数,且能拿到副作用函数的执行结果。

基于以上代码,我们可以实现一个基础的computed:

function computed(getter) {
  const effectFn = effect(getter, { lazy: true });
  const obj = {
    get value() {
      return effectFn();
    },
  };
  return obj;
}

computed接收一个getter,内部将getter作为副作用函数传给effect,并设置为懒加载,内部定义一个obj,该对象的value属性是一个访问器属性,只有当读取value的值时,才会执行effectFn并将其结果作为返回值。

const data = { a: 1, b: 2 };
const obj = new Proxy(data, ...);
const sumRes = computed(() => obj.a + obj.b);
console.log(sumRes.value);

以上代码可成功运行,结果为3。只有当我们访问sumRes.value的值时,才会执行effectFn

3. dirty

前边所实现computed仍存在一些问题,当我们多次访问sumRes.value的值时,effectFn会被多次执行,但理想状况下,obj.aobj.b并未发生改变,不应该重新执行计算。

解决这个问题的思路,就是对值进行缓存。

修改computed:

function computed(getter) {
  let value; // 用于缓存值
  let dirty = true; // 标识是否需要重新进行计算,true意味着脏(需要重新计算)
  const effectFn = effect(getter, { lazy: true });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false; // 计算完后重置为false
      }
      return value;
    },
  };
  return obj;
}

以上代码能够实现缓存,但其中的dirty只有由true改为false的过程,什么时候变为true呢?

分析一下,需要重新计算的情况,是getter所依赖的响应式数据发生了变化,也就是trigger时,此时我们需要使用上文的scheduler

看到这里又是lazy又是scheduler可能有点迷糊了,我们再捋一捋:

  • lazy:懒加载,避免effect(fn)fn的立即执行
  • scheduler:调度器,在effect(fn)中,fn所依赖的响应式数据发生变化时,不重新执行fn,而是执行调度器。

lazy推迟了首次执行,也就推迟了track收集依赖,而scheduler的触发需要trigger触发依赖,前提是track已完成。

修改computed:

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

4. 在effect中使用计算属性

// obj.a = 1 obj.b = 2
const sumRes = computed(() => obj.a + obj.b);
effect(() => {
  console.log(sumRes.value);
});
obj.a++;

理想情况下,上面的代码应该先打印出 3 ,后打印出 4。但实际上在前边代码实现的基础上,结果为只打印出 3。

分析原因,computed所返回的对象是一个普通对象,而非proxy实例,访问value时不会触发track。

解决方法:

  • 在读取计算属性的值时,手动进行track;
  • 在所依赖的响应式数据发生变化时,手动进行trigger;

修改computed:

function computed(getter) {
  let value; // 用于缓存值
  let dirty = true; // 标识是否需要重新进行计算,true意味着脏(需要重新计算)
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true; // 在调度器中将dirty重置为true
      trigger(obj, "value"); // 在所依赖的响应式数据发生变化时,手动进行trigger
    },
  });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false; // 计算完后重置为false
      }
      track(obj, "value"); //在读取计算属性的值时,手动进行track
      return value;
    },
  };
  return obj;
}

此时的依赖关系如下:

computed(obj)
    --- value
        --- effectFn