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

一、背景与痛点

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 整体架构设计

改造后的项目架构如下:

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会自动:

  1. 链接所有子项目到workspace
  2. 共用相同的依赖,通过硬链接节省空间
  3. 内部依赖通过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 成功经验

  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

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


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

相关推荐
FungLeo1 小时前
成为全栈·Node 后端篇·全文搜索:从 LIKE 到全文索引
node.js·全文索引·模糊搜索·全文搜索·成为全栈·like 搜索
mldong4 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排5 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60615 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角6 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!6 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰7 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi8 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马9 小时前
AI源码分析:拆解模型底层实现逻辑
前端