Skip to content

组件库方案

从零构建企业级 React/Vue 组件库的完整指南

目录


架构设计

Monorepo 结构

packages/
├── components/           # 组件源码
│   ├── src/
│   │   ├── Button/
│   │   │   ├── Button.tsx
│   │   │   ├── Button.test.tsx
│   │   │   ├── Button.module.css
│   │   │   └── index.ts
│   │   ├── Input/
│   │   └── index.ts      # 统一导出
│   ├── package.json
│   └── tsconfig.json
├── icons/                # 图标库
├── hooks/                # 通用 Hooks
├── utils/                # 工具函数
└── docs/                 # 文档站点

组件结构规范

typescript
// Button/index.ts
export { Button } from './Button';
export type { ButtonProps } from './Button';

// Button/Button.tsx
import { forwardRef } from 'react';
import styles from './Button.module.css';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'outline';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', loading, children, className, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={`${styles.button} ${styles[variant]} ${styles[size]} ${className || ''}`}
        disabled={loading || props.disabled}
        {...props}
      >
        {loading && <span className={styles.spinner} />}
        {children}
      </button>
    );
  }
);

Button.displayName = 'Button';

构建配置

多格式输出

typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [
    react(),
    dts({ include: ['src'] })  // 生成 .d.ts
  ],
  build: {
    lib: {
      entry: 'src/index.ts',
      name: 'MyUI',
      formats: ['es', 'cjs', 'umd'],
      fileName: (format) => `my-ui.${format}.js`
    },
    rollupOptions: {
      external: ['react', 'react-dom'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM'
        }
      }
    }
  }
});

package.json 配置

json
{
  "name": "@myorg/ui",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/my-ui.cjs.js",
  "module": "./dist/my-ui.es.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/my-ui.es.js",
      "require": "./dist/my-ui.cjs.js",
      "types": "./dist/index.d.ts"
    },
    "./styles": "./dist/style.css"
  },
  "files": ["dist"],
  "sideEffects": ["**/*.css"],
  "peerDependencies": {
    "react": ">=17.0.0",
    "react-dom": ">=17.0.0"
  }
}

Tree-shaking 支持

typescript
// src/index.ts - 具名导出支持 Tree-shaking
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';

// ❌ 避免 barrel 文件中的副作用
// export * from './Button'; // 可能妨碍 Tree-shaking

样式方案

方案对比

方案优点缺点
CSS Modules作用域隔离、普通 CSS 语法需要配置、类名动态
CSS-in-JS类型安全、动态样式运行时开销、SSR 复杂
Tailwind原子化、按需生成类名冗长、预设约束
vanilla-extract零运行时、类型安全学习成本

CSS 变量主题

css
/* theme.css */
:root, [data-theme="light"] {
  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
  --radius-md: 8px;
  --spacing-md: 16px;
}

[data-theme="dark"] {
  --color-primary: #60a5fa;
  --color-primary-hover: #3b82f6;
  --color-bg: #0f172a;
  --color-text: #f1f5f9;
}
css
/* Button.module.css */
.button {
  background: var(--color-primary);
  color: white;
  padding: var(--spacing-md);
  border-radius: var(--radius-md);
}

.button:hover {
  background: var(--color-primary-hover);
}

文档系统

Storybook 配置

typescript
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y',        // 无障碍检查
    '@storybook/addon-interactions' // 交互测试
  ],
  framework: '@storybook/react-vite'
};

export default config;

Story 编写

tsx
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'outline']
    },
    size: {
      control: 'radio',
      options: ['sm', 'md', 'lg']
    }
  }
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    children: 'Primary Button',
    variant: 'primary'
  }
};

export const Loading: Story = {
  args: {
    children: 'Loading...',
    loading: true
  }
};

发布流程

Changesets 版本管理

bash
# 安装
pnpm add -D @changesets/cli

# 初始化
npx changeset init

# 添加变更记录
npx changeset

# 更新版本号
npx changeset version

# 发布
npx changeset publish

CHANGELOG 示例

markdown
# @myorg/ui

## 1.2.0

### Minor Changes

- 新增 `Tooltip` 组件
- `Button` 组件支持 `loading` 状态

### Patch Changes

- 修复 `Modal` 关闭动画问题

CI/CD 配置

yaml
# .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v3
        with:
          node-version: 18
          registry-url: 'https://registry.npmjs.org'
      
      - run: pnpm install
      - run: pnpm build
      - run: pnpm test
      
      - name: Create Release PR or Publish
        uses: changesets/action@v1
        with:
          publish: pnpm changeset publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

按需导入

Babel 插件方式

javascript
// babel.config.js
module.exports = {
  plugins: [
    ['import', {
      libraryName: '@myorg/ui',
      libraryDirectory: 'es',
      style: true  // 自动引入样式
    }]
  ]
};

现代方案:package.json exports

json
{
  "exports": {
    ".": "./dist/index.js",
    "./Button": {
      "import": "./dist/Button/index.js",
      "types": "./dist/Button/index.d.ts"
    },
    "./Input": {
      "import": "./dist/Input/index.js",
      "types": "./dist/Input/index.d.ts"
    }
  }
}
typescript
// 使用方直接具名导入即可 Tree-shaking
import { Button } from '@myorg/ui';

高频面试题

Q1: 组件库如何支持 Tree-shaking?

  1. ESM 格式输出:使用 "type": "module" 和 ES 模块语法
  2. 避免副作用package.json 声明 "sideEffects": false 或列出有副作用的文件
  3. 具名导出:避免 export default,使用 export { Component }
  4. 独立入口exports 字段提供组件级入口

Q2: 组件库样式方案如何选择?

场景推荐方案
需要主题定制CSS 变量 + CSS Modules
追求类型安全vanilla-extract
已有 Tailwind 生态Tailwind + cva
动态样式多CSS-in-JS (styled-components)

Q3: 如何设计组件 API?

  1. 受控/非受控统一:同时支持 valuedefaultValue
  2. 组合优于继承:使用 Compound Components 模式
  3. 转发 ref:始终使用 forwardRef
  4. 扩展原生属性:继承 HTMLAttributes
  5. 语义化 Propsvariant 优于 type

Q4: 组件库如何保证质量?

  1. 单元测试:React Testing Library
  2. 视觉回归:Chromatic + Storybook
  3. 无障碍检测:@storybook/addon-a11y
  4. 类型检查:TypeScript strict mode
  5. Lint 规则:ESLint + Prettier

工具推荐

类别工具
构建Vite / tsup / unbuild
Monorepopnpm workspace / Turborepo
文档Storybook / Docusaurus
版本Changesets
测试Vitest / Playwright
样式vanilla-extract / Tailwind

前端面试知识库