前端工程化:如何使用monorepo进行多项目的高效管理
前言
假设我们有4个项目
electron
:使用Electron
创建的一个桌面端项目,UI和功能大部分与web一致web
:使用React
创建的一个web
项目service
:使用Nest.js
创建的一个后端服务,负责给web
和electron
提供bff
支持ssr
:使用Next.js
创建的一个后端SSR服务,负责给web
和electron
提供ssr
支持
我们的任务就是需要开发或者维护这4个项目,这4个项目里会用到一些可复用的代码package
如下
类别 | 描述 |
---|---|
common | 常量定义、hooks、utils等 |
controls | 原子组件,负责ui |
icons | 图标 |
openapi | axios,根据swagger的json生成ts代码 |
components | 业务组件 |
apps | 由业务组件组成的路由组件 |
如果分拆成多个项目多个仓库,这些可复用的代码
- 要么就是被开发人员来回
复制粘贴
- 要么就是找多个仓库单独维护多个package,然后
publish
到npm仓库或者本地开多个项目然后npm link
- 要么就是每个仓库里单独写一遍这种本可以抽出复用的逻辑
那么,有没有更好的方法可以提升我们团队的开发质量和发布质量呢?以下是我们的诉求
- 将项目和可复用package同处一个仓库
- 在项目walk后,改动package的代码,项目可热更新
- 如有需要,在部署时,可发布package的代码,供除该monorepo之外的仓库使用
monorepo
monorepo 是一种将多个项目代码存储在一个仓库里的软件开发策略
比如React
或Vscode
或Babel
(如下图)都使用了 monorepo 管理他们的代码。
目前有挺多方式可以搭建monorepo
- yarn workspaces:Yarn提供的monorepo的依赖管理机制
- lerna:一个开源的管理工具,用于管理包含多个软件包(package)的JavaScript 项目
我们使用lerna来初始化项目
官网文档:Lerna · 是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目 | Lerna 中文文档 (lernajs.cn)
github:github.com/lerna/lerna
安装
# 首先使用 npm 将 Lerna 安装到全局环境中:
# 推荐使用 Lerna 2.x 版本。
npm install --global lerna
# 接下来,我们将创建一个新的 git 代码仓库:
git init monorepo && cd monorepo
# 现在,我们将上述仓库转变为一个 Lerna 仓库:
lerna init
你的代码仓库目前应该是如下结构
目录改造
- 新建
applications
文件夹,内容为具体的端项目 packages
文件夹内新建多个子package,每一个package对应我们前言提到的可复用的代码package- 更改每个
package
以及applications
的package.json
里的name
- 在
lerna.json
的packages里增加"applications/*"
package
类别 | name | 描述 |
---|---|---|
common | @monorepo/common | 常量定义、hooks、utils等 |
controls | @monorepo/controls | 原子组件,负责ui |
icons | @monorepo/icons | 图标 |
openapi | @monorepo/openapi | axios,根据swagger的json生成ts代码 |
components | @monorepo/components | 业务组件 |
apps | @monorepo/apps | 由业务组件组成的路由组件 |
applications
类别 | name | 描述 |
---|---|---|
electron | @monorepo/electron | electron项目,ui和功能与web基本一致 |
web | @monorepo/web | web项目 |
service | @monorepo/service | 一个后端服务,负责给web和electron提供bff支持 |
ssr | @monorepo/ssr | 一个后端SSR服务,负责给web和electron提供ssr支持 |
lerna.json
{
"packages": [
"packages/*",
"applications/*"
],
"version": "0.0.0"
}
经过目录改造后,你的代码仓库目前应该是如下结构
tsconfig
使用tsconfig
里的paths帮助我们做模块解析
根目录
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"downlevelIteration": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
"isolatedModules": true,
"jsx": "react",
"module": "commonjs",
"moduleResolution": "node",
"newLine": "lf",
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitUseStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"paths": {
"@monorepo/apps": ["./packages/apps/src"],
"@monorepo/apps/lib/*": ["./packages/apps/src/*"],
"@monorepo/apps/es/*": ["./packages/apps/src/*"],
"@monorepo/common/lib/*": ["./packages/common/src/*"],
"@monorepo/common/es/*": ["./packages/common/src/*"],
"@monorepo/components": ["./packages/components/src"],
"@monorepo/components/lib/*": ["./packages/components/src/*"],
"@monorepo/components/es/*": ["./packages/components/src/*"],
"@monorepo/controls": ["./packages/controls/src"],
"@monorepo/controls/lib/*": ["./packages/controls/src/*"],
"@monorepo/controls/es/*": ["./packages/controls/src/*"],
"@monorepo/icons": ["./packages/icons/src"],
"@monorepo/icons/lib/*": ["./packages/icons/src/*"],
"@monorepo/icons/es/*": ["./packages/icons/src/*"],
"@monorepo/openapi": ["./packages/openapi/src"],
"@monorepo/openapi/dist/lib/*": ["./packages/openapi/src/*"]
},
"pretty": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"target": "es5"
}
}
package/applications
{
"extends": "../../tsconfig.json", // 按具体项目路径决定
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"]
}
}
package打包
package里的代码更类似于一个库,而非一个应用。因此不需要用webpack
那么重的打包器,可以使用gulp
或者rollup
来打包,可以按需按以下格式进行输出
cjs
esm
umd
使用gulp
或者rollup
也是因人而异的,比如这个package不需对外发布,则可以使用gulp;如果这个package需要对外发布,不论是发布到npm还是发布一个sdk包,那么都可以使用rollup来打包
以下是一个rollup的简单配置
import typescript from "rollup-plugin-typescript2";
import common from "rollup-plugin-commonjs";
import NodePath from "path";
import autoprefixer from "autoprefixer";
import url from "rollup-plugin-url";
import RollupJson from "@rollup/plugin-json";
import RollupUrl from "@rollup/plugin-url";
import RollupBabel from "@rollup/plugin-babel";
import RollPostcss from "rollup-plugin-postcss";
import RollProgress from "rollup-plugin-progress";
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import pkg from "./package.json";
console.info("EXPECTED EXTERNALS", [...Object.keys(pkg.peerDependencies || {})]);
const rollBabelConfig = {
babelHelpers: "runtime",
exclude: "node_modules/**",
};
const rollPostcssConfig = {
inject: true,
minimize: true,
modules: true,
plugins: [
autoprefixer({
remove: false,
}),
],
};
export default {
input: "./src/index.ts",
output: [
{
format: "cjs",
dir: "lib",
sourcemap: true,
preserveModules: true,
preserveModulesRoot: "src",
},
{
dir: "es",
format: "esm",
sourcemap: true,
preserveModules: true,
preserveModulesRoot: "src",
},
],
declaration: true,
external: [...Object.keys(pkg.peerDependencies || {})],
plugins: [
peerDepsExternal(),
RollPostcss(rollPostcssConfig),
url({
url: "inline",
limit: 1000,
emitFiles: true,
}),
RollupUrl({
fileName: "[dirname][hash][extname]",
sourceDir: NodePath.join(__dirname, ".."),
}),
typescript(),
RollupBabel(rollBabelConfig),
common({
include: /\/node_modules\//,
}),
RollupJson(),
RollProgress(),
],
};
以下是一个gulp的配置
import { src, dest, parallel } from "gulp";
function copyAssets(toDir: string) {
return function copyAssets() {
return src(["src/**/*.less", "src/**/*.png", "src/**/*.gif"]).pipe(dest(toDir));
};
}
export default parallel(copyAssets("lib"), copyAssets("es"));
在项目安装package
applications
是我们具体的项目,package
是可复用的软件包
这里需要使用lerna add
命令帮助我们在项目中安装package
leran add <PackageName>
:相当于 npm install 某个依赖, 默认所有包同时安装依赖, 也可以接收一个参数 --scope=PackageName
, 可以只针对该包安装对应依赖将本地或远程 package 作为依赖项添加到当前 Lerna 存储库中的软件包。和yarn add
和npm install
不同,一次只能添加一个软件包,使用方法如下
lerna add <package>[@version] [--dev] [--exact] [--peer]
因此我们使用lerna add
命令来将@monorepo/common
安装给@monorepo/electron
、@monorepo/web
lerna add @monorepo/common --scope=@monorepo/electron --scope=@monorepo/web
可以看到electron
和web
项目里,都成功安装了@monorepo/common
但是,这2个项目明明都用到了同一个依赖,为什么要在2个项目里都单独安装一次呢,想要解决这个问题,需要使用
yarn workspaces
配合lerna
yarn workspaces
yarn workspaces可以帮助我们便利的享受一条 yarn 命令安装或者升级所有依赖,可以使多个项目共享同一个 node_modules
目录
在根目录的packages.json里进行配置
{
"name": "root",
"private": true,
+ "workspaces": [
+ "applications/*",
+ "packages/*"
+ ],
"devDependencies": {
"lerna": "^4.0.0"
}
}
可以看到yarn workspaces帮助我们做了整合
webpack配置
我们需要使用webpack帮助我们实现修改package的代码,项目热更新,我们在@monorepo/web
按以下配置更改
以下为create-react-app
创建的项目
- 创建alias.js
- 创建modules.js
- 创建paths.js
alias.js
在webpack.config.js中,通过设置resolve属性可以配置查找“commonJS/AMD模块”的基路径,也可以设置搜索的模块后缀名,还可以设置别名alias
设置别名可以让后续引用的地方减少路径的复杂度
function getAlias() {
if (process.env.NODE_ENV === "development") {
return {
"@monorepo/apps/lib": "@monorepo/apps/src",
"@monorepo/apps/es": "@monorepo/apps/src",
"@monorepo/apps": "@monorepo/apps/src",
"@monorepo/common/lib": "@monorepo/common/src",
"@monorepo/common/es": "@monorepo/common/src",
"@monorepo/components/lib": "@monorepo/components/src",
"@monorepo/components/es": "@monorepo/components/src",
"@monorepo/components": "@monorepo/components/src",
"@monorepo/controls/lib": "@monorepo/controls/src",
"@monorepo/controls/es": "@monorepo/controls/src",
"@monorepo/controls": "@monorepo/controls/src",
"@monorepo/icons/lib": "@monorepo/icons/src",
"@monorepo/icons/es": "@monorepo/icons/src",
"@monorepo/icons": "@monorepo/icons/src",
"@monorepo/openapi/dist/lib": "@monorepo/openapi/src",
"@monorepo/openapi": "@monorepo/openapi/src"
};
}
return {};
}
module.exports = getAlias();
modules.js
在modules.js中将alias.js设置的对象进行格式化后,配置在getModules
return的webpackAliases
值里
const alias = require("./alias");
...
function getWebpackAliases(options = {}) {
const baseUrl = options.baseUrl;
if (!baseUrl) {
return alias;
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
if (path.relative(paths.appPath, baseUrlResolved) === "") {
return {
src: paths.appSrc,
...alias,
};
}
return alias;
}
...
// 如有Jest
function getJestAliases(options = {}) {
const baseUrl = options.baseUrl
if (!baseUrl) {
return alias
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl)
if (path.relative(paths.appPath, baseUrlResolved) === '') {
return {
'^src/(.*)$': '<rootDir>/src/$1',
...alias,
}
}
return alias
}
...
return {
webpackAliases: getWebpackAliases(options),
...
}
paths.js
paths中暴露projectDirectory
,路径为根目录
module.exports = {
projectDirectory: resolveApp("../../"),
...
};
webpack.config.js
在Webpack.config.js的module里增加关于“isEnvDevelopment && paths.projectDirectory].filter(Boolean)
”的配置,使得本地开发环境修改package里的代码可以实现热更新
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: [paths.appSrc, isEnvDevelopment && paths.projectDirectory].filter(Boolean)
...
}
后记
总而言之,上文叙述的是某些场景下使用monorepo的开发模式将各自独立的项目变成一个统一的工程整体,解决提升研发效率和工程质量
另外,大家在使用monorepo解决了自身项目需求时,产生过哪些问题呢?这些问题可以解决吗?欢迎大家在留言区一起讨论
转载自:https://juejin.cn/post/7043990636751503390