# 计算属性的实现原理
本文来讲述 Vue2 中的常用 API:computed 的实现原理。computed 计算属性,也是我们开发中非常常用的一个 API,他可以自动帮我们计算数据的值,在一个变量依赖于其他变量的时候,为我们提供的极大的便利性。本节我们就来探讨 Vue2 中计算属性的实现原理。提前声明:Vue2 中计算属性的实现有些弯弯绕绕,不易理解~我尽量说清楚吧!
文章内的源码并非真实源码,源码项目地址:Nwu23787/mini-Vue2: 手写 Vue2 核心源码 (github.com) (opens new window),源码中写的注释很多!
# 1. computed 浅谈
# 1.1 为什么有 computed
计算属性设计的初衷是为了解决我们 在模版中书写太多的代码逻辑 这个问题的。比如我们在 data 中定义了:
firstName = '张'
lastName = '三'
而我们的模版中需要展示全名,我们可能会这样书写模版:
<p>{{firstName+lastName}}</p>
我们设计模版内表达式的初衷是用于简单运算的,上面这样写没有什么问题。但是有时我们不得不做一些很大的计算工作,比如:
<div id="example">
{{ message.split('').reverse().join('') }}
</div>
在这个地方,模板不再是简单的声明式逻辑。我们必须看一段时间才能意识到,这里是想要显示变量 message 的翻转字符串。当你我们在模板中的多处包含此翻转字符串时,就会更加难以处理。
所以,对于任何复杂逻辑,我们都应当使用计算属性。
# 1.2 自动收集依赖
computed 和 watch 的一大区别就是,computed 可以自动收集依赖,而 watch 需要手动指定依赖。对于 computed,我们不需要知道属性依赖于哪些数据,Vue 认定,只要在 get 方法中使用到的其他数据,都是 computed 属性的依赖。至于原理,会在后面说到。
# 1.3 缓存
computed 在底层做了一个优化机制,就是缓存。当我们计算过一次计算属性的值之后,我们下一次取值时,如果这两次取值之间,计算属性依赖的数据没有发生变化,也就说明计算属性的值也没有发生变化,那么就不会执行计算属性的 get 方法重新计算计算属性的值,而是直接取走上一次计算的结果。
引用官网的一段话来说明缓存的作用: 我们为什么需要缓存?假设我们有一个性能开销比较大的计算属性 A,它需要遍历一个巨大的数组并做大量的计算。然后我们可能有其他的计算属性依赖于 A。如果没有缓存,我们将不可避免的多次执行 A 的 getter!如果你不希望有缓存,请用方法来替代。
# 2. 前置知识
# 2.1 懒 watcher
之前我们探讨 Vue2 原理时,定义的 watcher 全部都是用于重新渲染组件的,所以叫做渲染 watcher。但是,并非是只有组件才会有对应的 watcher,其实 computed 和 watch 也会产生 watcher,但是他们两个产生的 watcher 和渲染 watcher 不同。
首先第一个不同点就在于,渲染 wacther 在初始化的时候会执行一次传入的方法,也就是会初始化渲染一次。但是 computed 不会(watch 看情况,有可能会)。
第二个不同点在于,computed 和 watch 的更新方法是同步的,不会做异步的处理,一旦调用 update 方法,就会立即执行更新方法。而不是像渲染 watcher 一样加入 watcher 队列中去,再将刷新 watcher 队列的方法加入 nextTick 的回调队列,异步执行。
是否为懒 watcher,是通过我们实例化 Watcher 时传入的参数决定的,如果我们传入 lazy:true,那么实例化的就是一个懒 watcher。
我们来看 Watcher 类的源码:
class Watcher {
// vm watcher 对应的组件的实例,fn 组件对应的渲染函数
constructor(vm, fn, options) {
// 使用 id 来区分不同组件的 watcher
this.id = id++
// 把渲染函数绑定watcher到实例上,调用getter即可重新渲染,更新视图
this.getter = fn
// 标记是否是一个渲染watcher
this.renderWatcher = options
// 收集 watcher 对应的 dep
this.deps = []
// 使用 set 保存 deps 中所有 dep 的id,便于去重操作
this.depsId = new Set()
// 是否为懒 watcher
this.lazy = options.lazy
//计算属性的缓存值
this.dirty = this.lazy
// 初始化调用
this.lazy ? undefined : this.get()
// 记录 vm
this.vm = vm
}
// 渲染函数
get() {
// 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性)
pushTarget(this)
let value = this.getter.call(this.vm);
// 渲染结束,把当前的 watcher 出栈
popTarget()
return value
}
// 计算属性求值
evaluate() {
// 获取到用户定义的 get 方法的返回值
this.value = this.get()
this.dirty = false
}
// 计算属性用,让每一个计算属性
depend() {
for (let i = this.deps.length - 1; i >= 0; i--) {
this.deps[i].depend(this)
}
}
// 给 watch 添加 dep
addDep(dep) {
// 要判断这个 dep 是否已经被记录,防止重复记录
let id = dep.id
if (!this.depsId.has(id)) {
// id 不存在与 depsId 中,则这个 dep 没有被 watcher 收集过
this.deps.push(dep)
this.depsId.add(id)
// 让 dep 收集 watcher
dep.addSub(this)
}
}
// 更新视图
update() {
// 如果是计算属性依赖的值发生变化,标志脏值,下次取值会重新计算
if (this.lazy) {
this.dirty = true
} else {
// 把当前的 watcher 暂存在队列中
queueWatcher(this)
}
}
// 执行渲染逻辑
run() {
this.get()
}
}
需要注意的是,懒 watcher 中还有一个 dirty 属性,这个属性是用来实现缓存的,如果 dirty 为 true,则说明当前依赖发生了变化,需要重新计算 get 方法,得到最新的结果。这个我们下面会详细介绍。
# 2.2 Watcher 栈
还记得之前我们在渲染时收集 watcher 的过程中,如何判断对响应式数据的取值是否发生在渲染阶段吗?是通过在全局上挂载一个 Dep.target 属性来实现的,Dep.target 的值就是当前 watcher 的值。
但是,如果当前同时有多个 watcher 该怎么办呢?因为我们说了 computed 和 watch 对应也会产生一个 watcher。Vue2 就使用了栈来存储当前的所有 watcher,维护这个栈结构,让 Dep.target 始终指向栈中的最后一个 watcher。
来看源码:
// dep.js
let stack = []
/**
* watcher 入栈
* @param {Object} this watcher
*/
export function pushTarget(watcher) {
stack.push(watcher)
Dep.target = watcher // 全局记录
}
/**
* watcher 出栈
* @param {Object} this watcher
*/
export function popTarget() {
stack.pop()
Dep.target = stack[stack.length - 1]
}
至于为什么这样处理,后面会说。
# 3. Vue 内部是如何处理 computed 选项的
在介绍 computed 的实现原理之前,我们先要知道 Vue2 内部是怎么处理 computed 这个配置项的。
我们来看初始化中专门处理 computed 的方法 initComputed:
/**
* 初始化处理 computed 选项
* @param {*} vm Vue实例
*/
function initComputed(vm) {
const computed = vm.$options.computed
const wacthers = vm._computedWatchers = {} // 存储所有计算属性的 watcher,并保存到 vm 上
// computed 中书写的可能是对象,也可能是函数
for (let key in computed) {
let userDef = computed[key]
let fn = typeof userDef === 'function' ? userDef : userDef.get
// 为每一个计算属性创建一个 watcher,每次调用 watcher 时,执行 get 方法获取最新值
wacthers[key] = new Watcher(vm, fn, { lazy: true }) // new Watcher 默认会执行一次 fn,但 computed 默认是不初始化的,所以加入 lazy 配置项
// 把 computed 中定义的变量挂载到 vm 上去
defineComputed(vm, key, userDef)
}
}
/**
* 将 computed 中的属性挂载到 vm 上
* @param {Object} target vm
* @param {string} key 要挂载的属性
* @param {Object} userDef 用户传入的计算属性对象
*/
function defineComputed(target, key, userDef) {
const getter = typeof userDef === 'function' ? userDef : userDef.get
const setter = userDef.set || (() => { })
Object.defineProperty(target, key, {
get: createComputedGetter(key),
set: setter
})
}
可以看到,在初始化 Vue 的时候,Vue 就为 computed 中的每一个属性生成了一个 watcher,并在 Vue 实例上挂载了一个 _computedWatchers 对象,用于保存每个计算属性和对应的 watcher 之间的一一对应关系。同时,Vue 会将 computed 中定义的属性作为挂载到 vm 上去,方便模版中取值。
在为计算属性创建 watcher 时,我们传入的方法其实就是用户定义的 computed 的 get 方法。
举例,如果用户传入了这样的 computed 配置项:
computed:{
name(){
return a + b
},
age:{
get(){
return y + z
},
set(){
}
}
}
经过初始化后,vm 上会多出这几个属性:
vm._computedWatchers = {
name: Watcher,
age:Watcher
}
vm.age // 被 defineproperty 劫持
vm.name// 被 defineproperty 劫持
值得注意的是,**计算属性定义的变量本身是不会收集依赖的,我们甚至根本没有给他创建对应的 dep,所以计算属性本身根本没有办法收集 watcher。但是,计算属性会让其依赖的所有变量去收集渲染 watcher!**这点非常重要!!!后面也会详细说明。
# 4. 如何实现缓存?
Vue2 实现计算属性的缓存,主要原理就是 “脏值检测” 。这个 “脏值检测” 并非我们传统意义上的脏值监测,但是十分类似。对于缓存的实现,我们的思考是,设置两个变量,一个是 value,用于存放上一次计算得到的结果;一个叫做 dirty,用于检测两次取值之间,依赖数据是否发生了变化,如果变化,则取值时重新计算,否则直接向缓存的 value 中取值。
Vue2 中,将 dirty 和 value 都挂载在了计算属性对应的 watcher 实例上。在我们取值时,会触发属性使用 defineproperty 拦截的 get 方法,我们在get方法中进行判断,判断当前属性对应的 watcher 中的 dirty 值是否为 true(也就是是否为脏值),如果为 true,则执行用户自定义的计算属性的 get 方法,得到新的值,然后再将得到的新值挂载到 watcher 的 value 属性上,最后将 dirty 的值置为 false;如果 dirty 值为false,那么就直接向 watcher.value 上取缓存值即可。
那么我们如何获取到计算属性所对应的 watcher 呢?我们在前面初始化的时候说到了,所有的计算属性的 watcher 都被挂载到 vm 上了,具体来说就是挂载在 vm._computedWatchers 属性上,该属性为一个对象。对象的 key 为计算属性定义的变量名称,value 就是对应变量的 watcher。所以,我们通过 vm._computedWatchers[key] 的方式,就可以获取到计算属性 key 的 watcher了。
来看源码实现:
/**
* 将 computed 中的属性挂载到 vm 上
* @param {Object} target vm
* @param {string} key 要挂载的属性
* @param {Object} userDef 用户传入的计算属性对象
*/
function defineComputed(target, key, userDef) {
const getter = typeof userDef === 'function' ? userDef : userDef.get
const setter = userDef.set || (() => { })
Object.defineProperty(target, key, {
get: createComputedGetter(key),
set: setter
})
}
/**
* 加入了缓存(脏值监测)的get方法
* @param {string} key 计算属性变量名
* @returns 加入了脏值监测机制的 get 方法
*/
function createComputedGetter(key) {
return function () {
// 获取到对应计算属性的watcher
const watcher = this._computedWatchers[key]
// 如果是脏值,那么重新执行用户定义的 get 方法,进行计算
if (watcher.dirty) {
// 是脏值
watcher.evaluate()
}
if (Dep.target) {
......//暂时不管
}
return watcher.value
}
}
// watcher.js Watcher类的方法
// 执行传入的回调函数
get() {
// 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性)
pushTarget(this)
let value = this.getter.call(this.vm);
// 渲染结束,把当前的 watcher 出栈
popTarget()
return value
}
// 计算属性求值
evaluate() {
// 获取到用户定义的 get 方法的返回值
this.value = this.get()
this.dirty = false
}
# 5. 依赖数据如何收集计算属性 watcher?
前面我们说到了,不同类型的 watcher,他的更新函数是不同的。
对于渲染 watcher,他的更新函数其实就是封装了对应组件的渲染逻辑,需要组件更新的时候,并不会立即执行更新方法,而是将需要更新的组件对应的 watcher 加入 watcher 队列中,然后将刷新 watcher 队列的方法作为 nextTick 的回调传入 nextTick 的调度队列中,等待当前同步代码执行完成之后,按顺序执行 nextTick 调度队列中的每一个回调。执行到刷新 watcher 队列的回调时,才会遍历所有的 watcher,再执行每一个 watcher 的更新方法去更新组件视图。
对于计算属性的 watcher,更新操作远远没有渲染 watcher 这么复杂!因为他的更新函数不会改变视图,计算属性的 watcher 其实只有一个功能,那就是将 dirty 修改为 true!很奇怪吧,我们来分析一下:
首先,我们为什么要让依赖数据去收集 watcher 依赖?其实其中一个目的就是为了实现缓存,当依赖数据发生变化时,也就是依赖数据的 set 方法被触发,我们就会通过 dep.notify 去通知收集到的每一个 watcher,让其执行更新方法。而我们前面刚刚说到,computed 的更新方法就是将 dirty 设置为 ture,而 dirty 为 true 又意味着依赖数据发生了变化,所以需要重新取值!所以,让依赖数据收集到 computed 的 watcher,可以实现检测依赖数据是否发生了变化。
那么,如何让计算属性依赖的数据自动收集计算属性 watcher 呢?其实过程和收集渲染 watcher 一样。但是,渲染 watcher 和 计算属性 watcher 其实是同时存在的,这也就是为什么我们在前置知识中使用了栈这个结构来存储 watcher。
首先计算属性 watcher 是在初始化 Vue 的时候就被创建了,但并没有加入到 Dep 中的 watcher 栈中。在执行组件渲染前,又会创建组件对应的渲染 watcher,并且将渲染 watcher 入栈:
// 执行传入的回调函数
get() {
// 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性)
pushTarget(this)
let value = this.getter.call(this.vm);
// 渲染结束,把当前的 watcher 出栈
popTarget()
return value
}
渲染 watcher 入栈后,开始执行渲染逻辑,简单来说就是 render + update 方法。在执行 render 方法时,如果模版中使用到了计算属性定义的变量,那么就会触发该变量的 get 方法,而 dirty 的初值为 true,所以会执行计算属性 watcher 上的 evaluate 方法去计算最新的值:
// 执行传入的回调函数
get() {
// 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性)
pushTarget(this)
let value = this.getter.call(this.vm);
// 渲染结束,把当前的 watcher 出栈
popTarget()
return value
}
// 计算属性求值
evaluate() {
// 获取到用户定义的 get 方法的返回值
this.value = this.get()
this.dirty = false
}
evaluate 方法内部就是调用了 get 方法,调用 get 方法会导致计算属性 watcher 入栈,所以现在栈中就有两个 watcher 了:[渲染watcher,计算属性watcher]。
同时调用了用户自定义的计算属性的 get 方法,也就是使用依赖数据计算出了计算属性的值。那么,计算属性依赖的所有数据就被访问到,也就是触发了依赖数据使用 defineproperty 劫持的 get 方法,在 get 方法中,就会自动收集当前 Dep.target 上的 watcher,也就是计算属性的 watcher 了!(注意:我们前面说过,Dep.target 的值永远指向 watcher 栈中的最后一个watcher) 这样我们就收集到了所有依赖数据的计算属性 watcher!
# 6. 依赖数据如何收集渲染 watcher?
Vue2 中计算属性定义的变量本身是不会收集依赖的,我们甚至根本没有给他创建对应的 dep,所以计算属性本身根本没有办法收集 watcher。但是,计算属性会让其依赖的所有变量去收集渲染 watcher!
这句话非常重要!计算属性定义出来的变量和普通变量是不同的!!!那么为什么计算属性不收集 watcher 呢?很明显,因为计算属性不能被赋值,那么我们使用 defineproperty 劫持时,也没办法触发 set 方法,那么计算属性自然不能像其他普通变量一样,在 set 方法中通知所有的依赖。所以,根本没有必要去收集计算属性的依赖,即使收集了,也没办法做变量更新的派发。
Vue2 提出的替代方案是,让计算属性的每一个依赖,代替计算属性去收集渲染 watcher。因为依赖改变,会通知组件进行异步更新,计算属性在渲染过程中取值时也会重新计算值,依然可以渲染出计算属性更改后的最新值。
那么,如何让计算属性的依赖收集到渲染 watcher 呢?接着上一节说,上一节我们说到了,栈中有两个 watcher:[渲染watcher,计算属性watcher]。 在渲染过程中,所有的依赖数据收集到了计算属性的 watcher。在渲染完成之后,计算属性 watcher 出栈,Dep.target 的值更改为渲染 watcher。
此时,我们进行判断,如果计算属性 watcher 出栈之后,如果还栈中还存在 watcher ,那么就让所有的依赖继续收集上层 watcher,也就是渲染 watcher。
具体怎么让所有的依赖收集到渲染 watcher 呢?关键是我们怎么找到所有的依赖呢?其实所有的依赖都存储在渲染 watcher 中,因为我们之前在做渲染 watcher 和 dep 的收集时,是一个多对多、相互收集的关系。每一个组件对应的渲染 watcher 都会维护一个 deps 属性,用来存放所有收集了该渲染 watcher 的 dep;而每一个 dep 又收集了多个相关的渲染 watcher。
也就是说,计算属性 watcher 中的 deps 属性中,就保存着所以依赖数据对应的 dep。所以我们通过获取全局保存的渲染 watcher的 deps 属性,遍历每一个 deps 中的 dep,调用 dep 的 depend 方法,向 dep 中追加渲染 watcher。因为在定义 dep 和 watcher 时已经做了去重操作,所以不会导致 watcher 重复的问题。这样操作下来,所有的依赖数据对应的 dep 就收集到了渲染 watcher!
// 计算属性用,让每一个计算属性的依赖收集上层 watcher
depend() {
for (let i = this.deps.length - 1; i >= 0; i--) {
this.deps[i].depend(this)
}
}
# 7. 简单总结
- 在初始化 Vue 时,为每一个计算属性都创建了一个计算属性 watcher 实例,并且保存在全局上。这个 watcher 上有一个 dirty 属性,用于表示是否需要重新计算计算属性的值;以及一个 value 属性,用于缓存上一次的计算结果。
- 在初始化时,同时使用 defineproperty 劫持了计算属性定义的变量,并将其和普通变量一样挂载到 vm 实例上去,方便用户取值。
- 在对计算属性定义的变量取值时,会触发 defineproperty 定义的 get 方法。在这个方法中,会对 dirty 值进行判断,如果为 true,则执行用户自定义的计算属性的 get方法,进行计算属性新值的计算,然后将结果返回并挂载到 watcher.value 上,最后将 dirty 赋值为 false;如果为falsie,则直接向 watcher.value 上取值。这样就实现了缓存。
- 计算属性 watcher 的更新方法只做了一件事,那就是将 dirty 更改为 false,用于标记依赖数据已经被更改了。
- 计算属性的依赖数据在渲染的过程中(调用 render 函数时,因为要初次计算计算属性的值)被访问到,触发了 get 方法,就会收集到计算属性 watcher 。
- 计算属性本身根本不会收集依赖,只会让自己依赖的属性去收集依赖
- 让计算属性依赖的所有数据收集到渲染 watcher 的方式:先拿到全局保存的对应的计算属性 watcher(保存在 vm._computedWatcher 上),循环 watcher.deps,为每一个 deps 添加当前全局保存的渲染 watcher(保存在 Dep.target 上)
computed 这个部分有点绕,难于表述和理解。watch 的原理会简单很多~
本文共 5378 字(含代码),截止本篇,本系列累计 26615 字
← 数组的依赖收集和更新 watch 侦听器 →