导读
今天就来聊聊我们非常熟悉的计算属性吧,这也是前端面试常见的一个题目了,而且是属于稍微有点难度的题目, 如果你答得很好那就是一个很棒的加分点,要答好这个问题,那至少都要做到从源码的角度去分析计算属性的原理。
本文就从源码的角度去分析计算属性,适合那些了解响应式原理这部分源码的同学看,如果没学习过,那就得去补一补这块的知识啦。
computed的使用方法
先来看一下计算属性的用法,直接看官网的示例:
<div id="example">
<p>Original message: "{{ message }}"</sp>
<p>Computed reversed message: "{{ reversedMessage }}"</p>
</div>
var vm = new Vue({
el: '#example',
data: {
message: 'Hello'
},
computed: {
// 计算属性的 `getter`
reversedMessage: function () {
// `this` 指向 vm 实例
return this.message.split('').reverse().join('')
}
}
})
从上面的例子中我们可以知道,计算属性可以是一个函数,除此之外,我们还可以用对象的形式表示:
computed: {
reversedMessage: {
get: function () {
return this.message.split('').reverse().join('')
},
// setter
set: function (newValue) {
// dosomething
}
}
}
以上例子中我们手动给reversedMessage
定义了一个getter
和setter
。所以计算属性可以有两种定义方式:函数和对象自定义getter
、setter
方法。实际上,VueJS规定每一个计算属性都必须要有一个getter
方法,如果使用函数形式,那这个函数会被当作计算属性的getter
。
计算属性的特点
计算属性是基于响应式历来进行缓存但只在相关响应式依赖发生改变时它们才会重新求值
也就是说上面的case中只要 message
还没有发生改变,多次访问 reversedMessage
计算属性会立即返回之前的计算结果,而不必再次执行函数。而方法会每次都进行求值。在一些性能开销比较大的地方,可以使用计算属性。通俗点说就是computed
是一个懒求值的响应式数据。
计算属性的源码分析
还是得从new Vue
开始讲,实例化的过程中会进行计算属性的初始化,也就是调用initComputed
方法,它被定义在src/instance/state.js
function initComputed (vm: Component, computed: Object) {
// 创建watchers和vm._computedWatchers用于保存watchers
const watchers = vm._computedWatchers = Object.create(null)
const isSSR = isServerRendering() // 判断是否是服务器渲染环境
// 遍历computed属性
for (const key in computed) {
const userDef = computed[key]
// computed属性有两种方式,一种是function,一种是getter、setter,获取getter
const getter = typeof userDef === 'function' ? userDef : userDef.get
if (!isSSR) { // 如果不是服务器渲染环境
// 给每一个属性实例化一个computed watcher
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions // const computedWatcherOptions = { lazy: true }
)
}
// 判断key是否已经存在于vm上
if (!(key in vm)) {
// 如果是新的值,调用defineComputed函数将它挂载到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) // 这个属性已经定义在data中了
} else if (vm.$options.props && key in vm.$options.props) {
warn(`The computed property "${key}" is already defined as a prop.`, vm) // 这个属性已经定义在prop中了
}
}
}
}
首先创建了一个空对象watchers
和私有变量保存watchers
,这里巧妙利用了对象的引用性质,也将这些实例保存在vm._computedWatchers
上,然后遍历计算属性,取每一个属性的getter
,为每一个属性创建一个Watcher
实例保存到watchers
中,这是一个特殊的Watcher
实例,就叫它computedWatcher
,最后一个参数const computedWatcherOptions = { lazy: true }
,这个是计算属性的一个很重要的标志,我们先来分析一下实例化一个computedWatcher
发生了什么。
实例化computedWatcher
// src/core/observer/watcher.js
class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
// options
if (options) {
//...
this.lazy = !!options.lazy // 代表这是一个computedWatcher
//...
} else {
this.deep = this.user = this.lazy = this.sync = false
}
//...
this.dirty = this.lazy // for lazy watchers true
//...
this.value = this.lazy ? undefined : this.get()
}
}
这里我只保留了核心的部分,让我门一行一行代码分析,先看这一行this.lazy = !!options.lazy
,还记得我们传进来的最后一个参数const computedWatcherOptions = { lazy: true }
吗?其实这个就是computedWatcher
的标志,所有的computedWatcher
的lazy
属性都是true
。
接下来就到this.dirty = this.lazy
,这个变量字面意思就是表示值是否脏了,当dirty
为true
的时候就应该进行重新求值,false
的时候什么都不做,这就是计算属性缓存的原理。
最后一行this.value = this.lazy ? undefined : this.get()
我们都知道实例化Watcher
的时候会进行第一次的触发数据的getter
进行依赖的收集,但这行代码中lazy
使computedWatcher
实例化的时候不进行第一次的求值。那么第一次求值发生在什么地方,请继续往下看。
defineComputed(vm, key, userDef)
if (!(key in vm)) {
// 如果是新的值,调用defineComputed函数将它挂载到vm上
defineComputed(vm, key, userDef)
}
实例化computedWatcher
后,我们现在回到defineComputed
这个方法,它也是被定义在state.js
中:
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 ? createComputedGetter(key) : noop
sharedPropertyDefinition.set = userDef.set || noop
}
//...
Object.defineProperty(target, key, sharedPropertyDefinition)
}
这个方法目的也很明确,前面我们说过计算属性接受两种定义形式:函数和对象的getter
、setter
,所以先判断userDef
是不是函数,如果是,执行sharedPropertyDefinition.get = createComputedGetter(key)
,sharedPropertyDefinition
是一个对象:
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
}
很明显这是一个Object.defineProperty
的descriptor
对象。然后我们看createComputedGetter(key)
这个方法,同样也是在同一个文件里:
function createComputedGetter (key) {
return function computedGetter () {
//...
}
}
这是一个高阶函数,返回一个叫computedGetter
的函数,这个函数被传递给sharedPropertyDefinition.get
,这里的逻辑我们下面再分析,我们先回到前面,sharedPropertyDefinition.set = noop
这表明函数式的计算属性不能设置setter
,noop
是一个空函数。
如果userDef
不是函数形式,先判断是否有getter
,如果有也是调用createComputedGetter(key)
,否则设置为空函数。而对象形式的计算属性可以设置setter
,所以下面的一句检查用户是否自定义了setter
。
sharedPropertyDefinition.get = userDef.get ? createComputedGetter(key) : noop
sharedPropertyDefinition.set = userDef.set || noop
接下来执行Object.defineProperty(target, key, sharedPropertyDefinition)
,这个大家应该都很熟悉了,这里的target
是vm
,也就是当前的Vue
实例,实际上这个方法的作用就是将每一个计算属性代理到vm
上,这样我们就可以通过vm.xxx
访问到计算属性了。,但此时不会访问到真实的getter
,而是会访问到computedGetter
。
然后我们现在来讲讲computedGetter
,还是使用文章开头的那个case:<p>Computed reversed message: "{{ reversedMessage }}"</p>
,在渲染为真实DOM的过程中会访问到vm.reversedMessage
进行取值,此时会触发computedGetter
,而不是触发reversedMessage
函数代码如下:
function createComputedGetter (key) {
return function computedGetter () {
// vm._computedWatchers存在,取vm._computedWatchers[key]
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
// 只有值改变了,才会重新获取
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
此时会通过vm._computedWatchers
和key
(假如访问vm.xxx
,那么key
就是xxx
)获取到当前的computedWatcher
实例,前面我们讲过:实例化computedWatcher
的时候不会进行第一次的求值,仅仅将dirty
设置为true
,所以这时候就会调用watcher.evaluate()
方法,这个方法定义在watcher.js
中
//...
evaluate () {
this.value = this.get()
this.dirty = false
}
//...
非常简单对吧,就是调用this.get()
获取最新的值更新watcher.value
,此时就是调用真实的getter
了,也就是reversedMessage
函数,而调用函数过程中,会触发函数内所有响应式数据的getter
,进行依赖的收集,依赖收集的结果是将当前的computedWatcher
添加到响应式数据的dep
中。这里得注意,Dep.target
现在应该指向computedWatcher
,最后将dirty
设置为false
。
接下来到这里:
if (Dep.target) {
watcher.depend()
}
这里的作用就是将一些watcher
收集到到响应式数据的依赖中,用以接收数据更新的通知。来分析一下这个过程。首先判断Dep.target
是否为null
,Dep.target
有两种可能,renderWatcher
或者$watcher()
的普通watcher
。
watcher.depend()
是调用computedWatcher
上的depend
方法,如下:
//...
depend () {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
//...
watcher
上的deps
是一个数组,用来保存所有它所观察的响应式数据的dep
实例。先获取一个deps
的长度,然后遍历deps
数组,依次调用this.deps[i].depend()
,这个方法被定义在dep.js
文件:
// ...
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// ...
这里将dep
实例当作参数调用了Dep.target.addDep(this)
,定义在watcher.js,
:
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
newDepIds
和depIds
是ES6的Set
实例,newDeps
和deps
是数组,设计得这么复杂目的是为了避免重复收集依赖。首先将dep
实例保存到了newDeps
中,然后又调用了dep.addSub(this)
将watcher
添加到dep
的subs
中。所以绕来绕去的目的就是将renderWatcher
或者$watcher()
收集到计算属性观察的所有响应式数据的依赖中,以便接收到数据更新的通知。,最后就是返回计算属性的值return watcher.value
,就可以获取到计算属性当前的值了。
计算属性重新计算过程分析
前面分析了计算属性值缓存的原理,就是利用dirty
属性去控制是否重新计算值,那么什么情况下会重新计算值呢?当计算属性所依赖的响应式数据发生变化的时候就会进行计算属性的重新计算,下面我们来分析这个过程:
我们知道,当响应式数据改变的时候,会调用dep.notify()
去通知它收集的所有的watcher
进行更新,也就是调用watcher.update
方法。
update () {
/* istanbul ignore else */
if (this.lazy) {
// 计算属性更新
this.dirty = true
} else if (this.sync) {
// 如果是sync watcher,直接执行
this.run()
} else {
// 否则先放到队列里,nextTick再执行
queueWatcher(this)
}
}
如果是一个computedWatcher
,只会将dirty
设置为true
。前面提到,使用计算属性的watcher
也会收到通知,就假设是renderWatcher
也会收到通知,而且是比computedWatcher
收到通知的时机要晚一点的,因此。当renderWatcher
收到了通知,会发起dom的重新渲染,此时访问到了计算属性,触发了computedGetter
,这时候dirty
为true
,所以会进行重新求值watcher.evaluate()
和重新收集依赖watcher.depend()
。
面试回答参考模版
面试中这种问题的回答要把握好,太详细显得啰嗦,太简单又没深度,但要把整个过程描述清楚并不简单。我的建议是分步去描述,这里提供一个参考答案,不建议大家背答案,最好能够有自己的学习理解和总结,一定要总结归纳一些常见问题的答案,多练习打好腹稿,尤其是口才不好的同学,这样面试的时候才会流畅、清晰。
Q:能给我简单讲讲Vue的计算属性吗?
A:
- 首先从新建一个Vue实例开始吧,新建Vue实例会调用
initComputed
进行计算属性的初始化过程 - 初始化过程中会遍历计算属性,分别新建一个
computedWatcher
实例,这是一个特殊的Watcher
,lazy
属性为true
,实例化的时候并不会进行第一次求值,而是仅将dirty
属性为true
,lazy
和dirty
就是计算属性的核心 - 然后会使用
definedComputed
方法将拦截方法代理到vm上,每次读取计算属性就会触发computedGetter
方法,这个方法主要有个作用:重新求值和收集依赖 - DOM首次渲染会访问到计算属性,触发
computedGetter
方法,此时dirty
为true
,调用evaluate
方法进行计算属性第一次求值,拿到值后会将dirty
设置为false
,此时会进行收集依赖,computedWatcher
会观察计算属性方法内所用到的所有响应式数据,然后会将renderWatcher
也观察计算属性方法内所用到的所有响应式数据,这样就可以接收到数据改变时发出的通知了 - 当数据改变的时候,首先会通知到
computedWatcher
,因为lazy
为true
,只会将dirty
设置为true
,然后RenderWatcher
也接到了通知,进行rerender,又触发了computed getter,此时dirty为true,调用evaluate
方法计算取得最新值,并调用depend
方法进行重新收集依赖。
总结
以上就是本文的全部内容,希望可以帮助到大家~