Vue 中的计算属性,在开发中经常会用到,但是理解它似乎不是那么容易,这篇文章会用尽量简单的代码介绍计算属性的运行过程。
首先讲讲计算属性大概的思路,计算属性其实是一个 getter 函数,这个 getter 函数通过 Object.defineProperty 来定义,当每次试图获取计算属性的值的时候,getter 函数就会执行,返回值就是获得的计算属性的值。计算属性的值有可能依赖于 data,prop,computed 中的其他项,我们要进行一个依赖收集的操作,找出这个计算属性依赖于其他的什么,并订阅依赖项的变化(就是在依赖项的值发生变化时,通知计算属性),在计算属性收到通知后,重新计算值,并缓存,然后触发视图更新。
我做了一个简单的 demo,最后的效果是这样的
可以看到,当 a 和 b 的值改变的时候,total(a+b)也会改变,随即触发刷新视图的逻辑,我们接下来通过讲解这个 demo 来理解 computed 的大致运行过程
# 依赖收集
这里我们使用一个简单的发布 - 订阅模式,来对依赖进行收集和通知,下面是 Dep 类的定义。
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 的依赖是什么呢,先来看一个简单的例子
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 拦截的具体实现
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] 会作为订阅者保存的位置,他们的定义如下。
let depSymbol = Symbol("depSymbol"); | |
let global = typeof window !== "undefined" ? window : {}; |
# 加工计算属性
计算属性其实是一个特殊的 getter 函数,我们在传入的计算属性,一般是一个函数,那么这个函数需要做一些特殊的处理,转化成 getter。
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
<!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
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 来完成的,实现比较复杂,这里为了让代码简洁一点就直接写结果了。