聊聊中后台前端应用:模块相关的一些事

欧雷 发表于

0 条评论

在《聊聊中后台前端应用:目录结构划分模式》中讲述了「野生」、「分层」和「模块化」这三种划分目录结构的模式,本文就在假定项目中已经采用内聚性相对最高的「模块化」模式进行目录结构划分的基础上,聊聊模块相关的一些事儿——

模块边界

模块必须有其清晰的职责边界和有效的约束手段,否则它将会迅速变得臃肿且难以维护,进而在某个时机失控并爆炸,使得应用变成无法收拾的烂摊子。

要爆炸的沙鲁
要爆炸的沙鲁

如何确定模块的边界虽然可以在一定程度上给出一些指导原则和指标,但更多的是依赖拆分模块的人根据个人所掌握的知识和经验所进行的主观判断。

模块拆分准则

无论是用「模块化」模式还是「分层」模式去划分目录结构,都是按领域或业务拆分模块的,这是最基本的准则。

那么,「领域」和「业务」到底是指啥?它们有啥区别呢?

一般来说,在相对简单的语境中「领域」和「业务」大多可以划等号,无需严格区分这两者,理解为某一块业务的逻辑;而在较为复杂的语境中就可能要厘清它们之间的关系了——「业务」是相对具体的,与企业实际的业务活动密不可分;「领域」则是更为泛化的,可以是多个「业务」的底层,是「业务」中立的。

在业务系统中,「领域」和「业务」都可以包含由实体、关系和规则所构成的模型(领域模型或业务模型),这些是业务需求在架构或代码中的体现。

一个模块代表一个领域或业务,对应一个模型;同一个模块中的各个元素之间的关系要紧密,即内聚性要高,尽可能没有不太相关的东西——这是第二个基本准则。

提升模块内聚性或者说提炼模型是个随着对业务的理解和相关知识的增长而循序渐进的长期工作,不是一成不变的,也不可能一下就做得很好。

每个模块都是业务系统的子系统,同时它们又分别由实体/模型定义、请求服务、UI 组件等子系统所组成;各部分之间相互独立又有所联系——模块内部分层是第三个基本准则。

可以说,关注点分离和单一职责原则始终贯穿着模块拆分的全过程。

更为深入的探讨,可看由陶师傅组织的《业务逻辑拆分模式》。

文件引用方式

对于个人来说,寻求「方便」是人的本性(懒惰),同时「方便」有时会造成错误和混乱,在追求有序和稳定的多人协作中必然要限制「方便」的存在。

因此,为了保障模块的功能边界和依赖关系是清晰的、易理解的,有时需要刻意提高引用模块外资源的成本,比如文件引用路径的约束。

在有构建的前端应用中,通常会配置 @ 作为源代码文件夹的别名,之后在开发时只要有用到其他文件的资源,就 @/*,表面上是方便了,实际给理解系统和维护功能造成了很多麻烦——就像滥用继承机制一样。

鉴于此,给引用模块内、外文件的路径加上约束进行限制——

模块内可以文件间引用,需用相对路径;模块外只能对 shared 之类存放通用资源、基础设施等的进行文件引用,要用 @/* 的形式;得用框架提供的或自定义的(见下文)而不是 ES Modules 和 CommonJS 等「标准」的模块系统去引用其他模块的资源,这时并不一定是文件间引用。

模块系统

在讲「模块化」模式时有提到——

每个领域/业务模块下有一个 index.ts 文件,用于描述该模块依赖哪些模块的什么资源(请求服务、部件/业务组件等),以及它向其他模块提供什么资源。

为了提高灵活性,最好设计并实现一套模块注册与查找机制,以替代常规的 importexport。理想状况下,每个模块都可以跨应用使用。

并且上文也说在引用其他模块的资源时要用框架提供的或自定义的模块系统。按照当前的开发模式,不太有符合项目或架构需求的模块系统,几乎要去自定义或自己设计。

一个模块系统可以很简单也可以很复杂,但其基本功能无非是依赖管理,即依赖的收集与载入。

模块注册

模块的注册分为两步——

先设计用来描述模块信息的模块描述器,最简单的只需包含模块名、依赖模块的资源、提供给其他模块的资源以及模块会用到的 UI 组件:

export default {
  name: 'module-name',
  imports: ['[module-name].[resource-type].[resource-name]'],
  exports: {
    '[resource-type]': {
      '[resource-name]': 'foo',
    },
  },
  components: {
    '[LocalComponentName]': '[DependencyRefName]',
  },
};

其中,[module-name] 是模块名;[resource-type] 是资源类型,可以是 services(请求服务)、utils(工具函数)、 widgets(部件/业务组件)和其他任意类别的资源;[resource-name] 是资源名称。

components 是特殊的依赖资源,声明模块会用到的 UI 组件——既可以是控件/基础组件又可以是部件/业务组件。[LocalComponentName] 是在模块中使用 UI 组件时的名字,[DependencyRefName] 则是所依赖的 UI 组件的引用标识。

接下来,需要设计并实现一个传入模块描述器的模块注册函数。一般是用 Map 将处理后的模块信息保存在内存中:

const moduleMap = new Map();

function resolveModule(descriptor) {
  // 解释模块描述器并返回处理后的信息
}

function registerModule(descriptor) {
  moduleMap.set(descriptor.name, resolveModule(descriptor));
}

resolveModule() 中不仅要解释模块描述器,最好再检测下是否存在循环依赖和做些其他更「高级」点的功能。

然后,在前端应用的入口文件(如 Vue 应用的 main.ts)中统一注册模块。

模块查找

查找模块是为了获取指定模块的依赖资源和构造模块上下文(后文会讲)。

获取通过模块描述器的 imports 声明的依赖资源比较简单,直接根据依赖引用从保存在 moduleMap 上的模块信息中去取就可以了;而想要得到 components 所声明的 UI 组件在规则上就相对繁琐了一点——

[DependencyRefName][module-name].widgets.[resource-name] 形式的字符串时为引用其他模块定义的部件/业务组件,否则是控件/基础组件;若 [DependencyRefName]true,按照 [LocalComponentName] 去找控件/基础组件,不然以 [DependencyRefName] 去找。

从理论上来说,模块的查找要在使用模块时进行,理想状况下是在模块全部注册完毕的应用初始化之后;然而实际情况很可能是某个模块在还没注册时就去获取它本身的信息了。

比如,在模块描述器中声明了要提供给其他模块一个部件/业务组件,而这个部件/业务组件中又用到其他模块的资源,这时就需要查找到当前模块并载入其依赖:

// `animation/index.ts` 文件

import AnimationTable from './widgets/animation-table/AnimationTable.vue';

export default {
  name: 'animation',
  imports: [
    'common.widgets.TableView', // 依赖其他模块的 `TableView` 部件/业务组件
  ],
  exports: {
    widgets: {
      AnimationTable, // 提供给其他模块 `AnimationTable` 部件/业务组件
    },
  },
  components: {
    DataTable: 'common.widgets.TableView', // 本模块中用到的 `DataTable` 组件是 `common` 模块提供的 `TableView` 部件/业务组件
  },
};
<!-- `animation/widgets/animation-table/AnimationTable.vue` 文件 -->

<template>
  <div class="AnimationTable">
    <data-table />
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';

import context from '../../context'; // 模块上下文

@Component({
  components: context.getComponents(),
})
export default class AnimationTable extends Vue {}
</script>

<style lang="scss" src="./style.scss" scoped></style>

逻辑上来讲,这种情况是找不到具体依赖的资源的。

造成这个问题的原因是,常规的 import 是静态的、同步的,那个提供给其他模块的部件/业务组件的引入先于模块的注册,也就是时序问题。

目前有三种解决方案——

第一种是将静态且同步的 import '*' 改为动态且异步的 import('*'),前提是运行环境或构建工具支持:

// `animation/index.ts` 文件

export default {
  name: 'animation',
  imports: [
    'common.widgets.TableView',
  ],
  exports: {
    widgets: {
      AnimationTable: () => import('./widgets/animation-table/AnimationTable.vue'),
    },
  },
  components: {
    DataTable: 'common.widgets.TableView',
  },
};

第二种是在渲染时再去获取那个部件/业务组件的依赖资源:

<!-- `animation/widgets/animation-table/AnimationTable.vue` 文件 -->

<script lang="ts">
import { CreateElement, VNode } from 'vue';
import { Vue, Component } from 'vue-property-decorator';

import context from '../../context'; // 模块上下文

@Component
export default class AnimationTable extends Vue {
  private render(h: CreateElement): VNode {
    const { DataTable } = context.getComponents();

    return h('div', { staticClass: 'AnimationTable' }, [h(DataTable)]);
  }
}
</script>

<style lang="scss" src="./style.scss" scoped></style>

最后一种比较 tricky,在获取依赖资源时如果指定模块不存在,就先在 moduleMap 上创建一个相应的空对象作为占位符并将其返回,这样一来,部件/业务组件就拥有了依赖资源在内存中的引用地址;由于依赖资源是在部件/业务组件渲染时才会真正使用/调用,那时模块已经早早注册好了,所以能够顺利找到依赖资源。

模块上下文

在编程语言中,「上下文」一般是指让程序能够正常执行的一组环境变量,如执行上下文;而在应用开发中,通常衍生为用来维护作用于一定范围的状态的对象。

构造并传入或注入「上下文」是一种比较好的让 UI 组件变「瘦」的实践——

在 UI 组件树中从某一层往下的几层所包含的 UI 组件是一个相对独立的子系统,它们要协作完成同一个任务,与这个任务相关的状态和操作无需分散在各个 UI 组件中,经由「上下文」集中管理可让状态更好维护,状态变化更容易追踪。

另外,因为核心逻辑被隔离到了 UI 组件之外,前端的自动化测试会更好做。

本系列文章所阐述的体系中,主要有模块上下文和视图上下文。这里只说说模块上下文,视图上下文将在后续文章中说明。

「模块上下文」是模块级或者说模型级的上下文,相对来说它不是很重要,只是一个辅助角色,更为重要的是日后要讲的「视图上下文」。

模块上下文的主要功能就是获取依赖资源和发送请求:

interface ModuleContext<R> {
  getModuleName: () => string;
  getDependencies: (refPath?: string) => ModuleDependencies | ModuleResources | undefined;
  getComponents: () => { [key: string]: VueConstructor };
  execute: RepositoryExecutor<keyof R>;
}

除此之外,还可以结合 Vuex 做模块级的状态管理,提供将命名空间封装了的 commitdispatch 方法等。

总结

「模块化」就是分治或者说还原论在人造物方面的应用,这是处理复杂问题的基本手段。

然而,在软件开发中很多时候并没有真的很好地解决复杂问题,这说明单纯的形式上的「模块化」是没什么用的,必须要围绕着「模块」采取一系列措施。

几个月前我突然有个疑问——为什么不能像硬件一样设计软件?

当前的结论是——软件开发门槛和各种成本都低是导致质量差和可复用程度低的原因之一。

你们认为呢?