从Vue3的一个PR看computed性能优化
计算属性是基于它们的响应式依赖进行缓存的,**只在相关响应式依赖发生改变时它们才会重新求值。
在聊computed性能优化之前,我们先看看computed会有什么性能问题。
const age = ref(1)
const ageC = computed(() => age.value * 0)
const click = () => {
age.value++
}
如上所示,ageC是一个计算属性,根据上面计算属性的定义,当它依赖的age发生变化时。ageC对应effect的dirty属性会变为true,导致ageC.value被访问的时候,对应的getter会重新执行。注意看getter,这里我用了一个很极端的例子
() => age.value * 0
小数数学来了,任何数*0结果都是0。那也就是说age一直在变,但是ageC始终不变。那么这种情况,会发生什么?
上面的问题,在Vue2就已经存在了,我之前的文章有提到过 。因为Vue2和3两个版计算属性的依赖收集是有些区别的,所以两个版本我都分别解释一下。
vue2版本的计算属性的watcher,在初始化的时候会收集age的dep(相互的过程,那么aged的deg也会收集到ageC的watcher),当age发生变化,就可以通知ageC的watcher干点东西,比如更新页面。实际上很遗憾,ageC的watcher不具备通知页面更新的能力,更新页面是age的dep干的。
贴个源码简单解释一下,当render访问ageC的时候,实际会调用computedGetter这个方法,这个方法会让age的dep去收集render watcher**(watcher.depend())。通过计算属性watcher收集其响应式依赖的dep,再让这些dep去收集render watcher的方式,避免了age不在template上就收集不到render watcher的尴尬😅。**
function createComputedGetter(key) {
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
if (Dep.target) {
watcher.depend();
}
return watcher.value;
}
};
}
现在可以回答上面的问题了,很明显👓,页面更新只和计算属性依赖的响应式的值有关,所以当age变了,就一定会通知render watcher更新页面,顺便通知computed watcher把dirty改为true,以便render的时候重新计算。
if (watcher.dirty) {
watcher.evaluate();
}
render watcher开始干活了,虽然有diff把关不会真的操作到dom,但这里render watcher也是一次无意义的干活。
那么能不能让计算属性自己的watcher去通知页面更新,这样就有了机会做一层新旧值的比对。实际上vue2的时候有实现过这种机制,刚好黄轶老师的vue源码分析电子书,就是拿的vue2.5.17-beta.0版本分析,有兴趣可以看看这个版本的computed的实现。
但是又很遗憾,存在bug所以被弃用,这里贴出代码解释一下,created里面有两个定时器,对list更新两次,每次更新导致compValue重新计算,compValue返回的其实就是一个数组,这个数组的长度和list保持一直,每一项都是‘abc’。
实际表现是当页面list长度为4时,compValue还是2。说明第二次list更新并没有触发compValue重新计算。
<div id="app">
<div>
{{values}}
</div>
<diV>
{{ compValue }}
</diV>
<div>
{{ list }}
</div>
</div>
const vm = new Vue({
el: '#app',
data () {
return {
values: {},
list: []
}
},
methods: {
getValue (key) {
if (!this.values[key]) {
Vue.set(this.values, key, 'abc');
}
return this.values[key]
}
},
computed: {
compValue () {
return this.list.map((x, i) => {
return this.getValue('myValue')
})
}
},
created () {
setTimeout(() => {
this.list = [0, 1]
}, 2000)
setTimeout(() => {
this.list = [0, 1, 2, 3]
}, 4000)
}
})
第一次触发了computed计算,第二次却没有,对于这种问题,我觉得第一时间应该要考虑vue的cleanupDep。只有这个方法中会清理依赖,导致后续失去响应式。调试一番,果然问题是出在这里。vue2的依赖收集调试不是很直观,因为每个dep只有id,压根不知道哪个dep对应的是哪个属性,所以建议大家调试的时候可以稍微改下源码,给dep加多一个属性存放对应的key。大家如果有更好的方法,烦请在评论区指导我。
触发cleanupDep的地方在
Vue.set(this.values, key, 'abc');
在computed的getter执行过程中触发了Vue.set,这个方式具体干了什么,我有文章也分析过。对this.values新增了属性,那么就一定会触发this.values去通知收集到的watcher去更新。比如说对象新增了属性,那要通知页面更新吧。依赖收集有个很重要的逻辑就是通知了某个watcher干活,干完后就要清理一下依赖,就是把这次收集的和上次的对比,清理掉不在这次收集范围里面的。这里也一样,通知了页面进行更新,但是这个过程是异步的,我们先忽略。还有一个同步的更新,就是this.values还通知了compValue的watcher,毕竟compValue也依赖了this.values。这个是上面2.5.17-beta版本的一段代码,注意看第二行,const value = this.get()。
getAndInvoke (cb: Function) {
const value = this.get()
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
this.dirty = false
if (this.user) {
try {
cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
cb.call(this.vm, value, oldValue)
}
}
}
当compValue的watcher再次干活的时候,注意⚠️,本来compValue的getter已经访问了this.list,收集了list的dep,但是compValue的watcher干完活后(就是getter重新调用),开始了cleanupDeps。
Watcher.prototype.get = function get () {
pushTarget(this);
var value;
var vm = this.vm;
try {
value = this.getter.call(vm, vm);
} catch (e) {
if (this.user) {
handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value);
}
popTarget();
this.cleanupDeps();
}
return value
};
这个cleanupDeps很关键,因为这里会把list的dep给清理了。因为当上次触发cleanupDeps的时候,会把newDeps清空。
Watcher.prototype.cleanupDeps = function cleanupDeps () {
var this$1 = this;
var i = this.deps.length;
while (i--) {
var dep = this$1.deps[i];
if (!this$1.newDepIds.has(dep.id)) {
dep.removeSub(this$1);
}
}
var tmp = this.depIds;
this.depIds = this.newDepIds;
this.newDepIds = tmp;
this.newDepIds.clear();
tmp = this.deps;
this.deps = this.newDeps;
this.newDeps = tmp;
this.newDeps.length = 0;
};
list第一个更新的时候长度为2,这里有两次循环,第一个循环里面调用了Vue.set,导致compValue这个gettter重新执行,并且清理了依赖。第一次循环完后,newDeps为空,再走第一个循环,newDeps只会收集到this.value,而收集不到list,等这个getter走完,进行cleanupDeps的时候,因为这次并没有收集到list,于是list就被干掉了。同时list的dep也移除了compValue的watcher,所以第二次更新的时候,通知不到compValue重新计算。
computed: {
compValue () {
return this.list.map((x, i) => {
return this.getValue('myValue')
})
}
},
因为有上面的问题,所以vue并不会在响应式依赖值变化的时候马上对计算属性求值,而是仅仅把计算属性的dirty属性改变,等待真正访问计算属性的时候再求值。所以计算属性无法先对比新旧值,再通知页面更新,即使vue3的计算属性具备了通知render watcher更新的能力。
上面我们分析了computed的一个问题,接下来我们再看另外的一个问题。这段代码是从vue3的一个pr复制来的。顶部三个变量记录三个计算属性的getter调用次数,而min依赖于sec,我们可以看到sec大概是10个循环,值才会变化一次。但是min_counter和sec_counter结果一样,说明sec没改变,也会导致min重新充值。
let sec_counter = 0
let min_counter = 0
let hour_counter = 0
const ms = ref(0)
const sec = computed(() => { sec_counter++; return Math.floor(ms.value / 1000) })
const min = computed(() => { min_counter++; return Math.floor(sec.value / 60) })
const hour = computed(() => { hour_counter++; return Math.floor(min.value / 60) })
for (ms.value = 0; ms.value < 10000000; ms.value += 100) {
hour.value
}
console.log(`sec: ${sec.value}, sec_counter: ${sec_counter}`) // sec: 10000, sec_counter: 100001
console.log(`min: ${min.value}, min_counter: ${min_counter}`) // min: 166, min_counter: 100001
console.log(`hour: ${hour.value}, hour_counter: ${hour_counter}`) // hour: 2, hour_counter: 100001
优化后的效果,min_counter的次数是10001,减少了10倍。符合上面说的,sec的值100个循环才会变化一次。
let sec_counter = 0
let min_counter = 0
let hour_counter = 0
const ms = ref(0)
const sec = computed(() => { sec_counter++; return Math.floor(ms.value / 1000) })
const min = computed(() => { min_counter++; return Math.floor(sec.value / 60) })
const hour = computed(() => { hour_counter++; return Math.floor(min.value / 60) })
for (ms.value = 0; ms.value < 10000000; ms.value += 100) {
hour.value
}
console.log(`sec: ${sec.value}, sec_counter: ${sec_counter}`) // sec: 10000, sec_counter: 100001
console.log(`min: ${min.value}, min_counter: ${min_counter}`) // min: 166, min_counter: 10001
console.log(`hour: ${hour.value}, hour_counter: ${hour_counter}`) // hour: 2, hour_counter: 167
下面看看这个pr是如果实现的(注释部分是旧代码)
this.effect = new ReactiveEffect(getter, (_c) => {
if (!this._dirty) {
//this._dirty = true
//triggerRefValue(this)
if (_c) {
this._computedsToAskDirty.push(_c)
}
else {
this._dirty = true
}
triggerRefValue(this, this)
}
先看effect的初始化, 这个回调函数就是vue3新增的scheduler,新增了一个_c的参数,这个_c也是一个计算属性。原来的做法就是响应式值变化,触发scheduler,把_dirty改为true,接着再让这个计算属性通知依赖它的地方干活,可能是页面渲染,可能是更新其他计算属性。新的逻辑也有这个通知,但是注意⚠️,一旦传了_c,计算属性的_dirty不会改为true,而是走了另外的分支,这个_c被_computedsToAskDirty收集了。
再看看get的逻辑,计算属性真正被取值的地方。
get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
if (!self._dirty) {
for (const computedToAskDirty of self._computedsToAskDirty) {
computedToAskDirty.value
if (self._dirty) {
break
}
}
}
trackRefValue(self)
if (self._dirty || !self._cacheable) {
const newValue = self.effect.run()!
if (hasChanged(self._value, newValue)) {
triggerRefValue(this, undefined)
}
self._value = newValue
self._dirty = false
//self._value = self.effect.run()!
}
self._computedsToAskDirty.length = 0
return self._value
}
这一块是新增的重要逻辑 ,看上去很怪异!self._dirty才会走进去,但是里面self._dirty又怎么会改为true。并且当self._dirty是false的时候,多了一个toAskDirty的逻辑,原来就是return self._value就可以。
多了一个toAskDirty的逻辑,是因为上面的scheduler,有参数传入的情况,不会改变计算属性的dirty值,而是等get的时候去问。问谁?
if (!self._dirty) {
for (const computedToAskDirty of self._computedsToAskDirty) {
computedToAskDirty.value
if (self._dirty) {
break
}
}
}
再看看这两个计算属性,我们从头分析一下。首先sec的scheduler只能是ms这个普通ref去触发
const sec = computed(() => { sec_counter++; return Math.floor(ms.value / 1000) })
const min = computed(() => { min_counter++; return Math.floor(sec.value / 60) })
triggerEffects和triggerEffect多了一个computedToAskDirty的参数,从这个PR的修改文件来看,普通ref的triggerEffects传的是undefined。也就是说sec的effect.scheduler()传了undefined,sec的effect毫无疑问会改为true。
export function triggerEffects(
dep: Dep | ReactiveEffect[],
computedToAskDirty: ComputedRefImpl<any> | undefined,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// spread into array for stabilization
const effects = isArray(dep) ? dep : [...dep]
for (const effect of effects) {
if (effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
triggerEffect(effect, computedToAskDirty, debuggerEventExtraInfo)
}
}
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
triggerEffect(effect, computedToAskDirty, debuggerEventExtraInfo)
}
}
}
当重新render的时候,走到sec的get,这里会有一个新旧的对比,若变化 triggerRefValue(this, undefined)第二个参数传了undefined
if (self._dirty || !self._cacheable) {
const newValue = self.effect.run()!
if (hasChanged(self._value, newValue)) {
triggerRefValue(this, undefined)
}
self._value = newValue
self._dirty = false
//self._value = self.effect.run()!
}
triggerRefValue其实就是透传了computedToAskDirty给triggerEffects。而ref.dep,就包含了依赖sec的min和hour。因为computedToAskDirty传了undefined,所以这个两个计算属性的dirty也会改为true。
export function triggerRefValue(ref: RefBase<any>, computedToAskDirty: ComputedRefImpl<any> | undefined, newVal?: any) {
ref = toRaw(ref)
if (ref.dep) {
triggerEffects(ref.dep, computedToAskDirty)
}
}
以上是sec先被访问的情况,如果互换一下位置,渲染的时候先访问sec呢
const min = computed(() => { min_counter++; return Math.floor(sec.value / 60) })
const sec = computed(() => { sec_counter++; return Math.floor(ms.value / 1000) })
那么就会走到新加的if逻辑,以此遍历self._computedsToAskDirty,这个数组里面收集了它依赖的计算属性,在我们这里就是min,然后手动调用一次min.value,触发min的get,结合上面的分析,min的get执行,一旦值变化了就会把所有依赖它的计算属性的dirty改为true,这也解释上面的问题,一开始是false,为什么computedToAskDirty.value 后就会有个是否为true的判断。
if (!self._dirty) {
for (const computedToAskDirty of self._computedsToAskDirty) {
computedToAskDirty.value
if (self._dirty) {
break
}
}
}
最后一个问题,计算属性的_computedsToAskDirty是怎么收集它依赖的其他计算属性的?还是看effect初始化的地方
this.effect = new ReactiveEffect(getter, (_c) => {
if (!this._dirty) {
if (_c) {
this._computedsToAskDirty.push(_c)
}
else {
this._dirty = true
}
triggerRefValue(this, this)
}
})
triggerRefValue在通知依赖它的effect的时候,第二个参数传的是this,也就是它自己,再通过上面说的透传,最终会传给依赖它的effect.scheduler被收集起来。
最后拿两个计算属性a,b总结一下这个PR的思路。核心思想就是让依赖了其他计算属性的计算属性(b依赖a),在其他计算属性(a)通知它(b)要改变dirty属性的时候,改成了不马上改变,而是把它所依赖的计算属性(a)先收集起来,等b真正被取值的时候,再去问它所收集到计算属性们(a)有没有更新,如果这些计算属性(a)还没问访问,那么就手动访问一次。通过这样一个延迟改变dirty属性的方式,优化了性能。
这个PR我看完觉得很妙,但是很绕😵💫。
转载自:https://juejin.cn/post/7108679480226349063