关于 Vue 中计算与侦听属性的一些思考

本文来源于「Vue 虚拟实验室」,作者 · 张家博,本文作者写的比较用心

在学习 Vue.js 的计算属性和侦听器一节时,官方文档中有不少无法理解透彻的知识点,例如:

1. 到底 computed,methods,watch 的关系和区别在哪里?

2. 为何在 computed 中,只是简单地使用了 data 属性,就建立起了两者的依赖关系?

3. 计算属性的缓存是如何实现的?

好了,本着知其然知其所以然的态度,我开始了挖坑之旅,以上每个问题如果细究起来都可能是一篇几千字的文章,更别提其中包含的其它问题。我这里只能尽我所能,粗略地分析与解决以上几个问题。 

首先,我们先对 vue.js 教程的“计算属性和侦听器”这一小节进行一个回顾,具体涉及到的定义、示例、说明如下流程图所示,官方教程链接:

https://cn.vuejs.org/v2/guide/computed.html

关于 Vue 中计算与侦听属性的一些思考_第1张图片

以上便为官方教程知识点的一个梳理,希望在阅读这篇文章之前首先过一遍教程相关的章节,这样带着问题对原文有一个回顾,更能把注意点集中。

好了,来到我们文章开头提出的第一个问题:

1.到底computed,methods,watch的关系和区别在哪里?

第一个问题相对容易来解决(只是相对于其他问题而言),我们把computed , watch 的实现细节讨论放到后面,这里只解决他们的关系、区别和使用场景的问题。首先来看它们的定义方法:

关于 Vue 中计算与侦听属性的一些思考_第2张图片

以上就是最基础的写法,其中 this.firstName 和 this.lastName 是组件的 data 属性。

简而言之,可以做以下归纳:

1. 计算属性可以直接用于模版表达式,默认为 getter 方法,也可定义setter 方法来实现和 data 属性的双向绑定;

2. watch 属性需要指定一个 data 属性作为属性名( computed 属性也可以)用于监听这个属性的变化而做相应的函数调用,调用函数的两个参数分别为该属性在变化前和变化后的值;

3. methods 相当于我们平常使用的 Javascript 方法,类似于const now = function(){},可在生命周期钩子中调用,也可以相互之间调用。可以是一段简短的逻辑,也可以是一段处理复杂的异步操作,比较灵活。

以上内容是三种类型的定义和调用方法,我们接下来探讨三种类型的关系和区别。

1. 三种类型都可以在相应的调用函数中执行逻辑,由于 computed 需要保持纯函数,所有无副作用的逻辑都可以在computed中实现。但是正由于需要保持 computed 的“纯”,所以不建议在 computed 中去调用methods 中的方法,除非这个方法也无副作用(关于纯函数、副作用的内容我们在最后一个问题的相关段落中再仔细讨论)。

2. 我们可以用 watch 属性去侦听 data 属性的改变,也可以去侦听computed 的属性的变化,关于侦听 computed 属性的变化并没有在教程中提及,我们可以自行在 codesanbox 中去试试。另外,在 watch 属性中可以去调用 methods 中的方法,教程中最后一部分对 question属性的侦听示例中,就调用了getAnswer 这个方法。

3. methods 中的方法不仅可以被 computed 和 watch 来调用,也可以相互之间调用,下面是这三种类型的关系示意图:

关于 Vue 中计算与侦听属性的一些思考_第3张图片

当然针对三种类型的异同点和使用场景(使用场景可参考第一张图中的部分节点)远远不止以上描述的内容,此处只做了一个粗浅的分析。这里推荐一篇更加深入的文章,包括文章中的知识点和例子对理解这三个知识点很有帮助:

https://css-tricks.com/methods-computed-and-watchers-in-vue-js/

2.为何在computed中,只是简单地使用了data属性,就建立起了两者的依赖关系?

好了,终于来到了第二个问题,要说清楚依赖关系的建立,现有的线索把我导向了官方教程中的关于“深入响应式原理”的相关章节。但是官方教程可能出于篇幅的原因,把这部分的知识体系线索又指向了响应式编程:

Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接。

这里的响应式系统是怎样的?意识到这个问题我去把 ReactiveX 的主要概念过了一遍,感觉理解地不够具体,然后又翻了rxjs 的 API ,有了两点感触:一是按照这个模式我这篇文章怕是写不完了,感觉陷入了回调陷阱(呵呵),二是 Rx 真的很像 Generator/Iterator。后者通过对异步操作进行同步的表达以及引入的 async/await 语法糖是对js开发者来说曙光般的存在。如果你知道 enerator/iterator 的相关概念,可以通过阅读下面这段描述来初步了解 ReactiveX(Rx) 的样子。当然也可以略过下面这段。

在 generator 中,每次调用 next 方法,得到返回值是指向内部状态的指针对象,对象中包含value属性(yield 表达式的值)和 done 属性(是否结束),我们就可以根据这些返回值来进行逻辑处理和判断是否函数执行结束;在 Rx 中,类似地,被观察者每次emit一个事件,调用观察者 onNext 方法,当结束时,调用观察者的 onComplete 方法。不同的是使用 generator/iterator,消费者从生产者那拉取数据,线程阻塞直至数据准备好;使用 Observable,在数据准备好时,生产者将数据推送给消费者,数据可以同步或者异步地到达。

虽然知道了两者很像,但是这并没有解释响应式编程的核心:处理异步数据流。像点击事件、用户输入、网络请求等等,在今天的前端世界中,早就不是提交表单等待服务器返回 html 页面这么简单的事件了,越来越多像搜索框中用户输入的同时就在执行搜索操作、统计页面需要轮询不同的来源以刷新数据等这些应用场景,前者涉及到实时渲染、防止抖动等,后者需要根据不同逻辑设定不同的轮询时间,可能还需要处理权限。这就意味着现有的命令式编程模型需要写更多的逻辑、维护更多的中间状态、判断更多的条件、维护不断增长的 bug。响应式编程(声明式模型,对,SQL 就是声明式)就是为了解决这个问题而提出。

最基础的Rx只需要关注观察者模式,这也是和本文的问题息息相关,针对点击事件或者用户输入,我们可以监听他们,或者接受通知作出相应的处理,这就是最直白的观察者模式。再深入一些,我们同样可以监听变量、属性、缓存等,当他们改变的时候,可以触发我们观察者的回调函数,是不是很简单?好了我已经用了四段来描述响应式编程了,是时候来结合Vue的绑定机制来讨论依赖关系了。 

既然通过深入响应式原理这一章节我们知道了,Vue 会通过 Object.defineProperty方法,把对象中的所有属性全部转化为 getter/setter,我分析这步操作的原因主要是 Vue 会很巧妙地在转化 getter 中添加观察者相关的回调函数,又在 setter 中调用这个函数以实现依赖调用。具体的依赖注入过程如下图所示:

关于 Vue 中计算与侦听属性的一些思考_第4张图片

上图中还缺少 firstName 和 lastName 在改变时的流程:遍历依赖列表,这个列表是由一个个回调函数组成的,执行这些回调函数,就相当于通知 fullName 重新进行计算了(依赖列表中保存的是回调函数,并不是 fullName 这个对象或者属性)。

好的,依赖建立的原理已经理清楚了,如果你需要更深入地了解这个绑定是如何编码的,甚至真实的 Vue.js 中是如何实现的,可以参考文章:

https://skyronic.com/2016/12/vue-js-internals-how-computed-properties-work/

3.计算属性的缓存是如何实现的?

终于来到了第三个问题,虽然官方教程并没有给出明确的指示说计算属性是因为纯函数的原因而有了缓存的可能,但是实际上计算属性如果不是纯函数的话,就算实现了缓存也没有了意义。我们从教程中被重点标注的一句话入手:计算属性是基于它们的响应式依赖进行缓存的。看上去很容易理解,只要响应式依赖改变(例如触发了firstName的setter),如果依赖属性对应结果的关系还没有缓存,就重新计算函数的结果,并加入进缓存中。如果要深入理解这个过程的话,那我们就需要从两方面入手:一方面是什么样的函数符合可以缓存的条件,另一方面是针对这个函数如何实现缓存。

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用;副作用是计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。副作用可能包含:更改文件系统、数据库增删改记录、发送网络请求、DOM操作等等。

当函数中包含副作用时,函数就没有办法保证相同的输入会返回相同的输出了。我们会开始思考:为何保证相同的输入一定有相同的输出这么重要?其实这个逻辑对我们是有大量的好处,这里讨论的可缓存性只是好处中的一个小部分,其他的包括可测试性(有副作用的函数是非常难以写测试用例的,你甚至还需要在用例中添加临时变量或者修改原始代码以使其可测试)、可移植性、合理性等等。

另一个方面,从上一段我们知道了相同的输入会有相同的输出这一点对于做缓存是重要的,也是必须的。那缓存到底是如何实现的呢?我们还是画一张图来描述清楚一些:

关于 Vue 中计算与侦听属性的一些思考_第5张图片

图中的三个步骤分别用了三种不一样的颜色表示出来,第一步传入的参数和第三步是相同的,所以第三步用到了缓存中第一步的执行结果,直接就可以返回了。这样一个最基本的缓存实现完成了。而如何从代码层面实现以上的缓存逻辑呢?最基础的功能只需要实现一个 memoize 函数,这个函数的参数是一个函数,返回是另外一个函数,用一点函数式编程的小 trick 和闭包的知识,我们就可以把它做出来了(如果希望更深入了解相关原理,可以参考JS函数式编程指南和 MDN 中关于闭包的知识,这里由于篇幅限制就不多解释了):

关于 Vue 中计算与侦听属性的一些思考_第6张图片

最后运行这个函数 res(),第一次输出了 compute,而第二次没有进行再输出 compute,直接输出了最终结果,表明缓存起了作用。试想如果 totalName 这个方法中引入了副作用(例如去网络请求了一个随机字符作为 middleName),当第二次执行 res() 这个函数的时候,参数值由于没有改变,会直接从缓存中读取响应的结果,而不会再去网络请求middleName,最后的结果是和上次的结果一样的,这明显不太符合我们的预期。这样的论述也解释了为什么官方教程中提到的:

如果把 Date.now( ) 放到 computed 方法中去执行的话,计算属性将不再会更新,因为Date.now() 不是响应式依赖。如果你不希望有缓存,请用方法来替代。

以上就是关于计算属性和侦听器这一章节的探究,因为目前对于官方教程我只过完了基础章节,这就造成对 Vue 的理解肯定是片面的,另外在文中虽然我尽力去验证每一个想法,但是因为能力有限肯定会有一些错误的地方,欢迎指正与建议!


推荐阅读:

第六阶段 · 期待已久的 Vue

学习 Vue 从如何贡献代码开始

创建第一个 Vue 项目

一个页面 Vue 实例只能有一个吗?

你可能感兴趣的:(关于 Vue 中计算与侦听属性的一些思考)