Skip to content

组件库中的前端工程化实践

前端工程化是现代组件库开发的核心。本文将深入探讨 Ghuo Design 在工程化方面的实践经验,包括项目架构、构建流程、质量保证等方面。

项目架构设计

Monorepo 架构

采用 Monorepo 架构管理多个相关包:

ghuo-design/
├── packages/
│   ├── components/          # 组件包
│   ├── icons/              # 图标包
│   ├── theme/              # 主题包
│   ├── utils/              # 工具包
│   └── playground/         # 开发环境
├── docs/                   # 文档站点
├── scripts/                # 构建脚本
└── tools/                  # 开发工具

包管理策略

使用 pnpm workspace 管理依赖:

json
{
  "name": "ghuo-design",
  "private": true,
  "workspaces": [
    "packages/*",
    "docs"
  ],
  "devDependencies": {
    "@changesets/cli": "^2.26.0",
    "turbo": "^1.8.0"
  }
}

构建系统

多格式输出

支持多种模块格式以适应不同使用场景:

javascript
// rollup.config.js
export default {
  input: 'src/index.ts',
  output: [
    {
      file: 'dist/index.esm.js',
      format: 'esm'
    },
    {
      file: 'dist/index.cjs.js',
      format: 'cjs'
    },
    {
      file: 'dist/index.umd.js',
      format: 'umd',
      name: 'GhuoDesign'
    }
  ],
  plugins: [
    typescript(),
    vue(),
    postcss()
  ]
}

按需加载支持

生成支持按需加载的目录结构:

dist/
├── es/                     # ES modules
│   ├── button/
│   │   ├── index.js
│   │   └── style/
│   └── input/
├── lib/                    # CommonJS
└── umd/                    # UMD

样式处理

javascript
// 样式构建配置
const styleConfig = {
  plugins: [
    postcssImport(),
    postcssNested(),
    autoprefixer(),
    cssnano({
      preset: 'default'
    })
  ]
}

// 生成组件样式
const buildComponentStyles = async () => {
  const components = await glob('src/components/*/index.scss')
  
  for (const component of components) {
    const result = await postcss(styleConfig.plugins)
      .process(fs.readFileSync(component), { from: component })
    
    const outputPath = component.replace('src/', 'dist/es/')
      .replace('.scss', '.css')
    
    await fs.ensureDir(path.dirname(outputPath))
    await fs.writeFile(outputPath, result.css)
  }
}

开发环境

热重载开发

javascript
// vite.config.js
export default defineConfig({
  plugins: [
    vue(),
    dts({
      include: ['src/**/*'],
      exclude: ['src/**/*.stories.ts', 'src/**/*.test.ts']
    })
  ],
  build: {
    lib: {
      entry: 'src/index.ts',
      name: 'GhuoDesign',
      formats: ['es', 'cjs', 'umd']
    },
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

组件开发工具

vue
<!-- playground/App.vue -->
<template>
  <div class="playground">
    <g-config-provider :theme="currentTheme">
      <component 
        :is="currentComponent" 
        v-bind="componentProps"
        @change="handleChange"
      />
    </g-config-provider>
    
    <div class="controls">
      <g-select v-model="selectedComponent">
        <g-option 
          v-for="comp in components" 
          :key="comp.name"
          :value="comp.name"
        >
          {{ comp.label }}
        </g-option>
      </g-select>
      
      <property-panel 
        :component="currentComponent"
        v-model="componentProps"
      />
    </div>
  </div>
</template>

质量保证

TypeScript 集成

严格的类型定义:

typescript
// types/button.ts
export interface ButtonProps {
  /** 按钮类型 */
  type?: 'default' | 'primary' | 'dashed' | 'text' | 'link'
  /** 按钮大小 */
  size?: 'large' | 'middle' | 'small'
  /** 是否禁用 */
  disabled?: boolean
  /** 是否加载中 */
  loading?: boolean
  /** 点击事件 */
  onClick?: (event: MouseEvent) => void
}

export interface ButtonEmits {
  (e: 'click', event: MouseEvent): void
}

export interface ButtonSlots {
  default(): VNode[]
  icon(): VNode[]
}

单元测试

typescript
// __tests__/button.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Button from '../src/button.vue'

describe('Button', () => {
  it('renders correctly', () => {
    const wrapper = mount(Button, {
      props: { type: 'primary' },
      slots: { default: 'Click me' }
    })
    
    expect(wrapper.classes()).toContain('g-btn-primary')
    expect(wrapper.text()).toBe('Click me')
  })
  
  it('emits click event', async () => {
    const wrapper = mount(Button)
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toHaveLength(1)
  })
  
  it('disabled state', async () => {
    const wrapper = mount(Button, {
      props: { disabled: true }
    })
    
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeUndefined()
  })
})

E2E 测试

typescript
// e2e/button.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Button Component', () => {
  test('basic functionality', async ({ page }) => {
    await page.goto('/components/button')
    
    const button = page.locator('[data-testid="primary-button"]')
    await expect(button).toBeVisible()
    
    await button.click()
    await expect(page.locator('.message')).toContainText('Button clicked')
  })
  
  test('loading state', async ({ page }) => {
    await page.goto('/components/button')
    
    const loadingButton = page.locator('[data-testid="loading-button"]')
    await loadingButton.click()
    
    await expect(loadingButton).toHaveClass(/loading/)
    await expect(loadingButton.locator('.loading-icon')).toBeVisible()
  })
})

视觉回归测试

typescript
// visual/button.visual.ts
import { test } from '@playwright/test'

test.describe('Button Visual Tests', () => {
  test('button variants', async ({ page }) => {
    await page.goto('/visual-tests/button')
    
    // 截图对比
    await expect(page).toHaveScreenshot('button-variants.png')
  })
  
  test('button states', async ({ page }) => {
    await page.goto('/visual-tests/button-states')
    
    // 悬停状态
    await page.hover('[data-testid="hover-button"]')
    await expect(page).toHaveScreenshot('button-hover.png')
    
    // 激活状态
    await page.click('[data-testid="active-button"]')
    await expect(page).toHaveScreenshot('button-active.png')
  })
})

自动化流程

CI/CD 配置

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'pnpm'
      
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      
      - name: Type check
        run: pnpm run type-check
      
      - name: Lint
        run: pnpm run lint
      
      - name: Unit tests
        run: pnpm run test:unit
      
      - name: E2E tests
        run: pnpm run test:e2e
      
      - name: Build
        run: pnpm run build
      
      - name: Visual tests
        run: pnpm run test:visual

发布自动化

yaml
# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'pnpm'
      
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      
      - name: Build packages
        run: pnpm run build
      
      - name: Create Release Pull Request or Publish
        uses: changesets/action@v1
        with:
          publish: pnpm run release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

版本管理

使用 Changesets 管理版本:

markdown
<!-- .changeset/new-feature.md -->
---
"@ghuo-design/components": minor
---

Add new DatePicker component with enhanced accessibility features

代码质量

ESLint 配置

javascript
// .eslintrc.js
module.exports = {
  extends: [
    '@vue/typescript/recommended',
    'plugin:vue/vue3-recommended'
  ],
  rules: {
    // Vue 相关
    'vue/component-name-in-template-casing': ['error', 'kebab-case'],
    'vue/component-definition-name-casing': ['error', 'PascalCase'],
    
    // TypeScript 相关
    '@typescript-eslint/explicit-function-return-type': 'error',
    '@typescript-eslint/no-explicit-any': 'error',
    
    // 通用规则
    'prefer-const': 'error',
    'no-var': 'error'
  }
}

Prettier 配置

json
{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "es5",
  "tabWidth": 2,
  "printWidth": 80,
  "vueIndentScriptAndStyle": true
}

Husky 钩子

json
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss,less}": [
      "stylelint --fix",
      "prettier --write"
    ]
  }
}

性能优化

Bundle 分析

javascript
// scripts/analyze-bundle.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

const analyzeBundle = () => {
  return new BundleAnalyzerPlugin({
    analyzerMode: 'static',
    openAnalyzer: false,
    reportFilename: 'bundle-report.html'
  })
}

// 分析各组件大小
const analyzeComponentSizes = async () => {
  const components = await glob('dist/es/*/index.js')
  const sizes = []
  
  for (const component of components) {
    const stats = await fs.stat(component)
    const gzipSize = await getGzipSize(component)
    
    sizes.push({
      name: path.basename(path.dirname(component)),
      size: stats.size,
      gzipSize
    })
  }
  
  console.table(sizes.sort((a, b) => b.gzipSize - a.gzipSize))
}

Tree Shaking 优化

javascript
// 确保良好的 Tree Shaking
export { default as Button } from './button'
export { default as Input } from './input'
export { default as Select } from './select'

// 避免副作用
export const version = '2.0.0'
export * from './types'

文档自动化

API 文档生成

typescript
// scripts/generate-api-docs.ts
import { Project } from 'ts-morph'

const generateApiDocs = () => {
  const project = new Project({
    tsConfigFilePath: 'tsconfig.json'
  })
  
  const sourceFiles = project.getSourceFiles('src/components/**/*.vue')
  
  sourceFiles.forEach(sourceFile => {
    const component = sourceFile.getBaseName().replace('.vue', '')
    const props = extractProps(sourceFile)
    const events = extractEvents(sourceFile)
    const slots = extractSlots(sourceFile)
    
    generateMarkdown(component, { props, events, slots })
  })
}

const extractProps = (sourceFile: SourceFile) => {
  // 解析 defineProps 获取属性定义
  const propsInterface = sourceFile.getInterface('Props')
  return propsInterface?.getProperties().map(prop => ({
    name: prop.getName(),
    type: prop.getType().getText(),
    description: prop.getJsDocs()[0]?.getDescription()
  }))
}

变更日志生成

javascript
// scripts/generate-changelog.js
const generateChangelog = async () => {
  const commits = await getCommitsSinceLastTag()
  const changes = {
    features: [],
    fixes: [],
    breaking: []
  }
  
  commits.forEach(commit => {
    if (commit.message.startsWith('feat:')) {
      changes.features.push(commit)
    } else if (commit.message.startsWith('fix:')) {
      changes.fixes.push(commit)
    } else if (commit.message.includes('BREAKING CHANGE')) {
      changes.breaking.push(commit)
    }
  })
  
  const changelog = generateMarkdown(changes)
  await fs.writeFile('CHANGELOG.md', changelog)
}

监控和分析

使用统计

javascript
// 收集组件使用统计
const trackComponentUsage = () => {
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('event', 'component_usage', {
      component_name: 'Button',
      component_version: '2.0.0'
    })
  }
}

错误监控

javascript
// 错误边界组件
export const ErrorBoundary = defineComponent({
  name: 'ErrorBoundary',
  setup(_, { slots }) {
    const error = ref(null)
    
    onErrorCaptured((err, instance, info) => {
      error.value = err
      
      // 上报错误
      if (typeof window !== 'undefined' && window.Sentry) {
        window.Sentry.captureException(err, {
          contexts: {
            vue: {
              componentName: instance?.$options.name,
              errorInfo: info
            }
          }
        })
      }
      
      return false
    })
    
    return () => {
      if (error.value) {
        return h('div', { class: 'error-fallback' }, [
          h('h2', '组件渲染出错'),
          h('details', [
            h('summary', '错误详情'),
            h('pre', error.value.stack)
          ])
        ])
      }
      
      return slots.default?.()
    }
  }
})

最佳实践总结

架构设计

  1. 模块化 - 合理拆分功能模块
  2. 可扩展 - 预留扩展接口
  3. 标准化 - 统一代码规范
  4. 文档化 - 完善的文档体系

开发流程

  1. 自动化优先 - 尽可能自动化重复工作
  2. 质量门禁 - 严格的代码质量检查
  3. 持续集成 - 快速反馈和部署
  4. 监控告警 - 及时发现和解决问题

团队协作

  1. 规范统一 - 统一的开发规范
  2. 工具支持 - 完善的开发工具链
  3. 知识共享 - 定期的技术分享
  4. 代码审查 - 严格的代码审查流程

结语

前端工程化是一个持续演进的过程。通过合理的架构设计、完善的工具链和严格的质量保证,我们可以构建出高质量、可维护的组件库。

记住,工程化的目标是提高开发效率和代码质量,而不是为了工程化而工程化。选择合适的工具和流程,才能真正发挥工程化的价值。

Released under the MIT License.