Vue中的计算属性,在开发中经常会用到,但是理解它似乎不是那么容易,这篇文章会用尽量简单的代码介绍计算属性的运行过程。

首先讲讲计算属性大概的思路,计算属性其实是一个getter函数,这个getter函数通过Object.defineProperty来定义,当每次试图获取计算属性的值的时候,getter函数就会执行,返回值就是获得的计算属性的值。计算属性的值有可能依赖于data,prop,computed中的其他项,我们要进行一个依赖收集的操作,找出这个计算属性依赖于其他的什么,并订阅依赖项的变化(就是在依赖项的值发生变化时,通知计算属性),在计算属性收到通知后,重新计算值,并缓存,然后触发视图更新。

我做了一个简单的demo,最后的效果是这样的

 

可以看到,当a和b的值改变的时候,total(a+b)也会改变,随即触发刷新视图的逻辑,我们接下来通过讲解这个demo来理解computed的大致运行过程

依赖收集

这里我们使用一个简单的发布-订阅模式,来对依赖进行收集和通知,下面是Dep类的定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Dep {
constructor() {
// 初始化订阅者列表
this.subs = new Set();
}
// 添加订阅者
addSub(sub) {
!this.subs.has(sub) && this.subs.add(sub);
}
// 通知所有订阅者数据变动
notify(val) {
this.subs.forEach((sub) => {
sub.update(val);
})
}
}

那么computed的依赖是什么呢,先来看一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let extend = {
data : {
a : 1,
b : 2,
obj : {
c : 3
}
},
computed : {
total() {
let a = this.a;
let b = this.b;
let c = this.obj.c;
return a + b + c;
}
}
};

这里的total是根据data里的a,b和c计算出来的,所以total的依赖就是data里的a,b,c,那么我们要做的是就是把total让total作为订阅者,订阅a和b的数据变动,这样当a和b和c的数据发生变化时,total就能及时得到通知,并且更新它的值。

要是想得知a或b或c的数据变动,有两种办法,一个是使用 Object.defineProperty定义属性的set和get方法,另一个是用proxy拦截get和set方法,这里为了方便演示(简化代码),我们使用proxy,如果你不了解proxy,可以看看这里

我们来看看proxy拦截的具体实现

1
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
function observe(obj) {
if (typeof obj !== "object") return;
let proxy = getProxyObj(obj);
Object.keys(proxy).forEach((key) => {
if (typeof proxy[key] === "object") {
proxy[key] = observe(proxy[key]);
}
});
return proxy;
}

function getProxyObj(obj) {
let map = {};
let proxy = new Proxy(obj, {
get(target, key, proxy) {
if (global[depSymbol]) {
let dep = map[key] ? map[key] : (map[key] = new Dep());
dep.addSub(global[depSymbol]);
}
return target[key];
},
set(target, key, value, proxy) {
target[key] = value;
map[key] && map[key].notify(value);
}
});
return proxy;
}

我们在把一个对象传入observe方法之后,observe方法会先生成一个这个对象的proxy,然后遍历这个对象的属性,如果属性的值是一个对象,则递归调用observe,最后代理整个对象。

代理一个对象的方法是getProxyObj,这个方法返回的代理,拦截了访问对象属性时的get和set方法。那么get和set方法分别做了什么呢。

在get中,对象除了会正常返回属性的值,还会检查全局上的一个属性global[depSymbol]是否为空,为什么要去检查这个位置呢,其实这个位置放置的就是依赖于当前属性的值(当然不是真的值,是一个订阅者对象,但是这个订阅者对象是根据值的一些信息生成的),如果我们在计算 计算属性的值时候,提前把计算属性对应的订阅者放到这个位置,那么在计算属性计算的时候,必然会触发它所依赖的属性的get方法,从而被依赖的属性就能知道某个计算属性依赖于自身,从而把它添加到订阅者列表里。

在set方法中,除了会重新设定属性的值,还会检查有没有订阅了当前属性变化的订阅者,如果有,通知他们属性已经变化。也就是在被通知后,计算属性会重新计算它的值。

这里出现了一些陌生的变量,depSymbol和global,global就是全局,任何地方都能访问,depSymbol是个symbol,global[depSymbol]会作为订阅者保存的位置,他们的定义如下。

1
2
let depSymbol = Symbol("depSymbol");
let global = typeof window !== "undefined" ? window : {};

加工计算属性

计算属性其实是一个特殊的getter函数,我们在传入的计算属性,一般是一个函数,那么这个函数需要做一些特殊的处理,转化成getter。

1
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
function initComputed(obj, bindThis) {
if (typeof obj.computed !== "object") return;
Object.keys(obj.computed).forEach((key) => {
// 处理每一个计算属性
if (typeof obj.computed[key] === "function") {
wrapComputed(obj.computed, key, bindThis)
}
})
}

function wrapComputed(computedObj, key, bindThis) {
// obj是订阅者对象
let obj = {};
// 计算属性值的缓存
let valCache = undefined;
// 计算 计算属性的源函数
let originFun = computedObj[key];
// 给订阅者对象设置update函数
obj.update = function () {
// 重新计算计算属性的值
valCache = originFun.call(bindThis);
// 通知所有的订阅者数据更新
// 订阅者可能是视图或者是其他值
computedDepMap[key].notify(valCache);
}.bind(bindThis);
// 把计算属性定义成访问器属性
Object.defineProperty(computedObj, key, {
get() {
return valCache;
},
set(v) {
valCache = v;
}
});
// 计算一次计算属性的值
// 计算前先把计算属性对应的订阅者放到全局置顶位置
// 方便被收集
(function () {
global[depSymbol] = obj;
valCache = originFun.call(bindThis);
global[depSymbol] = undefined;
})();
}

initComputed会遍历所有的计算属性,然后调用wrapComputed处理这些计算属性,wrapComputed中用闭包保存了一个属性valCache,这就是计算属性的缓存,然后我们新建这个计算属性对应的订阅者对象,设置update函数(数据变动时会执行这个函数并传入对应参数),然后把计算属性设置成访问器属性,这样就可以直接使用vm.total这样的方式获取值,而不是vm.total(),最后我们把订阅者对象设置到global[depSymbol]上,然后执行一次计算属性的计算函数,这样计算属性的依赖就可以得知这个计算属性依赖于自身,并把这个订阅者对象添加到列表里。

全部代码

这是测试用的代码

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="proxy.js" defer></script>
</head>
<body>
<div id="app">

</div>
</body>
</html>

proxy,js

1
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
let depSymbol = Symbol("depSymbol");
let global = typeof window !== "undefined" ? window : {};

class Dep {
constructor() {
this.subs = new Set();
}
addSub(sub) {
!this.subs.has(sub) && this.subs.add(sub);
}
notify(val) {
this.subs.forEach((sub) => {
sub.update(val);
})
}
}
// 保存依赖于某个计算属性(比如视图或者其他计算属性)Dep
let computedDepMap = {};
computedDepMap["total"] = new Dep();
computedDepMap.total.addSub({
update(val) {
document.getElementById("app").innerText = `a + b + c = ${val}\na = ${proxy.a}\nb = ${proxy.b}\nc = ${proxy.obj.c}`;
}
});

function observe(obj) {
if (typeof obj !== "object") return;
let proxy = getProxyObj(obj);
Object.keys(proxy).forEach((key) => {
if (typeof proxy[key] === "object") {
proxy[key] = observe(proxy[key]);
}
});
return proxy;
}

function getProxyObj(obj) {
let map = {};
let proxy = new Proxy(obj, {
get(target, key, proxy) {
if (global[depSymbol]) {
let dep = map[key] ? map[key] : (map[key] = new Dep());
dep.addSub(global[depSymbol]);
}
return target[key];
},
set(target, key, value, proxy) {
target[key] = value;
map[key] && map[key].notify(value);
}
});
return proxy;
}

function initComputed(obj, bindThis) {
if (typeof obj.computed !== "object") return;
Object.keys(obj.computed).forEach((key) => {
if (typeof obj.computed[key] === "function") {
wrapComputed(obj.computed, key, bindThis)
}
})
}

function wrapComputed(computedObj, key, bindThis) {
let obj = {};
let valCache = undefined;
let originFun = computedObj[key];
obj.update = function () {
valCache = originFun.call(bindThis);
computedDepMap[key].notify(valCache);
}.bind(bindThis);
Object.defineProperty(computedObj, key, {
get() {
return valCache;
},
set(v) {
valCache = v;
}
});
(function () {
global[depSymbol] = obj;
valCache = originFun.call(bindThis);
global[depSymbol] = undefined;
})();
}






let extend = {
data : {
a : 1,
b : 2,
obj : {
c : 3
}
},
computed : {
total() {
let a = this.a;
let b = this.b;
let c = this.obj.c;
return a + b + c;
}
}
};


let proxy = observe(extend.data);
initComputed(extend, proxy);
console.log(extend.computed.total); // 6
proxy.a = 10;
proxy.b = 20;
proxy.obj.c = 30;
console.log(extend.computed.total); // 60

这里有些地方上面没有讲到,computedDepMap的属性对应用来保存订阅了某个计算属性的列表,比如在vue中,这样的语法,就说明这里依赖于total这个计算属性, computedDepMap[“total”]是一个Dep,Dep.subs里会保存这个依赖。至于我为什么要直接把它写出来,因为原本这部分在vue里是用模板编译生成AST来完成的,实现比较复杂,这里为了让代码简洁一点就直接写结果了。