Nodejs Monorepo (Workspace) 终极指南

简介

Monorepo 是一种管理项目的方式,它将多个项目放在一个仓库中,一般来说是一个主要项目/核心项目和一些依赖项目、一些边缘项目,这样可以方便地进行 CI/CD 等操作。

但我个人认为,Monorepo 是对跨仓库 DevOps 的机制不完善的一种妥协。

本文会在 Nodejs 环境下利用 Workspace 实现 Monorepo,以及对配置的详细理解和介绍。

所需工具

  1. yarn

  2. eslint(可选)

  3. prettier(可选)

我们使用 yarn workspace,这是因为 npm 的 workspace 会在每个 package 的目录各自创建一个 node_module,造成极大的浪费和不便。

tsconfig

  • exclude 表示从 include 解析时忽略的文件。不要忽略 spec.ts 否则 eslint 会漏掉它。

我们的例子是一个核心库和一个业务程序。

依赖关系

monorepo
	- packages
		- @monorepo/core
		- @monorepo/server

``@monorepo/core@monorepo/server` 依赖。

package.json 配置

monrepo

 1{
 2    "name": "monorepo",
 3    "version": "1.0.0",
 4    "description": "A monorepo in TypeScript",
 5    "private": true,
 6    "workspaces": [
 7        "packages/*"
 8    ],
 9    "scripts": {
10        "lint": "eslint . --ext .ts",
11        "prettier-format": "run-script-os",
12        "prettier-format:win32": "prettier --config .prettierrc \"./packages/**/*.ts\" --write",
13        "prettier-format:darwin:linux": "prettier --config .prettierrc 'packages/**/*.ts' --write",
14        "prettier-format:default": "prettier --config .prettierrc 'packages/**/*.ts' --write",
15        "prettier-watch": "run-script-os",
16        "prettier-watch:win32": "onchange \"packages/**/*.ts\" -- prettier --write {{changed}}",
17        "prettier-watch:darwin:linux": "onchange 'packages/**/*.ts' -- prettier --write {{changed}}",
18        "prettier-watch:default": "onchange 'packages/**/*.ts' -- prettier --write {{changed}}"
19    },
20    "devDependencies": {
21        "@types/bcrypt": "^5.0.1",
22        "@types/jest": "^29.5.6",
23        "@types/node": "^20.8.7",
24        "@typescript-eslint/eslint-plugin": "^6.8.0",
25        "@typescript-eslint/parser": "^6.8.0",
26        "eslint": "^8.52.0",
27        "eslint-config-prettier": "^9.0.0",
28        "eslint-plugin-jest": "^27.4.3",
29        "eslint-plugin-prettier": "^5.0.1",
30        "husky": "^8.0.3",
31        "jest": "^29.7.0",
32        "nodemon": "^3.0.1",
33        "onchange": "^7.1.0",
34        "prettier": "^3.0.3",
35        "rimraf": "^5.0.5",
36        "run-script-os": "^1.1.6",
37        "ts-jest": "^29.1.1",
38        "ts-node": "^10.9.1",
39        "typescript": "^5.2.2"
40    }
41}

前三行实际上都可以去掉。

  • private 用于禁止包被发布。防止误操作。

  • workspaces 字段设置 workspace 下有哪些包,这里使用了通配符,也可以展开写。

  • devDependencies 是开发所需依赖。packages 中的包会自动继承这些依赖。

@monorepo/server

 1{
 2    "name": "@monorepo/server",
 3    "version": "1.0.0",
 4    "description": "The package containing some utility functions",
 5    "scripts": {
 6        "build": "tsc",
 7        "start:dev": "npx nodemon --watch './src/**/*.ts' --exec 'ts-node' src/index.ts",
 8        "start": "yarn build && node dist/index.js",
 9        "test": "jest",
10        "test:dev": "jest --watchAll"
11    },
12    "husky": {
13        "hooks": {
14            "pre-commit": "yarn test && yarn prettier-format && yarn lint"
15        }
16    },
17    "keywords": [],
18    "author": "",
19    "license": "ISC",
20    "devDependencies": {
21        "@types/bcrypt": "^5.0.1",
22        "@types/jsonwebtoken": "^9.0.4"
23    },
24    "dependencies": {
25        "@monorepo/core": "^1.0.0",
26        "bcrypt": "^5.1.1",
27        "fastify": "^4.24.3",
28        "jest-cucumber": "^3.0.1",
29        "jsonwebtoken": "^9.0.2",
30        "reflect-metadata": "^0.1.13",
31        "sqlite3": "^5.1.6",
32        "typeorm": "^0.3.17"
33    }
34}

这里需要注意的是 "@monorepo/core": "^1.0.0", 在其依赖列表。

其它没什么可说的。安装所需的依赖即可。

@monorepo/core

 1{
 2    "name": "@monorepo/core",
 3    "version": "1.0.0",
 4    "main": "dist/index",
 5    "types": "dist/index",
 6    "files": [
 7        "dist"
 8    ],
 9    "scripts": {
10        "build": "tsc",
11        "test": "jest",
12        "test:dev": "jest --watchAll"
13    }
14}

这里重点关注 main, types, files

都加上了 dist。这样才能让 @monorepo/server 通过 import xx from '@monorepo/core' 的方式引入。 否则会提示找不到,必须通过 import xx from '@monorepo/core/dist' 来引入。

找不到包之类的报错如何排查 由于 yarn 的 workspace 统一使用项目根目录的 node_modules,因此直接到那里去执行 ls,排查包是否存在,以及 package.json 中的配置。

jest

jest 可以根据需求决定是否要配置。如果要继承根目录配置,参考此问题

如果配置了 jest.config.[ts|js],则应当加入到 .eslintrc 的 ignorePatterns 中。

eslint

  • .eslintignore 里添加 node_modules , dist, coverage

一般而言根目录放一个就行,不需要在 package 单独配置。

.eslintrc

 1{
 2  "root": true,
 3  "parser": "@typescript-eslint/parser",
 4  "plugins": [
 5    "@typescript-eslint",
 6    "prettier",
 7    "jest"
 8  ],
 9  "ignorePatterns": [
10    "jest.config.ts"
11  ],
12  "extends": [
13    "eslint:recommended",
14    "plugin:@typescript-eslint/recommended",
15    "prettier"
16  ],
17  "parserOptions": {
18    "project": [
19      "./tsconfig.json",
20      "./packages/server/tsconfig.json",
21      "./packages/core/tsconfig.json"
22    ]
23  },
24  "rules": {
25    "prettier/prettier": 2,
26    "@typescript-eslint/no-explicit-any": "warn",
27    "@typescript-eslint/no-floating-promises": [
28      "error"
29    ]
30  },
31  "env": {
32    "browser": true,
33    "node": true,
34    "jest/globals": true
35  }
36}

eslintrc 是支持级联查找的,所以放置 root 标志让其在此处(项目根目录)停止。

注意 parserOptions.project 需要列出所有 tsconfig.json。其它按需配置即可。

tsconfig.json

这位更是重量级。

{
  "files": [],
  "include": [],
  "exclude": [
    "**/dist/**",
  ],
  "references": [
    {
      "path": "./packages/server",
    },
    {
      "path": "./packages/core",
    }
  ],
  "compilerOptions": {
    "composite": true,
    "target": "es5",
    "module": "commonjs",
    "lib": [
      "es6"
    ],
    "allowJs": true,
    "strict": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "types": [
      "node",
      "jest"
    ],
    "skipLibCheck": true
  },
}

exclude 选项用于排除不需要编译的文件。 references 选项表示编译此项目需要先编译哪个别的项目。 composite 使得编译产物增加 tsconfig.tsbuildinfo,从而能够增量编译。增加 d.ts 从而能够发布类型信息。

由于开启了 reference 模式,直接执行 tsc 只会编译顶层项目(对应的 build info 也会生成)但 packages 中的项目不会被编译。

这个时候我们应当使用 tsc --build (简写 tsc -b )来编译。会按照 ref 顺序拓扑排序依次编译。

可以在顶层的 package.jsonscripts 加入:

1"build": "tsc --build",

你会看到网上有的开源项目虽然配置了 workspace,但是每次都需要手动重新编译依赖然后重新启动 nodemon,这就是因为没配置好 reference.

@monorepo/server 的 tsconfig

 1{
 2  "include": [
 3    "src/**/*"
 4  ],
 5  "references": [
 6    {
 7      "path": "../core"
 8    }
 9  ],
10  "extends": "../../tsconfig.json",
11  "compilerOptions": {
12    "rootDir": "src",
13    "outDir": "dist",
14  },
15}

@monorepo/core 的 tsconfig

 1{
 2  "include": [
 3    "src/**/*"
 4  ],
 5  "extends": "../../tsconfig.json",
 6  "compilerOptions": {
 7    "rootDir": "src",
 8    "outDir": "dist",
 9  },
10}

至此基本上就配置得差不多了。

lerna

当 package 越来越多,这些包的管理和发布也变复杂。lerna 是一个构建管理工具,可以简化发布、变动日志的生成等。

lerna watch 也是一个比较好用的功能,可以在依赖变化时自动构建并运行指定的命令,而且不再需要为不同的平台手动适配命令。

不过多数情况原生的 tsc 其实也够用了。

可以使用 npx lerna init --dryRun 尝试。本文不赘述。