computed 原理解析
在 Vue 官网文档里面,对 computed
有这么一句描述:
计算属性的结果会被缓存,除非依赖的响应式属性变化才会重新计算。注意,如果某个依赖 (比如非响应式属性) 在该实例范畴之外,则计算属性是不会被更新的。
这句话非常重要,涵盖了 computed
最关键的知识点:
-
computed
会搜集并记录依赖。 - 依赖发生了变化才会重新计算
computed
,由于computed
是有缓存的,所以当依赖变化之后,第一次访问computed
属性的时候,才会计算新的值。 - 只能搜集到响应式属性依赖,无法搜集到非响应式属性依赖。
- 无法搜集到当前
vm
实例之外的属性依赖。
如果仅局限于知道上述规则,而不理解内部机制,那么在实际开发中难免步步惊心,不敢甩手大干。
Vue 内部是怎么知道 computed
依赖的?
对于在 RequireJS
时代摸爬滚打过不少年的同学来说,可能一下就会联想到 RequireJS
获取依赖的原理,使用 Function.prototype.toString()
方法将函数转换成字符串,然后借助正则从字符串中查找 require('xxx')
这样的代码,最终分析出来依赖。
这种方式实际上有非常多的限制的:
- 如果在注释里面出现了
require('xxx')
,岂不是会匹配出多余的依赖。 - 在开发中,描述依赖的时候,必须要写成
require('xxxx')
的形式,require
中的字符串参数不能是各种动态的、复杂的字符串拼接,否则就无法解析了。
Vue 显然没有使用这么低效不准确的方式。
我们可以先看一段伪代码:
const vm = {
dependencies: [],
obj: {
get a() {
// this 指向 vm 对象。
this.dependencies.push('a');
return this.obj.b;
},
get b() {
this.dependencies.push('b');
return 1;
}
},
computed: {
c: {
get() {
// this 指向 vm 对象。
return this.obj.a;
}
}
}
};
vm.dependencies = [];
console.log(vm.c);
console.log('vm.c 依赖项:', vm.dependencies); // 输出: vm.c 依赖项: a, b
在上述代码中,访问 vm.c
之前,清空了一下 vm.dependencies
数组,访问 vm.c
的时候,会调用相应的 get()
方法,在 get()
方法中,访问了 this.obj.a
,而对于 this.obj.a
的访问,又会调用相应的 get
方法,在该 get
方法中,有一句代码 this.dependencies.push('a')
,往 vm.dependencies
中放置了当前执行流程中依赖到的属性,然后以此类推,在 vm.c
访问结束之后, vm.dependencies
里面就记录了 vm.c
的依赖 ['a', 'b']
了。
到这里,有的同学可能会产生新的疑问:如果在 vm.obj.a
中出现条件分支语句,岂不是会出现依赖搜集不完整的情况?且看如下修改后的代码:
const vm = {
dependencies: [],
obj: {
get a() {
// this 指向 vm 对象。
this.dependencies.push('a');
if (this.obj.d) {
return this.obj.b;
}
return 2;
},
get b() {
this.dependencies.push('b');
return 1;
},
get d() {
this.dependencies.push('d');
return this._d;
},
set d(val) {
this._d = val;
}
},
computed: {
c: {
get() {
// this 指向 vm 对象。
return this.obj.a;
}
}
}
};
vm.dependencies = [];
vm.obj.d = false;
console.log(vm.c);
console.log('vm.c 依赖项:', vm.dependencies); // 输出: vm.c 依赖项: a, d
vm.dependencies = [];
vm.obj.d = true;
console.log(vm.c);
console.log('vm.c 依赖项:', vm.dependencies); // 输出: vm.c 依赖项: a, d, b
从上述代码中看到,第一处依赖项输出只有 a
、 d
,并不是我们初步期望的是 a
、 d
、 b
。
实际上,这并不会带来什么问题,相反,还能在一些场景下提升性能,为什么这么说呢?
在第一次访问 vm.c
的时候,虽然只记录了 a
、 d
,两个依赖项,但是并不会引起 bug ,表面上看此时 vm.obj.b
变化了,应该重新计算 vm.c
的值,但是由于 vm.obj.d
还是 false
,所以 vm.obj.a
的值并不会改变,因此 vm.c
的值也不会改变,所以重新计算 vm.c
并没有意义。所以在这个时候,只有 a
、 d
发生变化的时候,才应该去重新计算 vm.c
。第二次访问 vm.c
,在 vm.obj.d
变为 true
之后,就能搜集到依赖为 a
、 d
、 b
,此时重新掉之前的依赖项,后续按照新的依赖项来标记 vm.c
是否应该重新计算。
缓存
在得知 computed
属性发生变化之后, Vue 内部并不立即去重新计算出新的 computed
属性值,而是仅仅标记为 dirty
,下次访问的时候,再重新计算,然后将计算结果缓存起来。
这样的设计,会避免一些不必要的计算,比如有以下 Vue 代码:
<template>
<div class="my-component">
...
</div>
</template>
<script>
export default {
data() {
return {
a: 1,
b: 2
};
},
computed: {
c() {
return this.a + this.b;
}
},
created() {
console.log(this.c);
setInterval(() => {
this.a++;
},1000);
}
};
</script>
第一次访问 this.c
的时候,记录了依赖项 a
、 b
,虽然后续通过 setInterval
不停地修改 this.a
,造成 this.c
一直是 dirty
状态,但是由于并没有再访问 this.c
,所以重新计算 this.c
的值是毫无意义的,如果不做无意义的计算反倒会提升一些性能。
记录的响应式属性都在当前实例范畴内
举个例子:
import Vue from 'vue';
Vue.component('Child', {
data() {
return {
a: 1
};
},
created() {
setInterval(() => {
this.a++;
}, 1000);
},
template: '<div>{{ a }}</div>'
});
const App = {
el: '#app',
template: '<div>{{ b }} - <Child ref="child" /></div>',
computed: {
b() {
return this.$refs.child && this.$refs.child.a;
}
}
};
new Vue(App);
从上述例子可以发现, Child
组件输出的 a
是不断变化的,而 App
组件输出的 b
是一直不会有什么内容的。
这应该是 Vue 的一种设计策略,开发当前组件的时候,就关注当前组件的数据就行了,不要牵连到其他地方的数据,不然会增加耦合度,和组件的解耦合初衷相违背。