阅读 137

通用 TypeScript 项目结构与实践经验

据说是 30 万行 TypeScript 项目实践干货?

在多年的 TypeScript 项目开发过程中,我们曾多次改变项目结构以适应新增的项目拆分需求,最终形成了现有的通用项目结构。这篇文章将会介绍这种通用的项目结构以及部分与之相关的实践经验。

项目包(package)结构

不管项目简单还是复杂,通常都是由一个或多个包组成。首先我们讨论单一包的项目结构,举个例子:

# 源文件
src/
  library/
    .eslintrc.js
    tsconfig.json
    index.ts
  program/
    .eslintrc.js
    tsconfig.json
    main.ts
test/
  .eslintrc.js
  tsconfig.json
  some.test.ts
prettier.config.js
tsconfig.json
package.json

# 构建产物
bld/
  library/
    index.js
  program/
    main.js
.bld-cache/
  test/
    some.test.js复制代码

首先:

  • src 目录下放置包内拆分的需要经过构建的项目源码,例子中 library、program、test 某种意义上都是平级的,test 和另外两者的区别在于它被认为无需产生构建产物(实际上不完全是,详见后文)。
  • library 和 program 是两个我们常用的名字,分别对应作为库使用的内容和作为程序执行的内容。很多时候 src 下都只有其中一个文件夹,当然也有很多时候存在更多文件夹。
  • 对于作为库使用的源码,我们的入口文件使用 index.ts;而作为程序执行的部分,入口文件则使用 main.ts。
  • 包内每一个拆分后的项目都有独立 eslintrc 和 tsconfig,以此保证配置的灵活性,区别不同的运行环节的规则和类型。项目中我们使用 run-in-every 小工具来执行 eslint。
  • 包内使用统一的 prettier 配置,由于 prettier 只关注非功能性的代码风格,于是整个包可以直接使用单一配置。
  • 全局的 tsconfig,用于引用拆分项目,方便使用 tsc --build 进行统一构建。当然拆分项目中,我们都启用了 composite 编译选项,并对依赖进行了引用。
  • eslint、tsconfig、prettier 配置我们都有额外的仓库维护配置和一些自有规则,后续会讲到与项目组织相关的部分。

构建产物部分:

  • 首先是与 src 对应的 bld(build)文件夹,这个起名跟个人习惯有关,常见的其他名称包括 out、lib、dist 等等。lib 当然在这个结构中不是很合适,具体可以根据爱好选用。
  • 然后是为“不需要构建产物的”拆分项目准备的 .bld-cache 文件夹,虽然应用层面不需要这个文件夹,但为了利用 TypeScript tsc --build 的缓存,我们特别增加了这样一个文件夹。

多包项目结构

很多时候,一个包已经无法满足我们,不过多包项目以我们的做法来讲,和单包项目是类似的:

packages/
  gateway/
    src/program/
      .eslintrc.js
      tsconfig.json
      main.ts
    package.json
  server/
    src/program/
    scripts/
    package.json
  web/
    src/
      program/
      service-worker/
    package.json
  shared/
    src/library/
    package.json
prettier.config.js
tsconfig.json
package.json复制代码

相对单包项目来说,多包项目额外提供了一级(或多级)目录进行项目组织,也方便更精细的包依赖管理,不过依然共用了同一个 prettier 配置。

依赖管理方面我们使用了 yarn workspace,包发布使用了 lerna。yarn workspace 会自动为包创建 node_modules 下的符号链接,便于其他包引入。

另外推荐一个使用 yarn 的必备工具 yarn-deduplicate,基本上已经进入了我们解决类型冲突等多种问题的标准处理方式中,强烈建议加入 CI。

模块组织

对于 TypeScript 源文件,我们将无前缀的文件看作是将导出给域外(以文件夹分隔)使用的文件或可供域外使用的入口文件,将以 @ 开头的文件作为域内自有的不进行导出的文件。举个例子:

src/library/
  utils/
    index.ts
    string.ts
    object.ts
  internal.ts
  feature.ts
  index.ts复制代码

以上文件在我们的约定中,所有内容都会被导出。

具体来说,library/utils/index.ts 文件内容如下:

export * from './string';
export * from './object';
复制代码

library/index.ts 内容如下:

export * from './utils';
export * from './feature';
export * from './internal';
复制代码

如果为 utils 文件夹和 internal.ts 文件加上 @ 前缀:

src/library/
  @utils/
    index.ts
    string.ts
    object.ts
  @internal.ts
  feature.ts
  index.ts复制代码

则对应的 library/index.ts 内容则应为:

export * from './feature';
复制代码

这一方案在实践中适应性挺好,不过有时某个巨型模块或者整个包中,需要导出的内容数量众多,为了避免名称冲突,需要导出的内容避免不了各种前缀。为了改善这个问题,我们开始实验性地使用 namespace 方案作为补充。

在创建 index.ts 文件地同时,如果创建 namespace.ts 文件,则原有 index.ts 文件的内容将加入 namespace.ts 中,而 index.ts 文件内容则变为:

export * as AwesomeNamespace from './namespace';
复制代码

为了方便,我们编写了一个叫 scoped-modules 的 eslint 规则,增加了以上规则的验证和自动修复。

很多细节限于篇幅和避免失焦没有展开,有兴趣的同学欢迎在评论区留下问题~

Photo by Joel Filipe on Unsplash

Makeflow(makeflow.com)让团队经验可以像文档一样详细地记录在流程中,指导和验证工作实践。每一次经验的迭代都可以通过任务的执行自然“推送”到整个团队,消除工作流程从想法到实践、从实践到改进之间的多种障碍。大到产品迭代管理,小到监控报警处置:记录、实践、再记录,把每一次进步写入团队基因——延续、变化、可复制。