前提
最近本小白接手了个五年老前端做的项目,技术栈是vue2
对于这种临时接盘的项目我内心是非常排斥的,
当然得先学习学习一下五年老前端的代码
结果一看,我还是皱起了眉头
我看到它的computed是这样写的(下面是模拟代码)
computed:{
hasXXX(){
return function(b){
return this.a + b
}
}
}
// <div>{{hasXXX('111')}}</div>
这是一种computed传参的写法,在掘金上甚至在一些公司的面试题上都存在
在我实习的阶段,也曾经写过这样的代码
毫无疑问这样的代码是可以正常运行的
但我觉得这可能偏离了computed(中文意思:计算)最初设计的初衷
computed计算属性的原理
这里我们先不说为何不建议在vue2使用computed传参的写法
先来看看vue2中计算属性实现的原理
接下来会涉及到部分源码,但是主要理解computed核心实现的机制即可
初始化computed
我们在vue2的核心库中找到初始化computed属性的入口
src/core/instance/state.js
在initState函数中,包含这很多我们平时写option API的时候用到的属性
比如Props、methods、Data、Computed等
initComputed函数作用是初始化我们的计算属性
接下来我们看看这个initComputed函数到底干了
src/core/instance/state.js
我们可以看到首先在vm实例上创建了_computedWatchers属性并指向watchers变量,
然后对参数传入的computed进行了一个for-in遍历,
接下来红框里的是对computed的对象与函数两种写法的兼容。
再往后我们看到在非SSR的情况下,对每个计算属性key都在watchers里初始化了一个Watcher实例,
根据它的作用,我们通俗地叫它计算属性Watcher,它是实现计算属性功能的核心,也是整个vue2响应式系统的核心。
最后调用defineComputed函数并传入vm实例,计算属性对象里的key,还有计算属性key对应的内容。
总结就是initComputed完成了一次属性遍历,完成了一些写法兼容,
初始化了计算属性Watcher,然后把工作交给defineComputed函数
接下来再看看defineComputed函数做了啥
src/core/instance/state.js
这个defineComputed似乎有些复杂,抛去对SSR做的处理(createGetterInvoker(userDef.get)),
我们估摸着能猜到这个函数做了些什么
首先这个sharedPropertyDefinition从哪里来呢?我们全局搜索一下
发现在这个js文件上方定义了
长这个样纸,是不是非常像我们Object.defineProperty第三个参数需要的对象参数,
没错,在defineComputed的最后一行代码中,sharedPropertyDefinition确实用来传入Object.defineProperty,
完成当前计算属性在vm实例上的数据劫持工作,使得我们可以在this上直接访问这个计算属性的值。
然后我们回顾一下上方的代码,可能就容易理解了,
这其实是对sharedPropertyDefinition设置了get()与set()两个方法
其中createComputedGetter是用来创建get()方法的工厂函数
接下来我们再看看createComputedGetter是如何创建一个计算属性的get的呢
src/core/instance/state.js
我们首先看createComputedGetter的入参key与返回值computedGetter,
key的作用是在实例中拿到对应key的计算watcher,
computedGetter是在计算属性的Object.defineProperty中的get方法,
即每次访问计算属性的值时都会调用这个computedGetter函数。
这里我们看到了很多计算属性watcher实例上的属性与方法,
比如watcher.dirty,watcher.evaluate(), watcher.depend(),watcher.value,
这些都会在后面一一介绍,我们先阅读代码猜猜看这些属性与方法是做什么的呢?
首先是当计算属性watcher实例的dirty属性为true的时候,会调用watcher.evaluate()方法。
这个dirty是什么一个状态?这个evaluate()方法是做了什么呢?
然后是Dep.target存在的时候需要触发watcher.depend(),这又是做了什么呢?
最后return了一个watcher.value,这个就比较好猜到了,是我们需要的计算属性的值
排除去实例化计算watcher的细节,一个computed的初始化就完成,
计算属性watcher实例化
接下来我们看看这个计算watcher实例是怎么样的
先回顾一下在初始化的时候传了些什么参数
一共传了4个参数
vm:组件实例,
getter:计算属性的get函数,
noop:暂时当它是一个空函数,实际上是一个回调函数,但是在computed中用不到,
computedWatcherOptions:包含一个lazy属性的对象,用来区分计算watcher和其它watcher的区别,
(补充知识,在vue2的响应式系统中有很多类型的watcher,比如渲染watcher,数据监听watcher)
接着我们来看看watcher类是怎么样的,我们直接来关注它的构造器函数
src\core\observer\watcher.js
我们看到了几个我们非常熟悉的字段
this.dirty当前值为true
this.lazy当前值为true
this.value当前值为undefined
this.getter当前值为我们传入的get函数
还要关注一下 this.deps
它的作用是收集当前watcher(观察者)的所有被观察者,举个例子:就是你们在B站里所有关注的up主
简单来说,实例化计算属性Watcher完成了上方的变量初始化,而且由于设置了this.lazy(惰性watcher状态)使得this.value值(即计算属性值)一开始是undefined的状态,记住这几个变量,我们还会在后续提到
第一次访问计算属性
很多人可能会好奇为啥this.value值(即计算属性值)一开始是undefined的状态
这是因为第一次访问计算属性值是在调用render函数的时候
render函数内”touch“了这个值才会进行计算
关于render函数调用的时机,可以看一下我这篇,有简单介绍
ok我们模拟先进行第一次访问计算属性,我们会去到get函数,即computedGetter函数
是的,又回到这里了,这里我们记得dirty值为true,所以调用计算属性watcher的evaluate()函数,
src\core\observer\watcher.js
来看这个evaluate()函数,调用了this.get()并赋值给this.value,然后把dirty值变false,这样我们就清晰了evaluate()函数是用来求this.value值的。
接着来看这个this.get()
src\core\observer\watcher.js
这里我们只关注红框内的代码
pushTarget(this)
value = this.getter.call(vm, vm)
popTarget()
this.getter在初始化时,我们知道是求值的get函数,比如是this.a + b ,里面包含了响应式数据this.a
(响应式数据的初始化先于计算属性初始化)
computed:{
XXX(){
return this.a + 1
}
}
// <div>{{XXX}}</div>
这样当计算属性get函数访问this.a的时候,this.a会进行依赖收集。pushTarget(this)与popTarget()则是把当前计算属性watcher放到全局,让this.a去收集。
在this.a进行收集依赖的时候,计算watcher的deps属性也会收集这个响应式数据的dep
这一步完成的效果是:当this.a变化是,会派发计算watcher的数据更新。
计算属性惰性求值
可能大家已经发现,计算属性是否重新求值取决于this.dirty的值,而这也是计算属性惰性求值的关键。
派发计算watcher的数据更新则是使用watcher.update () 函数
当this.a变化,会触发watcher.update () ,并把this.dirty设置为true,
这样在下一个render函数访问计算属性值的时候,就会重新求值,否者,还是获取this.value旧值
这样大大减少了计算次数,提高了代码性能。
计算属性内部响应式数据收集渲染watcher
我们已经理解了计算属性基于响应式数据this.a的变化惰性求值的功能,
但是还有一种情况就是在render函数中只访问了计算属性值,而并没有访问this.a响应式数据,this.a没有收集到页面渲染watcher的依赖
这样导致了一个问题就是在后续this.a响应式数据更新时并不会导致视图更新,只会把计算属性的this.dirty设置为true,页面并不会产生任何变化。
如何解决呢,这里又回到computedGetter与Watcher类的depend()方法
src\core\instance\state.js
src\core\observer\watcher.js
关键在红框内的代码。
由于缺少响应式前置知识,我在这里直接告诉大家做了什么操作
Dep.target在计算属性get()函数执行完后,通常指向一个渲染watcher,而这个渲染watcher正是内部响应式数据需要进行依赖收集的对象。
这时watcher.depend(),遍历调用deps(计算属性watcher关注的deps)中depend()函数,进行依赖收集。
就完成了计算属性内部响应式数据收集渲染watcher的过程
(所有的依赖收集都会用Set集合进行去重处理)
这样就完成了整个computed的功能
计算属性传参做了什么
介绍完了原理,又回到开头的例子
computed:{
hasXXX(){
return function(b){
return this.a + b
}
}
}
// <div>{{hasXXX('111')}}</div>
现在我们可以知道hasXXX的get()函数返回值是一个函数的引用,在过程中也没有访问任何响应式数据,所以在第一次求值后,dirty值一直不会变化,一直是该函数的引用,失去了computed惰性求值的特性,与直接写在methods中的函数表现上是一致的。
我看到某些掘金用户说使用计算属性传参而不用methods。这样的说法是错误的,原因就在computed原理和我上面的解释中。
应该怎么写计算属性
我翻阅了element-ui的代码,尝试去找到一个使用computed传参的例子,无功而返。
在我的角度看来,使用computed传参是没有必要的,它完全是可以成为一个方法,写在methods里。
可能很多人会说,那我就是要用computed传参,并且我这样写不就有缓存了吗,访问了this.a
computed:{
hasXXX(){
let d = this.a + this.c
return function(b){
return d + b
}
}
}
// <div>{{hasXXX('111')}}</div>
那其实这样可以改成一个计算属性和一个方法的组合
computed:{
xxx(){
return this.a + this.c
}
},
methods:{
hasXXX(b){
return this.xxx + b
}
}
// <div>{{hasXXX('111')}}</div>
你瞧,这样不也是可以的嘛
而且不用computed传参的好处是减少了不必要的初始化过程,这也相当于变相减少白屏时间喔。(虽然这一点点性能提升可以忽略不计。)
总结
在日常开发中,虽然computed计算属性真的很好用,但是也还是不要滥用哟。 该用methods的地方还是乖乖写methods吧