阅读 2132

[Vue.js进阶]从源码角度剖析计算属性的原理

image

前言

最近在学习Vue计算属性的源码,发现和普通的响应式变量内部的实现还有一些不同,特地写了这篇博客,记录下自己学习的成果

文中的源码截图只保留核心逻辑 完整源码地址

可能需要了解一些Vue响应式的原理

Vue 版本:2.5.21

计算属性的概念

一般的计算属性值是一个函数,这个函数会返回一个值,并且其函数内部还可能会依赖别的变量

一般的计算属性看起来和 method 很像,值都是一个函数,那他们有什么区别呢

计算属性和method的区别

将一个计算属性的函数放在 methods 中同样也能达到相同的效果

但是如果视图中依赖了这个 method 的返回值,并且当另外一个其他的响应式变量的修改导致视图被更新时, method 会重新执行一遍,即使这次的更新和 method 中依赖的变量没有任何关系!

而对于计算属性,只有当计算属性依赖的变量改变后,才会重新执行一遍函数,并重新返回一个新的值

点我看示例

当 otherProp 变量被修改导致更新视图的时候,methodFullName 每次都会执行,而 computedFullName 只会在页面初始化的时候执行一次,Vue 推荐开发者将 method 和 compute 属性区分开来,能够有效的提升性能,避免执行一些不必要的代码

回顾过计算属性的概念,接下来我们深入源码,来了解一下计算属性到底是怎么实现的,为什么只有计算属性的依赖项被改变了才会重新求值

从例子入手

这里我写了一个简单的例子,帮助各位理解计算属性的运行原理,下面的解析会围绕这个例子进行解析

const App = {
    template: `
     <div id="app">
        <div>{{fullName}}</div>
        <button @click="handleChangeName">修改lastName</button>
  </div>
    `,
    data() {
        return {
            firstName: '尤',
            lastName: '雨溪',
        }
    },
    methods: {
        handleChangeName() {
            this.lastName = '大大'
        }
    },
    computed: {
        fullName() {
            return this.firstName + this.lastName
        }
    }
}

new Vue({
    el: '#app',
    components: {
        App
    },
    template: `
    <App></App>
    `
}).$mount()
复制代码

fullName 依赖了 firstName 和 lastName,点击 button 会修改 lastName, 同时 fullName 会重新计算,视图变成"尤大大"

深入计算属性的源码

在日常开发中书写的计算属性,实际上内部都会保存一个 watcher, watcher 的作用是观察某个响应式变量的改变然后执行相应的回调,由 Watcher 类实例化而成, Vue 中定义了3个 watcher

  • render watcher: 模板依赖并且需要显示在视图上变量,其内部保存了一个 render watcher
  • computed watcher: 计算属性内部保存了一个 computed watcher
  • user watcher: 使用 watch 属性观察的变量内部保存了一个 user watcher

理解这3个 watcher 各自的作用非常重要,文本会着重围绕 computed watcher 展开

一个计算属性的初始化分为2部分

  1. 实例化一个 computed watcher
  2. 定义计算属性的 getter 函数

生成computed watcher

在初始化当前组件时,会执行 initComputed 方法初始化计算属性,会给每个计算属性实例化一个 computed watcher

在实例化 watcher 时传入不同的配置项就可以生成不同的 watcher 实例 ,当传入{ lazy: true } 时,实例化的 watcher 即为 computed watcher

定义计算属性的 getter 函数

在创建完 computed watcher 后,接着会定义计算属性的 getter 函数,我们在执行计算属性的函数时,实际上执行的是 computedGetter 这个函数

computedGetter代码很少,但是却是计算属性的核心,我们一步步来分析

dirty属性

通过 key 获取到第一步中定义的 computed watcher,随后会判断这个 computed watcher 的 dirty 属性是否为 true,当 dirty 为 true 时, 会执行 evaluate 方法, evaluate 内部会执行计算属性的函数,并且将 watcher 的 value 属性等于函数执行后的结果也就是最终计算出来的值,具体我们放到后面讲

dirty 属性是一个用来检测当前的 computed watcher是否需要重新执行的一个标志,这也是计算属性和普通method的区别,结合上图可以发现,当 dirty 为 false 时,就不会去执行 evaluate 也就不会执行计算属性的函数,可以看到最后直接就返回了 watcher.value 表示这次不会进行计算,会直接使用以前的 value 的值

当第一次触发computedGetter 时,dirty 属性的默认值是 true ,那是因为在初始化 computed watcher时候 Vue 将 dirty 属性等于了 lazy 属性,即为 true

知道 dirty 的默认值为 true,什么时候为 false 呢?我们接着来看 evalutate 具体的实现

evaluate方法

evaluate 方法是 computed watcher 独有的方法,代码也只有短短2行

get方法

第一行执行了 get 方法, get 方法是所有 watcher 用来求值的通用方法

get 主要就做了这三步

  1. 将当前这个 watcher 作为栈顶的 watcher 推入栈
  2. 执行getter方法
  3. 将这个 watcher 弹出栈

我们知道 Vue.js 会维护一个全局的栈用来存放 watcher ,每当触发响应式变量内部的 getter 时,就会收集这个全局的栈的顶部的 watcher(即Dep.target),将这个 watcher 存入响应式变量内部保存的dep中

第一步通过 pushTarget 将当前的 computed watcher 推入全局的栈中,此时Dep.target就指向这个栈顶的 computed watcher

第二步执行 getter 方法, 对于 computed watcher,getter 方法就是计算属性的函数,执行函数将返回的值赋值给 value 属性,而当计算属性的函数执行时,如果内部含有其他的响应式变量,会触发它们内部的 getter ,将第一步放入作为当前栈顶的 computed watcher 存入响应式变量内部的dep对象中

注意响应式变量内部的 getter 和 getter 方法不是一个函数

第三步将这个 computed watcher 弹出全局的栈

之所以将这个 computed watcher 推入又弹出,是为了让第二步执行内部的 getter 时,能让计算属性函数内部依赖的响应式变量收集到这个 computed watcher

对于计算属性来说,get 方法的作用就是进行求值

将dirty设为false

执行完 get 方法,即一旦计算属性执行过一次求值,就会将 dirty 属性设为 false,如果下次又触发了这个计算属性的 getter 会直接跳过求值阶段

结合🌰

在例子中,因为视图需要依赖 fullName 这个响应式变量,所以会触发它的内部的 getter,同时它又是一个计算属性,即会执行 computedGetter ,此时 dirty 属性为默认值 true,执行 evaluate => get => pushTarget

pushTarget 中,由于是 computed watcher 执行的 get 方法,所以 this 指向这个 computed watcher, 将它推入全局栈中作为 Dep.target,随后执行计算属性的函数

可以看到计算属性 fullName 的函数依赖了 firstName 和 lastName 这2个响应式变量,Vue在内部通过闭包的形式各自保存了一个 dep 对象,这个 dep 对象会收集当前栈顶的 watcher,即收集 fullName 这个计算属性的 computed watcher,所以当计算属性的函数执行完毕后,firstName 和 lastName 内部的 dep 对象中都会保存一个 computed watcher

收集完毕后,将 computed watcher 弹出,让栈恢复到之前的状态

depend方法

计算属性第二个特点就是它的 depend 方法,这个方法是 computed watcher 独有的

当 Dep.target 存在,说明在上一步弹出了 computed watcher 后全局的栈中仍有其他的 watcher。比如当视图中依赖了当前的计算属性,那当前栈顶的 watcher 就是 render watcher,亦或者另外一个计算属性内部依赖了当前的计算属性,那栈顶的 watcher 可能是另一个 computed watcher,不管怎么说只要有地方使用到这个计算属性,就会进入 depend 方法

watcher 的 depend 方法:

depend 方法也非常简短,它会遍历当前 computed watcher 的deps属性,依次执行 dep 的 depend 方法

deps 又是什么呢,前面说到 dep 是每个响应式变量内部保存的一个对象,deps 可想而知就是所有响应式变量内部 dep 的集合,那具体是哪些响应式变量呢?其实了解过响应式原理的朋友应该知道,这个 deps 实际上保存了所有收集了当前 watcher 的响应式变量内部的 dep 对象

这是一个互相依赖的关系,每个响应式变量内部的 dep 会保存所有的 watchers,而每个 watcher 的 deps 属性会保存所有收集到这个 watcher 的响应式变量内部的 dep 对象

(Vue之所以在 watcher 中保存 deps,一方面需要让计算属性能够收集依赖,另一方面也可以在注销这个 watcher 时能知道哪些 dep 依赖了这个 watcher,只需要调用 dep 里对应的注销方法即可)

接着就会遍历每个 dep 执行 dep.depend 方法:

这个方法的作用是给当前的响应式变量内部的 dep 收集当前栈顶的 watcher ,在例子中,因为视图中依赖了 fullName,所以当 get 方法执行结束 computed watcher 被弹出后,栈顶的 watcher 就变为原来的 render watcher

computed watcher 中的 deps 属性保存了2个 dep,一个是 firstName 的 dep,另一个是 lastName 的 dep,因为这2个变量在执行 get 方法第二步的时候收集了到这个 computed watcher

这时候执行 dep.depend 时会再次给这2个响应式变量收集栈顶的 watcher,即 render watcher,最终这2个变量内部的 dep 都保存了2个变量,一个 computed watcher,一个 render watcher

最终返回 watcher.value 作为显示在视图中的值

修改计算属性依赖的变量

前面说过,只有当计算属性的依赖项被修改时,计算属性才会重新进行计算,生成一个新的值,而视图中其他变量被修改导致视图更新时,计算属性不会重新计算,这是怎么做到的呢?

当计算属性的依赖项,即 firstName 和 lastName 被修改时,会触发内部的 setter,Vue 会遍历响应式变量内部的 dep 保存的 watcher,最终会执行每个 watcher 的 update 方法

可以看到 update 方法有3种情况:

  • lazy:只存在于 computed watcher
  • sync:只存在于 user watcher,当 user watcher 设置了 sync 会同步调用 watcher 不会延迟到 nextTick 后,基本不会用
  • 默认情况:一般的 user watcher 和 render watcher 都会执行 queueWatcher,将这些 watcher 放到 nextTick 后执行

通过前面的 evaluatedepend 方法,firstName 和 lastName 内部的 dep 中都会保存2个 watcher,一个 computed watcher,一个 render watcher,当 lastName 被修改时,会触发内部的 setter,遍历 dep 保存的所有 watchers,这里会先执行 computed watcher 的 update 方法

同时前面说到在 computed watcher 求值结束后,会将 dirty 置为 false,之后再获取计算属性的值时都会跳过 evaluate 方法直接返回以前的 value,而执行 computed watcher 的 update 方法会将 dirty 再次变成 true,整个computed watcher 只做这一件事,即取消 computed watcher 使用以前的缓存的标志

这个操作是同步执行的,也就是说即使 render watcher 或 user watcher 在 watchers 数组中比 computed watcher 靠前,但是由于前2个 watcher 一般是异步执行的,所以最终执行的时候 computed watcher 会优先执行

真正的求值操作是在 render watcher 中进行的,当遍历到第二个 render watcher 时,由于视图依赖了 fullName,会触发计算属性的 getter,再次执行之前的 computedGetter,此时由于上一步将 dirty 变成 true了,所以就会进入 evalutate 重新计算,此时 fullName 就拿到了最新的值"尤大大"了

修改非计算属性依赖的变量

回到一开始计算属性和 method 区别的那个例子,因为视图依赖了 otherProp 所以当这个响应式变量被修改时,会触发它内部 dep 保存的 render watcher 的 update 方法,它会重新收集依赖更新视图

当收集到 methodFullName 时,因为是一个普通的 method,每次视图更新 Vue 都会执行相应的方法,所以每次都会打印 "method",而当收集 computedFullName 时,会执行 computedGetter,但是因为 otherPorp 不是这个计算属性依赖的变量,没有触发过 computed watcher 的 update,所以 dirty 属性为 false,就会跳过evaluate 方法直接返回缓存的结果,因此不会每次打印 "computed"

总结

只有当计算属性依赖的响应式变量被修改时,才会使得计算属性被重新计算,否则使用的都是第一次的缓存值,原因是因为计算属性内部的 computed watcher 的 dirty 属性如果为 false 就会始终使用以前缓存的值

而计算属性依赖的响应式变量内部的 dep 都会保存这个 computed watcher,当它们被修改时,会触发 computed watcher 的 update 方法,将 dirty 标志位置为 true,这样下次有别的 watcher 依赖这个计算属性时就会触发重新计算

参考资料

Vue.js 技术揭秘

关注下面的标签,发现更多相似文章
评论