阅读 170

Everything you should know about Monorepo:那些你需要知道的 Monorepo 技术点

这篇文章旨在记录有关 Monorepo 的所有技术点,以及基于 typescript + yarn workspaces + lerna 进行多个 node package 开发管理的最佳实践。

NOTE: 本文针对 library 开发的场景进行描述,其中的实践并不一定满足所有的 monorepo 的场景,文章也不会全面的介绍各种 monorepo 的应用场景下的方案,但是会在部分章节必要的时候做一些简单的说明。

npm 和 yarn 之间的区别

正如我们所熟知的,npm 和 yarn 都是原生的 node packge 的管理工具,他们在包管理方面具有相互兼容的功能,比如,依赖管理(dependency management),包发布(publish),安装(install)等。

npm 是随着 node 的发布一起发布的,属于 node 官方维护的包管理工具。

yarn 是 Facebook 为了改善前期 npm 在大型项目中依赖安装性能而独立开发维护的包管理工具,在依赖管理方面,yarn 除了大幅提升了安装性能在,在分布发布系统中依赖一致性方面也进行了保证(yarn.lock), 从而解决了分布发布中由于依赖版本不一致而导致应用表现不一致的问题。

npm5 之后,npm 慢慢补齐了在性能与依赖一致性方面的差距,所以如果仅仅是单独的作为项目的依赖管理来说,使用 yarn 或者 npm 都是可以的。

yarn 与 npm 的最大区别是,yarn 原生引入了 workspaces 概念,让使用 yarn 进行依赖管理的项目具备了原生的 mono-repo 的能力,而 npm 原生至今也没有对等的功能,而要在不适用 yarn 的情况下实现 workspaces 的能力,我们不得不借助 lerna 来实现。接下来的章节,我们会具体介绍 mono-repo & yarn workspaces & lerna

什么是 Mono-repo

mono-repo 相对应的一个概念的 multi-repo. 这两个概念其实是同一个问题的不同解决方案。

在我们进行多个项目开发的过程中,总会存在一些可以复用的逻辑,这时候,我们会通过可复用逻辑的相关性,把若干可复用逻辑封装到同一个 package 中,然后我们可能还会期望根据 package 的相关性,将若干 package 聚合在一起进行管理(版本控制,git / svn),在进行 package 聚合管理的时候,就出现了两种不同的管理方案:

  • multi-repo: 最开始的时候,很多开发者会为每个独立的 package 创建一个 git 仓库,多个 package 就会存在多个相互独立的 git 仓库,这就是所谓的 multi-repo.

  • mono-repo(多包仓库): multi-repo 最大的问题在于同一个开发者同时维护多个 package, 且这些 package 之间还存在相互依赖的时候,管理起来会相当的麻烦,不仅需要检出多个仓库,而且需要在某个 package 更新之后,手动的去更新他的依赖方,而且不同 package 的相同依赖需要安装多份,所以为了解决上述问题,提高开发效率,mono-repo 诞生了,mono-repo 会在同一个 git 仓库中管理一组具有相关性的 package, 不同 package 之间的共同依赖会被提升,而且 package 之间的相互依赖也会在其中一个 package 更新后自动更新其依赖(也可以不自动升级,下面的章节进行介绍),并发布新的版本。

为什么 & 什么时候用 Monorepo

所以,从上一章节的介绍中,我们可以了解到使用 mono-repo 的硬核指标就是:就有相关性的一组 package 的管理,我们可以尝试问自己以下几个问题来确定我们是否需要使用 mono-repo:

  1. 正在维护的这一组 packages 具有相关性么?比如:都是一些工具类的库,或者是是一个系列工具的不同部件的封装(react),或者是一个体系系统下的不同插件(bable)

  2. 这些 packages 之间是否相互依赖?比如有一个 core package, 被多个其他 package 所依赖,并且需要在 core 版本发生变化后,升级依赖方的版本

  3. 这些 packages 之间是否有比较多得共同依赖?(具有局限性,适当参考)

怎么创建 Monorepo

从这一章节开始,我们就需要动手来实践,到底怎么创建 mono-repo, 以及集成一些最佳实践,让我们的项目更加标准与高效。

创建 mono-repo 业内其实有多种方案,鉴于笔者精力,本文只介绍其中比较流行且比较活跃的两种方案 yarn workspaceslerna.

我们先简单的对比下这两种方案:

  • 相同点

    • 都可以独立的创建 mono-repo

    • packages 之间的相互依赖(以下以 local dependency 来说明)都可以使用 syslink 来链接到本地,也可以是直接安装已经发布到指定 registry 中的版本

    • packages 的共同依赖都可以提升到根目录,避免重复安装,lerna 默认情况下不会提升,需要在 bootstrap 的时候显示指定 --hoist 参数

  • 不同点

    • yarn workspaces 不具具备 local dependency 之间语义版本的自动管理,包的统一发布等,需要进入到各个包内进行手动版本更新或者发布

    • lerna 默认使用 npm 作为包管理工具,但是通过 npmClient 来改为 yarn, 但是如果仅仅是使用 yarn 作为依赖管理工具,yarn 和 npm 区别不大

yarn2 对 workspaces 有了很多提升,也提供了对 local dependency 版本更新时的管理,workspaces 发布等新功能,感兴趣的可以深入研究,后续笔者实践后,也会整理一些使用心得出来,官方文档

从以上的对比中,我们可以发现,yarn workspaces 进行 mono-repo 的管理时,其实方案是不完备的,比如进行类似 react 或者 babel 这种有明确的版本管理的 library 类型的 mono-repo 管理只使用 yarn workspaces 是不够的,但是对于一个 node 开发的小型 web 项目,该项目由 client 端,server 端以及一个自己开发的组件库组成,对于这种类型的项目,没有很强的版本管理负担(仅组件库需要),且各个子项目又具备一定的独立性,为了提高开发场景下的构建效率,仅使用 yarn workspaces 来创建 mono-repo 是非常轻量的选择。

所以进行 library 的多包仓库的管理时,推荐的方案是 yarn workspaces + lerna 组合,没错,这两个方案是可以完美结合的,本身 yarn 的 workspaces 就是一个偏底层的能力,而 lerna 本身也仅是利用 npm 或 yarn 提供的能力来工作的,所以我们可以切换 larna 底层的多包能力为 yarn workspaces 而同时使用 leran 额外的命令来进行包的版本管理和发布。

动手创建

完整的项目可以参考 github.com/dancon/mono…

笔者假设各位看官已经安装 node 以及全局安装 yarn 1.x 或者 2.x ,如果没有,可以参考以下文档自行安装:

  • 在 github 创建一个远程项目,远程地址为

  • 检出远程仓库, 并进行项目初始化,这里使用 monorepo 进行演示

# 检出代码库
git clone <remote-repo url>

# 进入项目根目录
cd monorepo

# 初始化为 npm 项目
yarn init --yes
复制代码

<remote-repo url> 替换为你创建的 github 仓库地址

至此项目只有一个 package.json 文件

{
    "private": true,  // 避免根项目被发布出去
    "workspaces": [
        "packages/*"
    ]                 // 暂时填写为 packages/* 指定 workspace 位置为 packages
}
复制代码
# 确保在 monorepo 目录下,创建 packages 目录,作为 yarn workspace 或者子项目
mkdir packages
复制代码

至此,项目已经是在 yarn workspaces 模式下工作。

  • 配置 lerna 使用 yarn workspace
# 安装 lerna, 注意,使用 yarn add -W 将 lerna 安装到根目录
yarn add -W -D lerna

# lerna 初始化, 使用 independent 模式
yarn lerna init --independent
复制代码

yarn add 的时候,如果没有指定 -W 参数会报错

自行创建 .gitignore 文件

生成 lerna.json 配置文件,内容如下:

{
  "packages": [
    "packages/*"
  ],
  "version": "independent"
}
复制代码

添加以下配置,让 lerna 切换使用 yarn 进行依赖管理,并且使用 yarn workspaces

{
  // 此项配置为可选,在部分 IDE(VS Code 支持)中会读这个 json schema 进行配置项的智能提示
  "$schema": "http://json.schemastore.org/lerna",
  "packages": [
    "packages/*"
  ],
  "version": "independent",
  "npmClient": "yarn",       // 告知 lerna 使用 yarn 作为包管理工具
  "useWorkspaces": true      // 使用 yarn workspaces
}
复制代码

至此,已经支持使用 yarn workspaces + lerna 进行管理了。

  • 初始化 typescript
# 安装依赖
yarn add -W -D typescript

# 在更目录下初始化
yarn tsc --init
复制代码

修改根目录下的 tsconfig.json 为如下内容

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "allowJs": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  },
  // 排除一些不需要处理的文件,在后面的章节中会详细介绍其中的一些文件以及文件夹
  "exclude": [
    "node_modules",
    "packages/**/node_modules",
    "packages/**/lib",
    "packages/**/test",
    "packages/**/*.test.ts",
    "packages/**/jest.config.js"
  ]
}
复制代码
  • 创建 packages
# 接下来创建两个 package 分别为 core 和 pkg1
cd packages
mkdir core
mkdir pkg1
复制代码

每个 package 的目录结构如下:

.
├── src
│   └── index.ts     # 源码目录
├── jest.config.js   # jest 配置 yarn jest --init
├── package.json     # yarn init --yes
└── tsconfig.json    # package 的 ts 配置
复制代码

jest.config.js 的具体配置在下面的章节中重点说明

package.json 着重说明以下配置:

{
  "name": "@scope/core",
  "main": "lib/index.js",    
  "types": "lib/index.d.ts",
  "publishConfig": {             // 如果我们的包名包含在一个特殊的 @scope 下,为了能让包正常发布,必须添加该项配置
    "access": "public"
  },
  "devDependencies": {
    "@types/node": "^13.13.5"
  },
  "scripts": {
    "test": "jest",
    "build": "rm -rf lib && tsc"  // 添加构建脚本
  }
}
复制代码

tsconfig.json 配置内容如下:

{
  "extends": "../../tsconfig.json",  // 继承根目录下的 tsconfig.json 配置
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "lib"
  }
}
复制代码

接下来重点说明 mono-repo 中项目依赖管理,涉及以下几个主题:

  • 包的安装

  • 公共包安装

  • 为所有 package 安装包

  • 安装本地依赖

项目结合 lerna + workspaces, 所以包的安装方式有两种:

# 通过 yarn workspace 安装
yarn add -W <package-name>
yarn workspace <workspace-name> add <package-name> # <workspace-name> 为对应包 package.json 中的 name

# 通过 lerna 安装
lerna add <package-name> # 为所有 packages/* 安装依赖
lerna add <package-name> --scope <workspace-name> # 效果同 yarn workspace
复制代码

NOTE:

yarn add -W 效果和 lerna add 不指定 --scope 是不等价的

yarn add -W 是安装通用依赖,更新根目录下的 package.json

lerna add 不指定 --scope 是为所有的 package 安装依赖,更新所有 packages/**/package.json

如果我们需要为自己的包添加一个本地依赖,比如为 @scope/pkg1 添加 @scope/core 作为依赖,我们既可以使用 yarn workspace 或者 lerna add --scope 来安装

yarn workspace @scope/pkg1 add @scope/core@1.0.0

# 等价于

lerna add @scope/core@1.0.0 --scope @scope/pkg1
复制代码

以上方式指定了与本地 @scope/core 相同或者兼容的版本,所以 @scope/core 其实是通过 syslinks 的方式指向本地仓库

如果我们不指定版本,则 yarn 或者 lerna 会从 npm registry 中拉取在线包,这时候,我们项目中引用的就是 node_modules 中的包

以下图为 local dependency,指定相同或者兼容的版本号

不指定版本

版本管理与发布

yarn workspaces 并没有提供统一的版本管理与发布,如果不是 lerna, 我们也可以单独使用 yarn version,yarn publish 来进行管理,但是相互之间的依赖都需要人工管理,低效而易错。

使用 lerna 利用 commit convention 通过 git message 来自动的进行版本管理,并且在包版本变更后,自动更新依赖方的版本,并且可以各个包自动生成 CHANGELOG.md, 为了使用 lerna 的这些能力,我们只需进行以下配置:

lerna.json

{
  "packages": [
    "packages/*"
  ],
  "version": "independent",
  "npmClient": "yarn",
  "useWorkspaces": true,
  "command": {
    "publish": {
      "conventionalCommits": true,                // 启用 commit convention 自动进行版本管理
      "registry": "https://registry.npmjs.org/",  // 指定 registry
      "message": "chore: publish"                 // lerna 自动提交时的 commit message 前缀
    },
    "version": {
      "message": "chore: publish"
    }
  }
}
复制代码

第一次发布的时候,无需通过 commit message 来自动升级版本,这时候,我们可以使用 from-package

lerna publish from-package
复制代码

根目录下的 package.json 添加如下 scripts 命令

{
  "scripts": {
    "release": "lerna publish",       // 添加 release script
    "build": "lerna exec --stream yarn build",
    "test": "lerna exec -- yarn test --passWithNoTests",
    "lint": "eslint --ext js,jsx,ts,tsx packages --fix",
    "gen": "plop"
  }
}
复制代码

然后,后续的发布中通过 yarn 来执行

# 第一次发布
yarn release from-package

# 之后的发布
yarn release
复制代码

关于 commit convention 可以参考: Conventional Commits

mono-repo 中,关于 commit message 需要单独说明的是,在每次提交的时候,最好为每个包的修改指定对应的 scope, 比如:

git commit -m 'feat(core): add a new feature'

我们也可以利用 commitlint 来规范我们的提交信息,后面的章节会详细介绍其使用。

yarn workspaces

官方文档:yarn workspace | Yarn

接下来我们着重介绍一些常用的命令:

  • yarn workspaces info 展示当前项目中所有 workspace 的依赖关系

执行结果如下:

yarn workspaces v1.21.1
{
  "@pandolajs-test/core": {
    "location": "packages/core",
    "workspaceDependencies": [],
    "mismatchedWorkspaceDependencies": []
  },
  "@pandolajs-test/pkg1": {
    "location": "packages/pkg1",
    "workspaceDependencies": [
      "@pandolajs-test/core"
    ],
    "mismatchedWorkspaceDependencies": []
  }
}
✨  Done in 0.04s.
复制代码
  • yarn workspaces run <command> 在所有 workspace 中执行 <command>

lerna 中的等价命令:lerna run <command>

  • yarn workspace <workspace> <command> 在指定的 <workspace> 中执行 yarn 的命令

lerna

官方文档:GitHub - lerna/lerna: A tool for managing JavaScript projects with multiple packages.

  • 常用的全局参数

    • --since
  • 常用的命令

    • lerna bootstrap

    • lerna add

      • --scope
    • lerna publish

      • from-package
    • lerna run

    • lerna exec

Monorepo 最佳实践

使用 typescript

每个 workspace 中的 tsconfig.json 继承根目录下的 tsconfig.json 提升通用配置。

  • 安装 typescript
yarn add -W -D typescript
复制代码
  • 初始化项目
yarn tsc --init
复制代码

使用 jest 进行单测

官方文档:Getting Started · Jest

每个 workspace 中使用自己的 jest 配置

  • 安装依赖
yarn add -W -D jest @types/jest ts-jest
复制代码
  • 初始化配置
yarn jest --init
复制代码
{
    "preset": "ts-jest",
}
复制代码

使用 eslint & prettier 统一代码风格

更多查看文档:www.robertcooper.me/using-eslin…

  • 安装 lint 依赖
yarn add -W -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-config-standard eslint-config-standard-with-typescript eslint-plugin-import eslint-plugin-jest eslint-plugin-node eslint-plugin-prettier eslint-plugin-promise  eslint-plugin-standard
复制代码
  • 添加如下配置

.eslintrc.json

module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module',
    tsconfigRootDir: __dirname,
    project: [
      './packages/**/tsconfig.json'
    ]
  },
  env: {
    node: true
  },
  plugins: [
    '@typescript-eslint',
    'jest',
    'prettier'
  ],
  extends: [
    'eslint:recommended',
    'standard-with-typescript',
    'prettier/@typescript-eslint',
    'plugin:jest/recommended',
    'plugin:prettier/recommended'
  ],
  rules: {
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/triple-slash-reference': 'off',
    '@typescript-eslint/strict-boolean-expressions': 'off'
  }
}
复制代码
  • vscode 支持, .vscode/setting.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}
复制代码
  • prettier 配置

.prettierrc.json

{
  "semi": false,
  "tabWidth": 2,
  "singleQuote": true,
  "trailingComma": "none"
}
复制代码

可根据自己口味进行修改

  • git hook 配置 & lint-staged
yarn add -W -D husky lint-staged
复制代码
  • 配置如下:

.lintstagedrc

{
  "packages/**/*.{js,ts,jsx,tsx}": [
    "eslint --fix"
  ]
}
复制代码

.huskrc.json

{
  "hooks": {
    // commitlint 配置的 git hook
    "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
    // 注意这里 --since HEAD 的作用 参考:https://github.com/lerna/lerna/tree/master/core/filter-options#--since-ref
    "pre-commit": "lerna exec --concurrency 1 --stream lint-staged --since HEAD"
  }
}
复制代码

使用 commitlint 进行语义版本的自动化管理

官方文档:commitlint - Lint commit messages

  • 安装依赖
yarn add -W -D @commitlint/cli @commitlint/config-conventional @commitlint/config-lerna-scopes
复制代码
  • 添加配置文件

.commitlintrc.json

{
  "extends": [
    "@commitlint/config-conventional",
    "@commitlint/config-lerna-scopes"
  ]
}
复制代码

使用 plop 进行样板代码配置,加速开发效率

官方文档:GitHub - plopjs/plop: Consistency Made Simple

具体参考代码库实现:GitHub - dancon/monorepo

  • 安装依赖
yarn add -W -D plop
复制代码
  • 配置文件   plopfile.js
module.exports = function(plop) {
    // ...
}
复制代码
  • 常用方法

    • setGenerator

    • setHelper

  • 项目根目录配置以下 script

{
    "scripts": {
        "gen": "plop"
    }
}
复制代码

然后执行 yarn gen [template-name] 就可以创建你配置的模板,或者不指定 [template-name] 会列出所有你配置的模板。更多使用方式可以参考官方文档。

References