一、背景与痛点
1.1 项目背景
LabLIMS是一个实验室信息管理系统,前端架构采用了一种"类微前端"的设计模式------通过iframe将多个独立的Vue2子项目组织在一起。这种架构在项目初期确实带来了模块解耦的好处,但随着项目规模的扩大,问题也日益凸显。
1.2 面临的痛点
在改造之前,项目面临着以下几个核心问题:
1. 依赖管理混乱
项目包含40+个Vue2子项目,每个子项目都有独立的node_modules目录。这意味着:
- 相同的依赖(如Vue、Element-UI、Axios等)被重复安装数十次
- 磁盘占用动辄几十GB
npm install耗时极长,新同学入职配置环境需要半天时间
2. 开发体验差
开发子系统时需要:
- 单独启动对应的Vue项目
- 手动配置代理地址
- 需要一定的经验才能让子系统正常运行
- 子系统启动后没有完整的登录和会话环境,需要各种hack方式
3. 环境配置心智负担
- 打包和开发模式存在不同的hack方式
- 需要手动注释/放开某些代码来区分环境
sessionStorage写入target_server的逻辑复杂
4. 构建发布效率低
- 修改某个子项目后需要手动进入该目录执行打包
- 没有版本追踪机制
- 无法实现差量构建
二、技术选型与方案设计
2.1 为什么选择pnpm + monorepo
在调研了多种方案后,最终选择了pnpm + workspace的monorepo方案,主要基于以下考虑:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Lerna | 成熟稳定,功能全面 | 配置复杂,对pnpm支持不够友好 |
| Yarn Workspaces | 原生支持,使用广泛 | 依赖提升策略可能导致幽灵依赖 |
| pnpm Workspaces | 节省磁盘空间,依赖管理严格,安装速度快 | 学习成本略高,某些npm包可能不兼容 |
pnpm的核心优势:
- 非扁平化的node_modules结构:通过硬链接和符号链接,避免了依赖重复安装
- 严格的依赖管理:避免了"幽灵依赖"问题
- 极快的安装速度:比npm和yarn快2-3倍
2.2 整体架构设计
改造后的项目架构如下:
ruby
myapp/
├── cli/ # 新增的CLI工具
│ ├── bootstrap/ # 启动/构建命令
│ │ └── lib/cmd/
│ │ ├── dev.js # 开发命令
│ │ ├── build.js # 构建命令
│ │ ├── diff-build.js # 差量构建
│ │ └── set-version.js # 版本管理
│ ├── scripts/ # 注入脚本
│ ├── server/ # 静态服务
│ └── .env # 环境配置
├── srcVue/ # Vue子项目源码
│ ├── module-a/ # 业务模块A
│ ├── module-b/ # 业务模块B
│ ├── module-c/ # 业务模块C
│ └── ... # 其他40+子项目
├── webroot/ # 静态资源与打包产物
│ ├── distVue/ # 子项目打包输出
│ └── clientmenu/ # 菜单入口
├── tools/ # 工具库
│ └── intercept-encryption/ # 请求拦截工具
├── pnpm-workspace.yaml # workspace配置
└── package.json # 根项目配置
三、核心改造实现
3.1 pnpm workspace配置
首先,在项目根目录创建pnpm-workspace.yaml文件,定义workspace的包路径:
yaml
packages:
- 'tools/*'
- 'srcVue/*'
- 'srcVue/sub-group-a/*' # 子分组下有子项目
- 'srcVue/sub-group-b/*' # 子分组下有子项目
- '!srcVue/special-module' # 排除无法使用pnpm的项目
这样配置后,执行pnpm install会自动:
- 链接所有子项目到workspace
- 共用相同的依赖,通过硬链接节省空间
- 内部依赖通过
workspace:协议引用
3.2 CLI工具开发
为了统一管理所有子项目的启动和构建,开发了一套Node.js CLI工具。
3.2.1 命令设计
CLI工具的入口代码结构如下:

根目录package.json中定义的命令:
json
{
"scripts": {
"dev": "node ./cli/bootstrap dev",
"build": "node ./cli/bootstrap build",
"build:all": "node ./cli/bootstrap build all",
"server:static": "node ./cli/server",
"set-version": "node ./cli/bootstrap set-version",
"diff-build": "node ./cli/bootstrap diff-build"
}
}
3.2.2 开发命令实现
开发命令的核心逻辑是:通过inquirer提供交互式选择,然后启动对应的子项目:

javascript
// cli/bootstrap/lib/cmd/dev.js
const inquirer = require('inquirer');
const { getEnv, getSubProjectEntryWrapper } = require('../../../helper');
module.exports = async(argv) => {
const { vue_pro_path, common_use_list } = getEnv();
const commonUseList = JSON.parse(common_use_list.replace(/'/g, '"'));
const realPath = path.join(process.cwd(), vue_pro_path);
const getSubProjectEntry = getSubProjectEntryWrapper();
const projectList = await getSubProjectEntry(realPath);
// 支持命令行直接指定项目
if (argv) {
const targetIt = projectList.find(it => it.name === argv);
if (targetIt) {
execDev(targetIt);
return true;
}
}
// 交互式选择:常用项目优先
const { listAnswer } = await inquirer.prompt({
type: 'list',
name: 'listAnswer',
message: '请选择启动的子系统',
choices: [
...commonUseList.map(commonIt =>
projectList.find(it => it.name === commonIt)
).filter(it => !!it),
...projectList.sort((a, b) => a.name > b.name)
],
});
execDev(projectList.find(it => it.value === listAnswer));
}
3.2.3 全局代理配置
通过环境变量实现统一的代理配置,避免每个子项目单独修改:
javascript
// cli/.env
server_ip = http://192.168.1.100:8080
use_global_proxy = true
// dev.js
async function injectWebpackConfig(targetIt) {
const { server_ip, use_global_proxy } = getEnv();
if (use_global_proxy !== 'true') return;
const configStr = await readFile(`${value}/config/index.js`, 'utf-8');
const newConfigStr = configStr.replace(/target.*/g, (str) => {
return `target: "${server_ip}",`
});
await writeFile(`${value}/config/index.js`, newConfigStr, 'utf-8');
}
3.3 静态服务与登录环境
改造前,单独启动子项目无法获取登录状态。改造后,通过静态服务代理webroot目录,实现了完整的登录环境。
3.3.1 静态服务实现
javascript
// cli/server/index.js
const express = require('express');
const proxy = require('http-proxy-middleware');
const app = express();
// API代理
app.use('/myapp/svc/', proxy({
target: `${server_ip}`,
changeOrigin: true,
}));
// 静态资源服务
app.use('/myapp', express.static(path.join(process.cwd(), 'webroot')));
app.listen(port, () => {
console.log(`服务已启动:http://localhost:${port}/myapp`);
});
3.3.2 开发模式下的菜单路径替换
子项目在开发模式下路径与生产环境不同,需要动态替换:
javascript
// cli/scripts/index.js
function setAppStatic(express, app, key, rootPath) {
const modifiedUrl = [
'/myapp/clientmenu/js/clientmenu.js',
'/myapp/clientmenu/js/submenu.js',
'/myapp/login.html',
// ...
];
modifiedUrl.forEach(url => {
app.use(url, (req, res) => {
const content = fs.readFileSync(
path.resolve(process.cwd(), url.replace('/myapp', rootPath)),
'utf8'
);
// 将生产路径替换为开发路径
const newContent = content.replace(
/\/myapp\/distVue\/([a-zA-Z]+)\/\S*#\//g,
(str) => {
if (str.startsWith(`/myapp/distVue/${key}`)) {
return `/#/`;
}
return str;
}
);
res.end(newContent);
});
});
}
3.4 构建优化
3.4.1 差量构建
通过git diff对比版本差异,只构建变更的子项目:
javascript
// cli/bootstrap/lib/cmd/diff-build.js
async function getDiff() {
const commitHash = await getHash(); // 当前commit
const versionCommitHash = await getVersionHash(); // 上次构建的commit
if (versionCommitHash === commitHash) {
console.log('无变更,无需构建');
return;
}
// 获取变更文件列表
const { stdout } = await asyncExec(
`git diff --name-only ${versionCommitHash} ${commitHash}`
);
const changedFiles = stdout.trim().split('\n');
// 解析出变更的Vue项目
const projects = changedFiles
.map(item => {
const arr = item.split('/');
if (arr[0] === 'srcVue') {
// 处理子分组目录
if (arr[1] === 'sub-group-a' || arr[1] === 'sub-group-b') {
return arr[2];
}
return arr[1];
}
})
.filter(Boolean);
const diffProjectList = Array.from(new Set(projects));
// 只构建变更的项目
for (const project of diffProjectList) {
await execBuild(projectList.find(it => it.name === project));
}
setVersion();
}
这里的实现还不是很完善,另外还有许多其他的方式来实现一键差异打包
3.4.2 版本信息管理
每次构建自动生成版本信息,便于追踪:
javascript
// cli/helper.js
exports.setVersion = async() => {
const d = new Date();
const versionStr = `
Revision: ${execSync(`git rev-parse HEAD`)}
Branch: ${execSync(`git rev-parse --abbrev-ref HEAD`)}
Release: ${execSync(`git describe --always`)}
X-PackingTime: ${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${d.getHours()}:${d.getMinutes()}
`;
fs.writeFileSync(`webroot/version.txt`, versionStr);
};
3.5 内部公共包开发与使用
monorepo架构的一个重要优势是可以在workspace内部方便地开发和共享公共包。本项目开发了一个请求拦截加密SDK作为内部公共包。
3.5.1 公共包命名规范
采用@scope/package-name的命名方式,其中scope为项目或组织标识:
json
{
"name": "@myorg/intercept-encryption",
"version": "1.0.0",
"description": "请求拦截加密SDK",
"main": "dist/@myorg/intercept-encryption.es.js",
"scripts": {
"build": "rimraf -rf ./dist && rollup --config"
}
}
3.5.2 在根项目中引用
在根项目的package.json中通过workspace:协议引用内部包:
json
{
"dependencies": {
"@myorg/intercept-encryption": "workspace:^"
}
}
workspace:^协议表示引用workspace内部的包,^表示使用语义化版本兼容。pnpm会自动将这个依赖链接到本地的tools/intercept-encryption包。
3.5.3 公共包开发流程
1. 创建公共包目录结构
bash
tools/
└── intercept-encryption/
├── src/
│ ├── index.js # 入口文件
│ ├── utils.js # 工具函数
│ └── ajaxfileupload.js # 文件上传拦截
├── dist/ # 打包输出
├── package.json
├── rollup.config.js # 打包配置
└── readme.md
2. 开发公共包
javascript
// src/index.js
import { proxy } from 'ajax-hook';
import { encryptMethodsMap, decryptMethodsMap } from './utils';
export const intercept = (axios, rules = {}) => {
const { mid } = rules;
const interceptFlag = window.localStorage.interceptFlag || '0';
if (interceptFlag !== '1') return;
// 拦截fetch请求
window._fetch = window.fetch;
window.fetch = async (url, options) => {
// 加密处理逻辑...
return window._fetch(newUrl, options);
};
// 拦截ajax请求
proxy({
onRequest: (config, handler) => {
// 请求加密处理...
handler.next(config);
},
onResponse: (response, handler) => {
// 响应解密处理...
handler.next(response);
}
});
};
3. 配置rollup打包
javascript
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
export default {
input: 'src/index.js',
output: [
{
file: 'dist/@myorg/intercept-encryption.umd.js',
format: 'umd',
name: 'intercept-encryption'
},
{
file: 'dist/@myorg/intercept-encryption.es.js',
format: 'es'
}
],
plugins: [
resolve(),
commonjs(),
babel({ babelHelpers: 'bundled' })
]
};
4. 在子项目中使用
javascript
// 方式一:在Vue项目的main.js中引入
import axios from 'axios';
import { intercept } from '@myorg/intercept-encryption';
intercept(axios, {
mid: 'module-a',
success: () => console.log('请求成功'),
error: () => console.log('请求失败')
});
// 方式二:在HTML入口中引入(适用于老旧项目)
// <script src="js/jquery-3.5.1.min.js"></script>
// <script src="js/intercept-encryption.umd.js"></script>
// <script>
// window['intercept-encryption'].intercept();
// </script>
3.5.4 公共包版本管理
当公共包更新后,需要在根项目执行以下操作:
bash
# 1. 构建公共包
pnpm --filter @myorg/intercept-encryption build
# 2. 更新workspace依赖
pnpm install
# 3. 或者直接在根目录执行
pnpm build:request
四、改造效果
4.1 依赖管理优化
| 指标 | 改造前 | 改造后 | 提升 |
|---|---|---|---|
| node_modules体积 | ~30GB | ~3GB | 90%↓ |
| 依赖安装时间 | ~15分钟 | ~2分钟 | 87%↓ |
| 磁盘占用 | 每个子项目独立 | 全局共享 | 显著降低 |
4.2 开发体验提升
改造前:
bash
cd srcVue/module-a
npm install
npm run dev
# 手动配置代理
# 手动登录获取session
改造后:
bash
pnpm install # 一次性安装所有依赖
pnpm dev # 选择子系统启动
# 自动打开登录页面,无需额外配置
4.3 构建效率提升
- 单项目构建 :
pnpm build <project-name>选择性构建 - 全量构建 :
pnpm build:all一键构建所有项目 - 差量构建 :
pnpm diff-build只构建变更项目
五、关键技术点总结
5.1 子项目dev-server改造
在每个子项目的dev-server.js中注入静态服务逻辑:
javascript
// srcVue/module-a/build/dev-server.js
const { setAppStatic } = require('../../../cli/scripts');
const key = process.env.subName;
if (key) {
setAppStatic(express, app, key, '../../webroot');
uri = `${uri}/myapp`;
}
5.2 环境变量统一管理
通过.env文件集中管理配置:
env
# cli/.env
vue_pro_path = srcVue
port = 3900
common_use_list = ['module-a', 'module-b', 'module-c', 'module-d', 'module-e']
server_ip = http://192.168.1.100:8080
use_global_proxy = true
webroot_path = webroot
version_file_name = version.txt
5.3 请求拦截架构
为满足等保要求,增加了请求拦截工具:
bash
# 注入拦截标记
pnpm publish:intercept
# 注入不拦截标记
pnpm publish:noIntercept
六、经验与反思
6.1 成功经验
- 渐进式改造:没有一次性重构所有子项目,而是先建立CLI工具,再逐步迁移
- 保持兼容:保留了原有的项目结构,只是增加了管理工具层
- 文档先行:改造过程中同步更新开发指南,降低团队学习成本
6.2 遇到的坑
- pnpm兼容性 :部分老旧的npm包在pnpm下有问题,需要通过
.npmrc配置shamefully-hoist=true - 路径问题 :Windows和Linux的路径分隔符差异,需要使用
path.sep处理 - 子项目的子项目:某些目录下还有嵌套的子项目,需要在workspace配置中额外声明
6.3 后续优化方向
- 依赖版本统一:目前各子项目的依赖版本还不统一,需要进一步收敛
- 公共组件抽取:将各子项目共用的组件抽取到workspace公共包
- CI/CD集成:将差量构建集成到CI/CD流程中
- TypeScript迁移:逐步将子项目迁移到TypeScript
七、结语
这次改造证明了:即使是历史包袱沉重的老项目,也可以通过合理的架构设计和渐进式的改造策略,在不影响业务的前提下实现工程化升级。
pnpm + monorepo的方案不仅解决了依赖管理的痛点,更重要的是为后续的技术演进打下了基础。CLI工具的开发让开发体验得到了质的提升,差量构建则让发布效率大幅提高。内部公共包的开发模式也为团队代码复用提供了便利。
技术栈:Node.js + pnpm + Express + inquirer + commander
项目地址:内部项目,仅供参考思路
本文记录了一次完整的前端工程化改造实践,希望能给面临类似问题的同学提供一些参考。