0. 前言

前端开发最佳实践的意义在于,提供了我们开发中总结的模式相关的经验。这可以让我们按照最佳实践的规范进行开发。不仅提高我们的项目的可读性、可维护性、可扩展性。而且还能减少开发时候思考和选择带来的成本。

比如,如果我们确定了单页应用开发的最佳实践,我们知道了代码如何组织,目录如何划分,数据如何管理,路由如何管理,组件在不同场景下如何设计。那么我们完全可以在新开始任何一个SPA的项目时候就迅速搭建出一个完善的基础支持,基于这个基础去开发,就会大大提高开发的效率。并且在后续维护过程中,也会驾轻就熟。

下面说明下个人在开发和学习过程中总结出的代码组织的最佳实践

1. 模式

MVC是GUI程序的最佳实践,它的本质是关注点分离。程序可以分为数据层、视图层和控制层(业务逻辑层)。

它可以极大地提高项目的

  • 可读性:当读一个使用MVC模式开发的项目时候,很容易就可以从M中看到应用中包含哪些数据,从V中看到应用都包含哪些界面元素,从C中看出应用的业务逻辑。非常清晰流畅,极大减少了开发者的心智负担。
  • 可维护性:当使用MVC的项目需要修改时候,可能只修要修改M层或者只需要修改V层,而不同层之间的联系通常不会变化,这就让迭代更不容易出错。
  • 可扩展性:当新增功能或者对原有功能升级时候,由于层级清晰,新加的模块或逻辑对原有代码的影响非常小。

在实际开发中,以React框架为例,准确的说,React框架是一个View层面的库,它提供了我们数据到视图的映射,帮我们管理dom元素,但是并不提供我们数据管理和业务逻辑关联的能力。因此,使用React框架时候,应该尽量让它符合View层的定位,尽量少在React组件中写业务逻辑,只留下必要的交互逻辑。把controller层单独抽离出来。而model层,则可以使用redux来实现。

MVC三层之间如何关联呢?任何两层之间都是双向的关联

  1. M-V,包含两方面,一个是V层监听数据变化,更新界面,另一个是视图层直接改变数据。比如如果我们使用React框架,M和V之间的关联很简单使用connect即可让V层根据M层的变化渲染视图,并通过mapDispathToProps获得改变M层的能力
  2. M-C,由于业务逻辑主要在C层,因此M和C的关联是,C层获取数据、C层监听数据变化、C层改变数据。比如如果使用redux管理状态,那么C层改变M层,使用dispatch,监听使用subscribe,获取则使用getState方法。
  3. V-C,有两方面,一个是视图层调用controller的方法,这个比较容易。另一个是C层调用V层的方法,这种情况不太常见,主要出现在组件本身有一些副作用操作。实现的方式是,C层维护一个属性(比如叫“ref”),组件实例化(constructor或者componentDidMount)调用controller,将ref置为this,controller通过这个ref属性调用V层的副作用即可。

每个项目,抽象地说应该有3个维度,1. 职能 2. 通用性 3. 功能

按照职能,项目可以划分为MVC3层

按照通用性,不同的模块可以被分为通用性组件、业务组件;工具函数、通用业务模块、业务模块

按照功能,项目可以分为不同的业务模块,不同的业务模块之间尽量彼此独立

其中按照职能划分和按照功能划分项目,是为了降低项目的耦合度;而按照通用性划分则是为了增加模块复用性。

如果我们可以把项目按照这3个维度进行清晰地划分,就可以让项目清晰、结构稳固。

如果我们有一个最佳实践的方案可以让项目按照这3个维度划分管理,那我们就可以在开发任何一个新项目时候可靠地保证项目的质量和效率。

2. 目录结构

一个典型的、按照上面所述的3个维度划分的项目的目录结构示例如下

  1. ├── common // 通用业务组件
  2. ├── component // 通用ui组件
  3. ├── feature // 业务功能模块
  4. ├── signin
  5. ├── controller.js // 控制器,实现业务逻辑
  6. └── view.js // 视图,根据数据渲染界面
  7. └── signup
  8. ├── controller.js // 控制器,实现业务逻辑
  9. └── view.js // 视图,根据数据渲染界面
  10. ├── model // 所有数据
  11. ├── signin.js // 数据,提供数据存储和修改的能力
  12. └── signup.js // 数据,提供数据存储和修改的能力
  13. └── util // 工具方法

上面所示的目录只包含了主要的业务相关的目录,不包含基础支持,比如ajax、路由等。

3. 聚合模式

开发者在读代码的时候,有一个很常见的需求,就是希望能很轻松地看到整个项目某些“概览”。比如,在读一个前端项目时候,想看到这个项目都发了哪些ajax请求,或者一个有iframe有交互的项目,希望看到这个项目所有postMessage都有哪些类型,再或者,一个有埋点的项目,希望可以看到这个项目都有哪些上报事件,再或者一个项目中有websocket的应用,那么想要看到这个应用里都有哪些信令就是一个很正常的需求。

对于这种“概览”需求的支持情况,可以作为衡量一个项目可读性的指标之一。如果一个项目不支持“概览”这种需求,其实也是可以正常的实现功能的,比如,某个模块需要发送一个请求,那就直接调用相关的ajax库发送一个请求即可($.ajax(url, …)…),某个模块想要埋点,直接请求就行了。但是这样做会影响到项目的可读性。

为了支持这种“概览”的需求,我们可以采用“聚合模式”的开发规范。即我们在统一的地方罗列出不同业务功能都需要用到的一些方法或者模块,再将之导出,业务模块使用的时候需要引入这个统一个模块。比如把ajax请求都列在“service”模块中,其他业务模块用到的话需要引入这个service,虽然可能需要多写几行代码,但是大大提高了代码的可读性。

“聚合模式”规定了通用业务逻辑开发模式的最佳实践,即不同的业务模块可能有发送ajax的需求,也可能有多个业务模块都有监听信令的需求、埋点的需求。那么作为开发者,也需要合理地维护这些通用需求,那么“聚合模式”就是一个很好的解决方案。

4. init模式

开发者需要关注的一个问题是,模块的初始化的位置。

在开发的过程中很多模块需要初始化,比如监听其他模块的事件,或者监听数据的变化,或者加载外链资源等。我们在哪里做这些初始化的操作呢?

对于有些模块来说,一个可行的办法是,在该模块加载的时候就执行初始化操作。但是这种方法并不可取。原因是这导致读代码时候不容易理清代码的执行流程。

这就涉及到了另一个读代码时候的需求,即“理清流程”。如果一个项目,我们可以从入口开始,一步一步,就能理清这个应用的初始化流程,那么可以说从“理清流程”的角度,这个项目的可读性是好的。但是,如果很多模块都在加载时候初始化,相当于这个模块并没有显式地初始化,那么从入口开始梳理流程,就不容易注意到这些模块的初始化操作。

所以一个合理的原则是,每个模块如果需要初始化,必须暴露一个“init”或者“create”、“start”等操作,然后由其父组件进行初始化。(入口模块和流程无关的模块可以不遵循这个原则)。

如果我们的项目遵循“init模式”,那么从入口开始,可以看到都初始化了哪几个子模块,然后子模块又初始化了若干自己的子模块,这样一步一步,就是一个完整的树状的结构。可以很清晰地理清程序执行流程。

5. 杜绝事件总线

事件总线是一个用来解决模块间通信的方案,当然它也可以作为异步编程的解决方案。它会给代码可读性带来灾难性的影响。

提到事件总线的负面作用,不得不再提一下“理清流程”的需求。

上面提到init模式可以提供“理清流程”的支持,这是针对从入口开始理清整个应用的流程的需求。当开发者阅读代码时候,一个广义的“理清流程”的需求是,理清某行代码运行触发的后续影响。这也包括上面所说的场景,及入口初始化代码所触发的后续影响。

前端的执行就是一个循环,每次有触发就会单线程地一直执行完,函数调用函数一直调用到最后一层(调用栈溢出会报错)。然后等待下一次循环。那么都有哪些情况会触发一次执行呢?

  1. js初始加载后执行
  2. 定时器回调
  3. ajax回调
  4. dom和交互事件回调(onclick、onblur、new Image().onload)
  5. websocket回调
  6. 浏览器一些事件(resize、enterfullscreen等)

对于每个触发,如果我们读代码时候都能够轻松地“理清流程”,那么我们就能更轻松地看懂代码。

但是事件总线对我们“理清流程”起到了极大的阻碍。它通过事件监听和触发的方式让模块间可以自由通信,但也同时打断了这个执行的链条。事件总线的另一个问题是,由于任何模块都可以监听和触发任何事件,因此事件总线的机制增强了模块间的耦合。

我们希望解决事件总线带来的这两个问题,“打断执行链条”和“增加模块耦合”。但是事情并不能总是尽如人意。由于模块耦合性和可读性在“理清流程”的场景下是存在矛盾的。如果想要完美地“理清流程”,那么就只能让父模块调用子模块方法,一层一层地引用和调用,这样就把这个链条上的每个组件都给耦合起来了。作为最佳实践,我们选择牺牲这个“理清流程”的需求,而关注模块解耦。

经过上述讨论,我们如何保证让模块间尽量解耦,轻量地通信的情况下,又能尽量“理清流程”呢?或者说事件总线的替代方案是什么呢?

答案是,我们通过数据来进行模块间的通信。如果不同模块间共享数据,就将之存在同一的地方。如果一行代码触发的操作并不改变状态,而只是单纯地触发一个事件,那么可以断定这个操作一定会调用底层的接口,比如发送一个ajax请求、操作一个dom等,即是一个副作用(因为按照React的思想,每个应用和应用的每个组件,都可以认为是一个状态机,视图的变化完全取决于数据,视图是数据按照某个规则的一个映射,所以组件的变化的前提一定是数据的变化)。这种情况下,我们通过调用相应的controller来实现,由于是函数调用而不是事件触发和监听,因此这时候链条是清晰的。

总结一下,模块之间通过共享数据来通信,模块之间有副作用的操作直接调用controller的方法。

6. 代码执行先后顺序把控

上面提到的几个原则应该是比较完整的单页应用开发模式的最佳实践了。但是在实际开发中,还可能有一些细节问题。比如,因为模块间通过共享数据进行通信,那么可能一个模块A需要监听另一个模块B生产出来的数据dataB,根据数据dataB来决定自己的一些行为,但是模块A开始监听时候,dataB可能已经生产出来了,也可能还没有生产出来。如果已经生产出来了,那么监听数据变化并不会得到应有响应。这是个很常见的需求。解决方案是,先判断dataB是否已经生产出来了,如果已经生产出来了,就使用数据进行相应操作,如果没有,就监听,直到dataB生产出来,再执行相关操作。

上面逻辑可以封装为一个通用性模块,作用类似于mobx的autorun方法。

7. 关于状态管理

redux提供了状态管理的优秀解决方案,它认为每个应用(或者一个交互单元)都可以抽象为一个状态机,状态机 包含3个要素,状态(state),活动或操作(action),归并方法(reduce)。在一个应用的生命周期里,从一个状态,经过一个活动触发,会跳转到另一个状态,而从一个状态经过一个action跳转到下一个状态的逻辑就是reducer定义的。

状态机完整地描述了一个应用的实现。以状态机的角度理解应用,就可以很好地做到关注点分离。使代码的可读性和可维护性提升。

使用状态机的方式开发应用,我们需要做的工作是

  1. 定义应用的所有状态
  2. 定义应用所有可能的活动
  3. 定义所有的reducer,即由一个状态接收到一个action时候,应该跳到哪个状态

按照这种步骤,很容易就可以实现一个应用或者一个业务功能。

redux是一个状态管理框架。它提供开发者状态保存、action定义、reducer定义的接口规范。按照它规定的规范开发就可以使我们的项目代码工整易读。

redux使用时候,代码的组织按照传统的方式,是action name、action createor、reducer分别在不同目录中维护。这样隔离了一个业务模块的全景,代码不容易读,还增加了开发新功能时候的模板代码创建成本。

一个更推荐的redux代码组织的最佳实践的思想是,将每个业务模块的相关状态机逻辑放在一个文件中,这个文件中包含了这个业务模块的所有的state、action createor和reducer。dva封装了redux,其中的状态管理的代码组织就是这种思路。