在现如今的前端体系中,无论是阅读源码还是日常开发,应该都绕不过MultiRepo 和MonoRepo这两种最为常用的代码管理方式。所谓工欲善其事,必先利其器。当我们想要把业务代码写好的时候,良好的代码管理方式以及对应的基建工具就成为了一个非常重要的利器。这篇文章,笔者就会带领大家一步一步的从0到1搭建一个Monorepo项目,并配置统一的构建体系和开发体系。
前言
在搭建项目之前,我们需要先了解一些前置知识。
MonoRepo
Monorepo 是一种项目代码管理方式,指单个仓库中管理多个项目,有助于简化代码共享、版本控制、构建和部署等方面的复杂性。简单来说,在过去,当我们采用MultiRepo 来进行项目管理时,各个项目的构建、工具链、lint等等都需要单独配置,这样难免会造成代码冗余和效率低下。另外,如果项目与项目之间存在依赖关系时,开发环境下进行彼此的调试也十分繁琐。而Monorepo则是为了解决这些问题而生的。
脚手架
社区上比较成熟的MonoRepo工具有很多,例如比较常见的Lerna。但是在考虑到包管理工具上,我们选择了非常优雅快捷的Pnpm ,而恰巧Pnpm的workspace协议本身就让他成为了一个实现MonoRepo的利器。所以,最终还是采用了pnpm作为整体项目的基础脚手架。
初始化项目
我们以一个多包并且独立发布的组件库为例。
首先,我们利用npm init
去初始化一个工程,并利用pnpm
作为包管理工具。我们先提前下载一些基础的依赖包,如react
,react-dom
,typescript
等等。
csharp
npm init
pnpm i react react-dom typescript
然后,我们创建一个packages
目录来存放我们需要发布的组件,另外再新建一个utils
目录来存放一些工具代码。这些目录的代码实现我会在后续的内容中一步一步的带大家一起完成。
接着,为了搭建Monorepo架构,更加方便组件之间的引用和调试,我们还需要新建一个pnpm-workspace.yaml
去指定我们的工作区,配置完成之后,如果可用的 packages 与已声明的可用范围相匹配,pnpm会对声明区域内的package构建一个软链接link
到父级的node_modules
,这样在引用这个packages的时候,就会找到这个软链接并返回。这样就达到了实时引用和调试的效果了。
yaml
packages:
- "packages/**"
- "utils/**"
创建完毕之后,我们现在的代码目录是这样的:
go
├─packages
├─utils
├─package.json
├─pnpm-loack.yaml
├─package.json
├─pnpm-workspace.yaml
└─tsconfig.json
新建组件
为了模拟真实的开发场景,我们在packages下新建两个组件,一个为UI组件,另一个为工具类函数组件,借此来模拟不同的本地开发场景和构建发布场景。
在packages下新建一个yael-group-button
,输出一个UI组件。
js
// yael-group-button/src/App.tsx
import React, { FC } from 'react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
color: string;
}
const Button: FC<ButtonProps> = (props) => {
const { color, children, ...rest } = props
return (
<button style={{background: color}} {...rest}>
{children}
</button>
)
}
export default Button;
并在组件的根目录新建一个index.ts
导出我们的组件和interface:
js
// yael-group-button/index.ts
import Button from "./src/App";
export { type ButtonProps } from "./src/App";
export default Button;
同样的,我们在packages下新建一个yael-group-utils
,以同样的方式导出一个方法:
typescript
// yael-group-utils/src/index.ts
/**
* 导出一个两数相加方法
* @returns
*/
export const add = (a: number, b: number) => {
return a + b
};
完善构建体系
在开发组件的过程中,我们需要用到两套构建工具:
- 开发时的本地服务,用于编辑调试
- 最终的构建打包,用于发布生产
无疑,现如今社区里的构建工具非常多,大家可以根据自己组件库的组件类型去做不同的技术选型。
而在笔者看来,在本地开发的时候,webpack
和vite
的插件体系和开发体验是最好的,另外针对npm
的生产构建打包当中,rollup
无论是在插件生态,产物的体积大小和干净程度都是领先于webpack
的。无独有偶,vite
在构建生产的产物时所用的构建工具就是rollup
,这对本人的技术性选型是十分吻合的。
所以,在本次组件库的搭建当中,会利用到vite
去做本地开发的工具,再通过添加部分rollup
插件去完成生产发布产物的编译和打包。
接下来我会带领大家,一步一步的完成整个组件库的通用打包的配置开发。
创建入口
首先,我们在utils目录下新建一个pack文件夹,里面存放了所有的构建的配置
和启动的脚本代码
。 另外,为了方便别的组件可以直接调用,这个pack文件夹本身也作为一个npm包暴露出去。所以,我们先来完善一下打包工具的package.json
。
我们期望,无论是在哪个组件下,只要在pageage.json中的script下设置了如下命令,通过npm run build
就可以启动我们的打包代码。
json
"scripts": {
"build": "pack",
},
那么,我们的pack包在packages.json就必须提供一个以pack
命名的全局bin脚本,通过main
字段来配置我们的工具入口文件,再通过bin
字段来向外部暴露一个全局命令。
json
{
"name": "yael-pack",
"version": "1.0.0",
"description": "common pack",
"main": "./bin/index.js",
"bin": {
"pack": "./bin/index.js"
},
"author": "",
"license": "ISC",
"devDependencies": {
}
}
我们先随便在脚本入口文件bin/index.js
中写点代码验证一下。
js
// bin/index.js
console.log('打包+++')
接着,我们需要在根目录下注册我们这个npm包,怎么注册呢?其实很简单,利用pnpm提供的workspace
属性就可以了。
json
"devDependencies": {
"yael-pack": "workspace:*"
}
写好之后,只需要在根目录下执行pnpm i
,就可以在顶层的node_modules
里的bin目录下,看到我们的命令名称了。
现在,我们切换到任意一个组件目录,去执行pack都能看到生效了:
完善入口文件
我们接着来完善一下脚本的入口文件,不过在写代码之前,我们得先来梳理一下入口文件需要起到的作用,当我们在对应组件下执行pack
命令时,能够通过添加对应的参数来执行不同类型的构建流程。主要分为三种:
pack npm
构建生成npm包的产物pack js
构建生成umd格式的产物pack dev
启动本地服务
所以,我们可以这么来写:
js
#!/usr/bin/env node
// 获取命令中携带的参数
const argv = require("minimist")(process.argv.slice(2));
const command = argv._[0];
// 获取构建成npm包的方法
const build = require("./command/build.js")
// 获取构建成umd格式的方法
const buildjs = require('./command/buildJs.js')
// 获取启动本地服务的方法
const runDev = require('./command/dev.js')
// 构建方式的映射
const { MODE } = require("../constants");
const { BUILD_NPM, BUILD_JS, DEV } = MODE;
switch (command) {
case BUILD_NPM:
build();
break;
case BUILD_JS:
buildjs();
break;
case DEV:
runDev();
break;
default:
break;
}
入口文件其实非常简单,无非就是根据pack命令后面携带的参数,执行相对应的方法。
接下来,我们只需要一步一步的完善每一种构建类型所对应的方法。这里,我会在bin
目录下新建一个command
目录来存放各个构建类型所对应的方法。
完善构建NPM格式的方法
当我们需要发布一个npm包时,我们往往需要将我们的代码打包成两种模块规范的产物,分别是ESM
和CJS
,另外针对TS的项目引入,我们最好再生成一份类型声明的产物。 所以此时我们的述求就变得非常简单了:
- 利用rollup的output属性,生成两种模块规范的产物,分别放在esm和cjs目录下。
- 利用插件同步生成对应的类型声明文件。
我们可以这么来完善启动npm构建的脚本:
js
// bin/command/build.js
const path = require("path");
const { exec } = require("child_process");
const { MODE } = require('../../constants/index.js')
const { log } = require("../../utils/index.js")
module.exports = function() {
// 获取vite配置文件的路径,后续会说到
const configPath = path.resolve(__dirname, "../../../vite.config.js");
// 打包前先删除遗留的打包的产物
const init = `rm -rf dist`;
// 拼接打包的命令,这里需要通过cross-env注入一个环境变量,表明是用于打包NPM,后续会解释用途
const esm = `cross-env BUILD_MODE=${
MODE.BUILD_NPM
} vite build --config ${configPath}`;
// 完整命令
const command = `${init} && ${esm}`;
// log方法为打印命令执行的实时日志
// exec执行上一步拼接好的命令
log(exec(
command,
function (error) {
if (error) {
process.exit(1);
}
}
))
}
然后,我们在utils
目录下新建一个vite的配置文件,并完善打包的配置
js
// vite.config.js
import { defineConfig } from "vite";
import { resolve, join } from "path";
import dts from "vite-plugin-dts";
import babel from "@rollup/plugin-babel";
const { MODE } = require("./pack/constants/index.js");
// 获取命令启动时的目录
const base = process.cwd();
// 获取刚刚注入的环境变量
const { BUILD_MODE } = process.env;
// 这里通过一个方法,根据打包的类型,动态配置rollup的输出类型
// 暂时只有npm的判断,后续会继续完善打包UMD格式的输出
function getOutput(mode) {
let outputList = "";
switch (mode) {
case MODE.BUILD_NPM:
outputList = [
{
dir: "dist/cjs",
format: "cjs",
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
{
dir: "dist/esm",
format: "es",
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
];
break;
default:
break;
}
return outputList;
}
export default defineConfig({
build: {
// 设置打包时的默认入口,我们约定好所有的组件的入口都是src/index.ts
lib: {
entry: resolve(base, "./index.ts"),
},
rollupOptions: {
external: ['react'],
// 配置输出目录
output: getOutput(BUILD_MODE),
plugins: [
// 配置babel
babel({
babelHelpers: "runtime",
extensions: [".ts"],
}),
],
},
},
plugins: [
// 利用vite-plugin-dts生产类型声明文件,这里只有打包npm格式时才需要用到
BUILD_MODE === MODE.BUILD_NPM && dts({
entryRoot: resolve(base, "./"),
outDir: "dist/types",
}),
],
});
接着,我们还需要完善下组件里的package.json
: 通过main
和module
配置不同的模块规范下默认的导出路径,另外通过types
字段来配置类型声明文件默认导出路径,以及files
字段来配置发布组件时所上传的文件目录。
json
{
"name": "yael-group-button",
"version": "1.0.0",
"description": "按钮组件",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/types/index.d.ts",
"files": [
"dist"
],
"keywords": [],
"author": "yael",
"license": "ISC",
"peerDependencies": {
"@babel/runtime-corejs3": "^7.21.0"
}
}
配置完成之后,我们在Button组件的目录下新增一个命令:
json
"scripts": {
"build": "pack npm",
},
运行npm run build
:
可以看到,构建的产物和我们预期的一致。
完善构建UMD格式的方法
有时候公司内部会存在一些老旧项目,无法通过安装npm包的形式引入某个组件,这个时候我们就需要将组件打包成UMD的格式,通过script标签的时候引入。
在上面已经完成的代码的基础上,我们来继续完善构建UMD格式的方法。
首先,我们需要明确的是,对比构建NPM,构建UMD的最大区别在于你需要将你的组件方法挂载到全局的window
下,所以我们就需要自定义一个全局变量
来挂载你的方法。另外,为了更好的区分版本和组件名称,我们在构建之前也需要定义好打包出来的文件名称
以及版本
。 那么这三个属性可以怎么让构建的脚本知道呢?笔者认为定义在组件下的packages.json是最为合适的。
json
{
"name": "yael-group-utils",
"version": "1.0.0",
"moduleName": "yaelUtils",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/types/index.d.ts",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git@git.dev.sh.ctripcorp.com:corp-framework-components/corp-fe-libs.git"
},
"scripts": {
"build": "pack npm",
"build:umd": "pack umd"
},
"keywords": [],
"author": "",
"license": "ISC"
}
可以看到,package.json里本身就携带了name
,version
字段,对应上了我们上面说到的文件名和版本,我们只需要再定义一个moduleName
字段,来对应全局变量的名称即可。
接着,我们只需要在构建umd的脚本里获取这些信息,然后通过注入环境变量的方式去告诉rollup即可。
ini
// bin/command/buildJs.js
const path = require("path");
const { exec } = require("child_process");
const { MODE } = require("../../constants/index.js");
const { log } = require("../../utils/index.js");
/**
* 获取当前打包项目的package.json信息
*/
const pkgPath = path.resolve(process.cwd(), "package.json");
const pkg = require(pkgPath);
const { name, version, moduleName } = pkg;
module.exports = function () {
const configPath = path.resolve(__dirname, "../../../vite.config.js");
const initDist = `rm -rf dist`;
// 注入环境变量
const umd = `cross-env BUILD_MODE=${
MODE.BUILD_JS
} VERSION=${version} FILE_NAME=${name} NAME=${moduleName} vite build --config ${configPath}`;
const command = `${initDist} && ${umd}`;
log(
exec(
command,
function (error) {
if (error) {
process.exit(1);
}
}
)
);
};
针对UMD,我们再调整下vite.config.js
的代码:
js
import { defineConfig } from "vite";
import { resolve, join } from "path";
import dts from "vite-plugin-dts";
import babel from "@rollup/plugin-babel";
const { MODE } = require("./pack/constants/index.js");
const base = process.cwd();
const { BUILD_MODE, VERSION, FILE_NAME, NAME } = process.env;
function getOutput(mode) {
let outputList = "";
switch (mode) {
case MODE.BUILD_NPM:
outputList = [
{
dir: "dist/cjs",
format: "cjs",
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
{
dir: "dist/esm",
format: "es",
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
];
break;
case MODE.BUILD_JS:
outputList = [
{
name: `${NAME}`,
dir: `dist/${VERSION}`,
format: "umd",
entryFileNames: `${FILE_NAME}.js`,
},
];
break;
default:
break;
}
return outputList;
}
export default defineConfig({
build: {
lib: {
entry: resolve(base, "./index.ts"),
},
rollupOptions: {
external: ['react'],
output: getOutput(BUILD_MODE),
plugins: [
babel({
babelHelpers: "runtime",
extensions: [".ts"],
}),
],
},
},
plugins: [
BUILD_MODE === MODE.BUILD_NPM && dts({
entryRoot: resolve(base, "./"),
outDir: "dist/types",
}),
],
});
我们在yael-group-utils
下新增一个打包umd的命令:
json
"scripts": {
"build:umd": "pack umd"
},
执行npm run build:umd
,我们可以看到产物:
完善本地服务代码
无论是调试构建产物还是调试开发代码,我们都需要一个本地服务去做这些事情,基于vite本身的预编译和ES Mobule的机制,我们可以很轻松的做到这一点。
首先,我们在utils
目录下新建一个dev
目录和一个index.html
,所有的调试代码都由dev/index.tsx
来导出,最终在index.html
来呈现。
然后,来完善一下本地服务的启动脚本代码:
js
// bin/command/dev.js
const path = require("path");
const { exec } = require("child_process");
const { log } = require('../../utils')
module.exports = function() {
// 拼接启动命令的根目录路径
const rootDevPath = path.resolve(process.cwd(), "../../utils");
// 拼接配置文件的路径
const configPath = path.resolve(__dirname, "../../../vite.config.js");
const devCli = `${
"vite"
} ${rootDevPath} --config ${configPath}`;
log(exec(devCli, function(error) {
if (error) {
process.exit(1);
}
}))
}
dev目录下的入口也来补充一下:
jsx
// utils/dev/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './src';
const root = ReactDOM.createRoot(document.getElementById('root') as Element)
root.render(<App/>)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/dev/index.tsx"></script>
</body>
</html>
这样,我们在任意组件下新增一个本地服务启动的命令,执行npm run dev
就可以调试我们的代码了。
json
"scripts": {
"build": "pack npm",
"dev": "pack dev"
},
完善CHANGELOG和版本管理
当我们更新了某个组件的功能之后,我们往往需要将更新的功能点或者修复的bug,清晰的记录在CHANGELOG.md
里,如果同仓库里的组件存在相互依赖的情况,在升级某个基础组件的同时,我们还跌将依赖该组件的上层组件同步升级一版本。
那么这些事情能否利用一个工具来有序的完成呢?
答案就是changesets
。
应该怎么配置呢?其实非常简单。
首先,我们在根路径下安装:
css
pnpm i @changesets/cli -D -w
然后,执行初始化命令:
csharp
pnpm changeset init
当我们改动某个组件之后,我们执行pnpm changeset
,来缓存我们的改动,当我们确认修改已经完成,并且需要发布组件之后,我们执行pnpm changeset version
,就可以跟着步骤一步一步的完善我们的改动信息,最终changeset会同步修改我们对应组件的版本号和添加一个CHANGELOG.md
文件。非常方便!
最后
以上的所有代码都保存在了github仓库下:
如果觉得写得还不错同学,希望能点个小小的star。