前言
有幸参加 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
|
项目大致框架
- 提供一个 createRollupOpts 的方法 ,为每个包初始化一个 rollup 的 options,这里有个细节,将每个包的 node_modules 和 babel 相关的包都设置成 externals 外部依赖。
- createRollupOpts 生成配置,包含 ts,postcss,babel plugin,为每个包生成一份统一的打包配置,遍历所有包执行 rollup 打包。
- jest 的脚本很简单,就是传入包名,拼接目录,调用 require(‘jest’).run([…jestArgs])
搭建步骤
安装
初始化
1 2 3
| mkdir demo cd demo lerna init
|
生成以下目录
1 2 3
| ├── packages ├── package.json └── lerna.json
|
配置解释
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| { "packages": [ "packages/*" ], "npmClient": "yarn", "useWorkspaces": true, "version": "independent", "command": { "version": { "conventionalCommits": true, "message": "chore(release): publish [skip ci]" }, "publish": { "registry": "https://npm-registry.yy.com" } } }
|
注意: 只有符合规范
的 commit 提交才能正确生成CHANGELOG.md
文件。
如果提交的 commit 为fix
会自动升级版本的修订号;
如果为feat
则自动更新次版本号;
如果有BREAKING CHANGE
,则会修改主版本号。
创建模块
生成目录结构如下:
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 larna add package-1 --scope=package-2
|
集成 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/**', ], testMatch: [ '**/__tests__/**/*.test.js', ], testPathIgnorePatterns: [ '/node_modules/', ], testEnvironment: 'jest-environment-jsdom', transform: { '^.+\\.[t|j]sx?$': 'babel-jest', }, }
|
新建 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 机制
特点:静态化
,和运行时无关,即 编译时就能确定模块的依赖关系。
举例来说:
- ES import 只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面
- 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({ only: [/^\.{0,2}\//], }), babel({ babelrc: false, runtimeHelpers: true, exclude: /node_modules/, ...babelConfig, }), postcss({ autoModules: true, }), commonjs(), ], external: (id) => { 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', { modules: false, useBuiltIns: 'usage', corejs: 3 } ], '@babel/preset-typescript', '@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')
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
|
发布
执行打版本命令
发布到 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/