各位同学,大家好!
今天我们不谈那些虚头巴脑的架构图,也不聊那些写在 PPT 里的未来愿景。今天我们要聊的是一场发生在代码仓库里的“革命”。想象一下,你的项目像是一个由几十个小盒子组成的乐高城堡,每个盒子都是一个独立的微前端应用,或者是一个独立的业务包。以前,这些盒子是散落在各地的孤儿,现在,我们要用 React 把它们聚在一起,用 PNPM Workspaces 给它们发号施令,实现全自动化的脚本分发与版本对齐。
准备好迎接这场技术狂欢了吗?那就请坐好,咖啡我泡好了,我们来开始今天的讲座。
第一部分:当你的项目变成“难搞的早高峰”
首先,让我们直面现实。为什么我们需要这样一个“脚本执行平台”?难道写 npm run 不香吗?
真的很不香。特别是在 Monorepo(单体仓库)或者微前端架构下。
你肯定经历过这样的场景:产品经理跑过来说:“前端大哥,这个按钮的颜色要在所有包里同步改一下。”你深吸一口气,打开了十几个终端窗口,分别进入 package-a、package-b、package-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)来访问。
这有什么好处?好处大得惊人。
- 省磁盘空间:你的 20 个包如果都依赖
lodash,在传统模式下,你得有 20 个lodash文件。在 PNPM 里,大家共用一个。省下来的磁盘空间,够你存几百个 GIF 表情包。 - 速度:因为硬链接的查找速度极快,加上严格的依赖解析,PNPM 的安装速度简直像光一样。
为了启动我们的平台,我们需要在项目根目录下创建一个配置文件:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 'packages/ui/*'
看到了吗?就这么简单。告诉 PNPM,“凡是 apps 目录下的,还有 packages 目录下的,都是我的地盘。” PNPM 会自动识别这些工作区,并且确保它们的版本号不会乱飞。
第三部分:React 仪表盘 —— 大脑的视觉化
既然我们要打造一个平台,总得有个让人看着舒服的界面吧?React 就派上用场了。我们要构建一个类似 IDE 的控制台界面。
这个界面需要三个核心模块:
- 包列表看板:展示当前所有包的名称、版本、状态(是 Up to date 还是需要升级)。
- 指令发射器:一个输入框,让你输入类似
build、test、lint之类的指令。 - 实时日志流:就像看猫咪视频一样,实时滚动显示每个包的执行进度。
让我们先来定义一下数据结构。用 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 里。这个脚本需要做三件事:
- 获取 PNPM 识别到的所有包。
- 接收前端发来的指令(例如
build)。 - 使用
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,它会在应用加载时,递归扫描 apps 和 packages 目录,读取 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();
然后在 CommandCenter 的 handleExecute 里加上检查:
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 仓库里,不然你的同事会杀了你的。