计算属性
计算属性是基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。
侦听器
watch 响应数据的变化。当需要在数据变化时执行异步或开销较大的操作时,这个方式是最有用的。
计算属性 VS 侦听器
计算属性是计算出一个新的属性,并将这个属性挂载到 vm(Vue 实例上)。侦听器是监听 vm 上已经存在的响应式属性,所以可以用侦听器监听计算属性。
计算属性本质是一个惰性求值的观察者,具有缓存性,只有当依赖发生改成时,才会重新求值。侦听器是当依赖数据发生改变时就会执行回调。
在使用场景上,计算属性适合在一个数据被多少数据影响时使用,而侦听器适合在一个数据影响多个数据。
Vue2 Computed 原理分析
Vue 2.6.11
在 Vue2 中进行实例初始化时,会进行很多初始化,包括:初始化生命周期、初始化事件、初始化injections、初始化state(props,methods,data,computed,watch) 等等 。当在初始化 state 时就会进行 computed 的初始化。涉及到函数就是 initState。
function initMixin (Vue) {
Vue.prototype._init = function (options) {
var vm = this;
...
// 初始化props,methods,data,computed,watch
initState(vm);
...
};
}
调用 initState 函数会进行数据状态的初始化,在 Vue 中 props、methods、data、computed、watch 都可以被称为状态,所以被统一到 initState 函数中进行初始化。但是这里需要注意是先初始化 data,在初始化 computed,最后在初始化 watch。这个顺序其实是有一定讲究的。计算属性是基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。本质上计算属性是依赖响应式属性的,所以需要先将响应式属性初始化。而侦听器是监听 vm 上已经存在的响应式属性,实质上也是可以用侦听器监听计算属性的,所以 watch 是在计算属性初始化完之后进行初始化。
function initState (vm) {
...
// 初始化数据
if (opts.data) {
initData(vm);
} else {
observe(vm._data = {}, true /* asRootData */);
}
// 初始化计算属性
if (opts.computed) { initComputed(vm, opts.computed); }
// 初始化监听
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch);
}
...
}
接着调用 initComputed 函数进行 computed 的初始化,这里有几个点需要了解一下。
- 获取计算属性的定义 userDef 和 getter 求值函数,在 Vue 中定义一个计算属性有两种方法,一种是直接写一个函数,另外一种是添加 set 和 get 方法的对象形式。
- 计算属性的观察者 watcher 和 消息订阅器 dep。watcher 中实例化了 dep 并向 dep.subs 中添加了订阅者,dep 通过 notify 遍历 dep.subs 通知每个 watcher 更新。在创建 watcher 时传递了四个参数:vm 实例、getter 函数、noop 空格函数(watcher 的回调)、computedWatcherOptions 常量{ lazy: true }
在进行 Watcher 实例化时,传入常量{ lazy: true },会给当前 watcher 打上两个标记,一个标记是 lazy = true 表示当前 watcher 是计算属性的 watcher,一个标记是 dirty = ture,用于后续求值时标记是否需要重新求值 。
function initComputed (vm, computed) {
// $flow-disable-line
var watchers = vm._computedWatchers = Object.create(null);
// computed properties are just getters during SSR
var isSSR = isServerRendering();
// 遍历 computed 对象,为每一个属性进行依赖收集
for (var key in computed) {
// 1.
var userDef = computed[key];
// 获取 get
var getter = typeof userDef === 'function' ? userDef : userDef.get;
if (!isSSR) {
// 2.
watchers[key] = new Watcher(
vm, // vm 实例
getter || noop, // getter 求值函数或者是一个空函数
noop, // 空函数 function noop(a, b, c) {}
computedWatcherOptions // computedWatcherOptions 常量对象 { lazy: true };
);
}
if (!(key in vm)) {
// 3.
defineComputed(vm, key, userDef);
} else {
if (key in vm.$data) {
warn(("The computed property "" + key + "" is already defined in data."), vm);
} else if (vm.$options.props && key in vm.$options.props) {
warn(("The computed property "" + key + "" is already defined as a prop."), vm);
}
}
}
}
因为 computed 属性是直接挂载到实例对象中的,所以在定义之前需要判断对象中是否已经存在重名的属性,defineComputed 传入了三个参数:vm 实例、计算属性的 key 以及 userDef 计算属性的定义(对象或函数)。
defineComputed 定义计算属性。
if (!(key in vm)) {
defineComputed(vm, key, userDef);
} else {
...
}
function defineComputed (
target,
key,
userDef
) {
...
Object.defineProperty(target, key, sharedPropertyDefinition);
}
在 defineComputed 最后调用了原生的 Object.defineProperty 方法,并且在 Object.defineProperty(target, key, sharedPropertyDefinition); 传入属性描述符 sharedPropertyDefinition。 描述符初始化值为:
var sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
};
在 defineComputed 时,根据 Object.defineProperty 前面的代码可以看到 sharedPropertyDefinition 的 get/set 方法在经过 userDef 和 shouldCache 等多重判断后被重写,当非服务端渲染时,sharedPropertyDefinition 的 get 函数也就是createComputedGetter(key) 的结果。
var shouldCache = !isServerRendering();
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = shouldCache
? createComputedGetter(key)
: createGetterInvoker(userDef);
sharedPropertyDefinition.set = noop;
} else {
sharedPropertyDefinition.get = userDef.get
? shouldCache && userDef.cache !== false
? createComputedGetter(key)
: createGetterInvoker(userDef.get)
: noop;
sharedPropertyDefinition.set = userDef.set || noop;
}
if (sharedPropertyDefinition.set === noop) {
sharedPropertyDefinition.set = function () {
warn(
("Computed property "" + key + "" was assigned to but it has no setter."),
this
);
};
}
我们找到 createComputedGetter 函数调用结果并最终改写 sharedPropertyDefinition 大致呈现如下:
sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: function computedGetter () {
var watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
if (Dep.target) {
watcher.depend();
}
return watcher.value
}
},
set: userDef.set || noop
}
当计算属性被调用时便会执行 get 访问函数,从而关联上观察者对象 watcher。执行方法 evaluate。这个方法只有懒惰的观察者才会这样做。
Watcher.prototype.evaluate = function evaluate () {
this.value = this.get();
this.dirty = false;
};
到这里计算属性的初始化就已经完成,那计算属性又是如何根据响应式进行依赖缓存的了?
其实我们不难发现,当 vue 在执行 evaluate 方法时,本质上还是通过 watcher.get 来获取结算结果,当计算属性依赖的数据发生变化时,就会触发 set 方法,通知更新触发 update 方法。这是会将标记 dirty 设置为 ture,当再次调用computed 的时候就会重新计算返回新的值。
Watcher.prototype.update = function update () {
// computed Watcher
if (this.lazy) {
this.dirty = true;
} else if (this.sync) { // watch Watcher
this.run();
} else {
queueWatcher(this);
}
};
Vue3 Computed 原理分析
Vue 3.2.36
为了防止一部分同学对 vue3 的 computed
不是很熟悉,这里也会简单说下使用方式。
第一种使用方式,接受一个 getter 函数,并根据 getter 的返回值返回一个不可变的响应式 ref 对象。
const count = ref(1)
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value) // 2
plusOne.value++ // 错误
第二种使用方式,接受一个具有 get 和 set 函数的对象,用来创建可写的 ref 对象。
const count = ref(1)
const plusOne = computed({
get: () => count.value + 1,
set: val => {
count.value = val - 1
}
})
plusOne.value = 1
console.log(count.value) // 0
从使用方式来看,其实 vue3 和 vue2 机会是没有差别的。都是基于响应式依赖进行缓存。
举个例子:
const data = { name: '张三', age: 18 };
const state = reactive(data);
const newAge = computed(() => state.age + 1);
effect(() => {
document.getElementById('app').innerHTML = `${state.name}, 今年刚刚好${newAge.value}岁`
});
vue3 computed
依赖reactive
/ ref
响应属性的值进行计算,而 effect
依赖 computed
的值进行计算。
- computed 是 effect
- 变量 newAge 通过 age 计算而来
- 变量 age 收集了 computedEffect,而对于 computed 来说它收集渲染 effect。
computed
本身有两种使用方式:const xxx = computed(() => xxx)
const xxx1 = computed({get: () => {}, set: () => {}})
当我们在调用 computed 方法时,就会在这里需要统一做下区分,同时调用实现类ComputedRefImpl,这个方法比较简单,接下来我们重点分析下类ComputedRefImpl。
function computed(getterOrOptions, ...) {
let getter;
let setter;
const onlyGetter = isFunction(getterOrOptions);
if (onlyGetter) {
getter = getterOrOptions;
setter = () => {
console.warn('Write operation failed: computed value is readonly');
}
;
}
else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR);
...
return cRef;
}
ComputedRefImpl
类 :
class ComputedRefImpl {
constructor(getter, _setter, isReadonly, isSSR) {
this._setter = _setter;
this.dep = undefined;
this.__v_isRef = true;
this._dirty = true;
// 1.
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true;
triggerRefValue(this);
}
});
this.effect.computed = this;
this.effect.active = this._cacheable = !isSSR;
// 根据传入是否有setter函数来决定是否只读
this["__v_isReadonly" /* IS_READONLY */] = isReadonly;
}
get value() {
const self = toRaw(this);
trackRefValue(self);
if (self._dirty || !self._cacheable) {
self._dirty = false;
self._value = self.effect.run();
}
return self._value;
}
set value(newValue) {
this._setter(newValue);
}
}
这里将 ComputedRefImpl
类分为两块来解读。
第一块就是 **computed**
的初始化,调用 ComputedRefImpl
constructor 初始化,主要做两件事情:
- 创建 effect 对象,生成 watcher 监听函数并赋值给实例的 effect 属性。将当前 getter 当做监听函数,并附加调度器。
- 设置 computed ref 是否只是可读。设置是否可读的依据是:
onlyGetter||!setter
不过单单从构造方法来看其实和 computed 没有太大的关系,只是进行了初始化变量的操作,并创建了一个 ComputedRef 实例赋值给我们的调用。
我们发现声明一个 computed 时其实并不会执行 getter 方法,只有在读取 computed 值时才会执行它的 getter 方法,那么接下来我们就要关注 ComputedRefImpl 的 getter 方法。
上面提到的,第二部分就是 getter 方法的执行,getter 方法会在读取 computed 值的时候执行,而在 getter 方法中有一个叫 _dirty 的变量,它的意思是代表脏数据的开关,默认初始化时 _dirty 被设为 true ,在 getter 方法中表示开关打开,需要计算一遍 computed 的值,然后关闭开关,之后再获取 computed 的值时由于 _dirty 是 false 就不会重新计算。这就是 computed 缓存值的实现原理。
get value() {
...
if (self._dirty || !self._cacheable) {
self._dirty = false;
self._value = self.effect.run();
}
return self._value;
}
那么 computed 是怎么知道要重新计算值的呢?
computed 本身是依赖响应式属性的变化的,如果依赖的响应属性发生改变,会触发 effect 的 scheduler 函数执行。此方法就是 computed 内部依赖的状态变化时会执行的操作。所以最终的流程就是:computed 内部依赖的状态发生改变,执行对应的监听函数,这其中自然会执行 scheduler 里的操作。而在 scheduler 中将 _dirty 设为了 true 。
this.effect = new ReactiveEffect(getter, () => {
// effect 的 scheduler 函数执行
if (!this._dirty) {
this._dirty = true;
triggerRefValue(this);
}
});
也许看到这里有同学还会产生一个疑问,computed 是怎么知道内部依赖产生了变化呢?这是由于在我们第一次获取 computed 值(即执行getter方法)的时候对内部依赖进行了访问,在那个时候就对其进行了依赖收集操作,所以 computed 能够知道内部依赖产生了变化。
注意:上面提到的「第一次获取 computed 值」这里是第一次或者,而不是初始化 computed。
调试 Computed
Vue 3.2 +
在 Vue 3.2 + 的版本中,新增了 computed 调试的功能,computed 可接受一个带有 onTrack 和 onTrigger 选项的对象作为第二个参数:
onTrack 和 onTrigger 仅在开发模式下生效。
- onTrack 会在某个响应式 property 或 ref 作为依赖被追踪时调用。
- onTrigger 会在侦听回调被某个依赖的修改触发时调用。
这个调试 computed 在源码实现也比较简单,在 computed 初始化的时候,会将这个两个方法挂载 effect 上。const plusOne = computed(() => count.value + 1, {
onTrack(e) {
// 当 count.value 作为依赖被追踪时触发
debugger
},
onTrigger(e) {
// 当 count.value 被修改时触发
debugger
}
})
// 访问 plusOne,应该触发 onTrack
console.log(plusOne.value)
// 修改 count.value,应该触发 onTrigger
count.value++
当 computed 的 getter 被执行时,会触发跟踪依赖属性的function computed(getterOrOptions, debugOptions, isSSR = false) {
...
const cRef = new ComputedRefImpl(...);
if (debugOptions && !isSSR) {
cRef.effect.onTrack = debugOptions.onTrack;
cRef.effect.onTrigger = debugOptions.onTrigger;
}
return cRef;
}
trackRefValue
方法,如果存在 onTrack 就会执行 onTrack 回调。 ```javascript class ComputedRefImpl { … get value() { … trackRefValue(self); … } }
function trackRefValue(ref) { … trackEffects(ref.dep || (ref.dep = createDep()), { target: ref, type: “get” / GET /, key: ‘value’ }); … }
function trackEffects(dep, debuggerEventExtraInfo) { … activeEffect.onTrack(Object.assign({ effect: activeEffect }, debuggerEventExtraInfo)); … } ``` 类似的当依赖的属性被修改时,会触发 onTrigger 方法。
总结
不管在是 Vue 2 还是在 Vue 3 中,对 computed 本身的实现原理基本都是一样的。当使用 computed 计算属性时,组件初始化会对每一个计算属性都创建对应的 watcher , 然后在第一次调用自己的 getter 方法时,收集计算属性依赖的所有 data,那么所依赖的 data 会收集这个订阅者同时会针对 computed 中的 key 添加属性描述符创建了独有的 get 方法,当调用计算属性的时候,这个 get 判断 dirty 是否为 true,为真则表示要要重新计算,反之直接返回 value。当依赖的 data 变化的时候回触发数据的 set 方法调用 update() 通知更新,此时会把 dirty 设置成 true,所以 computed 就会重新计算这个值,从而达到动态计算的目的。