【VUE】源码分析 - computed计算属性的实现原理
从源码出发,剖析computed计算属性的响应式原理、缓存原理。
tip:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。
【VUE】源码分析 - computed计算属性的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - watch侦听器的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客
前言
在分析computed属性之前,我们已经介绍过了数据劫持的基本原理、侦听器的基本原理。而computed属性或多或少与它们存在某些联系。实现原理本质上,依旧是一个收集依赖,触发依赖的过程。本篇博客假设你对数据劫持充分了解,对Watcher类有了初步的认识。如果你对这些还不太熟悉,建议戳上方链接,学习前置知识后再来阅读本文。
步入正题
来自官方文档的介绍:在模板中放入太多的逻辑会让模板过重且难以维护。对于任何复杂逻辑,你都应当使用计算属性。
简单来说,就是模板语法中应该只关注结果,而不应该存在过多的逻辑。如果某些地方牵涉到过多过复杂的逻辑,那么就应该使用计算属性,将相关逻辑存放在其中,而模板中只需要使用此计算属性即可。
我们知道,在模板中使用响应式数据,那么当这些响应式数据发生改变的时候,就会通知到相应的模板节点,从而重新渲染最新的结果,展示到页面上。
那如果使用的是计算属性computed,它又是如何实现监听到相关数据的变化的呢?在多处地方使用同一个计算属性,它是否会触发多次逻辑运算呢?而这些,就是在本章需要讨论的内容。
computed的初始化
先来看computed的初始化函数:
const computedWatcherOptions = { lazy: true }
function initComputed(vm, computed) {
const watchers = (vm._computedWatchers = Object.create(null));
for (const key in computed) {
const userDef = computed[key];
const getter = typeof userDef === "function" ? userDef : userDef.get;
if (process.env.NODE_ENV !== "production" && getter == null) {
warn(`Getter is missing for computed property "${key}".`, vm);
}
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
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
);
} else if (vm.$options.methods && key in vm.$options.methods) {
warn(
`The computed property "${key}" is already defined as a method.`,
vm
);
}
}
}
}
首先,会在当前实例上定一个新的属性:_computedWatchers,用来存放处理好的computed。然后会对computed做一个for - in 循环,遍历其每一个属性(或方法)。而此处的computed,就是我们自己定义的computed属性。
而在循环体内部,会先获取到computed的getter函数,对应的就是函数形式的函数体,或者对象形式的get方法:
computed:{
fantasyName(){
return this.fantasy.firstName + this.fantasy.lastName
},
fantasyAge:{
get(){
return this.fantasy.age
},
set(val){
this.fantasy.age = val
}
}
}
上述就是两种不同的computed声明形式。而对于fantasyName来说,getter即为其函数体;而对于fantasyAge来说,getter对应的就是get方法。
接着往下走,还是我们熟悉的Watcher类。这也就是为什么我之前说的,Wathcer类服务了三种不同的"角色",当前实例,watch,computed。而此处也是computed的核心所在。我们先介绍完init函数,然后再单独介绍computed的Watcher。
循环体的最后,也就是对属性名的检测:computed属性的初始化顺序是放在props,methods,data之后的,如果与上述三种属性有重名属性,则报错。(此处有一个defineComputed函数,大家先简单理解为"最终定义"即可。在后续会做单独介绍)
至于为什么不允许重名呢,大家可以回想一下,vue为了操作的便利,将这些会用到的属性都给代理到了当前实例身上。也就是说,你可以使用 this.fantasy 去访问一个props、methods、data、computed。那么在这种情况下,重名肯定就会存在冲突。
而关于vue对于属性的解析顺序,先在这里做一个简单介绍,但暂时先不展开:
props > methods > data > computed > watch
大家只需要知道这个顺序即可。
computed的Watcher
介绍完computed初始化函数,我们着重分析其中的Watcher的实现。
在创建Watcher实例的时候,一共会传递4个参数:
const computedWatcherOptions = { lazy: true }
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
);
第一个参数未当前vue实例,第二个参数就是在之前取得的getter函数,若不存在,则取noop。而noop呢,就是提前定义好的"空函数",即 function noop(){}。第三个参数,依旧使用noop,第四个参数为选项,在此定义了 lazy为true。
如果大家对watch侦听器的Watcher类还有印象的话,应该还记得,在watch中,第三个参数传递的是回调函数,而第二个参数传递的就是表达式或者包含表达式的函数。而在computed中,并不需要使用到回调,因此,回调函数传递的就是一个空函数。而表达式,则是在computed自定义的函数体或者get函数(getter在之前有介绍,忘记的往前翻一翻)。
为什么computed定义的函数,不是作为回调,而是作为表达式呢?
首先需要明确的一点:computed的作用,并不是作为依赖数据项改变而触发的某种行为(这是侦听器做的事情);computed要做的事情,是将依赖数据做某些逻辑处理之后,传递给下一个"使用者",该使用者可以为模板,也可以为另一个computed,或者侦听器。
那么在此背景下,回调就并不是我们所在意的东西,甚至其压根就没有。我们需要关注的,就只是在经过computed这一层"拦截处理"之后,如何将依赖项下发的通知,再次传递到目标的。
一个简单的例子:
<div>{{fantasy.firstName}}</div>
<div>{{fantasyName}}</div>
data(){
return {
fantasy:{
firstName:'fantasy',
lastName:"II"
}
}
}
computed:{
fantasyName(){
return this.fantasy.firstName + this.fantasy.lastName
}
}
我们知道,如果在模板中使用了fantasy.firstName,那么就会将fantasy以及firstName的dep都绑定为此节点的依赖项,当其发生变化之时,就会通知到该节点。那么对与第二个(使用了computed属性)节点,并没有直接用到fantasy相关的数据,那么正常的通过get绑定依赖,肯定是无法实现的。而computed的Watcher,就是做了这么一件事情:作为中间层,帮助上层使用者(节点)绑定上下层依赖(响应式数据)。
将上层依赖绑定至computed
其实现依旧是在get方法(省略了watch相关的部分)上:
get () {
pushTarget(this)
let value = this.getter.call(vm, vm)
popTarget()
return value
}
这个方法已经讲过很多次,在此就不做赘述了。忘记了的戳这里回顾。
但是呢,他与watch还是存在区别的:
this.dirty = this.lazy
this.value = this.lazy
? undefined
: this.get()
不知道大家是否还有印象,在创建Wathcer实例的时候,computed会传递lazy为true的属性。这个属性有什么用呢?我们先不着急,一步一步地看。首先在这个位置,我们可以明确的是,computed的Wathcer,会一并在初始化的时候将dirty属性设为true,并且并不会在初始化一开始就去通过get获取到value。当然,在get中实现的添加依赖,也就暂时是还未执行的。
以上,就完成了computed的Watcher创建:初始化了一个没有调用get方法,同时也暂未添加上依赖 (这里的依赖指的是computed和相关响应式数据) 的Watcher实例。并且现在该实例的lazy属性为true,dirty属性为true。至于为什么这么做呢,通过接下来的分析你就会明白了。
定义computed
在最开始介绍computed初始化函数的结尾处,存在一个defineComputed是我们还没有介绍的。那么我们现在就来关注一下这个函数:
function defineComputed(
target: any,
key: string,
userDef: Object | Function
) {
if (typeof userDef === "function") {
sharedPropertyDefinition.get = createComputedGetter(key);
sharedPropertyDefinition.set = noop;
} else {
sharedPropertyDefinition.get = userDef.get
? userDef.cache !== false
? createComputedGetter(key)
: createGetterInvoker(userDef.get)
: noop;
sharedPropertyDefinition.set = userDef.set || noop;
}
if (
process.env.NODE_ENV !== "production" &&
sharedPropertyDefinition.set === noop
) {
sharedPropertyDefinition.set = function () {
warn(
`Computed property "${key}" was assigned to but it has no setter.`,
this
);
};
}
Object.defineProperty(target, key, sharedPropertyDefinition);
}
还是一步一步来。首先,userDef (即我们自己定义的computed属性)可能为function,也可能为一个带get函数,set函数,或其他属性的对象。
如果是简单的函数形式,那么将get定义为createComputedGetter ( 先简单理解为vue重新包装的get,后续会再做介绍 )。
如果不是函数形式,则为复杂的"选项"形式。用户可以定义get,set,或cache属性。如果用于定义了get,并且并没有将cache显式的定义为false,则还是一样的使用createComputedGetter。如果cache定义为false,那么使用另一个函数:createGetterInvoker。如果没有定义get,则设定为空函数。
如果用户定义了set,则computed属性的set即为用户自定义的set。否则,将会设定为一个调用会报错的set函数。
最后,再将重新包装好的computed的key,采用defineProperty的方式,定义到当前实例身上。而defineProperty默认会将没有设定的属性描述,设置为false。也就是当前属性的enumerable和configuable都为false。
以上就完成了对computed属性的定义过程。那么其中的set,又是如何处理的呢?
computed的set
我们从简单的看起:
function createGetterInvoker(fn) {
return function computedGetter () {
return fn.call(this, this)
}
}
createGetterInvoker,也就是当computed为对象的形式,并且设置了cache为false的时候,使用的set。可以看到,其就是简单的调用执行了而已,并没有做额外的逻辑处理 ( 这里的指代的就是缓存操作等 )
而另一个呢,就是会缓存的get。这也是整个computed中最重要的部分之一
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
}
}
}
首先是取得当前computed属性对应的watcher实例,大家应该还有印象,在computed初始化的时候,就创建了_computedWatchers属性,用来存放watcher实例。
然后,在get的内部,判断当前watcher实例的dirty属性,是否为true。而我们之前提到的:
在初始化computed的watcher实例的时候,是初始化了一个没有调用get方法,同时也暂未添加上依赖的Watcher实例。并且该实例的lazy属性为true,dirty属性为true。
如果dirty为true,则会触发实例身上的evaluate方法,否则就只是返回实例的value。为什么要这么做呢?
相信大家或多或少都会对计算属性的这一个性质有所了解:缓存。而此处,正是computed属性缓存的实现。
computed的缓存机制
如果我没有猜错的话,大家对于计算属性的缓存或许是这么理解的:computed去比较所依赖项的值,是否和上一次相比发生了变化,如果是,则重新计算;如果不是,则返回上一次的值。
但其实这种理解是有偏差的。vue是这么实现的:
1,如果值发生了变化,则通知computed,将dirty设置为true,但是此时并不会做计算,只是加上一个标记:"我不干净了,我需要重新算过"。
2,等待某个时刻上层使用者 ( 节点,或其他computed,或watch属性 ) 使用computed属性,也就是调用get方法的时候 ( 这个调用时机大家暂时先不用关注,后序会做介绍 ) ,发现了此计算属性的dirty属性为true,这个时候才会重新去做一次逻辑运算。并且,在第一次运算之后,就会马上将dirty属性重新变为false,也就是 "这个computed我给它处理过了,它干净了,后面的兄弟放心享用"。
3,等待再有上层使用者在此使用该属性的时候,发现了dirty已经是false了,则直接获取其value值,而不会再次做计算。
这么做的好处,对于computed的watcher实例来说,它不需要去做时机的判断,不关心自己什么时候更新,只需要暴露一个dirty属性即可;而对于上层使用者来说呢,也不需要去关心数据的变化形式如何,只需要在dirty的时候,调用一次计算方法即可。并且,每一次依赖数据的更新 ( 指的是computed属性的下层依赖数据 ),只需要重新计算一次。
介绍完缓存机制,我们再具体的来看代码是如何实现的。我们这里介绍的是某次依赖数据发生改变之后的更新流程。因为我们知道初始化的时候dirty一定为true,也就必然会执行一次更新操作。当一次更新发生时,下层依赖数据通知该实例update:
update () {
if (this.lazy) {
this.dirty = true
} else {
queueWatcher(this)
}
}
不知道大家对update方法是否还有印象。对于watch来说,update就是将对应的watcher实例添加至回调队列中,等待执行更新;而对于设置了lazy属性的watcher实例( 对应的也就是computed的watcher实例 )来说,就只需要将dirty设置为true即可。
此时,dirty为true,且我们先假设此时上层使用者能自己接收到更新通知 ( 在之后做介绍 ) 。也就是改变发生时,使用者一定会去重新调用 computed的get方法:
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
观察到watcher的dirty为true,调用evaluate方法:
evaluate () {
this.value = this.get()
this.dirty = false
}
evaluate方法内部会调用get方法,重新计算,并给value赋值,计算完成之后将dirty设置为false。
最后,computed的get函数返回value的值。
当接下来的使用者调用该computed之时,发现dirty为false,则直接跳过计算,返回value。
以上,就是整个update循环的基本流程。到这里呢,我们对于computed的流程已经了解的差不多了,但是我们遗留了另一个很重要的点还没介绍。
如何传递依赖
在之前,我们都是 "假设" 上层使用者能感应到下层数据的变化。那么在代码中,上层使用者,中间层computed,下层响应式数据,三者之间就是如何完成通知的传递的呢?
首先对于中间层computed和下层响应式数据,其实现在之前已经有提到过了:get。也就是当使用者去调用computed的时候,依赖就会顺利添加上了。
还是这一段代码:
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
evaluate内部会调用get,而get内部,就是一个求值,并且添加相关依赖的过程。比如:
fantasyName(){
return this.fantasy.firstName + this.fantasy.lastName
}
上面的computed属性,在求值的时候,就会将fantasy、firstName、lastName的依赖都给添加上。
再比如:
fantasyName(){
this.a
this.b
this.c
this.e
return this.fantasy.firstName + this.fantasy.lastName
}
即使最终结果没有用到abcde中任意一个,但是依赖同样还是会添加上。因为在函数内部使用了,就会触发响应式数据的get,同时也就会添加依赖。
添加之后呢,对于computed的watcher实例身上的deps属性就会存放着这些依赖 ( 关于deps属性,在之前已经介绍过,不做展开。忘记的戳这里 )。
而上层使用者在调用computed的时候,同样也会将自身设置为Dep.target,然后触发computed的watcher实例的depend方法:
depend() {
let i = this.deps.length;
while (i--) {
this.deps[i].depend();
}
}
很简单,就是让watcher实例中的deps,再重新添加上上层使用者为sub。这样也就实现了在之后的更新中,也就会直接绕过computed,直接通知到使用者,"我变了,你要给点反应"。这个时候,使用者也就会重新通过get获取computed最新的值。
这里比较难,画了一张流程图帮助大家理解:
而关于为什么会先通知computed,在通知使用者呢。因为在get内部的先后顺序为,先调用computed属性的watcher实例的get方法,将computed添加为数据的subs,再调用实例的depend方法,将使用者变为数据的subs。而subs为一个数组,会维护其插入的顺序,在触发的时候再依次调用即可。
以上,就是整个computed的源码分析。我们介绍了其依赖的绑定原理,作为中间数据对上下层信息传递的实现原理,以及computed的缓存原理。
到此为止,三个Watcher的使用者,我们已经介绍了两个(computed,watch)。而对于组件实例,因为牵扯的内容比较多,在短期内不会进行分析,至少要等到 vnode 之后,才能更好的理解。大家只需要先简单的理解为:组件实例的更新也是通过Watcher来实现的,同样是一个触发依赖,响应变化的过程。只不过接收到依赖通知之后,并不一定就会真实的更新,而是会做一次diff算法,才最终决定是否需要更新到页面。
文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。
更多推荐
所有评论(0)