提升编程体验:组件化与模块化设计

1,937 阅读9分钟

这是我参与更文挑战的第24天,活动详情查看: 更文挑战

  • 对于写业务代码,很多前端开发都觉得枯燥无趣,且认为容易达到技术瓶颈。其实并不是这样的,几乎所有被我们称之为“技术需求”“技术工具”的开发,它都来自业务的需要,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。

除了上面介绍的方式,在进行代码编程过程中,有许多设计模式和理念可以参考,其中有不少的内容对于解耦模块间的依赖很有帮助,比如接口隔离原则、最少的知识原则、迪米特原则等。

到这里,我介绍了前端应用中模块的划分、设计和解耦,这些架构和系统的设计在大型和复杂项目中更为常见。而在前端日常开发过程中,更多会涉及业务逻辑和界面开发的内容,因此我们常常说要进行组件化设计。

如何进行应用的组件化设计

首先,我们来定义一下什么是组件。

组件是怎样划分的 简单来说,组件可以扩展 HTML 元素、封装可重用的代码,比如:

<!--封装后的一个组件可能长这样-->
<my-component></my-component>

看起来这个组件什么都没有,这是因为我们将逻辑都封装在组件里面了。组件有自身的呈现形式、状态数据和功能逻辑,一只猫也可以是一个组件

  • 一般来说,组件的划分可以通过两个角度来进行。

    • 通过代码复用划分。我们在写代码的时候,会观察到一些代码在结构和功能上其实是可复用的,这时我们可以把它们封装,以减少重复的代码。

    • 通过视觉和交互划分。通常来说,组件的划分与视觉、交互等密切相关,我们可通过功能、独立性来判断是否适合作为一个组件。

当我们确定要将哪些功能划分成组件之后,就需要定义组件的职责,然后进行组件封装。

组件是怎样进行封装的

  • 其实组件封装过程和模块的职责定义有些相似,我们首先需要定义这个组件的职责。

  • 一个称职的组件,它提供了这些能力:

    • 组件内维护自身的数据和状态;

    • 组件内维护自身的事件(方法);

    • 对外提供配置接口,来控制展示以及具体功能;

    • 通过对外提供查询接口,可获取组件状态和数据。