对于写业务代码,很多前端开发都觉得枯燥无趣,且认为容易达到技术瓶颈。其实并不是这样的,几乎所有被我们称之为 “技术需求”“技术工具” 的开发,它都来自业务的需要,Angular、React、Vue 这类框架也是。
在前端领域,业务开发就真的只是调整样式、拼接模板、绑定事件、接口请求、更新页面这些内容吗?其实我们可以通过更好的代码设计,来提升写业务代码过程中的编程体验。
今天我就介绍一下如何对应用进行模块化和组件化的设计。
其实,在我们开始写重复的代码或是进行较多的复制粘贴时,我们大概就需要考虑对应用进行适当的抽象了,下面我们一起来看一下。
如何进行应用的模块化设计
当拿到一个设计好的应用之后,为避免出现文件内容过多、功能之间耦合严重等问题,提升项目代码的可用性和可维护性,我们需要对它进行模块拆分。
应用的模块与层级划分
每个人对于模块的理解都有所区别,因此模块的拆分有很多的方式。
- 对于简单的管理端应用,可以采用类似 MVC 这样的结构进行模块拆分,比如拆分成视图模块、数据模块、逻辑控制模块等。
- 对于页面内容较丰富的应用,可以结合业务进行更加细致的模块和组件拆分,比如拆分成核心模块、功能模块、公共组件模块等。
- 对于交互和逻辑复杂的应用,可以根据系统架构将应用进行模块和层级的划分,比如拆分成渲染层、数据层、网络层等。
举个例子,常见的客户端和服务端的通信过程可以根据功能模块进行分层,如图:
对于大型应用的模块划分,很多时候我们还需要结合模块粒度进行由上至下的多次划分,划分的规则可能是上述的规则,也可能跟应用的业务场景相关。
举个例子,像在线文档这样交互复杂的在线协作应用,可能将模块拆分成核心模块、功能模块、公共组件模块之后,还需要将各个模块进行分层或是二次模块划分处理。比如核心模块可分成渲染层、数据层、网络层,功能模块可分成函数计算模块、复制粘贴模块,等等。公共组件模块可拆分成头像模块、工具栏模块,等等。
模块划分与设计原则
在通用编程设计领域,架构设计也有很多的设计理念和原则,在这里,我介绍两种。
- 领域驱动设计(Domain-Driven Design,简称 DDD):从业务领域的角度来对系统进行领域划分和建模。
- 职责驱动设计(Responsibility-Driven Design,简称 RDD):从系统内部的角度来进行职责划分、模块拆分以及协作方式。
其中,领域驱动设计(DDD)用于业务领域的划分,在业务复杂的系统架构设计中比较实用。比如电商领域的商品、买家 / 卖家、订单、优惠券、风控等各个领域的划分。
但在前端领域中,不同的业务领域通常会通过不同的页面、组件等方式出现,比如商品页面、订单页面、优惠券页面等。因此领域驱动设计(DDD)很少在前端开发中使用,或者可以说在前端领域的应用与前端组件化思想比较相似。
至于职责驱动设计(RDD),它更倾向从角色和职责的角度来定义和划分模块,与前端的公共组件、工具库、MCV/MVVM 设计、功能模块划分等类似。职责驱动设计(RDD)在功能复杂的系统架构设计中可带来不少的帮助,比如上面提到的在线文档中的各个功能模块的设计中,可以通过对系统进行职责划分以及定义模块之间的边界和协作方式,从而清晰地模块的功能。
这两种设计模式并不是互斥的,我们可以配合一起使用,比如:
- 我们可以将与业务逻辑关系密切的功能按照业务领域进行划分和建模,比如电商网站中的购物车组件、商品组件等;
- 对于与前端实现(视图渲染逻辑、与服务端交互逻辑、与用户交互逻辑)相关的功能,我们可以在具体的系统搭建过程中对这些功能进行职责分配和模块划分。
当我们对模块进行划分之后,还需要考虑模块的设计、模块间的依赖关系和通信等问题。其中,最常见的便是如何解决模块间的依赖耦合的问题。
如何进行模块间依赖的解耦
相信你都听过低耦合、高内聚这样的说法,它们常常被用来描述系统设计中的模块依赖关系,其中:
- 低耦合基于抽象,使我们的系统更具模块化,不相关的事物不应相互依赖;
- 高内聚则意味着对象专注于单一职责。
低耦合和高内聚是每个设计良好的系统目标,关于具体的设计模式其实也有很多的书籍和课程专门讲述,这里我主要介绍在复杂前端领域中比较常用的依赖解耦方式。
首先,可以使用依赖倒置进行依赖解耦。依赖倒置原则有两个,包括:
- 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口;
- 抽象接口不应该依赖于具体实现,而具体实现则应该依赖于抽象接口。
举个例子,数据层模块(DataManager
)依赖了网络层模块(NetworkManager
)中的发送数据接口(sendData()
),还依赖了渲染层模块(RenderManager
)的更新界面接口(updateView
)。
我们可以通过 Typescript 定义接口,则我们可以表达为:
interface INetworkManagerDependency {
sendData: (data: string) => void;
}
interface IRenderManagerDependency {
updateView: () => Promise<void>;
}
class DataManager {
constructor(
networkManagerDependency: INetworkManagerDependency,
renderManagerDependency: IRenderManagerDependency
) {
}
}
这样,我们只按照约定依赖抽象的接口来实现功能调用,就不会依赖具体的模块和细节。
如果项目中有完善的依赖注入框架,就可以使用项目中的依赖注入体系,像 Angular 框架便自带依赖注入体系。依赖注入在大型项目中比较常见,对于各个模块间的依赖关系管理很实用,比如 VsCode 中就有使用到依赖注入。
除了使用依赖注入框架,在前端中更常见的依赖解耦方式还包括使用事件进行通信。
事件驱动其实常常在各种系统设计中会用到,可以解耦目标对象和它的依赖对象。目标只需要通知它的依赖对象,具体怎么处理,由依赖对象自己决定。
使用事件驱动的方式,可以快速又简单地实现模块间的解耦,但它常常又带来了更多问题,比如:
- 全局事件满天飞,不知道某个事件来自哪里,被多少地方监听了;
- 无法进行事件订阅的销毁管理,容易存在内存泄漏的问题;
- 事件维护困难,增加和调整参数影响面广,容易触发 Bug。
当然,这些问题也有解决方法,我会在第 18 讲介绍状态管理的时候进行更加详细地介绍。
除了上面介绍的方式,在进行代码编程过程中,有许多设计模式和理念可以参考,其中有不少的内容对于解耦模块间的依赖很有帮助,比如接口隔离原则、最少的知识原则、迪米特原则等。
到这里,我介绍了前端应用中模块的划分、设计和解耦,这些架构和系统的设计在大型和复杂项目中更为常见。而在前端日常开发过程中,更多会涉及业务逻辑和界面开发的内容,因此我们常常说要进行组件化设计。
如何进行应用的组件化设计
首先,我们来定义一下什么是组件。
组件是怎样划分的
简单来说,组件可以扩展 HTML 元素、封装可重用的代码,比如:
<!--封装后的一个组件可能长这样-->
<my-component></my-component>
看起来这个组件什么都没有,这是因为我们将逻辑都封装在组件里面了。组件有自身的呈现形式、状态数据和功能逻辑,一只猫也可以是一个组件,如图:
这只猫虽然是个 Gif 图片,但它可以拖动,也可以鼠标悬浮弹出一个提示框,还可以双击或长按让它消失。这些逻辑我们都可以封装在组件里,通过这样的方式对外屏蔽了实现细节,外部使用的时候只需要引入组件,然后在页面里插入<Kitty></Kitty>
这样一个组件就可以了。
一般来说,组件的划分可以通过两个角度来进行。
- 通过代码复用划分。我们在写代码的时候,会观察到一些代码在结构和功能上其实是可复用的,这时我们可以把它们封装,以减少重复的代码。
- 通过视觉和交互划分。通常来说,组件的划分与视觉、交互等密切相关,我们可通过功能、独立性来判断是否适合作为一个组件。
当我们确定要将哪些功能划分成组件之后,就需要定义组件的职责,然后进行组件封装。
组件是怎样进行封装的
其实组件封装过程和模块的职责定义有些相似,我们首先需要定义这个组件的职责。
一个称职的组件,它提供了这些能力:
- 组件内维护自身的数据和状态;
- 组件内维护自身的事件(方法);
- 对外提供配置接口,来控制展示以及具体功能;
- 通过对外提供查询接口,可获取组件状态和数据。
下面以视频网站为例来简单说明下:
1. 组件内维护自身的数据和状态,以图中的小卡片为例子:
这个小卡片,它维护着自己的数据:封面图、描述、头像、作者,还有一个初始的状态,就是目前我们看到的样子。这些内容保存在组件自己的作用域中,每个卡片组件都拥有自己的数据和状态。
2. 组件内维护自身的事件。
我们在把鼠标放在卡片上,随着鼠标的位置,顶部会有个小小的进度条,同时封面图会随着进度条的变化而改变,如图:
可见,小卡片组件封装有自己的mousemove
事件,以及对应的处理逻辑方法。
3. 对外提供配置项,来控制展示以及具体功能。我们看另外一个小卡片:
这个卡片和前面的卡片有些不一样,左下角展示的是视频时长,而不是头像和名字,我们可以通过传入配置项的方式来控制。
4. 通过对外提供查询接口,可获取组件状态。
大多数时候,组件独立维护着自身的数据和状态。但在一些特殊场景下,父组件或者应用需要知道组件当前的状态,比如在页面中要浮动展示最近聚焦的卡片视频内容,这时候外层需要知道卡片中的具体进度并在浮窗中播放。在这种情况下,我们需要对外提供接口,以供查询。
组件封装也可以包含一定的层级关系,比如卡片组件里也可以包括视频组件,提供点击播放的功能。在第 1 讲的时候我们就说过,现在很多前端应用最终会以组件树的方式呈现,这是因为 DOM 元素本身就是树状结构。
应用中的组件树
几乎任意类型的应用界面都可以抽象为一个组件树,例如 Github 上 Vue 主页,我们能看到页面能划分成一块块的内容块,其中有些也可以看作组件。
一般来说,这样的一个管理页面,我们可以抽象成这样的组件树:
以代码的方式来表达这样的组件树,会是这个样子:
<div id="app">
<app-header>
<header-search></header-search>
<header-nav></header-nav>
<header-aside></header-aside>
</app-header>
<app-view>
<group-info></group-info>
<app-tab></app-tab>
<app-tab-container>
<project-card></project-card>
<card-list></card-list>
</app-tab-container>
</app-view>
</div>
在这段代码中,我们不再使用一个个的<div>
来拼装成页面,在前端框架中我们会使用自定义组件名字,比如<my-component>
,使用自定义组件可快速高效地复用代码,也可以降低维护成本。
为了让组件与外界环境隔离(样式不会相互影响),像 Vue 这样的框架会通过在 DOM 节点上使用唯一的 ID 标记 DOM 元素,通过节点属性等方式匹配对应的元素并添加样式。
2011 年推出 Web Components,允许仅使用 HTML、CSS 和 JavaScript 创建可重用的组件,其中包括了自定义元素、Shadow DOM、HTML templates,这三项技术的结合。通过 Web Components,我们可以不依赖前端框架,只使用 HTML/CSS/JavaScript 也能创建在任何现代浏览器中运行的可重用组件。
小结
到此,相信你认识了前端应用中模块化和组件化的设计过程。实际上,组件也可以看作是带有视图功能的特殊模块,它在前端开发过程中更为常见。
不管是组件封装还是模块划分,过度的抽象都会导致代码难维护,代码可读性也差。当我们的应用很小,只有简单的功能的时候,我们甚至不需要对这些状态、数据等进行特殊管理,可能几个简单的变量就可以搞定了。
但随着应用组件数量变多,我们开始有了组件的作用域。当组件需要通信,我们可以通过简单的事件机制、或共享对象的方式来进行交互。
当我们的项目越做越大,要在上百个状态、上万条数据里要按照想要的方式去展示我们的应用,这时候需要一个合适的状态管理工具,我会在第 18 讲进行介绍。
这些知识都会比较抽象,需要反复思考和研究才可以掌握。如果在工作中只关注功能的实现和堆叠,很少关注如何通过合理的设计去减少不必要的代码、避免重复性的工作,那么这样的工作只会变得越来越枯燥和烦琐。
你觉得 Github 网站里,可以划分为多少个模块、怎样进行分层设计呢?欢迎在留言区进行交流。