关于 Vue App 开发的一些思考

我来 TalkingData 实习已经六个月了。在这六个月期间,我独立完成了三个前端 SPA 项目,从 Vue 1 & Vuex 1 到 Vue 2 & Vuex 2 都有使用。从最先开始的四个模块、八个功能,到最后多模块嵌套、数十个功能,项目的难度越来越大,复杂度越来越高,坑也越踩越多。第三个项目完成后,我仔细回顾了这三个项目的开发历程,重新整理了项目代码,发现了很多问题,也产生了很多思考。

个人愚见,还望大家多批评指正。

关于前端模块化

模块化是一个源于后端的概念。在我的理解中,模块化的目的在于提升代码的可复用度与可维护性,加速开发效率。同时模块化又意味着大量的解耦,将不同的模块尽可能的分开(因为这样才能彼此独立开发)。

传统的前端开发,每一个链接都对应一个实体页面,既没有软路由的概念,也没有单页应用的概念。我们访问不同的链接,得到的就是不同的页面。这样的设计有两个问题:由于页面彼此独立,页面元素的复用性较差;由于每次页面切换都要对整个页面进行刷新,页面加载的性能也相对较差。为了解决生产环境下的复用问题,SSI 技术应运而生了。后来,为了解决研发阶段代码复用的问题,像 Gulp 这样的构建工具诞生了。这应该可以算是很早的前端模块化的尝试。

进入 React 时代之后,前端模块化开始变得很常见了。React 作为视图层框架,将视图分割成了一个个组件,我们可以分别开发各个组件,再将这些组件组装起来,形成最终的页面。这时候,前端开发的过程就很像曾经的后端开发了:我们先把产品分割成组件,然后依次开发各个组件,再把各个组件组装起来,形成最终的产品。模块化的出现,也使得前端可以引入后端常见的单元测试,在开发早期就能避免很多 BUG 。

前端模块化是一件很有意思,又有点让人头疼的问题。SPA 的开发方式,更像是在搭积木:我们先按照图纸,把每一块积木( Component )的样子构建出来,再按照图纸把一块块积木搭建起来,组成我们想要的结构( View ),而有的积木是一扇窗户,可以打开、关闭,有的积木是一组轮子,可以转动。这些行为都是精确在积木上的,在设计和制作积木的过程中,就已经完成了。在搭积木的过程中,我们不再关心这些行为,而是更专注于怎么实现图纸要求的结构。这样的设计使得整个开发过程更加的清爽,但也伴随着一些问题:我们必须将各个组件尽可能的解耦,然后再将相关的组件通过额外的部件连接起来(比如用皮带连接方向盘和转向轴)。

搭积木的例子就举到这里,我们回归到问题本身:组件解耦之后,有些需要组件间相互配合完成的业务逻辑(换言之,组件间通信 & 状态共享)有些难以组织。在相同组件树上的组件,我们可以添加父组件进行管理,如果不在同一个组件树上呢?同时,为了进行组件间通信,我们不得不为组件多添加一些结构,或是父组件,或是其它的方式。这也是前端模块化让人比较头疼的地方。

关于组件间通信

因为我是从 React 转到 Vue 的,所以先聊聊 React 的组件间通信。React 有一个典型特征:单向数据流。所有的数据都只能由父组件传递给子组件,不能回传,即便是进行修改,也是子组件通过父组件传递进来的函数修改父组件的状态,再由父组件传递给子组件。React 是如何进行组件间通信的呢?它们通过操作父组件进行组件间通信。乍一看蛮合理,但是如果组件不在相同层级上,就很麻烦了:它们要一直向上回溯,找到共有的父组件,再通过 Props 逐级传递,将修改父组件的函数传递下去,才能实现组件间通信。

Vue 的处理方式很像,只不过它不需要传递函数,而是通过触发事件( Vue 1 的时候更简单,只需要使用 .sync 进行双向绑定就好了。Vue 2 也有 .sync,但是其本质还是通过触发事件完成数据修改)。

这样的做法问题很明显:为了完成组件间通信,我们必须要在至少一个父组件上,甚至整个组件树上下发处理函数 / 分发事件(Vue 的事件是不冒泡的)。如果组件通信的跨度很大,那我们的代码会变得非常难以维护。而且,如果这两个组件属于完全不同的组件树(比如属于完全独立的两个 Module 上),我们几乎没有办法妥善处理组件间通信(虽说根 View 肯定是所有组件的父组件,但是我们并不想在 View 层添加任何业务逻辑)。

Vue 提供了一个状态管理框架:Vuex,用来将状态(或者说,数据)从组件中剥离出来,外挂在整个应用上,以此来增强对应用状态结构的管理与状态共享(组件间通信)的支持。

Vuex

同时,Vue 也提供了另一种方式进行跨组件通信:Event Bus。它运用了观察者模式,通过在 Bus(事件总线,本质是一个 Vue 实例)上触发事件,再在 Bus 上捕获事件,来完成组件间通信。这种方式下,状态依然保存在各个组件内部。

Event Bus

关于 Event Bus 和 Vuex

Event Bus 托管数据

在我见到的 Vue 项目中,有的人会用 Event Bus 托管一部分数据。这样做本身没什么问题,但是我觉得违背了 Event Bus 的初衷。同时,这些数据既不属于组件树,又不在整个应用的数据结构上,无法妥善管理。

同时使用 Event Bus 和 Vuex

还有一个问题,我考虑了很久:一个项目究竟应不应该同时引入 Vuex 和 Event Bus 两套逻辑。这个答案没有正误,但我更倾向于不这么做。通常这样做的原因是:我只需要在局部进行组件间通信,而且我不想使用父组件管理子组件(可能逻辑比较复杂,可能新建父组件只为了完成这一个操作有点浪费),这时我会考虑使用 Event Bus 完成局部组件间通信的操作。举个例子:一个集群管理系统,当我关闭某台服务器的时候(关闭服务器的操作在对应服务器的卡片上),显示开启的服务器数量的组件会同时减去一,这个逻辑只有集群管理这一个页面有。我的做法是,如果整个项目没有使用 Vuex 进行状态管理,我们可以使用 Event Bus,但是如果使用了 Vuex,我们应当将该逻辑整合到 Vuex 上,由 Vuex 触发视图更新(即完成组件间通信),而不是直接启用一个 Event Bus。

谨慎使用 Vuex

很多文章都提到了,我们也许并不需要使用 Vuex,除非我们的项目真的“大”到需要一个状态管理机制来管理整个应用的状态。在我看来,评估一个项目是否需要 Vuex 的方式很简单:应用对数据共享的需求有多重,以及应用对数据缓存的依赖有多重:

  • 如果应用中包含大量需要数据共享的组件,无论是局部的还是全局的,我们都可以考虑启用 Vuex。
  • 如果页面在初始化时需要通过 Ajax 从服务器端请求大量的数据以完成页面渲染(尤其是不需要频繁更新的数据,比如一个日程表),我们可以考虑启用 Vuex(因为如果我们不外挂这些数据,组件销毁后就需要重新请求这些数据再重新渲染)。

同时,如果启用了 Vuex,我建议除了控制视图的状态属性(比如控制 Switch 组件是开状态还是关状态)和表单数据外,其它状态全部托管到 Vuex 上,在“关于业务逻辑”中我会解释为什么。

如何摆脱 Vuex

之前提到,不是所有的应用都需要使用 Vuex 进行状态管理,那我们该如何摆脱 Vuex?思路很清晰:全局状态本地化,局部状态局部化。

由于 Vue 对所谓“全局根组件”的概念比较淡薄,所以很多全局状态就会有点无处安放,这也是我最先开始启用 Vuex 的原因。其实仔细分析,一个应用的全局状态其实很少,我能归纳到的大概只有两点:登录状态 & 用户信息(鉴权信息)。

  • 关于登录状态,请务必下放到 Vue Router 中,然后对所有的需要登录后才能访问的页面,在路由层面进行统一控制。
  • 关于用户信息,其实前端没必要保存太多。用户信息显示最频繁的地方,通常是在 Header 上。所以用户信息可以直接作为 Header 的局部属性存储。鉴权信息可以利用 Session Storage 或者 Cookie 进行存储。其它的全局状态,也可以下放到 Cookie 和 Storage 中,在需要的组件中按需读取。

局部状态,我们可以通过添加父组件的形式进行局部的统一管理,也可以完全下放给组件本身,合理即可。但是,请不要使用 Event Bus 管理数据,同时也请有效控制 Event Bus 的数量,我认为一个就够了。

关于项目结构

以下的讨论,建立在一个使用了 Vuex 的 Vue SPA 项目上。

最先开始写项目的时候,由于项目本身不是很复杂,我没有在项目结构的设计上做任何的文章。在完成第三个项目的时候,面对频繁更迭的需求,不断添加的功能,整个项目的代码越改越乱,以至于无法维护了。我用了很久的时间重新整理并构建了新的项目结构,以解决以下几个问题:

  • 新功能添加频繁、需求变化频繁。
  • 项目依赖大量的 Ajax 请求,业务逻辑复杂。
  • 路由结构复杂,页面嵌套非常多。

处理项目的时候,我选用了这样的顺序:从功能出发,按照实际的业务模块构建页面的路由结构,再从路由结构出发构建出视图结构,再根据视图对组件进行分类。这样我们能得到一个很清晰的结构:一个项目被分成了不同的模块,每个模块有相互对应的视图与组件。然后,我们再根据组件结构,按照模块构建 Vuex 状态树,封装 Ajax API 。

总结一下:一切对项目的分割与归类都应当建立在 Module 上,Views,Components,Store,APIs 的结构一定要对应。

这样,无论进行测试还是进行维护,我们都可以在不干扰任何其他组件的情况下进行操作,也尽可能降低了耦合。所有的组件间通信,全部放到 Vuex 中完成,这样每一个 Module 中的组件都可以专注与自己本身(从 Vuex 中取用状态 & 推送状态到 Vuex 中),而不用考虑其他组件。

一个典型的项目结构是这样的:

Vue SPA Template

有两个一定要遵守的原则:

  • 视图层( Views )不应处理任何的业务逻辑,只负责组装组件( Components )。
  • 视图层的文件逻辑结构应当与路由结构完全相符。

关于业务逻辑

前后端解耦之后,前端对 Ajax 的依赖变得非常的重。许多原本可以直接由服务器渲染的数据,都需要前端通过 Ajax 的方式从后端拉取。这新增了许多业务逻辑。如何安放这些业务逻辑,成了一个难题。

我的第三个项目中,后端交付给前端的接口有 60 多个,几乎每一个页面的渲染都依赖至少一个接口来获取数据。我起先在组件中嵌入了大量的代码来处理 Ajax 的 Response,导致组件中的函数被拉的很长,逻辑很多很混乱,可维护性非常差。

我没有把对 Response 的处理封装到 APIs 的原因是:我需要控制整个请求过程,比如在加载的时候给出友好提示。同时我认为,我们不应该把处理 Response 的过程放到 APIs 中,因为 Response 通常与数据挂钩,我们应当将数据与请求隔离开,APIs 应该只专注于处理传入的请求内容和生成请求,而不对应用状态进行操控。

为了清理这些业务逻辑,我瞄准了 Vuex。除了发送表单的请求外,我将所有执行 Ajax 和处理 Response 的逻辑放到了 Vuex 的 Actions 内,而组件中只 Dispatch 请求数据的事件。这本身是合理的:Vuex 管理着整个应用的状态,我们通过更新 Vuex 的状态触发视图更新,渲染组件内容。同时,这样整理过之后,我对 Ajax 的维护变得更加容易了,我不再需要精确到某一个组件进行维护,而是直接定位到它所属的 Vuex Module 并维护对应的 Action 即可。同时,Vuex 的 Actions 支持 Promise,我们可以很容易的控制 Ajax 状态提示信息的显示。

我之所以建议除了控制视图的状态属性和表单数据外,其它状态全部托管到 Vuex 上,是因为通过 Ajax 拉取数据占用了整个项目数据来源的很大部分,同时 Ajax 操作会影响到其他本地状态,比如会影响到分页组件的分页数量。为了方便管理和维护,全部托管到 Vuex 上付出的代价我认为是可以接受的。

关于组件复用

我们一直在重复一个概念:模块化。模块化就意味着复用。我们应该如何寻找可复用的组件,又该如何复用呢?

通常,可复用的组件不应该包含过多(甚至不应该包含任何)业务逻辑。这些组件接收外界传递给它的参数,返回固定的结果(有点像纯函数,或者 React 中的 Dumb Component )。在项目开发中,我们很难一下子确定可复用的组件,我的建议是:首先不对组件进行复用,在开发完成后,对组件进行梳理,发现可复用的组件之后,再尝试对组件进行抽象。切忌强行对组件进行复用,否则会使抽象组件变得臃肿,事半功倍。

还有就是,视图层坚决不允许复用,不要试图通过在视图层对资源类型进行判断或者其他方式控制视图层的渲染结果。

关于项目管理

  • 一定要为每一次 Git Commit 留下清晰明确的日志,方便查阅和回滚。
  • 对每一个关键步骤都应该生成唯一的 Git Commit,而不是一口气提交全部的修改。
  • 对每一次发布,都应当添加版本标记,方便进行版本控制和回溯。

推荐阅读更多精彩内容