likes
comments
collection
share

从Vue3的一个PR看computed性能优化

作者站长头像
站长
· 阅读数 40

计算属性是基于它们的响应式依赖进行缓存的,**只在相关响应式依赖发生改变时它们才会重新求值。

在聊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
评论
请登录