基于 Yarn WorkSpace + Lerna + OrangeCI 搭建 Typescript Monorepo 项目实践

Lerna 已然成为搭建 monorepo 工程的首选,然而官方文档[1]并没有给出构建 monorepo 项目最后一公里的解决方案。而在这次在迁移搭建全民 K 歌基础库的实践中,在诸如 Orange CI 自动发布 npm 包等问题上就遇到了不少阻碍,我们把经验总结记录如下。

名词解释 :

Orange CI:腾讯内部开源的持续集成服务,类似于 Travis CI,一旦代码有变更,就自动运行构建和发布,并输出结果,是实现自动更新版本号及发布npm包的基础。

Monorepo:一种管理组织代码的方式,其主要特点是多个项目的代码存储在同一个 git repo 中

Multirepo:一种管理组织代码的方式,其主要特点是多个项目的代码存储在不同 git repo 中

一. 背景

早期全民 K 歌 web 项目基础库是夹杂在业务项目中,存在着许多问题

  • 基础库潜藏在业务代码

  • 基础库没有按照 package 分类

  • 不适合快速迭代开发

  • 难以对代码追踪溯源

  • 无版本号管理,无代码变更文档

  • 无代码使用文档

所以要更好管理基础库代码,从业务项目迁移基础库代码、独立发布 npm 包是解决问题的关键。

二. 代码管理方案对比

1. Git Submodule 、Git Subtree

优点:方便项目回馈更改

缺点:协同开发分支多、子模块数量多,管理成本高

2. Multirepo 划分为多个模块,一个模块一个 Git Repo

优点:模块划分清晰,每个模块都是独立的 repo,利于团队协作

缺点:由于依赖关系,所以版本号需要手动控制、调试麻烦、issue 难以管理

3. Monorepo 划分多个模块,所有模块均在一个 Git Repo

优点:代码统一管理、方便统一处理 issue 和生成 ChangeLog、调试代码 npm/yarn link 一把梭

缺点:统一构建、CI、测试和发布流程带来的技术挑战、项目体积变得更大

)

一图胜千言,很显然 Monorepo 是解决这次问题的最优解。所以接下来要在项目内采用 lerna + yarn workspace 架构,使用 Typescript 语言编写代码,利用 Orange CI 去完成版本号、ChangeLog 迭代。

三. 改造的实现

1. 依赖管理

由于 Monorepo 的特性,各个 package 之间可能会形成相互依赖,手动进行 npm link 对于多 package 的 Monorepo 来说,无疑是个巨大的负担,因此我们需要一个自动化的 npm link 操作脚本。

其实了解 Lerna 用法的同学都知道,这里只用 Lerna 的命令 lerna bootstrap 可以完美的解决这个问题,但在这里,我使用 Yarn workSpace 代替 npm,除了保证 package 相互依赖, Yarn 还带来显著的优点。

  1. Yarn 只使用唯一的 yarn.lock 文件,而不是每个项目都有一个 package-lock.json ,这能降低很多潜在性的冲突。

  2. lerna bootstap 会重复安装相同的依赖项。

  3. yarn why 命令,能提示为什么安装一个 package,还有什么 package 是依赖该 package,这就方便我们方便理清 monorepo 的依赖关系。

  4. Yarn workspace 是 Lerna 利用的底层机制,而且 Lerna 支持与 Yarn 协同工作。

使用 Yarn workspace,需要在根目录 package.json 添加以下内容

// package.json
{
  "name": "root",
  "private": true,
  "workspaces": ["packages/*"]
}

2. 项目初始化

lerna 初始化项目(采用 independent 管理模式)

lerna init --independent

新增 packages

lerna create @tencent/pkg1

lerna create @tencent/pkg2

// pkg1/package.json 配置
// pkg2/package.json 同理
{
 "name": "pkg1",
 "version": "0.0.1",
 "main": "lib/index.js", // 输出目录为lib
 "types": "./lib/index.d.ts" // 声明文件
}

根目录安装 Typescript 依赖

yarn add typescript -W -D

Typescript 完成初始化

// 根目录新建tsconfig.json
{
  "compilerOptions": {
    "module": "es2015",
    "target": "es5",
    "lib": ["esnext", "dom"],
    "baseUrl": "./packages",
    "paths": {
      "@tencent/*": ["*/src"]
    },
  },
  "include": ["packages/*"],
  "exclude": [
    "node_modules",
    "lib"
  ]
}

这个配置对于每个包都是相同的,并且是完全可选的。如果想为每个包分别定制设置,那么可以创建一个该 package 的 tsconfig.json ,否则根目录的 tsconfig.json 就会起作用。

这里根目录 tsconfig.json 的 paths 是这里的神奇之处:它告诉 TypeScript 编译器,每当一个模块尝试从 monorepo 导入另一个模块时,它都应该从 packages 文件夹中解析它。具体来说,它应指向该包的 src 文件夹,因为这是构建时将编译的文件夹。除此之外,在 IDE 点击依赖包的方法,就会跳转对应的源代码。

然而 compilerOptions.outDir compilerOptions.include 不能提升至根目录的 tsconfig,因为它们是相对于它们所在的配置进行解析的。(详见issue[2])

// 各package的tsconfig.json
{
  "extends": "../../tsconfig.json",

  "compilerOptions": {
    "outDir": "./lib"
  },

  "include": [
    "src/**/*"
  ]
}

到目前为止,最基本的 Monorepo + Yarn + Typescript 项目目录结构如下。

├── lerna.json
├── yarn.lock
├── package.json
├── packages
│   ├── pkg1
│   │   ├── package.json
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   └── pkg2
│       ├── package.json
│       ├── src
│       │   └── index.ts
│       └── tsconfig.json
└── tsconfig.json

3. 项目构建

Monorepo 的构建区别普通项目在于,各个 package 之间会存在相互依赖,比如 packageA 依赖 packageB,必须 packageA 构建完毕后 packageB 才能进行构建,否则就会报错。

这里就涉及到项目构建的执行顺序问题,实际上是要求项目以一种拓扑排序的规则进行构建,这里我们有两种解决方案:

  1. 使用 lerna run 构建所有 package,并依靠 lerna 通过查看每个 package 的依赖关系以正确的顺序构建软件包。

  2. 使用 Typescript 3.0 的新特性 Project References[3]

lerna run

@lerna/run [4] 按照拓扑顺序运行每个 package 的