徒手开荒-我用纯Nodejs+pnpm+monorepo改造了一个多vue2的iframe"微前端"项目

6 阅读9分钟

一、背景与痛点

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的核心优势

  1. 非扁平化的node_modules结构:通过硬链接和符号链接,避免了依赖重复安装
  2. 严格的依赖管理:避免了"幽灵依赖"问题
  3. 极快的安装速度:比npm和yarn快2-3倍

2.2 整体架构设计

改造后的项目架构如下:

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的包路径:

packages:
  - 'tools/*'
  - 'srcVue/*'
  - 'srcVue/sub-group-a/*'      # 子分组下有子项目
  - 'srcVue/sub-group-b/*'      # 子分组下有子项目
  - '!srcVue/special-module'    # 排除无法使用pnpm的项目

这样配置后,执行pnpm install会自动:

  1. 链接所有子项目到workspace
  2. 共用相同的依赖,通过硬链接节省空间
  3. 内部依赖通过workspace:协议引用

3.2 CLI工具开发

为了统一管理所有子项目的启动和构建,开发了一套Node.js CLI工具。

3.2.1 命令设计

CLI工具的入口代码结构如下:

cli.png

根目录package.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提供交互式选择,然后启动对应的子项目:

dev.png

// 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 全局代理配置

通过环境变量实现统一的代理配置,避免每个子项目单独修改:

// 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 静态服务实现

// 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 开发模式下的菜单路径替换

子项目在开发模式下路径与生产环境不同,需要动态替换:

// 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对比版本差异,只构建变更的子项目:

// 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 版本信息管理

每次构建自动生成版本信息,便于追踪:

// 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为项目或组织标识:

{
  "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:协议引用内部包:

{
  "dependencies": {
    "@myorg/intercept-encryption": "workspace:^"
  }
}

workspace:^协议表示引用workspace内部的包,^表示使用语义化版本兼容。pnpm会自动将这个依赖链接到本地的tools/intercept-encryption包。

3.5.3 公共包开发流程

1. 创建公共包目录结构

tools/
└── intercept-encryption/
    ├── src/
    │   ├── index.js          # 入口文件
    │   ├── utils.js          # 工具函数
    │   └── ajaxfileupload.js # 文件上传拦截
    ├── dist/                  # 打包输出
    ├── package.json
    ├── rollup.config.js      # 打包配置
    └── readme.md

2. 开发公共包

// 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打包

// 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. 在子项目中使用

// 方式一:在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 公共包版本管理

当公共包更新后,需要在根项目执行以下操作:

# 1. 构建公共包
pnpm --filter @myorg/intercept-encryption build

# 2. 更新workspace依赖
pnpm install

# 3. 或者直接在根目录执行
pnpm build:request

四、改造效果

4.1 依赖管理优化

指标改造前改造后提升
node_modules体积~30GB~3GB90%↓
依赖安装时间~15分钟~2分钟87%↓
磁盘占用每个子项目独立全局共享显著降低

4.2 开发体验提升

改造前

cd srcVue/module-a
npm install
npm run dev
# 手动配置代理
# 手动登录获取session

改造后

pnpm install  # 一次性安装所有依赖
pnpm dev      # 选择子系统启动
# 自动打开登录页面,无需额外配置

4.3 构建效率提升

  • 单项目构建pnpm build <project-name> 选择性构建
  • 全量构建pnpm build:all 一键构建所有项目
  • 差量构建pnpm diff-build 只构建变更项目

五、关键技术点总结

5.1 子项目dev-server改造

在每个子项目的dev-server.js中注入静态服务逻辑:

// 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文件集中管理配置:

# 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 请求拦截架构

为满足等保要求,增加了请求拦截工具:

# 注入拦截标记
pnpm publish:intercept

# 注入不拦截标记
pnpm publish:noIntercept

六、经验与反思

6.1 成功经验

  1. 渐进式改造:没有一次性重构所有子项目,而是先建立CLI工具,再逐步迁移
  2. 保持兼容:保留了原有的项目结构,只是增加了管理工具层
  3. 文档先行:改造过程中同步更新开发指南,降低团队学习成本

6.2 遇到的坑

  1. pnpm兼容性:部分老旧的npm包在pnpm下有问题,需要通过.npmrc配置shamefully-hoist=true
  2. 路径问题:Windows和Linux的路径分隔符差异,需要使用path.sep处理
  3. 子项目的子项目:某些目录下还有嵌套的子项目,需要在workspace配置中额外声明

6.3 后续优化方向

  1. 依赖版本统一:目前各子项目的依赖版本还不统一,需要进一步收敛
  2. 公共组件抽取:将各子项目共用的组件抽取到workspace公共包
  3. CI/CD集成:将差量构建集成到CI/CD流程中
  4. TypeScript迁移:逐步将子项目迁移到TypeScript

七、结语

这次改造证明了:即使是历史包袱沉重的老项目,也可以通过合理的架构设计和渐进式的改造策略,在不影响业务的前提下实现工程化升级。

pnpm + monorepo的方案不仅解决了依赖管理的痛点,更重要的是为后续的技术演进打下了基础。CLI工具的开发让开发体验得到了质的提升,差量构建则让发布效率大幅提高。内部公共包的开发模式也为团队代码复用提供了便利。

技术栈:Node.js + pnpm + Express + inquirer + commander

项目地址:内部项目,仅供参考思路


本文记录了一次完整的前端工程化改造实践,希望能给面临类似问题的同学提供一些参考。