Vue响应式之computed
# 前言
以前使用 computed 的时候一直觉得很神奇,怎么做到其中有个值变更, computed 会重新求值的
<div id="el">
</div>
<!-- using string template here to work around HTML <option> placement restriction -->
<script type="text/x-template" id="demo-template">
<div>
{{a}}
{{aDouble}}
</div>
</script>
<script>
var vm = new Vue({
el: '#el',
template: '#demo-template',
data: { a: 1 },
computed: {
// 仅读取
aDouble: function () {
return this.a * 2
},
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
当执行 vm.a = 2
时, aDouble 的取值也会变更,此时页面上显示为 2 4
先不看源码,根据前几篇文章的积累,我们自己尝试着设计 computed
# computed 设计
computed 属性可能是一个对象或者方法
aDouble: function () {
return this.a * 2
},
// or
aDouble: {
get:function(){
return this.a * 2
},
set:function(val){
console.log(val)
}
},
2
3
4
5
6
7
8
9
10
11
12
通过 Object.defineProperty
定义 aDouble 的 get/set ,如果传入的是方法,那么该方法就是 get
Object.defineProperty(vm.$computed,'aDouble',{
get:function(){
return this.a * 2
}
})
// or
Object.defineProperty(vm.$computed,'aDouble',{
get:function(){
return this.a * 2
},
set:function(val){
console.log(val)
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
接着需要对该对象进行响应式处理
defineReactive(vm.$computed,'aDouble')
后面调用 aDouble 的 getter 时就会调用原本的 get 方法
不过这里 set 倒是没必要,因为计算属性的变更是根据依赖属性的变更的,调用 set 并不会触发计算属性的变更,这个到时候看源码怎么做
aDouble 自身也是一个 Watcher , 当其中的属性变更,其需要得到通知
new Watcher(
vm,
getter,
noop
)
2
3
4
5
实例化 Watcher 时,调用 computed 属性的 getter . 当 getter 触发时,会对里面用到的响应式属性进行访问,由于此时 Dep.target
为该 watcher ,会被这些响应式属性的依赖添加进订阅者列表
也就是说,当 a 更新时,会触发 aDouble 的 watcher 进行更新
最后 computed 属性被作为观察目标,处理同 data 类似,不再赘述
考虑这个场景
d: function () {
if(this.c > 0){
return this.a
} else {
return this.b
}
},
2
3
4
5
6
7
初始化 Watcher 的时候, c 大于 0,a 的依赖会添加 watcher, 但是 b 没有访问到, b 的依赖也就不会添加 watcher
当 c 值变更 小于 0 时,此时访问到 b ,b 的依赖怎么添加 watcher 另外, a 的依赖也应该移除 watcher
所以实现上,当里面有依赖触发变更时,需要保证 Dep.target
有值,并重新收集依赖
这个留作问题,这个我们稍后看源码是怎么处理的
# 源码分析
· 从 initComputed 入手
// src\core\instance\state.js
function initComputed (vm: Component, computed: Object) {
// $flow-disable-line
const watchers = vm._computedWatchers = Object.create(null)
// computed properties are just getters during SSR
const isSSR = isServerRendering()
for (const key in computed) {
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get
// 必须要提供 getter
if (process.env.NODE_ENV !== 'production' && getter == null) {
warn(
`Getter is missing for computed property "${key}".`,
vm
)
}
// ssr 不进行监听
if (!isSSR) {
// 每个计算属性都会创建一个内部 watcher
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
// { lazy: true }
computedWatcherOptions
)
}
// 组件定义的计算属性已经在组件原型上定义。
// 我们只需要处理在实例化时定义的计算属性。
if (!(key in vm)) {
defineComputed(vm, key, userDef)
} else if (process.env.NODE_ENV !== 'production') {
// 重名属性警告
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)
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
主要是做了两件事:
- 创建内部 watcher 。 由于 lazy = true 因此不会马上调用 get 去收集依赖
- 执行 defineComputed(vm, key, userDef)
这里提出了一点,组件的计算属性有挂在原型和实例上的,这个后面再看
我们继续分析 defineComputed
// src\core\instance\state.js
export function defineComputed (
target: any,
key: string,
userDef: Object | Function
) {
const shouldCache = !isServerRendering()
// 定义计算属性的 get/set
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
}
// ... 省略:调用未定义 setter 的警告
Object.defineProperty(target, key, sharedPropertyDefinition)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
可以看到,这里定义 get/set 不是简单的将原本定义传入 Object.defineProperty
对于 set ,直接调用原本的定义;对于 get 会调用 createComputedGetter (非ssr)
我们看下 createComputedGetter 的处理
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
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
刚刚说到,创建的 watcher 是 lazy 的,同时 dirty 值与 lazy 一致
dirty 用来判断 watcher 是否需要添加/更新依赖
由于 dirty 为 true,会执行 watcher.evaluate()
evaluate () {
this.value = this.get()
this.dirty = false
}
2
3
4
触发 get ; get 我们很熟悉了,就是 pushTarget 并执行 getter 后 popTarget
比如我们的例子, getter 为
function (){ return this.a * 2 }
执行 getter 时, watcher 会添加/更新计算属性上使用到的响应式属性( 本例为 a )的依赖,并且 dirty 赋值为 false
watcher 的 value 值即 getter 的执行结果,下次访问该计算属性,只要 dirty 为 false ,就不会重新收集依赖,会返回 watcher 的 value
当依赖变更( 比如 a 的值发生变化)时,会调用 watcher 的 update 方法,
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
2
3
4
5
6
7
8
9
10
由于 lazy 为 true ,会将 dirty 值重新赋值 true ,当下次访问计算属性时,就会重新收集依赖
那么计算属性的访问是在哪进行的,为何计算属性的结果马上得到更新?
上面有段代码被我们忽略了
if (Dep.target) {
watcher.depend()
}
2
3
当初始化 Vue 的时候,创建了一个 Watcher w1, w1 调用了 getter 对页面进行解析,此时扫描到了 aDouble ,然后执行了 computedGetter ,所以一开始访问计算属性的时候,Dep.target
栈底为 w1,
此时执行 watcher.depend()
,会将 watcher 的所有依赖(比如这里有 a 的依赖)执行 depend 方法
// Watcher depend
depend () {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
// Dep depend
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
其结果为 w1 添加了 a 的依赖
所以当 a 变更时,会先后触发 watcher 和 w1 的变更,前者设置了 dirty 为 true,后者调用了 get 导致重新 render (此时 Dep.target 栈底为 w1),render 解析到 aDouble 导致再次执行了 computedGetter ,于是会更新依赖并返回计算结果
整个 computed 的流程如下:
- 初始化 computed 属性 aDouble ,该属性带了一个 getter 方法,创建内部 Watcher watcher 并调用 defineComputed
- Vue 实例调用 mount 绑定模板,此时会创建一个 Watcher w1,并进行页面模板解析
- 页面模板解析,访问到 aDouble,执行 computedGetter,初次执行会调用 Watcher.get 进而调用计算属性的 getter ,于是 watcher 会添加里面用的响应式属性(本例为 a)的依赖,最后设置并返回计算值
watcher.value
- 页面模板解析的过程是在 w1 初始化的时候执行的,其实现在 targetStack 栈底为 w1,于是执行
watcher.depend
导致 w1 会添加 watcher 的所有依赖(本例为 a) - ...多次访问 aDouble ,只要依赖不变, aDouble 会直接返回原来的计算值
- a 发生变更,由于添加顺序, watcher 和 w1 会被先后触发更新
- watcher 会将 dirty 置为 true
- w1 会进行页面模板解析(targetStack 栈底为 w1),解析到 aDouble 并触发 computedGetter ,会执行
watcher.evaluate()
(重新收集依赖) 和watcher.depend()
(w1 添加 watcher 收集的依赖),最后返回新的计算值。页面模板解析完毕,页面会进行更新
最后,还记得前面遗留了一个问题:组件的计算属性有挂在原型和实例上的
组件的计算属性都会挂载到原型上(目前不知道怎样才能挂载到实例上),于是每次实例化组件只会创建内部 Watcher 不会再次进行 defineComputed ,可能是一种优化吧
# 总结
本来以为 computed 可能会复用 defineReactive ,但是部分逻辑 defineReactive 可能需要修改
yyx 单独使用 defineComputed 的目的应该也是为了职责明确,更加可控。另外,vue 为了做一些优化,导致部分源码跳来跳去实在是难以读懂,好在大框架的设计还是可以的