利用Rollup与MySQL元数据构建一个支持动态预览的Ant Design组件库打包服务


当团队维护的组件库膨胀到数百个单元时,传统的整体构建模式开始暴露出严重的效率问题。任何一个细微的组件修改,都需要触发整个库的重新构建与发布,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的TableButtonTagModal组件来展示信息和发起操作。

// 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中的元数据,实现对组件间依赖关系的解析,当一个底层组件被构建时,可以自动触发所有依赖它的上层组件的重新构建。


  目录