前端代码组织
0. 前言
对于代码质量的评价,有3个指标,可读性、可扩展性和可维护性。这3个指标到底指的是什么?
我们常说的UI程序开发的MVC模式是指什么?它能够解决什么问题?
我们常说组件化,组件指的是什么?对于组件的不同认知会给前端项目的设计带来哪些影响?
状态管理是指什么?数据流是什么?
组件之间的耦合性指的是什么?
如果我们希望提高前端代码质量,对于上面的问题是需要深入思考的。真正理解了前端程序的特点和面对的问题,真正了解了业界的代码组织的最佳实践,才能更清楚我们的代码应该如何更好地组织。
1. 前端程序的特点和面对的问题
从最终的产出,也就是我们看到的界面看,前端的程序生成的就是一个一个视图的组合。那么我们设计前端程序时候,只要一个视图一个视图地编写,最后组合到一起就可以了吗?事情当然没有那么简单。
首先,从网页界面上看,这些视图有一定的结构,最外层是最大的一个视图容器,然后往里是一个个的子视图,然后子视图又包含子视图,子子孙孙无穷匮,形成了我们看到的网页。所以前端的视图其实是一个树状的结构,它由文档对象模型来描述(DOM)。
然后,这些视图不止是视图,对于前端程序来说,还有各种复杂的业务逻辑。从某种角度,前端程序可以简单地描述为视图 + 逻辑。
综上,前端程序的特点是,有树结构的视图,有复杂的业务逻辑。
基于前端程序的这个特点,我们应该如何组织我们的代码呢?
对于大型的程序,一定是要模块化的,所以对于这个问题,我们要回答的实际问题是,我们应该根据什么划分模块?
一个马上能想到的答案是,根据视图划分模块,这时候,我们也可以将之称为“组件”。这样,组件可以理解成“根据视图划分模块得到的程序单元”。
如果按照组件来组织我们的程序,我们还需要进一步思考的问题是,业务逻辑代码如何组织。因为上面已经说过,前端代码除了视图,还有大量复杂的业务逻辑。
如果业务逻辑也和视图一样,有着树状的结构,并且和视图有着一一对应的关系,那么我们的用组件的划分来组织代码的方案就完美无瑕了。
不幸的是,业务逻辑和视图结构并不具有紧密的联系。我们常常可以看到的情况是,前端界面上不同部分的视图都和某个业务逻辑有关。
那么我们还应不应该按照视图划分模块来组织我们的代码呢?答案是肯定的,因为这种是最直观的方式,视图也是代码的一部分,我们阅读代码的时候一定会参照界面,如果根据业务逻辑划分模块,很难保证我们的程序代码的清晰可读。
既然我们已经决定要按照组件的形式组织我们的代码,那么接下来要考虑几个问题
- 模块(组件)间如何通信
- 业务逻辑的代码如何组织?业务逻辑代码和组件之间是什么关系
- 如何保证模块内聚性(模块之间低耦合)
对于上面3个问题,最佳实践给出了答案,第一个问题的答案是:事件总线;第二个问题和第三个问题的答案是MVC
下面我们详细讨论事件总线和MVC
2. 组件间通信方式
通过上面的说明,我们知道,前端程序的一个特点是,组件之间并不是完全独立的,它们之间可能会由于具体的业务逻辑而有一些联系。所以,从程序角度讲,组件之间是有通信的需求的
那么组件之间如何通信呢?有两种方式:父子组件事件和发布-订阅模式
父子组件事件通信方式指事件只能在父子之间进行传递,没有其他途径。如果两个兄弟节点需要通信,需要子节点上溯到最近祖先节点,然后在向下传递到目标节点。
发布-订阅模式指有个全局事件总线单例,每个组件都可以引用这个事件总线单例并注册回调或者触发事件。
这两种组件通信方式各有优劣,下面我们来分析一下
父子组件事件的通信方式有个好处,就是可以保证执行链条的完整。
这里解释一下执行链条的概念。js代码都是什么时候执行的呢?从我们阅读代码的角度,js代码的执行起点有以下几个:
- js文件被加载到浏览器并被解释、执行
- 界面交互事件回调,如点击、valuechange、video的timeupdate、window.onresize等等
- iframe的postMessage
- 插件(如flash)的postMessage
- 接口的回调,如ajax回调、websocket回调
- 和native通信的回调
如果我们使用父子事件的通信方式,那么从每个js执行的起点,到最终执行结果,我们阅读代码时候都可以非常清晰地跟踪。
从保证执行链条完整这个角度,父子组件事件相对于发布-订阅模式更有优势。发布订阅模式的订阅者可以使任意引入事件总线单例的组件,所以如果一个开发者对项目不熟悉,他想知道一个触发的事件引起哪些变化的话,就需要全局搜索事件名称,看都有哪些组件订阅了这个事件。我们可以说是事件总线打断了这个执行链条。
我们再从另一个角度,模块耦合性的角度,比较一下这两种组件通信方式。
模块之间如何实现低耦合呢?就是尽可能少地产生联系、尽可能保证必要关联。我们知道,前端的程序中,组件并不是完全独立的,他们之间会根据业务逻辑产生关联,那么,如果我们可以保证除了业务逻辑带来的组件间关联,不会有其他关联,那么从理论上讲,就做到了最大化地组件解耦。
父子组件事件由于除了真正被一个业务逻辑关联起来的两个组件间有着紧密联系,在事件传递路径上的每个组件都和它们产生了关联,如果想要改变两个组件间布局,整个事件链条上的组件都需要重新设计。
而发布-订阅者模式则可以保证只有有关系的组件才会通信。组件可以充分解耦。
而对于发布-订阅者模式带来的打断执行链条的问题,则没有什么好的办法。而且从某种角度看,这可能并非是个问题。只要开发者对于业务足够熟悉,就可以看清整个程序的执行逻辑。
所以对于组件间通信的业界的最佳实践就是发布-订阅模式,只要开发者对业务和代码熟悉,因为模块耦合性很低,就可以非常高效地迭代了
全局事件总线可能带来代码的一定程度上的混乱,这个可以通过使用局部事件总线代替全局事件总线来解决。局部事件总线按照业务逻辑划分
3. MVC和状态管理
发布-订阅模式解决了组件间耦合的问题。我们现在从组件本身来看,只是用发布-订阅模式的组件是一个视图和逻辑的结合体,但是业务逻辑和视图之间联系并不紧密,一个视图可能对应多个业务逻辑,一个业务逻辑可能对应多个视图。这样看来,业务逻辑和视图也应该充分分离。如果我们将业务逻辑和视图解耦,那么视图的变动和业务逻辑的变动需求对于我们代码都更容易扩展。将业务逻辑和视图解耦后,组件就和之前的“视图+业务逻辑”的定位不同了,就只是视图的一个抽象。而业务逻辑单独抽离为一个模块,模块和组件之间通过某种方式关联,这就是MVC的思想。
MVC将程序抽象为model(数据)、controller(控制器)和view(视图)3层,model是实体的状态集合,controller是业务逻辑操作,view是视图层。在有些情况下,m层和c层没有分开,每个模块分为mc和v层。使用MVC模式开发程序,我们可以将视图按照界面的展示组织成一个树状的结构,即组件树。业务逻辑抽象成一个个的模块。这样代码会非常清晰、易维护。
通常我们可以把m层和c层统称为controller层v层成为view层。
进行这种抽象后,设计程序的时候还需要解决的问题是view层和controller层如何关联。关联其实很简单,只有两条线。controller层需要提供的就是一系列的操作方法以触发业务逻辑的开始,还需要提供数据改变或者一些事件的回调支持。这样,view层调用controller层的方法,然后监听controller层的数据变化重新渲染view,或者根据某些事件(如收到信令)做一些操作,就完成了整个程序的完整功能。
对应view层触发controller层的方法的实现很简单,只要controller模块暴露出一些方法,view层组件引用controller层的实例,并调用方法即可。
view层监听controller层的数据变化和事件应该怎么实现呢?答案是发布-订阅者模式。组件引用controller实例,并调用其api进行订阅即可。
MVC是UI程序设计的最佳实践,它将视图和业务逻辑层解耦,只将必要的有关联的组件产生通信。让整个程序充分解耦,极大地提高了可扩展性。
有些开发框架虽然标榜自己是MVC模式,但是controller层即负责业务逻辑又负责操作view层,这种模式并没有把业务逻辑和视图完全解耦,不是严格意义上的MVC模式。
使用MVC模式构建我们的应用,还需要考虑的一个问题是,M层和C层的代码如何组织,很多状态管理工具都提供了解决方案。因为视图层需要controller层提供的是操作方法和数据变化监听,我们也可以将数据成为状态,所以状态管理工具最终要解决的有两个问题
- 触发操作后,状态如何改变
- 状态改变后的通知
如果我们关注第二点的话,就不难理解很多的状态管理工具都有发布-订阅者的实现。
对于第一点,不同的工具有不同的方案,如redux是用状态机的思想管理操作后状态变化的实现;mobx则是直接改变状态,更灵活方便。
值得注意的是,状态管理工具并不能完全用来实现一个controller层,因为状态管理工具只提供数据的变化监听,其发布-订阅其实只是监听数据变化的实现,并不能监听常规事件。
4. 代码质量的指标
代码质量通常有3个指标,即
- 可读性
- 可扩展性
- 可维护性
也可以归结为2点
- 在迭代过程中尽量少产生问题(可维护性)
- 可以以更高的效率迭代(可读性、可维护性、可扩展性)
我们需要一直思考的问题是,当我们说可读性的时候,我们在说什么?当我们说可扩展性时候,我们在说什么?当我们说可维护性时候,我们在说什么?
目前阶段我对这些问题的理解是:
可读性有两方面:
- 阅读代码所带来的心智负担尽量低(可以通过某些规范和约定实现,比如我们约定React组件后缀都是jsx,那么我们一看到一个模块的后缀,就知道它的类型了)
- 思路清晰,容易理清代码逻辑(如果让组件使用父子事件的方式通信,在这方面,程序对新接触项目的开发者更友好)
提到可扩展性,我们需要知道迭代时候都有哪些“扩展”,然后根据这些具体的扩展场景分析不同开发模式下的效率
- 业务逻辑不变,定制新视图
- 不改变视图,改变布局
- 修改业务逻辑
- …
可维护性:
不管我们分析哪些指标,我们一定要罗列出具体场景,然后根据具体的场景分析不同开发模式下的具体实现会在这些指标上如何表现。