引言:企业级前端工程化的必要性
在企业级项目开发中,单纯的技术实现已经无法满足复杂的业务需求。我们需要建立完整的工程化体系来保证项目的可维护性、可扩展性和团队协作效率。
本文将以 Vue 3 为核心,构建一套完整的企业级工程化解决方案,从代码组织到部署上线的全流程实践。
1. Monorepo 架构设计
1.1 项目结构规划
bash
enterprise-vue-monorepo/
├── apps/ # 应用程序
│ ├── admin-dashboard/ # 管理后台
│ ├── customer-portal/ # 客户门户
│ ├── mobile-app/ # 移动端应用
│ └── marketing-site/ # 营销网站
├── packages/ # 共享包
│ ├── ui-components/ # UI组件库
│ ├── shared-utils/ # 工具函数库
│ ├── api-client/ # API客户端
│ ├── design-tokens/ # 设计令牌
│ └── eslint-config/ # ESLint配置
├── tools/ # 开发工具
│ ├── build-scripts/ # 构建脚本
│ ├── testing-utils/ # 测试工具
│ └── dev-server/ # 开发服务器
├── docs/ # 文档
│ ├── component-docs/ # 组件文档
│ ├── api-docs/ # API文档
│ └── design-system/ # 设计系统
├── .github/ # GitHub配置
│ ├── workflows/ # CI/CD工作流
│ └── templates/ # Issue/PR模板
├── scripts/ # 项目脚本
├── package.json # 根包配置
├── pnpm-workspace.yaml # PNPM工作空间配置
├── turbo.json # Turborepo配置
└── nx.json # Nx配置
1.2 Workspace 配置
yaml
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 'tools/*'
- 'docs/*'
json
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "storybook-static/**"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.{ts,tsx,vue}", "tests/**/*.{ts,tsx,vue}"]
},
"lint": {
"inputs": ["src/**/*.{ts,tsx,vue}", "*.{js,ts,json}"]
},
"type-check": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.{ts,tsx,vue}", "*.{ts,json}"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
},
"globalDependencies": [
"package.json",
"pnpm-lock.yaml",
"turbo.json",
".env",
".env.local"
]
}
1.3 根目录配置
json
// package.json
{
"name": "enterprise-vue-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"build:apps": "turbo run build --filter='./apps/*'",
"build:packages": "turbo run build --filter='./packages/*'",
"dev": "turbo run dev",
"dev:admin": "turbo run dev --filter=admin-dashboard",
"dev:docs": "turbo run dev --filter=component-docs",
"test": "turbo run test",
"test:unit": "turbo run test:unit",
"test:e2e": "turbo run test:e2e",
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"type-check": "turbo run type-check",
"clean": "turbo run clean && rm -rf node_modules",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "changeset publish",
"prepare": "husky install"
},
"devDependencies": {
"@changesets/cli": "^2.26.0",
"@commitlint/cli": "^17.4.4",
"@commitlint/config-conventional": "^17.4.4",
"husky": "^8.0.3",
"lint-staged": "^13.1.2",
"turbo": "^1.8.3",
"typescript": "^4.9.5"
},
"packageManager": "pnpm@8.0.0"
}
2. 组件库开发体系
2.1 设计系统基础
typescript
// packages/design-tokens/src/index.ts
export const designTokens = {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a'
},
semantic: {
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6'
},
neutral: {
0: '#ffffff',
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
1000: '#000000'
}
},
typography: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace']
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }]
},
fontWeight: {
normal: '400',
medium: '500',
semibold: '600',
bold: '700'
}
},
spacing: {
0: '0',
1: '0.25rem',
2: '0.5rem',
3: '0.75rem',
4: '1rem',
6: '1.5rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '5rem',
24: '6rem'
},
borderRadius: {
none: '0',
sm: '0.125rem',
base: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
full: '9999px'
},
shadows: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
base: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)'
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px'
},
zIndex: {
hide: -1,
auto: 'auto',
base: 0,
docked: 10,
dropdown: 1000,
sticky: 1100,
banner: 1200,
overlay: 1300,
modal: 1400,
popover: 1500,
skipLink: 1600,
toast: 1700,
tooltip: 1800
}
} as const
export type DesignTokens = typeof designTokens
export type ColorPalette = keyof typeof designTokens.colors
export type ColorShade = keyof typeof designTokens.colors.primary
2.2 组件库架构
typescript
// packages/ui-components/src/components/Button/Button.vue
<script setup lang="ts">
import { computed, type PropType } from 'vue'
import { designTokens } from '@enterprise/design-tokens'
import type { ComponentSize, ComponentVariant } from '../../types'
interface ButtonProps {
variant?: ComponentVariant
size?: ComponentSize
disabled?: boolean
loading?: boolean
block?: boolean
icon?: string
iconPosition?: 'left' | 'right'
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'medium',
disabled: false,
loading: false,
block: false,
iconPosition: 'left',
type: 'button'
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
// 计算样式类
const buttonClasses = computed(() => [
'btn',
`btn--${props.variant}`,
`btn--${props.size}`,
{
'btn--disabled': props.disabled,
'btn--loading': props.loading,
'btn--block': props.block,
'btn--icon-only': !$slots.default && props.icon
}
])
const handleClick = (event: MouseEvent) => {
if (props.disabled || props.loading) return
emit('click', event)
}
</script>
<template>
<button
:class="buttonClasses"
:type="type"
:disabled="disabled || loading"
@click="handleClick"
>
<!-- 加载状态 -->
<span v-if="loading" class="btn__spinner">
<svg class="animate-spin" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
fill="none"
stroke-dasharray="32"
stroke-dashoffset="32"
/>
</svg>
</span>
<!-- 左侧图标 -->
<span
v-if="icon && iconPosition === 'left' && !loading"
class="btn__icon btn__icon--left"
>
<component :is="icon" />
</span>
<!-- 按钮内容 -->
<span v-if="$slots.default" class="btn__content">
<slot />
</span>
<!-- 右侧图标 -->
<span
v-if="icon && iconPosition === 'right' && !loading"
class="btn__icon btn__icon--right"
>
<component :is="icon" />
</span>
</button>
</template>
<style scoped>
.btn {
/* 基础样式 */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: v-bind('designTokens.borderRadius.md');
font-family: v-bind('designTokens.typography.fontFamily.sans');
font-weight: v-bind('designTokens.typography.fontWeight.medium');
line-height: 1;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
position: relative;
overflow: hidden;
}
.btn:focus-visible {
outline: 2px solid v-bind('designTokens.colors.primary[500]');
outline-offset: 2px;
}
/* 尺寸变体 */
.btn--small {
padding: v-bind('designTokens.spacing[2]') v-bind('designTokens.spacing[3]');
font-size: v-bind('designTokens.typography.fontSize.sm[0]');
line-height: v-bind('designTokens.typography.fontSize.sm[1].lineHeight');
}
.btn--medium {
padding: v-bind('designTokens.spacing[3]') v-bind('designTokens.spacing[4]');
font-size: v-bind('designTokens.typography.fontSize.base[0]');
line-height: v-bind('designTokens.typography.fontSize.base[1].lineHeight');
}
.btn--large {
padding: v-bind('designTokens.spacing[4]') v-bind('designTokens.spacing[6]');
font-size: v-bind('designTokens.typography.fontSize.lg[0]');
line-height: v-bind('designTokens.typography.fontSize.lg[1].lineHeight');
}
/* 颜色变体 */
.btn--primary {
background-color: v-bind('designTokens.colors.primary[600]');
color: white;
border-color: v-bind('designTokens.colors.primary[600]');
}
.btn--primary:hover:not(.btn--disabled) {
background-color: v-bind('designTokens.colors.primary[700]');
border-color: v-bind('designTokens.colors.primary[700]');
}
.btn--secondary {
background-color: v-bind('designTokens.colors.neutral[100]');
color: v-bind('designTokens.colors.neutral[900]');
border-color: v-bind('designTokens.colors.neutral[300]');
}
.btn--secondary:hover:not(.btn--disabled) {
background-color: v-bind('designTokens.colors.neutral[200]');
border-color: v-bind('designTokens.colors.neutral[400]');
}
.btn--outline {
background-color: transparent;
color: v-bind('designTokens.colors.primary[600]');
border-color: v-bind('designTokens.colors.primary[600]');
}
.btn--outline:hover:not(.btn--disabled) {
background-color: v-bind('designTokens.colors.primary[50]');
}
.btn--ghost {
background-color: transparent;
color: v-bind('designTokens.colors.primary[600]');
border-color: transparent;
}
.btn--ghost:hover:not(.btn--disabled) {
background-color: v-bind('designTokens.colors.primary[50]');
}
/* 状态 */
.btn--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--loading {
cursor: wait;
}
.btn--block {
width: 100%;
}
.btn--icon-only {
aspect-ratio: 1;
}
/* 子元素 */
.btn__spinner {
width: 1em;
height: 1em;
}
.btn__icon {
width: 1em;
height: 1em;
flex-shrink: 0;
}
.btn__content {
flex: 1;
}
/* 动画 */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
2.3 组件库构建配置
typescript
// packages/ui-components/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
dts({
include: ['src/**/*'],
exclude: ['src/**/*.stories.ts', 'src/**/*.test.ts'],
outDir: 'dist/types'
})
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'EnterpriseUIComponents',
fileName: (format) => `index.${format}.js`,
formats: ['es', 'cjs', 'umd']
},
rollupOptions: {
external: ['vue', '@enterprise/design-tokens'],
output: {
globals: {
vue: 'Vue',
'@enterprise/design-tokens': 'DesignTokens'
},
exports: 'named'
}
},
cssCodeSplit: true,
sourcemap: true
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
3. 代码质量保证体系
3.1 ESLint 配置包
typescript
// packages/eslint-config/index.js
module.exports = {
extends: [
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended'
],
rules: {
// Vue 规则
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-emits-declaration': ['error', 'type-based'],
'vue/define-macros-order': ['error', {
order: ['defineProps', 'defineEmits', 'defineExpose']
}],
'vue/define-props-declaration': ['error', 'type-based'],
'vue/html-button-has-type': 'error',
'vue/html-self-closing': ['error', {
html: { void: 'always', normal: 'always', component: 'always' },
svg: 'always',
math: 'always'
}],
'vue/no-root-v-if': 'error',
'vue/no-undef-components': 'error',
'vue/no-undef-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-useless-v-bind': 'error',
'vue/padding-line-between-blocks': 'error',
'vue/prefer-separate-static-class': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/require-macro-variable-name': 'error',
'vue/require-typed-ref': 'error',
'vue/v-for-delimiter-style': ['error', 'in'],
// TypeScript 规则
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/consistent-type-imports': ['error', {
prefer: 'type-imports',
disallowTypeAnnotations: false
}],
'@typescript-eslint/no-import-type-side-effects': 'error',
// 通用规则
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
'template-curly-spacing': 'error',
'arrow-spacing': 'error',
'comma-dangle': ['error', 'never'],
'comma-spacing': 'error',
'comma-style': 'error',
'curly': ['error', 'multi-line'],
'dot-location': ['error', 'property'],
'eol-last': 'error',
'func-call-spacing': 'error',
'key-spacing': 'error',
'keyword-spacing': 'error',
'lines-between-class-members': ['error', 'always', {
exceptAfterSingleLine: true
}],
'multiline-ternary': ['error', 'always-multiline'],
'no-console': 'warn',
'no-debugger': 'error',
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'no-trailing-spaces': 'error',
'object-curly-spacing': ['error', 'always'],
'padded-blocks': ['error', 'never'],
'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'never'],
'space-before-blocks': 'error',
'space-before-function-paren': ['error', {
anonymous: 'always',
named: 'never',
asyncArrow: 'always'
}],
'space-in-parens': 'error',
'space-infix-ops': 'error',
'space-unary-ops': 'error',
'spaced-comment': 'error'
},
overrides: [
{
files: ['**/*.test.{js,ts,vue}', '**/*.spec.{js,ts,vue}'],
env: {
jest: true,
'vitest-globals/env': true
},
extends: [
'plugin:testing-library/vue',
'plugin:vitest-globals/recommended'
],
rules: {
'@typescript-eslint/no-explicit-any': 'off'
}
},
{
files: ['**/*.stories.{js,ts}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'vue/one-component-per-file': 'off'
}
}
]
}
3.2 TypeScript 配置
json
// packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@enterprise/*": ["../../packages/*/src"]
},
"types": ["vite/client", "vitest/globals"]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
3.3 Git Hooks 配置
bash
#!/bin/sh
# .husky/pre-commit
. "$(dirname "$0")/_/husky.sh"
# 运行 lint-staged
npx lint-staged
# 类型检查
echo "🔍 Running type check..."
npm run type-check
# 运行测试
echo "🧪 Running tests..."
npm run test:affected
json
// .lintstagedrc.json
{
"*.{js,ts,vue}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,vue}": [
"stylelint --fix",
"prettier --write"
],
"*.{json,md,yaml,yml}": [
"prettier --write"
],
"package.json": [
"sort-package-json"
]
}
javascript
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat', // 新功能
'fix', // 修复
'docs', // 文档
'style', // 格式(不影响代码运行的变动)
'refactor', // 重构
'perf', // 性能优化
'test', // 增加测试
'chore', // 构建过程或辅助工具的变动
'revert', // 回滚
'build', // 构建系统或外部依赖项的更改
'ci' // CI 配置文件和脚本的更改
]
],
'subject-max-length': [2, 'always', 72],
'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']],
'header-max-length': [2, 'always', 100]
}
}
4. 测试策略与自动化
4.1 单元测试配置
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts,vue}'],
exclude: ['node_modules', 'dist', 'e2e'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{js,ts,vue}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.test.{js,ts,vue}',
'src/**/*.spec.{js,ts,vue}',
'src/**/*.stories.{js,ts}',
'src/main.ts'
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@enterprise': resolve(__dirname, '../../packages')
}
}
})
typescript
// tests/setup.ts
import { vi } from 'vitest'
import { config } from '@vue/test-utils'
// 全局模拟
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
// 全局组件注册
config.global.components = {
// 注册测试中需要的全局组件
}
// 全局插件
config.global.plugins = [
// 添加测试中需要的插件
]
// 模拟 CSS 模块
vi.mock('*.module.css', () => ({
default: new Proxy({}, {
get: (target, prop) => prop
})
}))
4.2 组件测试示例
typescript
// packages/ui-components/src/components/Button/Button.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button Component', () => {
it('renders properly', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click me'
}
})
expect(wrapper.text()).toContain('Click me')
expect(wrapper.classes()).toContain('btn')
expect(wrapper.classes()).toContain('btn--primary')
expect(wrapper.classes()).toContain('btn--medium')
})
it('handles click events', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: { onClick },
slots: { default: 'Click me' }
})
await wrapper.trigger('click')
expect(onClick).toHaveBeenCalledOnce()
})
it('prevents click when disabled', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: { disabled: true, onClick },
slots: { default: 'Click me' }
})
await wrapper.trigger('click')
expect(onClick).not.toHaveBeenCalled()
expect(wrapper.classes()).toContain('btn--disabled')
})
it('shows loading state', () => {
const wrapper = mount(Button, {
props: { loading: true },
slots: { default: 'Click me' }
})
expect(wrapper.classes()).toContain('btn--loading')
expect(wrapper.find('.btn__spinner').exists()).toBe(true)
})
it('applies correct variant classes', () => {
const variants = ['primary', 'secondary', 'outline', 'ghost'] as const
variants.forEach(variant => {
const wrapper = mount(Button, {
props: { variant },
slots: { default: 'Button' }
})
expect(wrapper.classes()).toContain(`btn--${variant}`)
})
})
it('applies correct size classes', () => {
const sizes = ['small', 'medium', 'large'] as const
sizes.forEach(size => {
const wrapper = mount(Button, {
props: { size },
slots: { default: 'Button' }
})
expect(wrapper.classes()).toContain(`btn--${size}`)
})
})
it('renders with icon', () => {
const wrapper = mount(Button, {
props: { icon: 'star-icon' },
slots: { default: 'Starred' }
})
expect(wrapper.find('.btn__icon').exists()).toBe(true)
})
it('has correct accessibility attributes', () => {
const wrapper = mount(Button, {
props: { type: 'submit', disabled: true },
slots: { default: 'Submit' }
})
expect(wrapper.attributes('type')).toBe('submit')
expect(wrapper.attributes('disabled')).toBeDefined()
})
})
4.3 E2E 测试配置
typescript
// e2e/playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] }
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
})
5. CI/CD 流水线
5.1 GitHub Actions 工作流
yaml
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# 代码质量检查
quality-check:
name: Code Quality Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm type-check
- name: Check formatting
run: pnpm prettier --check .
# 单元测试
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
# E2E 测试
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm playwright install --with-deps
- name: Build applications
run: pnpm build
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: playwright-report/
# 构建检查
build-check:
name: Build Check
runs-on: ubuntu-latest
strategy:
matrix:
app: [admin-dashboard, customer-portal, mobile-app]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build ${{ matrix.app }}
run: pnpm --filter ${{ matrix.app }} build
- name: Check bundle size
run: |
cd apps/${{ matrix.app }}
npx bundlesize
# 安全扫描
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
# 依赖审计
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Audit dependencies
run: pnpm audit
- name: Check for outdated dependencies
run: pnpm outdated
continue-on-error: true
5.2 部署流水线
yaml
# .github/workflows/deploy.yml
name: Deploy Pipeline
on:
push:
branches: [main]
tags: ['v*']
jobs:
# 构建和推送 Docker 镜像
build-and-push:
name: Build and Push Images
runs-on: ubuntu-latest
strategy:
matrix:
app: [admin-dashboard, customer-portal, mobile-app]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY_URL }}/enterprise/${{ matrix.app }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=sha-
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./apps/${{ matrix.app }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 部署到 Kubernetes
deploy-to-k8s:
name: Deploy to Kubernetes
runs-on: ubuntu-latest
needs: build-and-push
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v3
with:
version: 'v1.28.0'
- name: Configure kubectl
run: |
echo "${{ secrets.KUBECONFIG }}" | base64 -d > kubeconfig
export KUBECONFIG=kubeconfig
- name: Deploy to staging
run: |
export KUBECONFIG=kubeconfig
export IMAGE_TAG=sha-${{ github.sha }}
envsubst < k8s/staging/kustomization.yaml | kubectl apply -k -
- name: Wait for deployment
run: |
export KUBECONFIG=kubeconfig
kubectl rollout status deployment/admin-dashboard -n staging
kubectl rollout status deployment/customer-portal -n staging
- name: Run smoke tests
run: |
# 运行部署后的烟雾测试
pnpm test:smoke --env=staging
# 生产部署(需要手动批准)
deploy-to-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: deploy-to-k8s
if: startsWith(github.ref, 'refs/tags/v')
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to production
run: |
export KUBECONFIG=kubeconfig
export IMAGE_TAG=${{ github.ref_name }}
envsubst < k8s/production/kustomization.yaml | kubectl apply -k -
- name: Notify deployment
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
channel: '#deployments'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
6. 多环境管理
6.1 环境配置管理
typescript
// tools/config-manager/src/index.ts
import { z } from 'zod'
// 环境配置schema
const EnvironmentConfigSchema = z.object({
app: z.object({
name: z.string(),
version: z.string(),
environment: z.enum(['development', 'staging', 'production']),
debug: z.boolean(),
port: z.number().int().min(1000).max(65535)
}),
api: z.object({
baseUrl: z.string().url(),
timeout: z.number().int().positive(),
retries: z.number().int().min(0),
apiKey: z.string().optional()
}),
database: z.object({
host: z.string(),
port: z.number().int(),
username: z.string(),
password: z.string(),
database: z.string(),
ssl: z.boolean()
}),
redis: z.object({
host: z.string(),
port: z.number().int(),
password: z.string().optional(),
db: z.number().int().min(0)
}),
security: z.object({
jwtSecret: z.string().min(32),
corsOrigins: z.array(z.string()),
rateLimitWindowMs: z.number().int().positive(),
rateLimitMaxRequests: z.number().int().positive()
}),
monitoring: z.object({
enabled: z.boolean(),
sentryDsn: z.string().optional(),
logLevel: z.enum(['debug', 'info', 'warn', 'error']),
metricsEndpoint: z.string().url().optional()
}),
features: z.object({
enableNewDashboard: z.boolean(),
enableAdvancedAnalytics: z.boolean(),
enableBetaFeatures: z.boolean()
})
})
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>
class ConfigManager {
private config: EnvironmentConfig | null = null
// 加载配置
async loadConfig(environment: string): Promise<EnvironmentConfig> {
try {
// 从多个来源加载配置
const baseConfig = await this.loadFromFile(`config/base.json`)
const envConfig = await this.loadFromFile(`config/${environment}.json`)
const secretsConfig = await this.loadFromSecrets(environment)
const envVarsConfig = this.loadFromEnvVars()
// 合并配置(优先级:环境变量 > 密钥 > 环境配置 > 基础配置)
const mergedConfig = {
...baseConfig,
...envConfig,
...secretsConfig,
...envVarsConfig
}
// 验证配置
this.config = EnvironmentConfigSchema.parse(mergedConfig)
return this.config
} catch (error) {
throw new Error(`Failed to load configuration: ${error.message}`)
}
}
// 获取配置
getConfig(): EnvironmentConfig {
if (!this.config) {
throw new Error('Configuration not loaded. Call loadConfig() first.')
}
return this.config
}
// 从文件加载配置
private async loadFromFile(filePath: string): Promise<any> {
try {
const { readFile } = await import('fs/promises')
const content = await readFile(filePath, 'utf-8')
return JSON.parse(content)
} catch (error) {
console.warn(`Could not load config from ${filePath}:`, error.message)
return {}
}
}
// 从密钥管理系统加载配置
private async loadFromSecrets(environment: string): Promise<any> {
try {
// 这里可以集成 AWS Secrets Manager, Azure Key Vault 等
// 示例使用环境变量模拟
const secretsPrefix = `SECRETS_${environment.toUpperCase()}_`
const secrets: any = {}
Object.keys(process.env).forEach(key => {
if (key.startsWith(secretsPrefix)) {
const configKey = key.replace(secretsPrefix, '').toLowerCase()
secrets[configKey] = process.env[key]
}
})
return secrets
} catch (error) {
console.warn('Could not load secrets:', error.message)
return {}
}
}
// 从环境变量加载配置
private loadFromEnvVars(): any {
const envConfig: any = {}
// 映射环境变量到配置结构
const envMappings = {
'APP_NAME': 'app.name',
'APP_PORT': 'app.port',
'API_BASE_URL': 'api.baseUrl',
'API_TIMEOUT': 'api.timeout',
'DB_HOST': 'database.host',
'DB_PORT': 'database.port',
'DB_USERNAME': 'database.username',
'DB_PASSWORD': 'database.password',
'REDIS_HOST': 'redis.host',
'REDIS_PORT': 'redis.port',
'JWT_SECRET': 'security.jwtSecret',
'LOG_LEVEL': 'monitoring.logLevel',
'SENTRY_DSN': 'monitoring.sentryDsn'
}
Object.entries(envMappings).forEach(([envVar, configPath]) => {
const value = process.env[envVar]
if (value !== undefined) {
this.setNestedProperty(envConfig, configPath, this.parseValue(value))
}
})
return envConfig
}
// 设置嵌套属性
private setNestedProperty(obj: any, path: string, value: any): void {
const keys = path.split('.')
let current = obj
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (!(key in current)) {
current[key] = {}
}
current = current[key]
}
current[keys[keys.length - 1]] = value
}
// 解析值类型
private parseValue(value: string): any {
// 尝试解析为数字
if (/^\d+$/.test(value)) {
return parseInt(value, 10)
}
// 尝试解析为布尔值
if (value === 'true') return true
if (value === 'false') return false
// 尝试解析为JSON
if (value.startsWith('{') || value.startsWith('[')) {
try {
return JSON.parse(value)
} catch {
// 如果解析失败,返回原字符串
}
}
return value
}
}
export const configManager = new ConfigManager()
// Vue 插件
export function createConfigPlugin(environment: string) {
return {
async install(app: any) {
const config = await configManager.loadConfig(environment)
app.config.globalProperties.$config = config
app.provide('config', config)
}
}
}
6.2 Docker 多阶段构建
dockerfile
# apps/admin-dashboard/Dockerfile
# 基础镜像
FROM node:18-alpine as base
WORKDIR /app
RUN npm install -g pnpm
# 依赖安装阶段
FROM base as deps
COPY package.json pnpm-lock.yaml ./
COPY ../../packages/ui-components/package.json ./packages/ui-components/
COPY ../../packages/shared-utils/package.json ./packages/shared-utils/
RUN pnpm install --frozen-lockfile --prod
# 开发依赖安装阶段
FROM base as dev-deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# 构建阶段
FROM dev-deps as builder
COPY . .
COPY --from=deps /app/node_modules ./node_modules
# 设置构建参数
ARG BUILD_ENV=production
ARG API_BASE_URL
ARG SENTRY_DSN
ARG VERSION
ENV NODE_ENV=${BUILD_ENV}
ENV VITE_API_BASE_URL=${API_BASE_URL}
ENV VITE_SENTRY_DSN=${SENTRY_DSN}
ENV VITE_VERSION=${VERSION}
RUN pnpm build
# 生产运行时镜像
FROM nginx:alpine as runtime
# 安装运行时依赖
RUN apk add --no-cache \
curl \
jq \
&& rm -rf /var/cache/apk/*
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx.vh.default.conf /etc/nginx/conf.d/default.conf
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
# 暴露端口
EXPOSE 80
# 启动脚本
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
7. 监控与日志体系
7.1 应用监控配置
typescript
// packages/monitoring/src/index.ts
import * as Sentry from '@sentry/vue'
import { BrowserTracing } from '@sentry/tracing'
import type { App } from 'vue'
interface MonitoringConfig {
sentryDsn?: string
environment: string
release?: string
enablePerformanceMonitoring: boolean
enableUserFeedback: boolean
sampleRate: number
tracesSampleRate: number
}
class ApplicationMonitor {
private config: MonitoringConfig
private performanceObserver?: PerformanceObserver
constructor(config: MonitoringConfig) {
this.config = config
}
// 初始化监控
init(app: App) {
this.initSentry(app)
this.initPerformanceMonitoring()
this.initErrorBoundary(app)
this.initUserFeedback()
}
// 初始化 Sentry
private initSentry(app: App) {
if (!this.config.sentryDsn) return
Sentry.init({
app,
dsn: this.config.sentryDsn,
environment: this.config.environment,
release: this.config.release,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracingOrigins: ['localhost', /^\//]
})
],
sampleRate: this.config.sampleRate,
tracesSampleRate: this.config.tracesSampleRate,
beforeSend(event, hint) {
// 过滤敏感信息
if (event.exception) {
const error = hint.originalException
if (error?.message?.includes('password') ||
error?.message?.includes('token')) {
return null
}
}
return event
}
})
}
// 性能监控
private initPerformanceMonitoring() {
if (!this.config.enablePerformanceMonitoring) return
// Web Vitals 监控
this.observeWebVitals()
// 自定义性能指标
this.observeCustomMetrics()
// 资源加载监控
this.observeResourceLoading()
}
private observeWebVitals() {
// FCP (First Contentful Paint)
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.reportMetric('fcp', entry.startTime)
}
}
}).observe({ entryTypes: ['paint'] })
// LCP (Largest Contentful Paint)
new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
this.reportMetric('lcp', lastEntry.startTime)
}).observe({ entryTypes: ['largest-contentful-paint'] })
// FID (First Input Delay)
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const fid = entry.processingStart - entry.startTime
this.reportMetric('fid', fid)
}
}).observe({ entryTypes: ['first-input'] })
// CLS (Cumulative Layout Shift)
let clsValue = 0
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
}
this.reportMetric('cls', clsValue)
}).observe({ entryTypes: ['layout-shift'] })
}
private observeCustomMetrics() {
// 组件渲染时间
this.performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.startsWith('vue-component:')) {
this.reportMetric('component-render', entry.duration, {
component: entry.name.replace('vue-component:', '')
})
}
}
})
this.performanceObserver.observe({ entryTypes: ['measure'] })
}
private observeResourceLoading() {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const resource = entry as PerformanceResourceTiming
this.reportMetric('resource-load', resource.duration, {
name: resource.name,
type: this.getResourceType(resource.name),
size: resource.transferSize || 0
})
}
}).observe({ entryTypes: ['resource'] })
}
// 错误边界
private initErrorBoundary(app: App) {
app.config.errorHandler = (error, instance, info) => {
console.error('Vue Error:', error, info)
// 上报错误
Sentry.captureException(error, {
tags: {
component: instance?.$options.name || 'Unknown',
errorInfo: info
}
})
}
// 全局未捕获错误
window.addEventListener('error', (event) => {
this.reportError(event.error, {
filename: event.filename,
lineno: event.lineno,
colno: event.colno
})
})
// Promise 拒绝
window.addEventListener('unhandledrejection', (event) => {
this.reportError(event.reason, {
type: 'unhandled-promise-rejection'
})
})
}
// 用户反馈
private initUserFeedback() {
if (!this.config.enableUserFeedback) return
// 添加用户反馈按钮
const feedbackButton = document.createElement('button')
feedbackButton.innerHTML = '反馈'
feedbackButton.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
padding: 10px 15px;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
`
feedbackButton.addEventListener('click', () => {
Sentry.showReportDialog()
})
document.body.appendChild(feedbackButton)
}
// 上报指标
private reportMetric(name: string, value: number, tags: Record<string, any> = {}) {
// 发送到监控系统
if (window.gtag) {
window.gtag('event', name, {
value: Math.round(value),
...tags
})
}
// 发送到自定义端点
this.sendToMetricsEndpoint({
name,
value,
tags,
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
})
}
// 上报错误
private reportError(error: any, context: Record<string, any> = {}) {
Sentry.captureException(error, {
tags: context
})
}
private getResourceType(url: string): string {
if (url.includes('.js')) return 'script'
if (url.includes('.css')) return 'stylesheet'
if (url.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) return 'image'
if (url.includes('.woff')) return 'font'
return 'other'
}
private async sendToMetricsEndpoint(data: any) {
try {
await fetch('/api/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
} catch (error) {
console.warn('Failed to send metrics:', error)
}
}
}
// Vue 插件
export function createMonitoringPlugin(config: MonitoringConfig) {
return {
install(app: App) {
const monitor = new ApplicationMonitor(config)
monitor.init(app)
app.config.globalProperties.$monitor = monitor
app.provide('monitor', monitor)
}
}
}
总结
通过本文的企业级工程化实践,我们构建了一套完整的 Vue 3 开发体系:
🏗️ 核心架构
- Monorepo 管理 - 统一代码组织和依赖管理
- 组件库体系 - 设计系统驱动的可复用组件
- 配置管理 - 多环境配置和密钥管理
- 工程化工具 - 代码质量保证和自动化流程
🔧 开发体验
- 类型安全 - 完整的 TypeScript 支持
- 代码规范 - ESLint + Prettier + Commitlint
- 测试策略 - 单元测试 + 集成测试 + E2E测试
- 热重载 - 快速开发反馈循环
🚀 部署运维
- 容器化 - Docker 多阶段构建优化
- CI/CD - 自动化构建、测试、部署流水线
- 监控告警 - 完整的可观测性体系
- 安全扫描 - 依赖审计和漏洞检测
💡 最佳实践
- 渐进式架构 - 支持逐步迁移和扩展
- 团队协作 - 标准化的开发流程和规范
- 性能优化 - 构建优化和运行时监控
- 可维护性 - 清晰的代码组织和文档体系
这套企业级工程化解决方案不仅解决了大型项目的技术挑战,更为团队协作和项目长期维护奠定了坚实基础。通过标准化的工具链和流程,能够显著提升开发效率和代码质量,为企业数字化转型提供强有力的技术支撑。