当团队维护的组件库膨胀到数百个单元时,传统的整体构建模式开始暴露出严重的效率问题。任何一个细微的组件修改,都需要触发整个库的重新构建与发布,CI/CD流水线被无意义的等待时间占据。开发人员想要快速验证一个组件的独立变更,流程变得异常笨重。我们的痛点很明确:需要一套机制,能够按需、独立、快速地构建和预览任意组件,而不是牵一发而动全身。
初步的构想是搭建一个内部服务:开发者通过一个Web界面选择需要构建的组件,服务后台接收请求,拉取该组件的元信息,动态生成打包配置,执行构建,并将产物存放到可供预览的静态资源服务器。这本质上是一个元数据驱动的动态打包平台。
技术选型决策过程很直接。我们需要一个地方持久化存储组件的元信息,例如源码路径、依赖关系、版本号等,关系型数据库MySQL是显而易见的选项,其结构化和事务性非常适合这个场景。前端界面需要快速开发且功能完备,Ant Design提供了丰富的企业级UI组件,能让我们聚焦于核心业务逻辑。而打包工具的核心,我们放弃了更常见的Webpack,选择了Rollup。原因在于,我们的目标是打包独立的库组件,Rollup专注于JavaScript库的打包,其对ESM的友好支持和更简洁的输出,使其成为这个场景下更轻量、更高效的选择。整个服务的粘合剂,则采用Node.js与Express框架。
数据模型:用MySQL定义组件资产
一切始于数据结构。我们需要两张核心表:一张用于存储组件的基础元数据 (components),另一张用于记录每次构建任务的状态和日志 (build_tasks)。
components 表的设计必须包含足够的信息来驱动构建:
-
id: 组件唯一标识。 -
name: 组件名,如'CustomButton'。 -
package_name: npm包名,如'@our-lib/custom-button'。 -
version: 当前版本。 -
entry_point: 相对于代码库根目录的入口文件路径,这是Rollup配置的关键输入,例如'src/CustomButton/index.tsx'。 -
dependencies: JSON格式的字符串,存储其直接依赖的内部组件ID列表,用于未来的依赖分析。
build_tasks 表则是一个任务队列和日志系统:
-
id: 任务唯一标识。 -
component_id: 关联的组件ID。 -
status: 任务状态,枚举值:'PENDING','RUNNING','SUCCESS','FAILED'。 -
artifact_path: 构建成功后,产物的存放路径。 -
build_log: 存储Rollup构建过程中的stdout和stderr,用于排错。 -
created_at/updated_at: 时间戳。
这是可以直接在MySQL中执行的DDL语句:
-- 文件: schema.sql
CREATE DATABASE IF NOT EXISTS component_builder;
USE component_builder;
-- ----------------------------
-- Table structure for components
-- ----------------------------
DROP TABLE IF EXISTS `components`;
CREATE TABLE `components` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL COMMENT '组件名称 (e.g., "DataGrid")',
`package_name` varchar(255) NOT NULL COMMENT 'NPM包名 (e.g., "@my-scope/data-grid")',
`version` varchar(50) NOT NULL DEFAULT '1.0.0' COMMENT '组件版本',
`entry_point` varchar(512) NOT NULL COMMENT '相对于项目根目录的入口文件路径',
`dependencies` json DEFAULT NULL COMMENT '依赖的内部组件ID列表 (e.g., [1, 5, 12])',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_package_name` (`package_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组件元数据表';
-- ----------------------------
-- Table structure for build_tasks
-- ----------------------------
DROP TABLE IF EXISTS `build_tasks`;
CREATE TABLE `build_tasks` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`component_id` int(11) unsigned NOT NULL COMMENT '关联的组件ID',
`status` enum('PENDING','RUNNING','SUCCESS','FAILED') NOT NULL DEFAULT 'PENDING' COMMENT '构建状态',
`artifact_path` varchar(1024) DEFAULT NULL COMMENT '构建产物存放路径',
`build_log` longtext COMMENT '构建过程日志',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_component_id` (`component_id`),
CONSTRAINT `fk_component_id` FOREIGN KEY (`component_id`) REFERENCES `components` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组件构建任务表';
-- 插入一些种子数据用于测试
INSERT INTO `components` (`name`, `package_name`, `version`, `entry_point`, `dependencies`)
VALUES
('AsyncButton', '@my-lib/async-button', '1.0.2', 'packages/AsyncButton/index.tsx', '[2]'),
('Icon', '@my-lib/icon', '1.1.0', 'packages/Icon/index.ts', '[]'),
('Modal', '@my-lib/modal', '1.3.0', 'packages/Modal/index.tsx', '[1, 2]');
核心服务:动态生成Rollup配置的Node.js后端
后端服务是整个系统的中枢。它响应前端请求,从MySQL查询元数据,动态拼装Rollup配置,并通过Rollup的JavaScript API执行构建。
项目的基本结构如下:
/component-builder-service
|-- /src
| |-- /routes
| | |-- api.ts
| |-- /services
| | |-- db.ts
| | |-- rollup.service.ts
| |-- app.ts
|-- package.json
|-- tsconfig.json
首先是数据库连接模块,使用mysql2/promise以支持async/await。
// src/services/db.ts
import mysql from 'mysql2/promise';
// 在生产环境中,这些配置应来自环境变量
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'your_password',
database: 'component_builder',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
// 定义数据模型接口
export interface ComponentMeta {
id: number;
name: string;
package_name: string;
version: string;
entry_point: string;
dependencies: number[];
}
export async function getComponentMetaById(id: number): Promise<ComponentMeta | null> {
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
'SELECT * FROM components WHERE id = ?',
[id]
);
if (rows.length === 0) {
return null;
}
return rows[0] as ComponentMeta;
}
// ... 其他数据库操作函数,如createBuildTask, updateTaskStatus等
export async function createBuildTask(componentId: number): Promise<number> {
const [result] = await pool.execute<mysql.ResultSetHeader>(
'INSERT INTO build_tasks (component_id, status) VALUES (?, ?)',
[componentId, 'PENDING']
);
return result.insertId;
}
export async function updateTaskStatus(taskId: number, status: 'RUNNING' | 'SUCCESS' | 'FAILED', log: string, artifactPath?: string) {
await pool.execute(
'UPDATE build_tasks SET status = ?, build_log = ?, artifact_path = ? WHERE id = ?',
[status, log, artifactPath || null, taskId]
);
}
接下来是最核心的部分:rollup.service.ts。这个服务负责生成配置并执行构建。这里的关键在于,Rollup的配置是一个普通的JavaScript对象,这使得动态生成变得非常简单。
// src/services/rollup.service.ts
import path from 'path';
import * as rollup from 'rollup';
import typescript from 'rollup-plugin-typescript2';
import postcss from 'rollup-plugin-postcss';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { getComponentMetaById, updateTaskStatus } from './db';
// 假设我们的组件库源码在 '../component-library'
const MONOREPO_ROOT = path.resolve(__dirname, '../../../component-library');
const OUTPUT_DIR = path.resolve(__dirname, '../../public/artifacts');
// 这个函数是动态配置生成器
function createRollupConfig(meta: { package_name: string; entry_point: string; }): rollup.RollupOptions {
const { package_name, entry_point } = meta;
const componentName = package_name.split('/')[1] || package_name;
return {
input: path.join(MONOREPO_ROOT, entry_point),
output: [
{
file: path.join(OUTPUT_DIR, componentName, 'index.esm.js'),
format: 'esm',
sourcemap: true,
},
{
file: path.join(OUTPUT_DIR, componentName, 'index.cjs.js'),
format: 'cjs',
sourcemap: true,
},
],
// 关键点:将所有peerDependencies标记为外部依赖,不打包进产物
external: ['react', 'react-dom', 'antd'],
plugins: [
nodeResolve(),
commonjs(),
typescript({
tsconfig: path.join(MONOREPO_ROOT, 'tsconfig.json'),
// 确保使用干净的缓存目录,避免构建冲突
clean: true,
}),
postcss({
extract: true, // 将CSS提取到单独文件
modules: true, // 启用CSS Modules
sourceMap: true,
minimize: true,
}),
],
};
}
// 执行构建的主函数
export async function triggerBuild(componentId: number, taskId: number) {
let buildLog = '';
try {
await updateTaskStatus(taskId, 'RUNNING', 'Fetching component metadata...');
const meta = await getComponentMetaById(componentId);
if (!meta) {
throw new Error(`Component with ID ${componentId} not found.`);
}
const config = createRollupConfig(meta);
// 捕获Rollup日志
config.onwarn = (warning, warn) => {
buildLog += `[WARN] ${warning.message}\n`;
warn(warning);
};
const bundle = await rollup.rollup(config);
// 确保output配置是数组形式
const outputOptions = Array.isArray(config.output) ? config.output : [config.output];
for (const options of outputOptions) {
if (options) {
await bundle.write(options);
}
}
await bundle.close();
const componentName = meta.package_name.split('/')[1] || meta.package_name;
const artifactPath = `/artifacts/${componentName}/`;
buildLog += 'Build successful.\n';
await updateTaskStatus(taskId, 'SUCCESS', buildLog, artifactPath);
} catch (error: any) {
console.error(`Build failed for task ${taskId}:`, error);
const errorMessage = error.stack || error.message || 'Unknown error';
buildLog += `\n[ERROR] ${errorMessage}`;
await updateTaskStatus(taskId, 'FAILED', buildLog);
}
}
最后,Express路由将这一切串联起来。
// src/routes/api.ts
import express from 'express';
import { createBuildTask } from '../services/db';
import { triggerBuild } from '../services/rollup.service';
const router = express.Router();
// ... 其他获取组件列表、获取任务状态的接口 ...
router.post('/build/:componentId', async (req, res) => {
try {
const componentId = parseInt(req.params.componentId, 10);
if (isNaN(componentId)) {
return res.status(400).json({ error: 'Invalid component ID' });
}
// 1. 创建一个构建任务记录
const taskId = await createBuildTask(componentId);
// 2. 立即返回任务ID,不阻塞HTTP请求
res.status(202).json({ message: 'Build task accepted', taskId });
// 3. 异步触发实际的构建过程
// 在真实项目中,这里应该用消息队列(如RabbitMQ/Kafka)解耦,
// 以防止服务重启导致构建任务丢失,并支持更复杂的调度。
// 但为了简化,我们这里直接异步执行。
triggerBuild(componentId, taskId).catch(err => {
// 全局错误捕获,以防triggerBuild内部的try/catch失效
console.error(`Unhandled exception in build process for task ${taskId}:`, err);
});
} catch (error) {
console.error('Error creating build task:', error);
res.status(500).json({ error: 'Failed to create build task' });
}
});
export default router;
界面呈现:使用Ant Design构建操作台
前端是一个React应用,使用Ant Design的Table、Button、Tag和Modal组件来展示信息和发起操作。
// src/components/ComponentDashboard.tsx
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Modal, Spin, message } from 'antd';
import axios from 'axios';
// 定义接口返回的数据结构
interface ComponentData {
id: number;
name: string;
package_name: string;
version: string;
}
interface BuildTask {
id: number;
status: 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED';
build_log: string;
artifact_path?: string;
}
const ComponentDashboard: React.FC = () => {
const [components, setComponents] = useState<ComponentData[]>([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [currentTask, setCurrentTask] = useState<BuildTask | null>(null);
useEffect(() => {
// 假设有/api/components接口获取所有组件
axios.get('/api/components')
.then(res => setComponents(res.data))
.catch(() => message.error('Failed to fetch components'))
.finally(() => setLoading(false));
}, []);
const handleBuild = async (componentId: number) => {
try {
const res = await axios.post(`/api/build/${componentId}`);
const { taskId } = res.data;
message.success(`Build task #${taskId} started.`);
pollTaskStatus(taskId);
} catch {
message.error('Failed to start build task.');
}
};
const pollTaskStatus = (taskId: number) => {
setModalVisible(true);
setCurrentTask({ id: taskId, status: 'PENDING', build_log: 'Waiting for task to start...' });
const intervalId = setInterval(async () => {
try {
// 假设有/api/tasks/:taskId接口获取任务状态
const res = await axios.get<BuildTask>(`/api/tasks/${taskId}`);
const task = res.data;
setCurrentTask(task);
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
clearInterval(intervalId);
if(task.status === 'SUCCESS') {
message.success(`Task #${taskId} completed successfully.`);
} else {
message.error(`Task #${taskId} failed.`);
}
}
} catch {
clearInterval(intervalId);
message.error('Lost connection to task status poll.');
}
}, 2000); // 每2秒轮询一次
};
const columns = [
{ title: 'Component Name', dataIndex: 'name', key: 'name' },
{ title: 'Package', dataIndex: 'package_name', key: 'package_name' },
{ title: 'Version', dataIndex: 'version', key: 'version', render: (tag: string) => <Tag color="blue">{tag}</Tag> },
{
title: 'Action',
key: 'action',
render: (_: any, record: ComponentData) => (
<Button type="primary" onClick={() => handleBuild(record.id)}>
Build & Preview
</Button>
),
},
];
return (
<>
<Table
dataSource={components}
columns={columns}
rowKey="id"
loading={loading}
/>
<Modal
title={`Build Task #${currentTask?.id}`}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
footer={[
currentTask?.status === 'SUCCESS' && (
<Button key="preview" type="primary" href={currentTask.artifact_path} target="_blank">
Preview Artifacts
</Button>
),
<Button key="close" onClick={() => setModalVisible(false)}>
Close
</Button>
]}
width={800}
>
{currentTask ? (
<div>
<p>Status: <Tag color={
currentTask.status === 'RUNNING' ? 'processing' :
currentTask.status === 'SUCCESS' ? 'success' :
currentTask.status === 'FAILED' ? 'error' : 'default'
}>{currentTask.status}</Tag>
</p>
<pre style={{ background: '#f5f5f5', padding: '10px', maxHeight: '400px', overflowY: 'auto' }}>
<code>{currentTask.build_log}</code>
</pre>
{(currentTask.status === 'PENDING' || currentTask.status === 'RUNNING') && <Spin />}
</div>
) : <Spin />}
</Modal>
</>
);
};
export default ComponentDashboard;
架构整合与流程图
整个系统的交互流程可以用一个简单的时序图来表示。
sequenceDiagram
participant User
participant Frontend (React + AntD)
participant Backend (Node.js + Express)
participant RollupJS_API
participant MySQL_DB
User->>Frontend: 点击 "Build" 按钮
Frontend->>Backend: POST /api/build/{componentId}
Backend->>MySQL_DB: INSERT INTO build_tasks (status='PENDING')
MySQL_DB-->>Backend: 返回 taskId
Backend-->>Frontend: 202 Accepted { taskId }
Note right of Backend: 异步执行构建任务
Backend->>MySQL_DB: SELECT * FROM components WHERE id={componentId}
MySQL_DB-->>Backend: 返回组件元数据
Backend->>Backend: 根据元数据动态生成Rollup配置
Backend->>RollupJS_API: rollup.rollup(config)
activate RollupJS_API
Backend->>MySQL_DB: UPDATE build_tasks SET status='RUNNING'
RollupJS_API-->>Backend: (构建过程...)
deactivate RollupJS_API
alt 构建成功
Backend->>Backend: 保存产物到静态目录
Backend->>MySQL_DB: UPDATE build_tasks SET status='SUCCESS', artifact_path='...'
else 构建失败
Backend->>MySQL_DB: UPDATE build_tasks SET status='FAILED', build_log='...'
end
loop 轮询任务状态
Frontend->>Backend: GET /api/tasks/{taskId}
Backend->>MySQL_DB: SELECT * FROM build_tasks WHERE id={taskId}
MySQL_DB-->>Backend: 返回任务状态
Backend-->>Frontend: { taskStatus }
end
Note over Frontend: 任务完成后停止轮询,更新UI
User->>Frontend: 查看构建日志或预览产物链接
这个方案虽然只是一个原型,但它验证了元数据驱动动态构建的可行性。当前实现存在一些显而易见的局限性。例如,构建任务的执行是单线程的,高并发请求会导致任务排队;服务重启会中断正在进行的构建;简单的轮询机制在规模扩大时会给服务器带来压力。
未来的迭代路径很清晰:首先,引入一个可靠的消息队列(如RabbitMQ)来解耦任务的接收和执行,创建独立的Worker进程池来消费队列中的构建任务,从而实现真正的并行构建和更好的容错性。其次,可以引入更智能的缓存策略,对已经构建过的、源码未发生变化的组件直接返回缓存产物。最后,可以扩展MySQL中的元数据,实现对组件间依赖关系的解析,当一个底层组件被构建时,可以自动触发所有依赖它的上层组件的重新构建。