组件库中的前端工程化实践
前端工程化是现代组件库开发的核心。本文将深入探讨 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?.()
}
}
})最佳实践总结
架构设计
- 模块化 - 合理拆分功能模块
- 可扩展 - 预留扩展接口
- 标准化 - 统一代码规范
- 文档化 - 完善的文档体系
开发流程
- 自动化优先 - 尽可能自动化重复工作
- 质量门禁 - 严格的代码质量检查
- 持续集成 - 快速反馈和部署
- 监控告警 - 及时发现和解决问题
团队协作
- 规范统一 - 统一的开发规范
- 工具支持 - 完善的开发工具链
- 知识共享 - 定期的技术分享
- 代码审查 - 严格的代码审查流程
结语
前端工程化是一个持续演进的过程。通过合理的架构设计、完善的工具链和严格的质量保证,我们可以构建出高质量、可维护的组件库。
记住,工程化的目标是提高开发效率和代码质量,而不是为了工程化而工程化。选择合适的工具和流程,才能真正发挥工程化的价值。