Vue.js设计与实现(第五章)— computed

355 阅读8分钟

大家好,我是小瑜。 半年时间转瞬即逝,仿佛昨天才刚敲响2024年新年钟声。试问自己这半年工作和学习进步了么,都收获了些什么?看着自己年初下定目标的完成率陷入了沉思。反思自己还是不够努力,一天天的积累会积少成多,但是天天的懒惰会导致自己的一事无成。在这么卷的现状下,需要适当的反思和自我批评。

自我批判结束!

通过前五章的学习,大致了解vue响应式系统,那么接下来就通过前面的知识,试着实现 computed 计算属性。

通过6个步骤依次实现及完善 computed, 搓搓手开始吧!

1. effect增加lazy

目前使用effect会立即执行,但是有些场景,希望需在需要的时候再执行 需要在effect中增加一个lazy属性 lazy 和之前的调度器一样,通过options选择对象执行

const data = { foo: 1 };
const effectFn = effect(
  () => {
    console.log(obj.foo);
  },
  {
    lazy: true,
  }
);

// 手动触发
btn.onclick = () => {
  effectFn();
};

判断options中是否存在lazy,并控制是否执行 为了可以受控控制执行,需要将副作用函数返回,提供effect手动调用执行

// 添加options参数
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();

    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  effectFn.deps = [];
  //  将 options 挂载到 effectFn 上
  effectFn.options = options;
  // 只有非lazy的时候,才执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn; //新增
}

2024-06-16-15-23-20.gif

2. 传递getter并返回任何值

希望可以传递一些计算方法,并返回计算过后的结果 例如希望的函数可以将传入的函数进行执行,并返回结果

import { effect, obj } from "./index.js";

const data = { foo: 1 ,bar:2 };

const obj = new Proxy(data,...)

const effectFn = effect(() => obj.foo + obj.bar, {
  lazy: true,
});

btn.onclick = () => {
  // value 是getter 的返回值 这里的结果为3
  const value = effectFn();
  console.log(value); // 3
};

可以将effect第一项也就是将 () => obj.foo + obj.bar 在effect中执行,并且将此结果返回出来,这样就可以得到目的,在之前effect基础上增加逻辑 下方代码1 和 2 中 即可完成这些逻辑的执行并且将结果返回

/**
 * 通过新增代码可以看到,传递给effect函数的fn才是真正的副作用函数,
 * 而effectFn是我们包装后的副作用函数,在effectFn中我们将fn的执行结果存储到res中
 */
export function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    // 1.将fn的执行结果存储到res中 新增
    console.log(fn, "@@fn");
    const res = fn();

    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];

    //2.将res作为effectFn的返回值 新增
    return res;
  };
  effectFn.deps = [];
  //  将 options 挂载到 effectFn 上
  effectFn.options = options;
  // 只有非lazy的时候,才执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

此时就可以获取到正确的结果 为3 2024-06-16-15-33-50.gif

3. 实现computed

通过给effect增加lazy以及传递getter并返回计算后的结果,此时就可以将这两处逻辑封装成计算属性 例如 将 () => obj.foo + obj.bar 作为getter传入给effect,并且将effect设置为lazy,即可完成

/**
 * 计算属性
 */
function computed(getter) {
  // 把getter作为副作用函数,创建一个lazy的effect
  const effectFn = effect(getter, { lazy: true });
  const obj = {
    get value() {
      console.log("执行effect 获取计算结果");
      return effectFn();
    },
  };
  return obj;
}

const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value, "@@sumRes"); // 结果为3

4. computed增加缓存

这里已经实现了一个计算属性,但是尝试着将 sumRes.value 多次读取 发现每次都会执行 effectFn ,即使依赖的值没有发生变化,也就是说现在的 computed 并没有缓存相同的值。这里通过打印来说明问题

const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value, "@@sumRes"); // 执行effect 获取计算结果 结果为3
console.log(sumRes.value, "@@sumRes"); // 执行effect 获取计算结果 结果为3
console.log(sumRes.value, "@@sumRes"); // 执行effect 获取计算结果 结果为3

所以需要给 computed 在依赖没有发生变化的情况下增加缓存 这里需要设置两个变量 分别是 value 用来计算上一次的计算结果 dirty 是否需要重新执行effect

/**
 * 实现计算属性
 */
function computed(getter) {
  // 用来缓存上一次计算的值
  let value;
  // 用来表示是否需要重新计算值,true代表需要重新计算
  let dirty = true;
  // 把getter作为副作用函数,创建一个lazy的effect
  const effectFn = effect(getter, { lazy: true });
  const obj = {
    get value() {
      if (dirty) {
        console.log("执行effect");
        value = effectFn();
        // 计算完之后,将dirty设置为false,下次访问直接使用缓存到value中的值
        dirty = false;
      }
      return value;
    },
  };
  return obj;
}

此时不论执行多少次 在依赖相同的情况下 effect只会执行第一次 后续只是在读取value的值 这里通过使用不同的依赖进行多次执行,现在 computed 的value 已被成功缓存

const sumRes1 = computed(() => obj.foo + obj.bar);
const sumRes2 = computed(() => obj.foo - obj.bar);
console.log(sumRes1.value, "@@sumRes1"); // 执行effect  3
console.log(sumRes1.value, "@@sumRes1"); // 3
console.log(sumRes2.value, "@@sumRes2"); // 执行effect  2 
console.log(sumRes2.value, "@@sumRes2"); // 2

5. 解决修改响应式数据不重新计算问题

用代码来说下现在的问题

const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value, "@@sumRes"); // 执行effect  3
console.log(sumRes.value, "@@sumRes"); // 3
console.log(sumRes.value, "@@sumRes"); // 3
btn.onclick = () => {
  obj.foo++;
  console.log(sumRes.value, "@@sumRe222s"); // 3
};

问题出在 obj.foo 已经自增了,但是计算属性并没有更新最新的值,还是 结果还是3 而不是期望的4 问题出在 当修改响应式数据时,不会重复计算,因为computed内部设置了缓存,dirty为false无法进行effect 解决方案是: 我们为effect添加了调度器,它会在getter函数中所以来的响应式数据发生变化时执行, 这样我们可以在调度器中将dirty重置为true,当下一次修改响应式时,就会重新运行计算属性 这样既可以保证数据的缓存,也可以保证只要修改副作用就可以重新触发effect执行

/**
 * 实现计算属性
 */
export function computed(getter) {
  // 用来缓存上一次计算的值
  let value;
  // 用来表示是否需要重新计算值,true代表需要重新计算
  let dirty = true;
  // 把getter作为副作用函数,创建一个lazy的effect
  const effectFn = effect(getter, {
    lazy: true,
    // 执行effect时,会触发调度器的执行,这里就可以将dirty重置为true
    scheduler() {
      dirty = true;
    },
  });
  const obj = {
    get value() {
      if (dirty) {
        console.log("执行effect");
        value = effectFn();
        // 计算完之后,将dirty设置为false,下次访问直接使用缓存到value中的值
        dirty = false;
      }
      return value;
    },
  };
  return obj;
}

2024-06-16-16-15-07.gif

6. 解决effect嵌套导致时不更新问题

例如下方代码,期望的是当修改响应式数据后,effect可以及时获取到最新的计算属性值,但是当前sumRes的值始终为 3 并没有获取最新的值

const sumRes = computed(() => obj.foo + obj.bar);
effect(() => {
  console.log(sumRes.value, "@@sumRes"); 
});
btn.onclick = () => {
  obj.foo++;
};

原因是因为这里出现了effect的嵌套,导致没有收集相同且最新的的依赖,导致更新始终为3 要解决这个问题,可以在 get 读取的时候 手动的添加 track(obj, "value"); 并且当计算属性依赖的响应式数据发生变化时,手动调用trigger函数触发响应式。

export function computed(getter) {
  // 用来缓存上一次计算的值
  let value;
  // 用来表示是否需要重新计算值,true代表需要重新计算
  let dirty = true;
  // 把getter作为副作用函数,创建一个lazy的effect
  const effectFn = effect(getter, {
    lazy: true,
    // 1.添加调度器,在调度器中奖dirty重置为true
    scheduler() {
      dirty = true;
      // 2. 当计算属性依赖的响应式数据发生变化时,手动调用trigger函数触发响应式
      trigger(obj, "value");
    },
  });
  const obj = {
    get value() {
      if (dirty) {
        console.log("执行effect");
        value = effectFn();
        // 计算完之后,将dirty设置为false,下次访问直接使用缓存到value中的值
        dirty = false;
      }
      // 3. 当读取 value 时,手动调用 track 函数进行追踪
      track(obj, "value");
      return value;
    },
  };
  return obj;
}

此时就可以正确的触发effect执行 2024-06-16-16-25-02.gif

完整代码

const data = { foo: 1, bar: 2 };
let activeEffect;
const effectStack = [];

const bucket = new WeakMap();

/**
 * 通过新增代码可以看到,传递给effect函数的fn才是真正的副作用函数,
 * 而effectFn是我们包装后的副作用函数,在effectFn中我们将fn的执行结果存储到res中
 */
export 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.deps = [];
  //  将 options 挂载到 effectFn 上
  effectFn.options = options;
  // 只有非lazy的时候,才执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn; //新增
}

export const obj = new Proxy(data, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    trigger(target, key);
    return true;
  },
});

function track(target, key) {
  if (!activeEffect) return target[key];
  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);
  activeEffect.deps.push(deps);
}

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  const effectsToRun = new Set(effects);
  effectsToRun.forEach((effectFn) => {
    // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      effectFn();
    }
  });
}

function cleanup(fn) {
  fn && fn.deps.forEach((dep) => dep.delete(fn));
  fn.deps.length = 0;
}

/**
 * 新增 scheduler 调度器
 * 通过set 将任务添加到调度器任务队列中自动去重
 */
// 调度器任务队列
export const jobQueue = new Set();
const p = Promise.resolve();
let isFlushing = false;

export function flushJob() {
  if (isFlushing) return;
  isFlushing = true;
  p.then(() => {
    jobQueue.forEach((job) => job());
  }).finally(() => {
    isFlushing = false;
    // jobQueue.length = 0;
  });
}

/**
 * 实现计算属性
 */
export function computed(getter) {
  // 用来缓存上一次计算的值
  let value;
  // 用来表示是否需要重新计算值,true代表需要重新计算
  let dirty = true;
  // 把getter作为副作用函数,创建一个lazy的effect
  const effectFn = effect(getter, {
    lazy: true,
    // 添加调度器,在调度器中奖dirty重置为true
    scheduler() {
      dirty = true;
      // 当计算属性依赖的响应式数据发生变化时,手动调用trigger函数触发响应式
      trigger(obj, "value");
    },
  });
  const obj = {
    get value() {
      if (dirty) {
        console.log("执行effect");
        value = effectFn();
        // 计算完之后,将dirty设置为false,下次访问直接使用缓存到value中的值
        dirty = false;
      }
      // 当读取 value 时,手动调用 track 函数进行追踪
      track(obj, "value");
      return value;
    },
  };
  return obj;
}

以上就是 computed 的实现过程。 是不是感觉也不是特别的难,基本都是结束effect 来进行二次封装。 关于effect的实现过程,感兴趣的同学可以翻阅前四章节。