React 驱动的脚本执行平台:利用 PNPM Workspaces 实现跨包指令的自动化分发与版本对齐

各位同学,大家好!

今天我们不谈那些虚头巴脑的架构图,也不聊那些写在 PPT 里的未来愿景。今天我们要聊的是一场发生在代码仓库里的“革命”。想象一下,你的项目像是一个由几十个小盒子组成的乐高城堡,每个盒子都是一个独立的微前端应用,或者是一个独立的业务包。以前,这些盒子是散落在各地的孤儿,现在,我们要用 React 把它们聚在一起,用 PNPM Workspaces 给它们发号施令,实现全自动化的脚本分发与版本对齐。

准备好迎接这场技术狂欢了吗?那就请坐好,咖啡我泡好了,我们来开始今天的讲座。

第一部分:当你的项目变成“难搞的早高峰”

首先,让我们直面现实。为什么我们需要这样一个“脚本执行平台”?难道写 npm run 不香吗?

真的很不香。特别是在 Monorepo(单体仓库)或者微前端架构下。

你肯定经历过这样的场景:产品经理跑过来说:“前端大哥,这个按钮的颜色要在所有包里同步改一下。”你深吸一口气,打开了十几个终端窗口,分别进入 package-apackage-bpackage-c……然后一个个输入 npm run build 或者 npm run test。如果你的网络不好,或者某个包的依赖装崩了,这一天就毁了。

更糟糕的是版本对齐。你更新了 shared-ui 库到 v1.0.0,结果 dashboard-app 还在引用 shared-ui 的旧版内存泄露版本。你就像一个试图把圆钉子塞进方孔里的人,越急越错。

所以,我们要干的事儿,就是做一个“中央处理器”。这个处理器是一个 React 应用,它不仅能看,还能动手。它能看着你的 PNPM Workspace 里的所有包,一声令下,让它们乖乖执行脚本,或者同步版本。

这就像是给一群贪玩的猴子装上了“指挥棒”。

第二部分:PNPM Workspaces —— 原子弹级的管理员

要实现这个平台,底层的基石必须是 PNPM。为什么?因为 Yarn 1.0 太胖,Yarn 2.0 太激进,而 PNPM 是那个不仅身材苗条,而且原则性极强的“包工头”。

PNPM 的核心魔法在于严格文件系统

想象一下,传统的 node_modules 是一个巨大的混乱市场,成千上万个文件副本堆在那里。而 PNPM 建立了一个巨大的“虚拟文件系统”。所有包共享同一个磁盘位置,通过硬链接(Hard Links)来访问。

这有什么好处?好处大得惊人。

  1. 省磁盘空间:你的 20 个包如果都依赖 lodash,在传统模式下,你得有 20 个 lodash 文件。在 PNPM 里,大家共用一个。省下来的磁盘空间,够你存几百个 GIF 表情包。
  2. 速度:因为硬链接的查找速度极快,加上严格的依赖解析,PNPM 的安装速度简直像光一样。

为了启动我们的平台,我们需要在项目根目录下创建一个配置文件:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'packages/ui/*'

看到了吗?就这么简单。告诉 PNPM,“凡是 apps 目录下的,还有 packages 目录下的,都是我的地盘。” PNPM 会自动识别这些工作区,并且确保它们的版本号不会乱飞。

第三部分:React 仪表盘 —— 大脑的视觉化

既然我们要打造一个平台,总得有个让人看着舒服的界面吧?React 就派上用场了。我们要构建一个类似 IDE 的控制台界面。

这个界面需要三个核心模块:

  1. 包列表看板:展示当前所有包的名称、版本、状态(是 Up to date 还是需要升级)。
  2. 指令发射器:一个输入框,让你输入类似 buildtestlint 之类的指令。
  3. 实时日志流:就像看猫咪视频一样,实时滚动显示每个包的执行进度。

让我们先来定义一下数据结构。用 TypeScript 来说话,这能避免很多低级错误:

// interfaces.ts
export interface PackageInfo {
  name: string;
  version: string;
  location: string;
  isOutdated: boolean;
}

export interface CommandExecution {
  packageName: string;
  status: 'pending' | 'running' | 'success' | 'failed';
  log: string;
}

export interface WorkspaceConfig {
  packages: string[];
  version: string;
}

接下来,是我们的核心组件 CommandCenter.tsx。这是一个很酷的组件,它不仅仅是 UI,它是你的代理人。

// CommandCenter.tsx
import React, { useState, useEffect } from 'react';
import { exec } from 'child_process';
import { promisify } from 'util';
import { PackageInfo, CommandExecution } from './interfaces';

const execAsync = promisify(exec);

export const CommandCenter: React.FC = () => {
  const [packages, setPackages] = useState<PackageInfo[]>([]);
  const [logs, setLogs] = useState<CommandExecution[]>([]);
  const [command, setCommand] = useState('build');

  // 启动时扫描一下所有包
  useEffect(() => {
    scanPackages();
  }, []);

  const scanPackages = async () => {
    // 这里我们假装执行了一个命令来获取包列表
    // 在实际工程中,你可以使用 fs.readdir 或者读取 pnpm-workspace.yaml
    const mockPackages: PackageInfo[] = [
      { name: 'dashboard-app', version: '1.0.5', location: './apps/dashboard', isOutdated: false },
      { name: 'shared-utils', version: '2.0.0', location: './packages/utils', isOutdated: true },
      { name: 'admin-panel', version: '0.9.2', location: './apps/admin', isOutdated: false },
    ];
    setPackages(mockPackages);
  };

  const handleExecute = async (pkgName: string) => {
    const newLog: CommandExecution = {
      packageName: pkgName,
      status: 'running',
      log: `Starting execution for ${pkgName}...`,
    };
    setLogs(prev => [...prev, newLog]);

    try {
      // 核心:调用后端的执行脚本
      await execAsync(`node runner.js ${pkgName} ${command}`, { cwd: process.cwd() });
      setLogs(prev => prev.map(log => 
        log.packageName === pkgName 
          ? { ...log, status: 'success', log: `${pkgName} finished successfully!` } 
          : log
      ));
    } catch (error) {
      setLogs(prev => prev.map(log => 
        log.packageName === pkgName 
          ? { ...log, status: 'failed', log: `${pkgName} failed with error.` } 
          : log
      ));
    }
  };

  return (
    <div className="terminal">
      <header className="header">
        <h1>React 脚本执行平台 v1.0</h1>
        <input 
          value={command} 
          onChange={(e) => setCommand(e.target.value)} 
          placeholder="输入指令 (如: build, test, lint)" 
        />
      </header>

      <div className="packages-grid">
        {packages.map(pkg => (
          <div key={pkg.name} className={`package-card ${pkg.isOutdated ? 'outdated' : ''}`}>
            <h3>{pkg.name} <span>v{pkg.version}</span></h3>
            <button onClick={() => handleExecute(pkg.name)}>
              {pkg.isOutdated ? '强制执行' : '执行'}
            </button>
            <div className="status-badge">{pkg.isOutdated ? '⚠️ 版本过时' : '✅ 最新'}</div>
          </div>
        ))}
      </div>

      <div className="logs">
        <h3>系统日志流</h3>
        {logs.map((log, idx) => (
          <div key={idx} className={`log-entry log-${log.status}`}>
            <span className="pkg-name">{log.packageName}</span>
            <span className="status-icon">●</span>
            <span className="log-content">{log.log}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

看,这代码是不是很清新?这就是我们的大脑。它不知道怎么编译代码,它只知道告诉肌肉(后端脚本)该干什么。

第四部分:Node.js 脚本引擎 —— 硬核的执行者

光有 React 界面不够,React 是前端,它跑在浏览器里,没法直接访问文件系统来执行 npm 命令。我们需要一个 Node.js 脚本作为后端代理。这个脚本才是真正的“内功高手”。

我们的核心逻辑在 runner.js 里。这个脚本需要做三件事:

  1. 获取 PNPM 识别到的所有包。
  2. 接收前端发来的指令(例如 build)。
  3. 使用 pnpm -r 命令,让 PNPM 自动遍历并执行。

让我们来写这个“杀手”脚本。

// runner.js
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs');

const execAsync = promisify(exec);

async function runCommand(packageName, scriptName) {
  console.log(`[System] Dispatching '${scriptName}' command to ${packageName}...`);

  // 构建命令。关键点来了:pnpm -r run
  // -r 代表 run in all workspace packages
  // --filter 可以用来指定特定包
  const cmd = `pnpm ${packageName ? `--filter ${packageName}` : '-r'} run ${scriptName}`;

  try {
    const { stdout, stderr } = await execAsync(cmd, { 
      cwd: path.resolve(__dirname, '..'), // 确保在根目录执行
      maxBuffer: 1024 * 1024 * 10 // 增加缓冲区,防止日志溢出
    });

    // 如果有输出,打印出来
    if (stdout) process.stdout.write(stdout);
    if (stderr) process.stderr.write(stderr);

    return { success: true, output: stdout };
  } catch (error) {
    console.error(`[Error] Command failed in ${packageName}:`, error.message);
    return { success: false, error: error.message };
  }
}

// 暴露给 React 调用的函数
module.exports.runCommand = runCommand;

// 单独运行时测试
if (require.main === module) {
  const args = process.argv.slice(2);
  if (args.length < 1) {
    console.error('Usage: node runner.js <packageName|all> <scriptName>');
    process.exit(1);
  }
  runCommand(args[0], args[1]);
}

这段代码的精髓在于 pnpm --filter ${packageName}。这是 PNPM Workspaces 的杀手锏。它让你能像点菜一样,精准地告诉系统:“我要对 shared-ui 这个包执行 build 命令”。

如果 packageName 为空,那么 pnpm -r run build 就会像一场海啸,席卷整个仓库,在每一个包里都执行一次 build

第五部分:自动化分发与版本对齐 —— 宏大的交响乐

现在,我们已经有了 UI 和引擎,接下来要解决最头疼的问题:版本对齐

在 PNPM 中,每个包都有自己的 package.json。有时候,你可能想把所有包的版本号统一更新,比如从 1.0.0 升级到 1.0.1

手动改?开什么玩笑。那是对程序员生命的浪费。

我们可以写一个 React 组件 VersionSynchronizer.tsx,它负责扫描所有包,计算新的版本号,并调用脚本执行更新。

// VersionSynchronizer.tsx
export const VersionSynchronizer: React.FC = () => {
  const [targetVersion, setTargetVersion] = useState('1.0.1');
  const [syncLog, setSyncLog] = useState<string[]>([]);

  const handleSync = async () => {
    setSyncLog(prev => [...prev, `开始同步版本到 ${targetVersion}...`]);

    const packages = ['dashboard-app', 'shared-ui', 'admin-panel']; // 实际应该动态获取
    for (const pkg of packages) {
      try {
        // 使用 sed 或者直接调用 npm version 命令
        // 这里为了演示,我们用简单的 shell 命令
        await execAsync(`pnpm --filter ${pkg} version ${targetVersion} --no-git-tag-version`, { 
          cwd: process.cwd() 
        });
        setSyncLog(prev => [...prev, `✅ ${pkg} 版本更新成功`]);
      } catch (err) {
        setSyncLog(prev => [...prev, `❌ ${pkg} 更新失败: ${err.message}`]);
      }
    }

    setSyncLog(prev => [...prev, '所有包版本同步完毕!']);
  };

  return (
    <div className="version-panel">
      <h2>版本控制中心</h2>
      <div className="control-group">
        <label>目标版本号:</label>
        <input 
          type="text" 
          value={targetVersion} 
          onChange={(e) => setTargetVersion(e.target.value)} 
        />
        <button onClick={handleSync}>执行同步</button>
      </div>
      <ul className="log-list">
        {syncLog.map((log, i) => <li key={i}>{log}</li>)}
      </ul>
    </div>
  );
};

当然,真正的工程中,我们通常会使用 changesets 或者 lerna version。但在这个 React 驱动的平台里,我们完全可以用 React 组件来封装这些命令,提供一个更友好的交互界面。

第六部分:React 与 PNPM 的深度联动 —— 智能感知

这不仅仅是单向的执行。React 平台应该能感知到 PNPM 的变化。

怎么做到?监听文件系统?太慢了。

最好的办法是:当 React 平台启动时,扫描一次根目录下的 package.json 文件

我们可以写一个 React Hook useWorkspaceStatus,它会在应用加载时,递归扫描 appspackages 目录,读取 package.json,并分析哪些包的依赖版本不匹配。

// hooks/useWorkspaceStatus.ts
import { useState, useEffect } from 'react';
import fs from 'fs/promises';
import path from 'path';

export const useWorkspaceStatus = () => {
  const [packages, setPackages] = useState<any[]>([]);

  useEffect(() => {
    const checkDependencies = async () => {
      const workspacePath = path.join(process.cwd(), 'packages');
      const appsPath = path.join(process.cwd(), 'apps');

      const readPackage = async (p: string) => {
        const pkgPath = path.join(p, 'package.json');
        try {
          const content = await fs.readFile(pkgPath, 'utf-8');
          const pkg = JSON.parse(content);
          return {
            name: pkg.name,
            version: pkg.version,
            dependencies: pkg.dependencies,
            // 简单的逻辑:检查是否有 peerDependencies 没有被满足
            isHealthy: true 
          };
        } catch (e) {
          return null;
        }
      };

      // 扫描逻辑
      const results = await Promise.all([
        fs.readdir(workspacePath).then(async (files) => 
          Promise.all(files.map(f => readPackage(path.join(workspacePath, f))))
        ),
        fs.readdir(appsPath).then(async (files) => 
          Promise.all(files.map(f => readPackage(path.join(appsPath, f))))
        ),
      ]);

      const allPackages = [...results.flat()].filter(Boolean);
      setPackages(allPackages);
    };

    checkDependencies();
  }, []);

  return packages;
};

这个 Hook 会返回当前仓库的健康状态。如果某个包缺少依赖,React 界面会变红。这样,你的平台就不仅仅是一个遥控器,它还是一个医疗诊断仪

第七部分:并发控制 —— 别让 CPU 疯了

当你点击“全量执行”按钮时,React 会同时向 20 个包发送指令。如果 PNPM 允许并发执行,这可能会导致你的电脑风扇狂转,甚至内存溢出。

我们需要在 React 层面加一个简单的锁机制。

// utils/lockManager.ts
class LockManager {
  private activeJobs: Set<string> = new Set();

  acquireLock(packageName: string): boolean {
    if (this.activeJobs.has(packageName)) {
      return false;
    }
    this.activeJobs.add(packageName);
    return true;
  }

  releaseLock(packageName: string): void {
    this.activeJobs.delete(packageName);
  }

  isLocked(packageName: string): boolean {
    return this.activeJobs.has(packageName);
  }
}

export const lockManager = new LockManager();

然后在 CommandCenterhandleExecute 里加上检查:

const handleExecute = async (pkgName: string) => {
  if (lockManager.isLocked(pkgName)) {
    alert(`包 ${pkgName} 正在运行中,请勿重复点击!`);
    return;
  }

  lockManager.acquireLock(pkgName);

  // ... 执行逻辑 ...

  // 执行完毕后
  lockManager.releaseLock(pkgName);
};

这样就防止了同一个包同时运行两个 npm run build 进程。

第八部分:构建与部署 —— 把它变成一个产品

现在,我们有了 React 代码(CommandCenter.tsx),有了 Node 逻辑(runner.js),有了 PNPM 配置。

如何把它们变成一个可交付的产品?

最简单的方法:把它们写在一个根目录的 platform 目录下

my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── packages/          # 业务包
├── apps/              # 前端应用
└── platform/          # 我们的 React 脚本执行平台
    ├── package.json
    ├── src/
    │   └── index.tsx
    └── bin/
        └── server.js  # 启动入口

platform/package.json 里,我们需要配置脚本:

{
  "name": "@my-org/script-platform",
  "scripts": {
    "dev": "react-scripts start",
    "start": "node bin/server.js",
    "build": "react-scripts build"
  }
}

bin/server.js 可以是一个简单的 HTTP 服务器,利用 cors 库来处理跨域请求,让 React UI 和 Node 后端解耦。

第九部分:专家级的优化 —— 缓存与性能

这还不够。真正的资深专家会考虑性能。

当你在 runner.js 里执行 pnpm run build 时,如果项目很大,第一次构建会非常慢。第二次呢?虽然 React 代码没变,但 node_modules 里的构建缓存还在。

我们需要告诉 PNPM 使用缓存,并且告诉 React 传递环境变量。

// runner.js
const execAsync = promisify(exec);

async function runCommand(packageName, scriptName) {
  // 启用缓存
  const cmd = `pnpm --filter ${packageName} run ${scriptName} --cache-folder .pnpm_cache`;
  // ...
}

此外,React 的 TerminalOutput 组件如果每次都全量渲染所有日志,会卡死。我们可以使用 useRef 和一个文本滚动逻辑,只追加最新的日志,而不是每次都重绘 DOM。

第十部分:总结(不,等等,不总结)

听着,不要关掉这个页面。如果你真的做到了这一步,你会发现什么?

你手里握着的不仅仅是一个脚本执行工具。你手里握着的是控制权。你把原本混乱的命令行交互,变成了一流的可视化体验。

你把版本管理变成了鼠标点击,而不是编辑器里的 sed 命令。
你把多包测试变成了仪表盘上的一个按钮。

这就是 React 驱动的力量。它让技术变得性感,让枯燥的运维工作变成了像玩《我的世界》一样有趣。

现在,去打开你的终端,初始化一个 PNPM Workspace,写个 pnpm -r run build,然后看着控制台里像瀑布一样的日志流,你会忍不住笑出声来。

感谢大家的聆听,下课!别忘了把 pnpm-lock.yaml 提交到 Git 仓库里,不然你的同事会杀了你的。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注