深入浅出 Vue3 计算属性源码

2,017 阅读8分钟

Vue.js Computed Properties | malcoded

前言

计算属性一直是 Vuejs 被认为设计最为巧妙的特性之一

它允许开发者封装复杂的表达式,返回计算后的结果,实现数据分层,提高视图更新时的性能与代码可读性

import { ref, computed } from "vue";
const number = ref(1);
const addOne = computed(() => number.value + 1);
const addTwo = computed(() => addOne.value + 2);
const multiplyThree = computed(() => addTwo.value * 2);

可以说计算属性是 Vuejs 版本的“纯函数”

  • 没有副作用:计算属性为一个或多个表达式的封装,不会造成依赖以外的数据修改(实际也可以在 getter 里发送请求、改变 DOM,但是不推荐
  • 无法被“修改”:只要计算属性的依赖不变,计算属性的值就不会改变,即使重复访问计算属性,也不会多次触发计算

本文避开晦涩的源码,用简单的代码实现计算属性,帮助大家更多从设计层面理解计算属性

effect

分析计算属性前,必须要了解 effect

effect 在官方文档中没有记载,但它撑起了响应式系统的半壁江山,可以从官网中找到一个类似的 watchEffect

image-20220416160556871

import { ref, watchEffect } from "vue";
const number = ref(1);
watchEffect(() => console.log(number.value)); // -> logs 1
number.value++; // -> logs 2

watchEffect 的第一个参数是函数,当 watchEffect 执行时会立即执行该函数,同时响应地跟踪它的依赖关系,并在依赖关系发生变化时重新运行它

上述例子中,当响应式对象 number 更新时,console.log 会重新执行

如果把 watchEffect 换成 effect,上述代码同样成立

import { ref, effect } from "vue";
const number = ref(1);
effect(() => console.log(number.value)); // -> logs 1
number.value++; // -> logs 2

因此得出一个初步结论: effect 和 watchEffect 功能相似,接收一个函数并立即运行,同时响应地跟踪它的依赖关系

function effect(fn) {
  fn();
  return fn;
}

实际在 fn 执行时,还会跟踪响应式对象的依赖关系,本文跳过了这部分实现,将重点放在计算属性本身

了解了 effect 的基本作用,回头看计算属性的参数,是不是与 effect 有些许相似?

import { ref, effect, computed } from "vue";
const number = ref(1);
​
effect(() => console.log(number.value)); // -> logs 1
const addOne = computed(() => number.value + 1);
​
number.value++; // -> logs 2
console.log(addOne.value); // -> logs 3

两者都接收一个函数作为参数,当响应式对象 number 更新时,console.log 会重新执行,计算属性的值也会更新,两者区别在于

  • 计算属性的返回值是计算的结果,只有访问计算属性时才执行函数
  • effect 一般不需要声明返回值,并且会立即执行函数

事实上,计算属性就是 Vuejs 基于 effect 扩展而生的能力

Whaaat? | Minions bilder, Lustige bilder, Fun bilder

除了继承自 effect 本身的依赖追踪能力,计算属性还有两个特点

  • 延迟计算
  • 缓存结果

延迟计算

计算属性初始化时不会触发计算,只有在访问时才触发计算

import { ref, computed } from "vue";
const number = ref(1);
const addOne = computed(() => {
  console.log("run computed");
  return number.value + 1;
});
number.value++;
// console.log(addOne.value);

改造一下案例,在计算属性的 getter 中添加一行 console.log ,以此判断是否触发了计算

接着注释最后一行,查看控制台输出

image-20220416190221330

没有任何日志,意味着没有触发计算。随后去掉注释

image-20220416190326820

控制台打印了 run computed,以此验证计算属性只有在访问时才触发计算。这一点很容易理解,没有必要给未使用的变量定义值,节省不必要的计算

为了实现延迟计算的能力,需要改造下上一章节实现的 effect,首先避免函数立即执行,添加一个 lazy 选项

function effect(fn, options = {}) {
  if (!options.lazy) {
    fn();
  }
  return fn;
}

当 options.lazy = true 时,允许手动控制 effect 的执行时机,延迟计算就完成了一半

<template>
  <button @click="addOne">{{ number }}</button>
</template>
​
<script setup>
import { ref, effect } from "vue";
const number = ref(1);
const addOne = effect(
  () => {
    number.value = number.value + 1;
  },
  { lazy: true }
);
</script>

Kapture 2022-04-16 at 19.32.07

接着实现计算属性本身,由于计算属性的返回值就是 getter 的返回值,且只有在访问计算属性时才触发重新计算,很容易联想到通过访问器属性实现

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

访问 value 属性 -> 触发访问器属性 -> 手动触发 effect -> 执行 getter

至此实现了完整的延迟计算能力

缓存结果

计算属性在第一次触发计算后会缓存计算结果

如果依赖不变,多次访问计算属性始终返回第一次计算的缓存

import { ref, computed } from "vue";
const number = ref(1);
const addOne = computed(() => {
  console.log("run computed");
  return number.value + 1;
});
number.value++;
console.log(addOne.value);
console.log(addOne.value);

image-20220417171917088

第一次访问计算属性时,控制台打印了 run computed,第二次访问时控制台没有打印日志,却得到同样的结果

证明从第二次访问开始不会触发计算,使用的都是第一次的缓存。对大量数据(例如表格,feed 流)更新时,合理运用计算属性可以有效避免计算资源浪费

实现缓存能力非常简单,利用闭包存储缓存

  function computed(getter) {
+   let dirty = true
+   let value
    const effectFn = effect(getter, { lazy: true });
    const computedValue = {
      get value() {
+       if(dirty){
+         value = effectFn()
+         dirty = false
+       }
        return value
      },
    };
    return computedValue;
  }

添加一个 dirty 的标志,默认 true,当第一次访问计算属性时进行计算,拿到结果后缓存起来,并将 dirty 变成 false

后续访问计算属性时,由于 dirty 为 false,跳过计算返回缓存即可

到这里只能算完成一半。当计算属性的依赖改变时,还需要清空缓存重新计算

我们知道,依赖改变会重新触发 effect 的执行,但 effect 在初始化时就已经被定义,无法修改,那么有办法介入 effect 执行的过程,将计算属性中的 dirty 变成 true 呢?

我们进一步改造 effect 的实现

  function effect(fn, options = {}) {
    if (!options.lazy) {
      fn();
    }
+   if (options.scheduler) {
+     fn.scheduler = options.scheduler;
+   }
    return fn;
  }

传入一个 scheduler (调度器)配置项,一旦 effect 初始化时声明了 scheduler 且 effect 的依赖改变时,则将 fn 执行的时机、次数以及方式完全交给 scheduler 处理

image-20220425160826270

具体依赖更新时是如何触发 effect 的,涉及到 Vuejs 响应式原理的更新逻辑,由于篇幅有限本文不做深入探讨,这里假设 effect 能够识别触发的类型,分别执行 fn、scheduler

这样当计算属性的依赖改变时,让 scheduler 代替 fn 执行,停止使用计算属性缓存,并使下次访问计算属性时,重新触发计算

  function computed(getter) {
    let dirty = true;
    let value;
    const effectFn = effect(getter, {
      lazy: true,
+     scheduler(fn) {
+       if (!dirty) {
+         dirty = true;
+       }
+     },
    });
    const computedValue = {
      get value() {
        if (dirty) {
          value = effectFn();
          dirty = false;
        }
        return value;
      },
    };
   return computedValue;
}

通知更新

实现了缓存功能后,剩下最后一个问题

前面说道,计算属性拥有延迟计算的特点,即使计算属性的依赖更新了,但只有访问计算属性时才会触发重新计算

import { ref, computed, effect } from "vue";
const number = ref(1);
const addOne = computed(() => number.value + 1);
effect(() => console.log(addOne.value)); // -> logs 2
number.value++; // -> logs 3

上述例子中,当 number.value++ 时,effect 会重新执行,依次打印 2, 3。但如果换成上一章节实现的计算属性

import { ref, effect } from "vue";
function computed(getter) {
  let dirty = true;
  let value;
  const effectFn = effect(getter, {
    lazy: true,
    scheduler(fn) {
      dirty = true;
    },
  });
  const computedValue = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    },
  };
  return computedValue;
}
​
const number = ref(1);
const addOne = computed(() => number.value + 1);
effect(() => console.log(addOne.value)); // -> logs 2
number.value++;

image-20220417205636068

控制台没有打印数字 3,换句话说当 number.value++ 时,并没有使 effect 重新执行

解决方法是将计算属性与 effect 建立“联系”,当 effect 执行时,一旦访问了计算属性,计算属性需要保存此时的 effect。另一方面当计算属性的依赖更新时,需要取出 effect 并重新执行

+ let activeEffect;
​
  function effect(fn, options = {}) {
+   activeEffect = fn;
    if (!options.lazy) {
      fn();
    }
    if (options.scheduler) {
      fn.scheduler = options.scheduler;
    }
    return fn;
  }
​
  function computed(getter) {
    let dirty = true;
    let value;
+   let deps = new Set()
    const effectFn = effect(getter, {
      lazy: true,
      scheduler(fn) {
        if (!dirty) {
          dirty = true;
+         deps.forEach(effect => effect());
        }
      },
    });
    const computedValue = {
      get value() {
        if (dirty) {
          value = effectFn();
          dirty = false;
        }
+       deps.add(effect);
        return value;
      },
    };
    return computedValue;
  }

通过 activeEffect 变量记录当前正在运行的 effect

运行 effect 过程中,一旦访问了计算属性,就将其存储在访问的计算属性内部的 deps 中,由于可能会有多个 effects 访问同一个计算属性,因此 deps 是一个数组

当计算属性的依赖更新时,触发 scheduler,取出 deps 并依次执行,让 effets 重新访问计算属性并执行新一轮的计算

总结

计算属性有两个特点

  • 延迟计算:初始化时不会触发计算,只有在访问时才触发计算
  • 缓存结果:依赖不变,多次访问计算属性始终返回第一次计算的缓存

Vuejs 通过访问器属性实现延迟计算,当访问计算属性的 value 时再触发计算

Vuejs 通过闭包实现缓存结果,当依赖更新时,通过特殊的 scheduler 清空缓存并通知存储的 effects 重新访问计算属性

附上一个可用的计算属性案例(依赖标准的 effect 实现)

import { ref, effect } from "vue";
​
const effectStack = [];
​
const customEffect = (fn, options) => {
  effectStack.push(fn);
  const res = effect(fn, options);
  effectStack.pop();
  return res;
};
​
function computed(getter) {
  let dirty = true;
  let value;
  let deps = new Set();
  const effectFn = customEffect(getter, {
    lazy: true,
    scheduler(fn) {
      if (!dirty) {
        dirty = true;
        deps.forEach((effect) => effect());
      }
    },
  });
  const computedValue = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      const activeEffect = effectStack[effectStack.length - 1];
      if (activeEffect) {
        deps.add(activeEffect);
      }
      return value;
    },
  };
  return computedValue;
}
​
const number = ref(1);
const addOne = computed(() => number.value + 1);
customEffect(() => {
  console.log(addOne.value);
}); // -> logs 2
number.value++; // -> logs 3

参考资料

《Vue.js 设计与实现》

Vue.js website

Vue3源码解析(computed-计算属性)