在业务开发中实践 monorepo

前言

有幸参加 9 月 21 日成都举办的第五届FEDAY,其中,工程师王泽的《框架开发中的基础设施搭建》,重点介绍了白鹭引擎最新产品 Egret Pro 在 monorepo 方面的工程实践。不止白鹭引擎,目前很多大型的开源库项目,例如 vue,babel,react 等等,都采用 monorepo 去管理代码。

其实,不止是大型类库,monorepo 也适用于我们实际的业务开发场景。

我们通常都会将这些库拆分成多个,创建 git 仓库,打包上传 npm,这样貌似没有什么问题。

但是当库与库之间产生依赖的时候,问题就暴露出来,修改一个库,依赖它的库也要相应更新版本号,重新发包。当库越来越多,关系越来越复杂,这个维护的过程就相当头痛。

这个时候,Lerna 正好符合这样的场景。

Lerna

Lerna 是一个 monorepo(多包单仓库)管理工具。

将多个包放到一个 repo 里,每个 packages 独立发布。

执行发布时,不需要手动维护各个包的版本号,版本会自动打上并发布。

Lerna 项目文件结构:

1
2
3
4
5
6
7
8
9
├── lerna.json
├── package.json
└── packages
├── package-1
│ ├── index.js
│ └── package.json
└── package-2
├── index.ts
└── package.json

项目大致框架

  1. 提供一个 createRollupOpts 的方法 ,为每个包初始化一个 rollup 的 options,这里有个细节,将每个包的 node_modules 和 babel 相关的包都设置成 externals 外部依赖。
  2. createRollupOpts 生成配置,包含 ts,postcss,babel plugin,为每个包生成一份统一的打包配置,遍历所有包执行 rollup 打包。
  3. jest 的脚本很简单,就是传入包名,拼接目录,调用 require(‘jest’).run([…jestArgs])

搭建步骤

安装

1
yarn global add lerna

初始化

1
2
3
mkdir demo
cd demo
lerna init

生成以下目录

1
2
3
├── packages // 包存放文件夹
├── package.json
└── lerna.json // lerna配置文件

配置解释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"packages": [
"packages/*" // 指定存放包的位置
],
"npmClient": "yarn",
"useWorkspaces": true, // 使用yarn workspace
"version": "independent", // 独立模式,每个包有独立的版本
"command": {
"version": {
"conventionalCommits": true, // 使用conventionalCommits规范升级版本和生成changelog
"message": "chore(release): publish [skip ci]"
},
"publish": {
"registry": "https://npm-registry.yy.com"
}
}
}

注意: 只有符合规范
的 commit 提交才能正确生成CHANGELOG.md文件。

如果提交的 commit 为fix会自动升级版本的修订号;

如果为feat则自动更新次版本号;

如果有BREAKING CHANGE ,则会修改主版本号。

创建模块

1
lerna create package-1

生成目录结构如下:

1
2
3
4
5
6
7
8
9
10
├── lerna.json
├── package.json
└── packages
└── package-1
├── __tests__
│ └── a.test.js
├── lib
│ └── index.js
├── package.json
└── README.md

添加依赖

1
2
lerna create package-2 // 新建一个package-2
larna add package-1 --scope=package-2 // 添加package-2到package-1

集成 jest

jest 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const path = require('path')
module.exports = {
collectCoverage: true, // 收集测试时的覆盖率信息
coverageDirectory: path.resolve(__dirname, './coverage'), // 指定输出覆盖信息文件的目录
collectCoverageFrom: [
// 指定收集覆盖率的目录文件,只收集每个包的lib目录,不收集打包后的dist目录
'**/lib/**',
'!**/dist/**',
],
testMatch: [
// 测试文件匹配规则
'**/__tests__/**/*.test.js',
],
testPathIgnorePatterns: [
// 忽略测试路径
'/node_modules/',
],
testEnvironment: 'jest-environment-jsdom', // 运行环境
transform: {
'^.+\\.[t|j]sx?$': 'babel-jest', // 使用babel
},
}

新建 scripts 文件夹,添加 jest.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const minimist = require('minimist')
const rawArgs = process.argv.slice(2)
const args = minimist(rawArgs)
const path = require('path')
let rootDir = path.resolve(__dirname, '../')

if (args.p) {
rootDir = rootDir + '/packages/' + args.p
}
const jestArgs = ['--runInBand', '--rootDir', rootDir]

console.log(`\n===> running: jest ${jestArgs.join(' ')}`)

require('jest').run(jestArgs)

根目录 package.json

1
2
3
4
5
{
"scripts": {
"test": "node scripts/jest.js"
}
}

运行测试脚本

1
2
3
4
5
// 执行全部测试
yarn test

// 执行某个包测试
yarn test -p package-1

集成 rollup 打包

rollup

介绍

rollup 从设计之初就是面向ES module的,它诞生时 AMD、CMD、UMD 的格式之争还很火热,作者希望充分利用ES module机制,构建出结构扁平性能出众的类库。

ES module 机制

特点:静态化,和运行时无关,即 编译时就能确定模块的依赖关系。

举例来说:

  1. ES import 只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面
  2. ES import 的模块名只能是字符串常量,并且是 immutable 的,不能赋值

为什么是 rollup 不是 webpack

webpack 简化了 Web 开发各个环节,包括图片自动base64,资源缓存(chunkId),按路由做代码拆分,懒加载等,其更适合打包 APP 应用。

而 rollup 打包后生成的 bundle 内容十分干净

  • 编译时依赖处理(rollup)自然比运行时依赖处理(webpack)性能更好
  • tree-shaking:静态分析代码中的 import,并将排除任何未实际使用的代码
  • 支持导出es模块文件(webpack 不支持导出 es 模块)

scripts/rollup.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const babel = require('rollup-plugin-babel')
const resolve = require('rollup-plugin-node-resolve')
const commonjs = require('rollup-plugin-commonjs')
const path = require('path')
const babelConfig = require('./babel.config')
const fs = require('fs')
const rollupTypescript = require('rollup-plugin-typescript')
const postcss = require('rollup-plugin-postcss')

module.exports = (opt, format = 'cjs') => {
const file = `${path.resolve(opt.path, './dist')}/index.${format}.js`
const getInput = (filename) => path.resolve(opt.path, `./lib/${filename}`)
const isTs = fs.existsSync(getInput('index.ts'))
const input = isTs ? getInput('index.ts') : getInput('index.js')
return {
inputOptions: {
input,
plugins: [
isTs && rollupTypescript(),
resolve({
// This makes anything that doesn't start with /, ./ or ../ as external
only: [/^\.{0,2}\//],
}),
babel({
babelrc: false,
runtimeHelpers: true,
exclude: /node_modules/,
...babelConfig,
}),
postcss({
autoModules: true,
}),
commonjs(),
],
external: (id) => {
// 配合babel配置,将core-js作为external打包
return opt.externals.includes(id) || /core-js|babel|runtime/.test(id)
},
},
outputOptions: {
file,
format,
name: opt.name,
sourcemap: true,
exports: 'named',
},
}
}

Babel

简单配置一下 babel,结合 babel 的 usage 配置,使代码 run anywhere。

scripts/babel.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
presets: [
[
'@babel/preset-env',
{
// tree-shaking for webpack or rollup(force false in rollup)
modules: false,
// “usage”: 不需要手动在代码里写import‘@babel/polyfilll’,
// 打包时会自动根据实际代码的使用情况,babel会自动根据env引入代码里实际用到的部分polyfilll模块
useBuiltIns: 'usage',
corejs: 3
}
],
// ts支持
'@babel/preset-typescript',
// react jsx 支持
'@babel/preset-react'
]
plugins: [...yourCustomPlugins]
}

构建脚本

按照 jest 脚本的套路,写一个批量打包的脚本:scripts/build.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
const minimist = require('minimist')
const rawArgs = process.argv.slice(2)
const args = minimist(rawArgs)
const fs = require('fs')
const path = require('path')
const packages = fs.readdirSync(path.resolve(__dirname, '../packages/'))
const rollupOptions = require('./rollup.config')
const rollup = require('rollup')

const packageBuildConfig = {}

// 遍历所有的包生成配置参数
packages
.filter((item) => /^([^.]+)$/.test(item))
.forEach((item) => {
let packagePath = path.resolve(__dirname, '../packages/', item)
const { name, dependencies } = require(path.resolve(
packagePath,
'package.json'
))
packageBuildConfig[item] = {
path: packagePath,
name,
externals: Object.keys(dependencies || {}),
}
})

function build(configs) {
// 遍历执行配置项
configs.forEach(async (config) => {
const watcher = rollup.watch()
watcher.on('event', (event) => {
if (event.code === 'ERROR' || event.code === 'FATAL') {
return
}
if (event.code === 'END') {
console.log(`${config.name} build successed!`)
}
})
const options = ['cjs', 'es'].map((format) => rollupOptions(config, format))
options.forEach(async (opt) => {
const bundle = await rollup.rollup(opt.inputOptions)
await bundle.write(opt.outputOptions)
})
})
}

console.log('\n===> running build')

// 根据 -p 参数获取执行对应的webpack配置项
if (args.p) {
if (packageBuildConfig[args.p]) {
build([packageBuildConfig[args.p]])
} else {
console.error(`${args.p} package is not find!`)
}
} else {
// 执行所有配置
build(Object.values(packageBuildConfig))
}

运行构建脚本

1
2
3
4
5
// 全部打包
yarn build

// 指定打包
yarn build -p package-1

发布

执行打版本命令

1
lerna version

发布到 npm

1
lerna publish from-package

总结

我们通过 leran 创建了一个工具库,无论它们是基础的函数库或者是公共的业务逻辑库,甚至是 React 的自定义 hook,react 组件。通过简单的命令,使所有包拥有统一的测试,构建流程。
至此,一个简单的工具库搭建完毕。

参考

lerna:https://github.com/lerna/lerna

babel: https://babeljs.io/

rollup.js: https://www.rollupjs.com/

jest: https://jestjs.io/