2023年9月22日 · developer · 6分钟阅读

Vue3 响应式(3)-计算属性 computed

上文Vue3 响应式(2)-分支切换、嵌套、和无限递归解决了原有响应式系统的一些问题,实现了较为完善的数据绑定。本文在此基础上实现简单的计算属性功能 computed

调度器 Scheduler

在实现计算属性前,先介绍一下调度器 Scheduler,它将在实现计算属性和监听等特性时使用。

在原有的实现中,每次响应式数据发生变化时都会自动的调用副作用函数。但有时候我们可能希望控制副作用函数的调用时机,或者在调用前后做一些其他的事情,而不是让副作用函数直接执行。

通过之前的代码我们知道,副作用函数的执行是在 trigger 方法中的以下片段:

function trigger(target, key) {
  // ...
  effectToRun.forEach(fn => {
    if (fn !== activeEffect) fn()
  })
}

如果想要控制副作用函数的执行实际,则只需要传入一个回调函数,在上述代码片段中不直接执行 fn,而是把 fn 作为参数传给回调函数。具体如何执行副作用函数就可以通过传入不同的回调函数来决定了。这里可以通过给副作用函数绑定一个 scheduler 参数来实现,代码改动如下:

function effect(fn, options={}) {
  const effectFn = () => {
    //...
  }
  effectFn.depSets = []
  effectFn.options = options
  effectFn()
}

function trigger(target, key) {
  // ...
  effectToRun.forEach(fn => {
    if (fn === activeEffect) return
    if (fn.options.scheduler) {
      // 如果设置了调度器,则将副作用函数的执行交给调度器
      fn.options.scheduler(fn)
    } else {
      // 否则默认直接执行副作用函数
      fn()
    }
  })
}

计算属性 computed

computed 的设计目标

我们希望设计这样的一个函数 computed,该函数接受一个函数作为参数 (记为 getter),并返回一个对象 (记作 obj),该对象的值由 getter 计算获得。每当 getter 中所依赖的数据发生变化时,obj.value 都能被重新计算获得更新。

function computed(getter) {
  // ... 
  return {
    value: // computed by getter
  }
}

const obj = computed(() => {
  // ... depends on some value
  return res
})

console.log(obj.value)

并且在使用 Vue 的过程中我们还会发现,当计算属性被定义后而没有被使用前,计算属性所依赖的变量变化是不会被触发计算的,即“懒”执行。因此我们总结设计的 computed 要达到以下目标:

  • 响应式,当所依赖的响应式数据变化时能够更新状态
  • 返回一个由 getter 计算获得的值
  • “懒”执行,只有 obj.value 被读取时才会触发计算
  • 缓存,当计算结果不变时,computed 能够使用缓存值以减少重复的计算

返回值的实现

首先对于响应式的要求,我们之前实现的 effect 函数完全可以达到目的,直接调用 effect(getter) 就可以把 getter 中的依赖与 getter 绑定起来,每当依赖发生变化,getter 就会被重新执行。但问题在于无法拿到 getter 返回的值,为了达到这一目的可以对原有的 effect 做如下改动:

function effect(fn, options={}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)

    const res = fn()  // 获取副作用函数的结果

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

    return res  // 返回副作用函数结果
  }
  effectFn.depSets = []
  effectFn.options = options
  effectFn()  
  return effectFn  // 返回 effectFn
}

通过以上的改动,让 effectFn 函数从 effect 中返回,那么就可以从外部调用 effectFn 了,并且任何时候调用 effectFn 获得的返回值都是根据最新的依赖所计算的。

lazy 懒执行的实现

增加 options.lazy 来控制是否在调用 effect 时立即执行 effectFn,非常简单:

function effect(fn, options={}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)

    const res = fn()  // 获取副作用函数的结果

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

    return res  // 返回副作用函数结果
  }
  effectFn.depSets = []
  effectFn.options = options
  if (!options.lazy) {
    effectFn()  // 立即执行一次 effectFn 建立绑定
  }
  return effectFn  // 返回 effectFn
}

有了 lazy: true 选项,当我们不调用 effect 返回的 effectFn 时,effectFn 永远不会执行,并且也不会发生数据的绑定。这样无论计算属性所依赖的值发生多少次变化都不会触发计算属性。

但是,这里的“懒执行”其实仍然有一个问题 (不够“懒”),我将在下面介绍并解决它。

包装 computed

有了以上基础,包装计算属性就顺理成章了。我们只需要在每次读取 obj.value 时去执行一下 effectFn 并把其返回值作为 obj.value 返回给用户就可以了。这样自然而然想到使用对象的 get 方法。

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

缓存的实现

当我们多次读取 obj.value 时,即使依赖没有发生变化,还是会反复调用 effectFn 中的副作用函数 fn 来重新计算获得相同的结果。这显然是对性能的浪费。因此需要对其实现缓存的功能。缓存的实现也非常简单,在 computed 中缓存计算结果并设置标记,而在依赖发生变化时重置标记。当有标记时重新计算更新缓存,否则直接返回缓存。

function computed(getter) {
  let dirty = true 
  let cache  // 缓存计算结果
  const effectFn = effect(getter, { lazy: true });
  return {
    get value() {
      if (dirty) {
        // 需要重新计算
        cache = effectFn()  // 更新缓存
        dirty = false  // 设置标记
      }
      return cache;
    }
  };
}

但以上的修改只完成了“设置标记”的工作,还应该在依赖发生变化即在 trigger 中调用 effectFn 时重置标记。此时调度器 Scheduler 就派上了用场:

function computed(getter) {
  let dirty = true  // 标记是否需要从新计算
  let cache  // 缓存计算结果
  const effectFn = effect(getter, { 
    lazy: true,
    scheduler: () => {
      dirty = true
    }
  });
  return {
    get value() {
      if (dirty) {
        cache = effectFn()
        dirty = false
      }
      return cache;
    }
  };
}

通过调度器就完成了“清除标记”的工作。同时它还解决了“懒执行”中的遗留问题:在原来的代码中,一旦计算属性的依赖发生,无论有没有读取计算属性的值,都会触发它所关联的副作用函数 (即 getter),这并不“懒”。但通过调度器,跳过了 triggerfn 的执行,仅仅设置了 dirty 标记。因此依赖发生变化时,并不会执行副作用函数,除非真正的读取计算属性的值,真正实现了“懒”执行。

最后一个问题

当我们使用 effect 包裹一个依赖于计算属性的副作用函数时,当然希望当计算属性发生变化时会触发副作用函数。这就像使用 Vue 时把计算属性渲染到模板中,当计算属性发生变化时更新模板中的值。例如:

const data = reactive({ foo: 1, bar: 2})
const sumRes = computed(() => data.foo + foo.bar)

effect(() => {
  document.body.innerText = sumRes.value
})

data.foo = 100

当把 data.foo 的值更新为 100 时,我们希望更新页面文本的副作用函数会被调用,从而重新计算 sumRes.value 把最新的值 202 更新到页面。但实际上这并没有发生。这是因为在定义计算属性时只会将 data.foo, foo.bar 与包装 getter 的副作用函数关联,而不会与包裹计算属性的外层副作用函数产生关联。这和我们在上一篇文章中介绍的嵌套的 effect 是一样的,即内部的副作用函数里依赖的变量发生变化,只会触发内部副作用函数而不会触发外部副作用函数。但在计算属性的需求下,我们需要它来触发外部的副作用函数。

为了修复上面的问题,需要在 computed 的实现中通过手动调用 tracktrigger 添加关联:

function computed(getter) {
  let dirty = true  // 标记是否需要从新计算
  let cache  // 缓存计算结果
  const effectFn = effect(getter, { 
    lazy: true,
    scheduler: () => {
      dirty = false 
      trigger(obj, 'value')  // 2.发生变化时触发副作用函数
    }
  });
  const obj = {
    get value() {
      if (dirty) {
        cache = effectFn()
        dirty = false
      }
      track(obj, 'value')  // 1.读取值时添加关联
      return cache;
    }
  };
  return obj
}

当计算属性出现在 effect 内部时,此时 activeEffect 的值则为当前的副作用函数,因此读取计算属性的值时会调用 track 把计算属性的 value 与当前的副作用函数关联起来。自然地,当计算属性的值更新时,则会调用 trigger 把相关的副作用函数执行。

小结

本文主要介绍了调度器和计算属性的实现,其中计算属性涉及“懒”执行和缓存等问题。最终的代码实现如下。可以点击这里在线运行测试。

console.log("code: Vue3_reactive_3_computed");

let activeEffect;
const effectStack = [];
const bucket = new WeakMap();

const data = reactive({ foo: 1, bar: 2 });
const sumRes = computed(() => data.foo + data.bar);

effect(() => {
  document.body.innerText = sumRes.value;
});

setTimeout(() => {
  data.foo = 100;
}, 2000);

function computed(getter) {
  let dirty = true;
  let cache;
  const effectFn = effect(getter, {
    lazy: true,
    scheduler: () => {
      dirty = true;
      trigger(obj, "value");
    }
  });
  const obj = {
    get value() {
      if (dirty) {
        cache = effectFn();
        dirty = false;
      }
      track(obj, "value");
      return cache;
    }
  };
  return obj;
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key);
      return Reflect.get(target, key);
    },
    set(target, key, value) {
      const res = Reflect.set(target, key, value);
      trigger(target, key);
      return res;
    }
  });
}

function track(target, key) {
  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);
  activeEffect.depSets.push(deps);
}

function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  if (!deps) return;
  const depsToRun = new Set(deps);
  //  depsToRun.forEach(fn => fn())
  depsToRun.forEach((fn) => {
    if (fn === activeEffect) return;
    if (fn.options.scheduler) {
      // 如果设置了调度器,则将副作用函数的执行交给调度器
      fn.options.scheduler(fn);
    } else {
      // 否则默认直接执行副作用函数
      fn();
    }
  });
}

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);

    const res = fn(); // 获取副作用函数的结果

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

    return res; // 返回副作用函数结果
  };
  effectFn.depSets = [];
  effectFn.options = options;
  if (!options.lazy) {
    effectFn(); // 立即执行一次 effectFn 建立绑定
  }
  return effectFn; // 返回 effectFn
}

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

参考资料

[1] vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.[EB/OL]. [2023-09-22]. https://github.com/vuejs/core.

[2] Vue.js - 渐进式 JavaScript 框架 | Vue.js[EB/OL]. [2023-09-22]. https://cn.vuejs.org/.

[3] 霍春阳. Vue.js设计与实现[M/OL]. 人民邮电出版社, 2022[2023-09-19]. https://book.douban.com/subject/35768338/.

[4] JavaScript | MDN[EB/OL]. 2023-04-10. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript.

评论

登录 — 登录后参与讨论。