前端工程化:如何使用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的开发模式将各自独立的项目变成一个统一的工程整体,解决提升研发效率和工程质量
正如uWyndA 的 2021 年终总结 - 掘金 (juejin.cn)提到,只是在对的场景使用对的技术,毕竟软件开发没有「银弹」
另外,大家在使用monorepo解决了自身项目需求时,产生过哪些问题呢?这些问题可以解决吗?欢迎大家在留言区一起讨论
作者:uWydnA
链接:https://juejin.cn/post/7043990636751503390