背景
在讲具体的实践之前,简单罗列一下项目背景与后期维护的困境,以简述改造原因、改造的技术基础:
先简单将项目概括为 产品业务、接口实现、公共组件 三个部分
最初代码设计
产品业务
-
新项目基于某历史项目开发,通过目录隔离实现简单的业务层面的分离、抽象组件的复用
-
出于项目规模、项目团队成员构成、项目本身特性等角度考虑,新项目拆分两个代码,采用微前端的方式进行组合
-
为减小风险,采用多主分支模式管理 project-a 和 project-b
注:该微前端框架已于前不久开源,在公司某项目支撑着数十个子应用的大系统的运行。此项目本身也要求同时作为该大系统的子应用进行接入,最终形成多级微前端结构。
到这里我们可能已经大概形成了这样一个目录(基于旧项目开发的部分):
- src
# 业务部分
- project-a
- module-1
- module-2
- ...
- project-b
- module-1
- ...
当然还有另一个代码库(该项目的独立功能模块):
- src
- project
- module-1
- module-2
- ...
- components
- utils
- api
- ...
到这里已经形成了:
- 两个代码库(两个大功能模块)
- 某一个代码库同时包含两个产品的代码,基于目录的轻隔离
- 两个大功能模块与后端服务之间存在交叉依赖(前后端功能模块拆分不完全一致)
接口部分
- 采用了 openapitools 自动生成接口代码;
- 多个独立的接口 sdk 以对应后端多个服务模块;
- sdk 代码的管理以简单的目录的方式进行区分(历史上尝试过 git submodules、npm package, 但对于多人协作的团队、以及代码管理与编译部署的自动化过程并不友好)
到这里我们可能已经大概形成了这样一个目录:
- src
# 业务部分
- project-a
- module-1
- module-2
- ...
- project-b
- module-1
- ...
# 接口部分
- api-a
- index.js
- ...
- api-b
- api-c
- ...
公共组件
- 以抽象组件(包括 utils、components 等)为主,即业务无关的组件,可以跨项目复用
- 包含部分为实现多个 module(即project目录下的多个功能模块)之间复用的业务组件
到这里我们可能已经大概形成了这样一个目录:
- src
# 业务部分
- project-a
- module-1
- module-2
- ...
- project-b
- module-1
- ...
# 接口部分
- api-a
- index.js
- ...
- api-b
- api-c
- ...
# 公共组件
- components
- comp-a # 抽象组件,允许跨项目复用
- comp-b
- busi-a # 业务组件,仅允许某个 project 下的业务代码使用
- ...
- filters
- utils
- ...
这样的结构,在前期,多团队、多代码库、多主分支、目录隔离的方式,比较简单,各技术层次的人也容易理解,适合前期的快速开发。
到后期,事实上遇到了这样一些问题:
问题暴露
研发风险增加
- 微前端部分,即使提供了集成开发的能力,但基于开发习惯、服务复杂度的考虑,部分研发人员更习惯独立开发调试。这与实际生产环境的运行模式并不一致,会存在一些风险。
用户体验较差
- 两个项目样式一致性较差;
开发成本/代码维护成本增加
- 两个代码库之间需要复用的组件变多,但组件代码复用存在障碍,常复制了事;
- 研发队伍成员缩减,两个子团队合并为一个,此时同时维护两个代码库成本变高;
- 代码规范不一致
- 编译配置不一致,当时考虑引入 webpack5 来解决一些代码复用问题,但同时升级编译代码比较麻烦,也便于统一接入 swc 等工具来优化编译速度;
- 由于业务代码仅采用软隔离,使得部分代码引用关系出现混乱,且不好区分哪些组件可跨项目复用
- 后续产品上扩展了其它应用,使得5的问题变得突出。
部署成本增加
- 部署方式调整为蓝绿部署,两个子项目都需要蓝绿,并保持版本一致,这导致了项目的部署、机器成本变高;
- 部署时要同时编译两个项目
这些问题,可能各自都有自己的解决方案,只是综合起来,改造成本、维护成本并不低,所以尝试考虑 monorepo 的方式进行改造。
改造目标
- 多主分支合并为一个;
- 多代码库合并为一个;
- 多应用部署改为单应用部署;
- 明确组件范围:项目间共享、项目模块间共享;
- 统一编译脚本、编译配置、代码规范;
- 拆包后可以利用 pnpm 自动分析项目依赖关系并行打包
- 集成 swc,优化编译配置
实践
代码结构设计
项目拆分为多个层,且每个层拥有多个包:
- 业务层(Business)
- API SDK (API)
- 基础框架/UI 层(common包)、基础工具-编译、文档、配置等(Workspace)
约定引用关系为,上层可以为引用下层的包,但不能反向引用;同一层的不同 package 之间(兄弟关系)不能互相引用。
目录规划
- build # 统一存放编译脚本
- packages
# 业务部分
# @scope/project-a
- project-a
- src
- components # 项目内公共组件
- modules # 项目业务模块
- module-1
- module-2
- ...
package.json
# @scope/project-a
- project-b
- src
- components # 项目内公共组件
- modules # 项目业务模块
- module-1
- module-2
- ...
package.json
- ...
# 接口部分
# @scope/api-a
- api-a
- src
index.ts
package.json
# @scope/api-b
- api-b
- src
index.ts
package.json
# 公共组件, 可跨项目复用
- common # @scope/common
- src
package.json
.eslintrc.js # 基础规范
package.json
tsconfig.json # 基础 ts 配置
openapitools.json # 统一管理 API sdk 配置
pnpm-workspace.yaml # 项目空间声明
... # 其它辅助配置
重写编译脚本
这部分当时是完全重写了,细节实现较多,不好详述,所以简单记一下。
build部分设计主要考虑:
-
包类型, 该项目只有三种类型,即:
- api 的打包,仅需要处理 ts -> js 转换、压缩,且无需代码规则检查,输出为 umd
- common包, 包含 tsx\ts\js\jsx\less 各种文件,输出为 umd, 无 html entry
- web 的打包,文件类型同2,输入 html 文件入口、JModule框架入口文件
-
脚本兼容:macos, windows,
- 这里在 import 文件的时候,需要考虑 windows 路径兼容的问题,url.pathToFileURL, url.fileURLToPath 可以解决这些问题。
- webpack exclude 规则需要考虑 (\|/) 路径
-
执行类型: build(生产)、dev(开发)、API sdk 生成,即编译的入口脚本将有三个:
- bin/build.mjs
- bin/dev.mjs
- bin/createApiSdk.mjs
-
配置继承,以实现 ts 配置规范、代码规范:
#### ESLint ####
const path = require('path');
module.exports = {
extends: [
path.resolve(__dirname, '../../.eslintrc.js'),
],
rules: {
"import/no-extraneous-dependencies": [
"error",
{
"packageDir": [
path.resolve(__dirname, './'),
path.resolve(__dirname, '../../')
],
},
],
}
}
##### Typescript #####
{
"extends": "../../tsconfig.json",
"include": [
"src/**/*.ts",
"global.d.ts"
]
}
注:webpack 的 context 目录需要正确配置,babel\ts\eslint 都是基于这个目录来查找的配置文件。
5. 功能规划
- build
- bin
dev.mjs
createApiSDK.mjs
build.mjs
- configs
features.mjs # 通过webpack的rule\plugin 提供每一个编译特性功能的实现
jmodule.mjs # 统一提供 JModule 微前端配置解析,便于集成开发的实现
env.mjs # .env 文件支持
webpackBase.mjs # webpack 基础配置
bundles.mjs # 每个package 的特性编译配置
bundle.mjs # bundle class 的实现,解析 bundles, 并转换为webpack config
最终 bundles 配置文件demo 参考:
[{
id: 'common',
bundleType: BundleTypes.PACKAGE, # 包类型
optimization: optimizationForPackage, # 优化策略,‘true/false/swc'
entry: './src/index.ts', # 入口文件
loaders: ['lint', 'ts', 'js', 'css', 'vue', 'assets'], # 通过 features实现,即支持 eslint, ts, js 等特性
outputPath: 'dist',
enableSourceMap: true,
alias: {
'@': resolve(__cwd, './packages/common/src'),
},
devConfig: {
port: 8080,
jmodule: {
platform: {...},
},
},
}]
- 改写 package.json 的 scripts, 所有包统一编译命令为 build\dev
代码改造
主要是按 规划的目录,把代码迁移到各自的位置,这个过程需要分离一些代码、调整代码引用方式,比如公共组件的引用统一调整为 @scope/common,代码规范适配。主要是一些体力活了。
这里通常会遇到 ts 编译类型声明查找、类型文件输出(ts 默认不支持输出到单个dts文件)问题,为保证始终能正确找到类型声明,在 common 包里引用代码,也采取 @scope/common/xxx 的方式引用,相对于webpack alias,可以很好规避 ts 类型查找的问题。
代码仓库合并
主要是如何保留两个代码仓库的 commit history,这个有不少相关文章,eg:segmentfault.com/a/119000002…
问答
之前有同事问过一些问题,在这里记录一下
Q1. 何为 monorepo, 是不是把几个代码库放到一个仓库就行了
A1. 感觉这个有点像,我练了一套剑法,招式都有了,但内功心法全无,算不算学会了这套剑法?个人理解,通常单纯的合并代码库,但相互之前没有联系,各自管理依赖,各自编译,合并的意义不大。曾经有个朋友跟我讲monorepo 就像一个龙身9个头,我可以统一控制他们,很便利;而如果是单纯的合并,则似乎更像是,把9条独立的龙关到了一个笼子里。
Q2. 每个包的 package.json 的依赖声明,是单独自己写,还是写到最外层?
A1. 我没有一个完美的答案,但通常,不管怎么做,好像问题都不大。如果某个依赖包是某个package自己需要的,那就放在自己的package.json里面;至于共享的依赖,比如当前这个项目下 build 过程中的所有依赖则都在 workspace下;另外,这个项目里的 @scope/*,谁依赖谁一般自己的package 里写清楚,这样包管理工具才好分析依赖关系,来确定谁先编译,谁跟谁可以同时编译。
还有一些问题,想起来了再补充...