🌟 引言
现代前端开发已经远远超越了简单的HTML、CSS、JavaScript三件套。随着应用复杂度的增加,前端工程化成为了提高开发效率、保证代码质量、优化用户体验的关键。本文将带你深入了解前端工程化的完整体系,从项目搭建到生产部署,构建现代化的前端开发工作流。
🚀 现代前端工程化架构
1. 项目初始化与脚手架
自定义脚手架工具
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
| #!/usr/bin/env node
const { program } = require('commander'); const inquirer = require('inquirer'); const chalk = require('chalk'); const fs = require('fs-extra'); const path = require('path'); const { execSync } = require('child_process');
class ProjectGenerator { constructor() { this.templatePath = path.join(__dirname, 'templates'); this.questions = [ { type: 'input', name: 'projectName', message: '请输入项目名称:', validate: (input) => { if (!input) return '项目名称不能为空'; if (!/^[a-zA-Z0-9-_]+$/.test(input)) { return '项目名称只能包含字母、数字、横线和下划线'; } return true; } }, { type: 'list', name: 'framework', message: '选择前端框架:', choices: [ { name: 'React + TypeScript', value: 'react-ts' }, { name: 'Vue 3 + TypeScript', value: 'vue3-ts' }, { name: 'Vanilla TypeScript', value: 'vanilla-ts' } ] }, { type: 'list', name: 'buildTool', message: '选择构建工具:', choices: [ { name: 'Vite (推荐)', value: 'vite' }, { name: 'Webpack 5', value: 'webpack' } ] }, { type: 'checkbox', name: 'features', message: '选择需要的功能:', choices: [ { name: 'ESLint + Prettier', value: 'linting', checked: true }, { name: 'Husky + lint-staged', value: 'git-hooks', checked: true }, { name: 'Jest + Testing Library', value: 'testing', checked: true }, { name: 'Storybook', value: 'storybook' }, { name: 'PWA', value: 'pwa' }, { name: 'Docker', value: 'docker' }, { name: 'GitHub Actions', value: 'ci-cd', checked: true } ] }, { type: 'list', name: 'cssPreprocessor', message: '选择CSS预处理器:', choices: [ { name: 'Sass/SCSS', value: 'sass' }, { name: 'Less', value: 'less' }, { name: 'Stylus', value: 'stylus' }, { name: '原生CSS', value: 'css' } ] }, { type: 'confirm', name: 'installDeps', message: '是否立即安装依赖?', default: true } ]; }
async create() { console.log(chalk.blue.bold('\n🚀 欢迎使用前端项目脚手架\n'));
try { const answers = await inquirer.prompt(this.questions); await this.generateProject(answers); } catch (error) { console.error(chalk.red('创建项目失败:'), error.message); process.exit(1); } }
async generateProject(config) { const { projectName, framework, buildTool, features, cssPreprocessor, installDeps } = config; const targetPath = path.join(process.cwd(), projectName);
await page.goto('/admin/products'); });
test('should create new product', async ({ page }) => { await page.click('[data-testid="add-product-button"]'); await page.fill('[data-testid="product-name"]', 'Test Product'); await page.fill('[data-testid="product-price"]', '99.99'); await page.fill('[data-testid="product-description"]', 'This is a test product'); await page.click('[data-testid="save-product-button"]'); await expect(page.locator('[data-testid="success-message"]')).toBeVisible(); await expect(page.locator('text=Test Product')).toBeVisible(); });
test('should edit existing product', async ({ page }) => { await page.click('[data-testid="product-item"]:first-child [data-testid="edit-button"]'); await page.fill('[data-testid="product-name"]', 'Updated Product'); await page.click('[data-testid="save-product-button"]'); await expect(page.locator('text=Updated Product')).toBeVisible(); });
test('should delete product', async ({ page }) => { const productCount = await page.locator('[data-testid="product-item"]').count(); await page.click('[data-testid="product-item"]:first-child [data-testid="delete-button"]'); await page.click('[data-testid="confirm-delete"]'); await expect(page.locator('[data-testid="product-item"]')).toHaveCount(productCount - 1); }); });
test.describe('Cross Browser Tests', () => { ['chromium', 'firefox', 'webkit'].forEach(browserName => { test(`should work in ${browserName}`, async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toBeVisible(); }); }); });
test.describe('Mobile Tests', () => { test.use({ viewport: { width: 375, height: 667 }, isMobile: true, hasTouch: true });
test('should be responsive on mobile', async ({ page }) => { await page.goto('/'); await expect(page.locator('[data-testid="mobile-menu-button"]')).toBeVisible(); await page.click('[data-testid="mobile-menu-button"]'); await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible(); }); });
|
🚀 性能优化策略
1. 构建性能优化
Webpack优化配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
| const path = require('path'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const CompressionPlugin = require('compression-webpack-plugin');
class WebpackOptimizer { static getOptimizationConfig(isDev = false) { return { splitChunks: { chunks: 'all', minSize: 20000, maxSize: 244000, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', priority: 10, reuseExistingChunk: true }, react: { test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, name: 'react', chunks: 'all', priority: 20 }, ui: { test: /[\\/]node_modules[\\/](antd|@ant-design|element-ui)[\\/]/, name: 'ui', chunks: 'all', priority: 15 }, utils: { test: /[\\/]node_modules[\\/](lodash|moment|dayjs|axios)[\\/]/, name: 'utils', chunks: 'all', priority: 12 }, common: { name: 'common', minChunks: 2, chunks: 'all', priority: 5, reuseExistingChunk: true } } }, runtimeChunk: { name: 'runtime' }, minimize: !isDev, minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: !isDev, drop_debugger: !isDev, pure_funcs: isDev ? [] : ['console.log', 'console.info'] }, mangle: { safari10: true }, format: { comments: false } }, extractComments: false, parallel: true }), new CssMinimizerPlugin({ minimizerOptions: { preset: [ 'default', { discardComments: { removeAll: true }, normalizeWhitespace: true } ] } }) ] }; }
static getPerformanceConfig() { return { hints: 'warning', maxEntrypointSize: 512000, maxAssetSize: 512000, assetFilter: (assetFilename) => { return !assetFilename.endsWith('.map'); } }; }
static getPlugins(isDev = false, analyze = false) { const plugins = [];
if (!isDev) { plugins.push( new CompressionPlugin({ algorithm: 'gzip', test: /\.(js|css|html|svg)$/, threshold: 8192, minRatio: 0.8 }) );
plugins.push( new CompressionPlugin({ filename: '[path][base].br', algorithm: 'brotliCompress', test: /\.(js|css|html|svg)$/, compressionOptions: { level: 11 }, threshold: 8192, minRatio: 0.8 }) ); }
if (analyze) { plugins.push( new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false, reportFilename: 'bundle-report.html' }) ); }
return plugins; } }
module.exports = WebpackOptimizer;
|
Vite优化配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
| import { defineConfig } from 'vite'; import { visualizer } from 'rollup-plugin-visualizer'; import { createHtmlPlugin } from 'vite-plugin-html';
export class ViteOptimizer { static getBuildConfig(isDev = false) { return { target: 'es2015', outDir: 'dist', assetsDir: 'assets', sourcemap: isDev, minify: isDev ? false : 'terser', terserOptions: { compress: { drop_console: !isDev, drop_debugger: !isDev } }, rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom'], router: ['react-router-dom'], ui: ['antd', '@ant-design/icons'], utils: ['lodash-es', 'dayjs', 'axios'], charts: ['echarts', 'recharts'] }, chunkFileNames: (chunkInfo) => { const facadeModuleId = chunkInfo.facadeModuleId ? chunkInfo.facadeModuleId.split('/').pop() : 'chunk'; return `js/${facadeModuleId}-[hash].js`; }, assetFileNames: (assetInfo) => { const info = assetInfo.name.split('.'); const ext = info[info.length - 1]; if (/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/i.test(assetInfo.name)) { return `media/[name]-[hash].${ext}`; } if (/\.(png|jpe?g|gif|svg)(\?.*)?$/.test(assetInfo.name)) { return `images/[name]-[hash].${ext}`; } if (/\.(woff2?|eot|ttf|otf)(\?.*)?$/i.test(assetInfo.name)) { return `fonts/[name]-[hash].${ext}`; } return `assets/[name]-[hash].${ext}`; } } }, optimizeDeps: { include: [ 'react', 'react-dom', 'react-router-dom', 'antd', 'lodash-es', 'dayjs' ], exclude: ['@vite/client', '@vite/env'] } }; }
static getPlugins(isDev = false, analyze = false) { const plugins = [];
plugins.push( createHtmlPlugin({ minify: !isDev, inject: { data: { title: 'Frontend Engineering App', injectScript: isDev ? '' : ` <script> // 预加载关键资源 const preloadLinks = [ '/assets/main.css', '/assets/vendor.js' ]; preloadLinks.forEach(href => { const link = document.createElement('link'); link.rel = 'preload'; link.href = href; link.as = href.endsWith('.css') ? 'style' : 'script'; document.head.appendChild(link); }); </script> ` } } }) );
if (analyze) { plugins.push( visualizer({ filename: 'dist/stats.html', open: true, gzipSize: true, brotliSize: true }) ); }
return plugins; } }
|
2. 运行时性能优化
React性能优化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209
| import React, { memo, useMemo, useCallback, lazy, Suspense } from 'react'; import { debounce, throttle } from 'lodash-es';
const LazyComponent = lazy(() => import('./HeavyComponent'));
interface VirtualListProps { items: any[]; itemHeight: number; containerHeight: number; renderItem: (item: any, index: number) => React.ReactNode; }
export const VirtualList: React.FC<VirtualListProps> = memo(({ items, itemHeight, containerHeight, renderItem }) => { const [scrollTop, setScrollTop] = React.useState(0);
const visibleItems = useMemo(() => { const startIndex = Math.floor(scrollTop / itemHeight); const endIndex = Math.min( startIndex + Math.ceil(containerHeight / itemHeight) + 1, items.length );
return items.slice(startIndex, endIndex).map((item, index) => ({ item, index: startIndex + index })); }, [items, itemHeight, containerHeight, scrollTop]);
const handleScroll = useCallback( throttle((e: React.UIEvent<HTMLDivElement>) => { setScrollTop(e.currentTarget.scrollTop); }, 16), [] );
const totalHeight = items.length * itemHeight; const offsetY = Math.floor(scrollTop / itemHeight) * itemHeight;
return ( <div style={{ height: containerHeight, overflow: 'auto' }} onScroll={handleScroll} > <div style={{ height: totalHeight, position: 'relative' }}> <div style={{ transform: `translateY(${offsetY}px)` }}> {visibleItems.map(({ item, index }) => ( <div key={index} style={{ height: itemHeight }}> {renderItem(item, index)} </div> ))} </div> </div> </div> ); });
interface SearchInputProps { onSearch: (query: string) => void; delay?: number; placeholder?: string; }
export const SearchInput: React.FC<SearchInputProps> = memo(({ onSearch, delay = 300, placeholder = '请输入搜索关键词' }) => { const [value, setValue] = React.useState('');
const debouncedSearch = useMemo( () => debounce((query: string) => { onSearch(query); }, delay), [onSearch, delay] );
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const newValue = e.target.value; setValue(newValue); debouncedSearch(newValue); }, [debouncedSearch]);
React.useEffect(() => { return () => { debouncedSearch.cancel(); }; }, [debouncedSearch]);
return ( <input type="text" value={value} onChange={handleChange} placeholder={placeholder} className="search-input" /> ); });
interface LazyImageProps { src: string; alt: string; placeholder?: string; className?: string; }
export const LazyImage: React.FC<LazyImageProps> = memo(({ src, alt, placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtc2l6ZT0iMTgiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBmaWxsPSIjOTk5Ij5Mb2FkaW5nLi4uPC90ZXh0Pjwvc3ZnPg==', className }) => { const [isLoaded, setIsLoaded] = React.useState(false); const [isInView, setIsInView] = React.useState(false); const imgRef = React.useRef<HTMLImageElement>(null);
React.useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setIsInView(true); observer.disconnect(); } }, { threshold: 0.1 } );
if (imgRef.current) { observer.observe(imgRef.current); }
return () => observer.disconnect(); }, []);
const handleLoad = useCallback(() => { setIsLoaded(true); }, []);
return ( <img ref={imgRef} src={isInView ? src : placeholder} alt={alt} className={`${className} ${isLoaded ? 'loaded' : 'loading'}`} onLoad={handleLoad} loading="lazy" /> ); });
export const usePerformanceMonitor = () => { const [metrics, setMetrics] = React.useState({ renderTime: 0, componentCount: 0, memoryUsage: 0 });
React.useEffect(() => { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); entries.forEach((entry) => { if (entry.entryType === 'measure') { setMetrics(prev => ({ ...prev, renderTime: entry.duration })); } }); });
observer.observe({ entryTypes: ['measure'] });
const memoryInterval = setInterval(() => { if ('memory' in performance) { const memory = (performance as any).memory; setMetrics(prev => ({ ...prev, memoryUsage: memory.usedJSHeapSize / 1024 / 1024 })); } }, 5000);
return () => { observer.disconnect(); clearInterval(memoryInterval); }; }, []);
const measureRender = useCallback((name: string, fn: () => void) => { performance.mark(`${name}-start`); fn(); performance.mark(`${name}-end`); performance.measure(name, `${name}-start`, `${name}-end`); }, []);
return { metrics, measureRender }; };
|
🚀 部署策略与CI/CD
1. 多环境部署配置
Docker部署
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
|
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./ COPY yarn.lock ./
RUN yarn install --frozen-lockfile --production=false
COPY . .
RUN yarn build
FROM nginx:alpine AS production
RUN apk add --no-cache curl
COPY nginx.conf /etc/nginx/nginx.conf COPY default.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost/ || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| user nginx; worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid;
events { worker_connections 1024; use epoll; multi_accept on; }
http { include /etc/nginx/mime.types; default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 2048; client_max_body_size 16M;
gzip on; gzip_vary on; gzip_min_length 1024; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
include /etc/nginx/conf.d/*.conf; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html index.htm;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-XSS-Protection "1; mode=block" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; }
location ~* \.(woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public"; add_header Access-Control-Allow-Origin "*"; try_files $uri =404; }
location ~* \.html$ { expires -1; add_header Cache-Control "no-cache, no-store, must-revalidate"; try_files $uri $uri/ /index.html; }
location / { try_files $uri $uri/ /index.html; }
location /api/ { proxy_pass http://backend:3000/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; }
location /health { access_log off; return 200 "healthy\n"; add_header Content-Type text/plain; } }
|
GitHub Actions CI/CD
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
| name: Deploy Frontend Application
on: push: branches: [main, develop] pull_request: branches: [main]
env: NODE_VERSION: '18' REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}
jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4
- name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'yarn'
- name: Install dependencies run: yarn install --frozen-lockfile
- name: Run linting run: yarn lint
- name: Run type checking run: yarn type-check
- name: Run unit tests run: yarn test:coverage
- name: Upload coverage reports uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }}
e2e-test: runs-on: ubuntu-latest needs: test steps: - name: Checkout code uses: actions/checkout@v4
- name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'yarn'
- name: Install dependencies run: yarn install --frozen-lockfile
- name: Install Playwright run: npx playwright install --with-deps
- name: Build application run: yarn build
- name: Run E2E tests run: yarn test:e2e
- name: Upload test results uses: actions/upload-artifact@v3 if: always() with: name: playwright-report path: playwright-report/
build-and-push: runs-on: ubuntu-latest needs: [test, e2e-test] if: github.event_name == 'push' permissions: contents: read packages: write
steps: - name: Checkout code uses: actions/checkout@v4
- name: Setup Docker Buildx uses: docker/setup-buildx-action@v3
- name: Login to Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=sha,prefix={{branch}}- type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max
deploy-staging: runs-on: ubuntu-latest needs: build-and-push if: github.ref == 'refs/heads/develop' environment: staging steps: - name: Deploy to staging run: | echo "Deploying to staging environment..." # 这里添加部署到测试环境的脚本
deploy-production: runs-on: ubuntu-latest needs: build-and-push if: github.ref == 'refs/heads/main' environment: production steps: - name: Deploy to production run: | echo "Deploying to production environment..." # 这里添加部署到生产环境的脚本
lighthouse-audit: runs-on: ubuntu-latest needs: deploy-staging if: github.ref == 'refs/heads/develop' steps: - name: Checkout code uses: actions/checkout@v4
- name: Run Lighthouse CI uses: treosh/lighthouse-ci-action@v10 with: configPath: './lighthouserc.json' uploadArtifacts: true temporaryPublicStorage: true
|
2. 监控与告警
性能监控
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
| interface PerformanceMetrics { fcp: number; lcp: number; fid: number; cls: number; ttfb: number; }
class PerformanceMonitor { private metrics: Partial<PerformanceMetrics> = {}; private observers: PerformanceObserver[] = [];
constructor(private reportUrl: string) { this.initObservers(); this.setupErrorTracking(); this.trackUserInteractions(); }
private initObservers() { const paintObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.name === 'first-contentful-paint') { this.metrics.fcp = entry.startTime; } } }); paintObserver.observe({ entryTypes: ['paint'] }); this.observers.push(paintObserver);
const lcpObserver = new PerformanceObserver((list) => { const entries = list.getEntries(); const lastEntry = entries[entries.length - 1]; this.metrics.lcp = lastEntry.startTime; }); lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] }); this.observers.push(lcpObserver);
const fidObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { this.metrics.fid = entry.processingStart - entry.startTime; } }); fidObserver.observe({ entryTypes: ['first-input'] }); this.observers.push(fidObserver);
let clsValue = 0; const clsObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!(entry as any).hadRecentInput) { clsValue += (entry as any).value; } } this.metrics.cls = clsValue; }); clsObserver.observe({ entryTypes: ['layout-shift'] }); this.observers.push(clsObserver);
const navigationObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const navEntry = entry as PerformanceNavigationTiming; this.metrics.ttfb = navEntry.responseStart - navEntry.requestStart; } }); navigationObserver.observe({ entryTypes: ['navigation'] }); this.observers.push(navigationObserver); }
private setupErrorTracking() { window.addEventListener('error', (event) => { this.reportError({ type: 'javascript', message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error?.stack, timestamp: Date.now() }); });
window.addEventListener('unhandledrejection', (event) => { this.reportError({ type: 'promise', message: event.reason?.message || 'Unhandled Promise Rejection', stack: event.reason?.stack, timestamp: Date.now() }); });
window.addEventListener('error', (event) => { if (event.target !== window) { this.reportError({ type: 'resource', message: `Failed to load resource: ${(event.target as any)?.src || (event.target as any)?.href}`, element: (event.target as any)?.tagName, timestamp: Date.now() }); } }, true); }
private trackUserInteractions() { document.addEventListener('visibilitychange', () => { this.reportMetric('visibility', { hidden: document.hidden, timestamp: Date.now() }); });
let sessionStart = Date.now(); window.addEventListener('beforeunload', () => { this.reportMetric('session', { duration: Date.now() - sessionStart, timestamp: Date.now() }); }); }
private async reportError(error: any) { try { await fetch(`${this.reportUrl}/errors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...error, url: window.location.href, userAgent: navigator.userAgent, timestamp: Date.now() }) }); } catch (e) { console.error('Failed to report error:', e); } }
private async reportMetric(type: string, data: any) { try { await fetch(`${this.reportUrl}/metrics`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, data, url: window.location.href, timestamp: Date.now() }) }); } catch (e) { console.error('Failed to report metric:', e); } }
public getMetrics(): Partial<PerformanceMetrics> { return { ...this.metrics }; }
public async reportAllMetrics() { await this.reportMetric('performance', this.metrics); }
public destroy() { this.observers.forEach(observer => observer.disconnect()); this.observers = []; } }
const monitor = new PerformanceMonitor('https://api.example.com/monitoring');
window.addEventListener('load', () => { setTimeout(() => { monitor.reportAllMetrics(); }, 0); });
|
📊 总结与最佳实践
核心要点
工程化体系
- 统一的项目结构和代码规范
- 自动化的构建和部署流程
- 完善的测试和质量保证体系
性能优化
- 构建时优化:代码分割、压缩、缓存
- 运行时优化:懒加载、虚拟滚动、防抖节流
- 监控告警:性能指标、错误追踪、用户体验
开发效率
- 热更新和快速构建
- 智能的代码提示和错误检查
- 自动化的代码格式化和修复
团队协作
- 统一的开发环境和工具链
- 规范的Git工作流和代码审查
- 完善的文档和知识分享
未来趋势
- 微前端架构:模块化、独立部署、技术栈无关
- 边缘计算:CDN、边缘渲染、就近服务
- AI辅助开发:智能代码生成、自动化测试、性能优化建议
- Web Assembly:高性能计算、跨语言开发
- Serverless:函数即服务、按需计费、自动扩缩容
前端工程化是一个持续演进的过程,需要根据项目需求和团队情况选择合适的工具和方案。关键是要建立完善的开发工作流,提高开发效率和代码质量,为用户提供更好的产品体验。
本文涵盖了前端工程化的核心内容,从项目搭建到生产部署的完整流程。希望能帮助你构建高效、可维护的前端开发体系。 检查目录是否存在
if (await fs.pathExists(targetPath)) {
const { overwrite } = await inquirer.prompt([
{
type: ‘confirm’,
name: ‘overwrite’,
message: 目录 ${projectName} 已存在,是否覆盖?,
default: false
}
]);
if (!overwrite) {
console.log(chalk.yellow('已取消创建'));
return;
}
await fs.remove(targetPath);
}
console.log(chalk.green(`\n📁 创建项目目录: ${projectName}`));
await fs.ensureDir(targetPath);
// 生成基础文件结构
await this.generateBaseStructure(targetPath, config);
// 生成配置文件
await this.generateConfigFiles(targetPath, config);
// 生成源代码
await this.generateSourceCode(targetPath, config);
// 生成package.json
await this.generatePackageJson(targetPath, config);
// 安装依赖
if (installDeps) {
await this.installDependencies(targetPath);
}
// 初始化Git
await this.initGit(targetPath, features.includes('git-hooks'));
console.log(chalk.green.bold('\n✅ 项目创建成功!'));
console.log(chalk.cyan('\n📖 接下来的步骤:'));
console.log(chalk.white(` cd ${projectName}`));
if (!installDeps) {
console.log(chalk.white(' npm install'));
}
console.log(chalk.white(' npm run dev'));
console.log(chalk.white('\n🎉 开始你的前端之旅吧!\n'));
}
async generateBaseStructure(targetPath, config) {
const structure = {
'src': {
'components': {},
'utils': {},
'styles': {},
'assets': {
'images': {},
'icons': {}
},
'types': {},
'hooks': {},
'services': {},
'store': {},
'__tests__': {}
},
'public': {},
'docs': {},
'.vscode': {}
};
await this.createDirectoryStructure(targetPath, structure);
}
async createDirectoryStructure(basePath, structure) {
for (const [name, children] of Object.entries(structure)) {
const dirPath = path.join(basePath, name);
await fs.ensureDir(dirPath);
if (typeof children === 'object' && Object.keys(children).length > 0) {
await this.createDirectoryStructure(dirPath, children);
}
}
}
async generateConfigFiles(targetPath, config) {
const { buildTool, features, cssPreprocessor } = config;
// TypeScript配置
await this.generateTsConfig(targetPath);
// 构建工具配置
if (buildTool === 'vite') {
await this.generateViteConfig(targetPath, config);
} else {
await this.generateWebpackConfig(targetPath, config);
}
// ESLint配置
if (features.includes('linting')) {
await this.generateESLintConfig(targetPath, config);
await this.generatePrettierConfig(targetPath);
}
// Git hooks配置
if (features.includes('git-hooks')) {
await this.generateHuskyConfig(targetPath);
}
// 测试配置
if (features.includes('testing')) {
await this.generateJestConfig(targetPath, config);
}
// VS Code配置
await this.generateVSCodeConfig(targetPath);
// 环境变量配置
await this.generateEnvFiles(targetPath);
// Docker配置
if (features.includes('docker')) {
await this.generateDockerConfig(targetPath, config);
}
// CI/CD配置
if (features.includes('ci-cd')) {
await this.generateGitHubActions(targetPath, config);
}
}
async generateTsConfig(targetPath) {
const tsConfig = {
compilerOptions: {
target: "ES2020",
lib: ["DOM", "DOM.Iterable", "ES6"],
allowJs: true,
skipLibCheck: true,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
strict: true,
forceConsistentCasingInFileNames: true,
noFallthroughCasesInSwitch: true,
module: "ESNext",
moduleResolution: "node",
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
jsx: "react-jsx",
baseUrl: ".",
paths: {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/utils/*": ["src/utils/*"],
"@/types/*": ["src/types/*"],
"@/hooks/*": ["src/hooks/*"],
"@/services/*": ["src/services/*"],
"@/store/*": ["src/store/*"]
}
},
include: [
"src/**/*",
"src/**/*.tsx",
"src/**/*.ts"
],
exclude: [
"node_modules",
"dist",
"build"
]
};
await fs.writeJSON(path.join(targetPath, 'tsconfig.json'), tsConfig, { spaces: 2 });
}
async generateViteConfig(targetPath, config) {
const { framework, features } = config;
let viteConfig = `import { defineConfig } from 'vite'
import { resolve } from ‘path’
`;
if (framework === 'react-ts') {
viteConfig += `import react from '@vitejs/plugin-react'
; } else if (framework === 'vue3-ts') { viteConfig += import vue from ‘@vitejs/plugin-vue’
`;
}
if (features.includes('pwa')) {
viteConfig += `import { VitePWA } from 'vite-plugin-pwa'
`;
}
viteConfig += `
export default defineConfig({
plugins: [`;
if (framework === 'react-ts') {
viteConfig += `
react(),`;
} else if (framework === 'vue3-ts') {
viteConfig += `
vue(),`;
}
if (features.includes('pwa')) {
viteConfig += `
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
}
}),`;
}
viteConfig += `
],
resolve: {
alias: {
‘@’: resolve(__dirname, ‘src’),
},
},
server: {
port: 3000,
open: true,
cors: true,
},
build: {
target: ‘es2015’,
outDir: ‘dist’,
assetsDir: ‘assets’,
sourcemap: false,
rollupOptions: {
output: {
chunkFileNames: ‘js/[name]-[hash].js’,
entryFileNames: ‘js/[name]-[hash].js’,
assetFileNames: ‘[ext]/[name]-[hash].[ext]’,
manualChunks: {
vendor: [‘react’, ‘react-dom’],
utils: [‘lodash’, ‘axios’],
},
},
},
chunkSizeWarningLimit: 1000,
},
optimizeDeps: {
include: [‘react’, ‘react-dom’],
},
})
`;
await fs.writeFile(path.join(targetPath, 'vite.config.ts'), viteConfig);
}
async generateESLintConfig(targetPath, config) {
const { framework } = config;
const eslintConfig = {
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'@typescript-eslint/recommended'
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
rules: {
'indent': ['error', 2],
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': 'warn',
'no-debugger': 'error'
}
};
if (framework === 'react-ts') {
eslintConfig.extends.push('plugin:react/recommended', 'plugin:react-hooks/recommended');
eslintConfig.plugins.push('react', 'react-hooks');
eslintConfig.parserOptions.ecmaFeatures = { jsx: true };
eslintConfig.settings = { react: { version: 'detect' } };
} else if (framework === 'vue3-ts') {
eslintConfig.extends.push('plugin:vue/vue3-recommended');
eslintConfig.plugins.push('vue');
}
await fs.writeJSON(path.join(targetPath, '.eslintrc.json'), eslintConfig, { spaces: 2 });
// .eslintignore
const eslintIgnore = `node_modules/
dist/
build/
*.min.js
coverage/
.nyc_output/
`;
await fs.writeFile(path.join(targetPath, ‘.eslintignore’), eslintIgnore);
}
async generatePrettierConfig(targetPath) {
const prettierConfig = {
semi: true,
trailingComma: 'es5',
singleQuote: true,
printWidth: 100,
tabWidth: 2,
useTabs: false,
bracketSpacing: true,
arrowParens: 'avoid'
};
await fs.writeJSON(path.join(targetPath, '.prettierrc'), prettierConfig, { spaces: 2 });
// .prettierignore
const prettierIgnore = `node_modules/
dist/
build/
coverage/
*.min.js
*.min.css
`;
await fs.writeFile(path.join(targetPath, ‘.prettierignore’), prettierIgnore);
}
async generatePackageJson(targetPath, config) {
const { projectName, framework, buildTool, features, cssPreprocessor } = config;
const packageJson = {
name: projectName,
version: '1.0.0',
description: `A modern ${framework} project with ${buildTool}`,
main: 'index.js',
scripts: {
dev: buildTool === 'vite' ? 'vite' : 'webpack serve --mode development',
build: buildTool === 'vite' ? 'vite build' : 'webpack --mode production',
preview: buildTool === 'vite' ? 'vite preview' : 'serve -s dist',
type: 'tsc --noEmit'
},
keywords: [framework, buildTool, 'typescript', 'frontend'],
author: '',
license: 'MIT',
dependencies: {},
devDependencies: {
typescript: '^5.0.0',
'@types/node': '^20.0.0'
}
};
// 添加框架相关依赖
if (framework === 'react-ts') {
packageJson.dependencies = {
...packageJson.dependencies,
react: '^18.2.0',
'react-dom': '^18.2.0'
};
packageJson.devDependencies = {
...packageJson.devDependencies,
'@types/react': '^18.2.0',
'@types/react-dom': '^18.2.0'
};
} else if (framework === 'vue3-ts') {
packageJson.dependencies = {
...packageJson.dependencies,
vue: '^3.3.0'
};
}
// 添加构建工具依赖
if (buildTool === 'vite') {
packageJson.devDependencies.vite = '^4.4.0';
if (framework === 'react-ts') {
packageJson.devDependencies['@vitejs/plugin-react'] = '^4.0.0';
} else if (framework === 'vue3-ts') {
packageJson.devDependencies['@vitejs/plugin-vue'] = '^4.0.0';
}
}
// 添加代码质量工具
if (features.includes('linting')) {
packageJson.devDependencies = {
...packageJson.devDependencies,
eslint: '^8.45.0',
'@typescript-eslint/eslint-plugin': '^6.0.0',
'@typescript-eslint/parser': '^6.0.0',
prettier: '^3.0.0'
};
packageJson.scripts.lint = 'eslint src --ext .ts,.tsx --fix';
packageJson.scripts.format = 'prettier --write "src/**/*.{ts,tsx,css,scss,json}"';
}
// 添加Git hooks
if (features.includes('git-hooks')) {
packageJson.devDependencies = {
...packageJson.devDependencies,
husky: '^8.0.0',
'lint-staged': '^13.0.0'
};
packageJson.scripts.prepare = 'husky install';
packageJson['lint-staged'] = {
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
'*.{css,scss,json,md}': ['prettier --write']
};
}
// 添加测试工具
if (features.includes('testing')) {
packageJson.devDependencies = {
...packageJson.devDependencies,
jest: '^29.0.0',
'@testing-library/jest-dom': '^5.16.0',
'@types/jest': '^29.0.0'
};
if (framework === 'react-ts') {
packageJson.devDependencies['@testing-library/react'] = '^13.0.0';
packageJson.devDependencies['@testing-library/user-event'] = '^14.0.0';
}
packageJson.scripts.test = 'jest';
packageJson.scripts['test:watch'] = 'jest --watch';
packageJson.scripts['test:coverage'] = 'jest --coverage';
}
await fs.writeJSON(path.join(targetPath, 'package.json'), packageJson, { spaces: 2 });
}
async installDependencies(targetPath) {
console.log(chalk.yellow('\n📦 安装依赖中...'));
try {
execSync('npm install', {
cwd: targetPath,
stdio: 'inherit'
});
console.log(chalk.green('✅ 依赖安装完成'));
} catch (error) {
console.error(chalk.red('❌ 依赖安装失败:'), error.message);
}
}
async initGit(targetPath, useHooks) {
try {
execSync('git init', { cwd: targetPath, stdio: 'pipe' });
// 创建.gitignore
const gitignore = `# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Production builds
dist/
build/
Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
IDE
.vscode/
.idea/
*.swp
*.swo
OS
.DS_Store
Thumbs.db
Logs
logs/
*.log
Coverage
coverage/
.nyc_output/
Cache
.cache/
.parcel-cache/
`;
await fs.writeFile(path.join(targetPath, ‘.gitignore’), gitignore);
if (useHooks) {
execSync('npx husky install', { cwd: targetPath, stdio: 'pipe' });
execSync('npx husky add .husky/pre-commit "npx lint-staged"', {
cwd: targetPath,
stdio: 'pipe'
});
}
console.log(chalk.green('✅ Git仓库初始化完成'));
} catch (error) {
console.error(chalk.red('❌ Git初始化失败:'), error.message);
}
}
}
// CLI程序入口
program
.version(‘1.0.0’)
.description(‘现代前端项目脚手架’)
.action(async () => {
const generator = new ProjectGenerator();
await generator.create();
});
program.parse(process.argv);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291
| ### 2. 构建工具配置与优化
#### Webpack 5 高级配置
```javascript // webpack.config.js - 生产级Webpack配置 const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const CompressionPlugin = require('compression-webpack-plugin'); const { DefinePlugin } = require('webpack'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin');
const isDevelopment = process.env.NODE_ENV === 'development'; const isProduction = process.env.NODE_ENV === 'production'; const isAnalyze = process.env.ANALYZE === 'true';
module.exports = { mode: process.env.NODE_ENV || 'development', entry: { main: './src/index.tsx', vendor: ['react', 'react-dom'] }, output: { path: path.resolve(__dirname, 'dist'), filename: isProduction ? 'js/[name].[contenthash:8].js' : 'js/[name].js', chunkFilename: isProduction ? 'js/[name].[contenthash:8].chunk.js' : 'js/[name].chunk.js', assetModuleFilename: 'assets/[name].[contenthash:8][ext]', clean: true, publicPath: '/' }, resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js', '.json'], alias: { '@': path.resolve(__dirname, 'src'), '@components': path.resolve(__dirname, 'src/components'), '@utils': path.resolve(__dirname, 'src/utils'), '@types': path.resolve(__dirname, 'src/types'), '@hooks': path.resolve(__dirname, 'src/hooks'), '@services': path.resolve(__dirname, 'src/services'), '@store': path.resolve(__dirname, 'src/store') }, fallback: { "path": require.resolve("path-browserify"), "os": require.resolve("os-browserify/browser"), "crypto": require.resolve("crypto-browserify") } }, module: { rules: [ // TypeScript/JavaScript { test: /\.(ts|tsx|js|jsx)$/, exclude: /node_modules/, use: [ { loader: 'babel-loader', options: { presets: [ ['@babel/preset-env', { targets: { browsers: ['> 1%', 'last 2 versions'] }, useBuiltIns: 'usage', corejs: 3 }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript' ], plugins: [ '@babel/plugin-proposal-class-properties', '@babel/plugin-proposal-object-rest-spread', isDevelopment && 'react-refresh/babel' ].filter(Boolean), cacheDirectory: true } } ] }, // CSS/SCSS { test: /\.(css|scss|sass)$/, use: [ isProduction ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', options: { modules: { auto: true, localIdentName: isProduction ? '[hash:base64:8]' : '[path][name]__[local]--[hash:base64:5]' }, sourceMap: isDevelopment } }, { loader: 'postcss-loader', options: { postcssOptions: { plugins: [ 'autoprefixer', 'postcss-preset-env', isProduction && 'cssnano' ].filter(Boolean) } } }, 'sass-loader' ] }, // 图片资源 { test: /\.(png|jpe?g|gif|svg|webp)$/i, type: 'asset', parser: { dataUrlCondition: { maxSize: 8 * 1024 // 8KB } }, generator: { filename: 'images/[name].[contenthash:8][ext]' } }, // 字体文件 { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: 'asset/resource', generator: { filename: 'fonts/[name].[contenthash:8][ext]' } } ] }, plugins: [ new HtmlWebpackPlugin({ template: './public/index.html', filename: 'index.html', inject: true, minify: isProduction ? { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true } : false }), new DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 'process.env.REACT_APP_VERSION': JSON.stringify(process.env.npm_package_version), '__DEV__': isDevelopment }), new ForkTsCheckerWebpackPlugin({ typescript: { configFile: path.resolve(__dirname, 'tsconfig.json') } }), new ESLintPlugin({ extensions: ['js', 'jsx', 'ts', 'tsx'], exclude: 'node_modules', emitWarning: isDevelopment, emitError: isProduction, failOnError: isProduction }), isProduction && new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css', chunkFilename: 'css/[name].[contenthash:8].chunk.css' }), isProduction && new CompressionPlugin({ algorithm: 'gzip', test: /\.(js|css|html|svg)$/, threshold: 8192, minRatio: 0.8 }), isAnalyze && new BundleAnalyzerPlugin({ analyzerMode: 'server', openAnalyzer: true }) ].filter(Boolean), optimization: { minimize: isProduction, minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: isProduction, drop_debugger: isProduction }, format: { comments: false } }, extractComments: false }), new CssMinimizerPlugin() ], splitChunks: { chunks: 'all', cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', priority: 10 }, react: { test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, name: 'react', chunks: 'all', priority: 20 }, common: { name: 'common', minChunks: 2, chunks: 'all', priority: 5, reuseExistingChunk: true } } }, runtimeChunk: { name: 'runtime' } }, devServer: { port: 3000, hot: true, open: true, compress: true, historyApiFallback: true, static: { directory: path.join(__dirname, 'public') }, client: { overlay: { errors: true, warnings: false } } }, devtool: isDevelopment ? 'eval-source-map' : 'source-map', performance: { hints: isProduction ? 'warning' : false, maxEntrypointSize: 512000, maxAssetSize: 512000 }, stats: { colors: true, modules: false, children: false, chunks: false, chunkModules: false } };
|
Vite 高级配置与插件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
| import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import { resolve } from 'path'; import { visualizer } from 'rollup-plugin-visualizer'; import { VitePWA } from 'vite-plugin-pwa'; import legacy from '@vitejs/plugin-legacy'; import { createHtmlPlugin } from 'vite-plugin-html'; import eslint from 'vite-plugin-eslint'; import { chunkSplitPlugin } from 'vite-plugin-chunk-split';
export default defineConfig(({ command, mode }) => { const env = loadEnv(mode, process.cwd(), ''); const isProduction = mode === 'production'; const isDevelopment = mode === 'development';
return { plugins: [ react({ fastRefresh: isDevelopment, babel: { plugins: [ isDevelopment && 'react-refresh/babel' ].filter(Boolean) } }),
eslint({ include: ['src/**/*.ts', 'src/**/*.tsx'], exclude: ['node_modules', 'dist'], cache: false }),
createHtmlPlugin({ inject: { data: { title: env.VITE_APP_TITLE || 'React App', description: env.VITE_APP_DESCRIPTION || 'A modern React application' } }, minify: isProduction }),
chunkSplitPlugin({ strategy: 'split-by-experience', customSplitting: { 'react-vendor': ['react', 'react-dom'], 'router-vendor': ['react-router-dom'], 'ui-vendor': ['antd', '@ant-design/icons'], 'utils-vendor': ['lodash', 'dayjs', 'axios'] } }),
VitePWA({ registerType: 'autoUpdate', includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'], manifest: { name: env.VITE_APP_TITLE || 'React App', short_name: env.VITE_APP_SHORT_NAME || 'App', description: env.VITE_APP_DESCRIPTION || 'A modern React application', theme_color: '#ffffff', background_color: '#ffffff', display: 'standalone', icons: [ { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' }, { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' } ] }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], runtimeCaching: [ { urlPattern: /^https:\/\/api\.example\.com\/.*/i, handler: 'CacheFirst', options: { cacheName: 'api-cache', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 }, cacheableResponse: { statuses: [0, 200] } } } ] } }),
legacy({ targets: ['defaults', 'not IE 11'] }),
isProduction && visualizer({ filename: 'dist/stats.html', open: true, gzipSize: true, brotliSize: true }) ].filter(Boolean),
resolve: { alias: { '@': resolve(__dirname, 'src'), '@components': resolve(__dirname, 'src/components'), '@utils': resolve(__dirname, 'src/utils'), '@types': resolve(__dirname, 'src/types'), '@hooks': resolve(__dirname, 'src/hooks'), '@services': resolve(__dirname, 'src/services'), '@store': resolve(__dirname, 'src/store'), '@assets': resolve(__dirname, 'src/assets') } },
css: { preprocessorOptions: { scss: { additionalData: `@import "@/styles/variables.scss";` } }, modules: { localsConvention: 'camelCase', generateScopedName: isProduction ? '[hash:base64:8]' : '[name]__[local]___[hash:base64:5]' }, postcss: { plugins: [ require('autoprefixer'), require('postcss-preset-env')({ stage: 3, features: { 'nesting-rules': true } }) ] } },
server: { port: 3000, open: true, cors: true, proxy: { '/api': { target: env.VITE_API_BASE_URL || 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } },
build: { target: 'es2015', outDir: 'dist', assetsDir: 'assets', sourcemap: isProduction ? 'hidden' : true, minify: 'terser', terserOptions: { compress: { drop_console: isProduction, drop_debugger: isProduction } }, rollupOptions: { output: { chunkFileNames: 'js/[name]-[hash].js', entryFileNames: 'js/[name]-[hash].js', assetFileNames: (assetInfo) => { const info = assetInfo.name.split('.'); const ext = info[info.length - 1]; if (/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/i.test(assetInfo.name)) { return `media/[name]-[hash].${ext}`; } if (/\.(png|jpe?g|gif|svg)(\?.*)?$/.test(assetInfo.name)) { return `images/[name]-[hash].${ext}`; } if (/\.(woff2?|eot|ttf|otf)(\?.*)?$/i.test(assetInfo.name)) { return `fonts/[name]-[hash].${ext}`; } return `assets/[name]-[hash].${ext}`; } } }, chunkSizeWarningLimit: 1000, reportCompressedSize: false },
optimizeDeps: { include: [ 'react', 'react-dom', 'react-router-dom', 'axios', 'lodash' ] },
define: { __DEV__: isDevelopment, __PROD__: isProduction, __VERSION__: JSON.stringify(process.env.npm_package_version) } }; });
|
🔧 代码质量与规范
1. 代码检查与格式化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
| module.exports = { root: true, env: { browser: true, es2021: true, node: true, jest: true }, extends: [ 'eslint:recommended', '@typescript-eslint/recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', 'plugin:jsx-a11y/recommended', 'plugin:import/recommended', 'plugin:import/typescript', 'prettier' ], parser: '@typescript-eslint/parser', parserOptions: { ecmaFeatures: { jsx: true }, ecmaVersion: 'latest', sourceType: 'module', project: './tsconfig.json' }, plugins: [ 'react', 'react-hooks', '@typescript-eslint', 'jsx-a11y', 'import', 'prettier' ], settings: { react: { version: 'detect' }, 'import/resolver': { typescript: { alwaysTryTypes: true, project: './tsconfig.json' } } }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-non-null-assertion': 'warn', '@typescript-eslint/prefer-const': 'error', '@typescript-eslint/no-var-requires': 'error',
'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', 'react/display-name': 'off', 'react/jsx-uses-react': 'off', 'react/jsx-uses-vars': 'error', 'react/jsx-key': 'error', 'react/jsx-no-duplicate-props': 'error', 'react/jsx-no-undef': 'error', 'react/no-children-prop': 'error', 'react/no-danger-with-children': 'error', 'react/no-deprecated': 'error', 'react/no-direct-mutation-state': 'error', 'react/no-find-dom-node': 'error', 'react/no-is-mounted': 'error', 'react/no-render-return-value': 'error', 'react/no-string-refs': 'error', 'react/no-unescaped-entities': 'error', 'react/no-unknown-property': 'error', 'react/require-render-return': 'error',
'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn',
'import/order': [ 'error', { groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling', 'index' ], 'newlines-between': 'always', alphabetize: { order: 'asc', caseInsensitive: true } } ], 'import/no-unresolved': 'error', 'import/no-cycle': 'error', 'import/no-self-import': 'error', 'import/no-duplicates': 'error',
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 'no-alert': 'warn', 'no-var': 'error', 'prefer-const': 'error', 'no-unused-expressions': 'error', 'no-duplicate-imports': 'error', 'no-useless-return': 'error', 'eqeqeq': ['error', 'always'], 'curly': ['error', 'all'], 'brace-style': ['error', '1tbs'], 'comma-dangle': ['error', 'es5'], 'quotes': ['error', 'single', { avoidEscape: true }], 'semi': ['error', 'always'], 'indent': ['error', 2, { SwitchCase: 1 }], 'max-len': ['warn', { code: 100, ignoreUrls: true }], 'object-curly-spacing': ['error', 'always'], 'array-bracket-spacing': ['error', 'never'] }, overrides: [ { files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'], env: { jest: true }, rules: { '@typescript-eslint/no-explicit-any': 'off' } } ] };
|
2. 提交规范与自动化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [ 2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert' ] ], 'type-case': [2, 'always', 'lower-case'], 'type-empty': [2, 'never'], 'scope-case': [2, 'always', 'lower-case'], 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], 'subject-empty': [2, 'never'], 'subject-full-stop': [2, 'never', '.'], 'header-max-length': [2, 'always', 72], 'body-leading-blank': [1, 'always'], 'body-max-line-length': [2, 'always', 100], 'footer-leading-blank': [1, 'always'], 'footer-max-line-length': [2, 'always', 100] } };
module.exports = { branches: ['main', 'master'], plugins: [ '@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', [ '@semantic-release/changelog', { changelogFile: 'CHANGELOG.md' } ], '@semantic-release/npm', [ '@semantic-release/git', { assets: ['CHANGELOG.md', 'package.json'], message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}' } ], '@semantic-release/github' ] };
|
🧪 测试策略与实践
1. 单元测试与集成测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
| import { formatDate, debounce, throttle, deepClone } from '../helpers';
describe('Helper Functions', () => { describe('formatDate', () => { it('should format date correctly', () => { const date = new Date('2023-12-25T10:30:00Z'); expect(formatDate(date, 'YYYY-MM-DD')).toBe('2023-12-25'); expect(formatDate(date, 'MM/DD/YYYY')).toBe('12/25/2023'); });
it('should handle invalid date', () => { expect(formatDate(null)).toBe(''); expect(formatDate(undefined)).toBe(''); }); });
describe('debounce', () => { jest.useFakeTimers();
it('should debounce function calls', () => { const mockFn = jest.fn(); const debouncedFn = debounce(mockFn, 1000);
debouncedFn(); debouncedFn(); debouncedFn();
expect(mockFn).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(mockFn).toHaveBeenCalledTimes(1); });
it('should pass arguments correctly', () => { const mockFn = jest.fn(); const debouncedFn = debounce(mockFn, 1000);
debouncedFn('arg1', 'arg2');
jest.advanceTimersByTime(1000);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); }); });
describe('deepClone', () => { it('should clone primitive values', () => { expect(deepClone(42)).toBe(42); expect(deepClone('hello')).toBe('hello'); expect(deepClone(true)).toBe(true); expect(deepClone(null)).toBe(null); });
it('should clone objects deeply', () => { const original = { name: 'John', age: 30, address: { city: 'New York', country: 'USA' }, hobbies: ['reading', 'swimming'] };
const cloned = deepClone(original);
expect(cloned).toEqual(original); expect(cloned).not.toBe(original); expect(cloned.address).not.toBe(original.address); expect(cloned.hobbies).not.toBe(original.hobbies); });
it('should handle circular references', () => { const obj: any = { name: 'test' }; obj.self = obj;
const cloned = deepClone(obj);
expect(cloned.name).toBe('test'); expect(cloned.self).toBe(cloned); }); }); });
import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Button } from '../Button';
describe('Button Component', () => { it('should render with correct text', () => { render(<Button>Click me</Button>); expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument(); });
it('should handle click events', async () => { const handleClick = jest.fn(); const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1); });
it('should be disabled when loading', () => { render(<Button loading>Loading</Button>); const button = screen.getByRole('button'); expect(button).toBeDisabled(); expect(screen.getByText(/loading/i)).toBeInTheDocument(); });
it('should apply correct variant styles', () => { const { rerender } = render(<Button variant="primary">Primary</Button>); let button = screen.getByRole('button'); expect(button).toHaveClass('btn-primary');
rerender(<Button variant="secondary">Secondary</Button>); button = screen.getByRole('button'); expect(button).toHaveClass('btn-secondary'); });
it('should support different sizes', () => { const { rerender } = render(<Button size="small">Small</Button>); let button = screen.getByRole('button'); expect(button).toHaveClass('btn-small');
rerender(<Button size="large">Large</Button>); button = screen.getByRole('button'); expect(button).toHaveClass('btn-large'); });
it('should show loading spinner when loading', () => { render(<Button loading>Submit</Button>); expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); });
it('should prevent multiple clicks when loading', async () => { const handleClick = jest.fn(); const user = userEvent.setup();
render(<Button loading onClick={handleClick}>Submit</Button>);
const button = screen.getByRole('button'); await user.click(button); await user.click(button);
expect(handleClick).not.toHaveBeenCalled(); }); });
import { renderHook, waitFor } from '@testing-library/react'; import { useApi } from '../useApi'; import * as api from '../../services/api';
jest.mock('../../services/api');
const mockApi = api as jest.Mocked<typeof api>;
describe('useApi Hook', () => { beforeEach(() => { jest.clearAllMocks(); });
it('should fetch data successfully', async () => { const mockData = { id: 1, name: 'Test User' }; mockApi.getUser.mockResolvedValue(mockData);
const { result } = renderHook(() => useApi(() => api.getUser(1)));
expect(result.current.loading).toBe(true); expect(result.current.data).toBeNull(); expect(result.current.error).toBeNull();
await waitFor(() => { expect(result.current.loading).toBe(false); });
expect(result.current.data).toEqual(mockData); expect(result.current.error).toBeNull(); expect(mockApi.getUser).toHaveBeenCalledWith(1); });
it('should handle errors', async () => { const mockError = new Error('API Error'); mockApi.getUser.mockRejectedValue(mockError);
const { result } = renderHook(() => useApi(() => api.getUser(1)));
await waitFor(() => { expect(result.current.loading).toBe(false); });
expect(result.current.data).toBeNull(); expect(result.current.error).toEqual(mockError); });
it('should refetch data', async () => { const mockData = { id: 1, name: 'Test User' }; mockApi.getUser.mockResolvedValue(mockData);
const { result } = renderHook(() => useApi(() => api.getUser(1)));
await waitFor(() => { expect(result.current.loading).toBe(false); });
expect(mockApi.getUser).toHaveBeenCalledTimes(1);
result.current.refetch();
await waitFor(() => { expect(result.current.loading).toBe(false); });
expect(mockApi.getUser).toHaveBeenCalledTimes(2); }); });
|
2. E2E测试配置
// e2e/playwright.config.ts - Playwright配置
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'],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/results.xml' }]
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-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',
port: 3000,
reuseExistingServer: !process.env.CI
}
});
// e2e/tests/user-flow.spec.ts - E2E测试用例
import { test, expect } from '@playwright/test';
test.describe('User Authentication Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should login successfully', async ({ page }) => {
// 点击登录按钮
await page.click('[data-testid="login-button"]');
// 填写登录表单
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
// 提交表单
await page.click('[data-testid="submit-button"]');
// 验证登录成功
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
await expect(page.locator('[data-testid="welcome-message"]')).toContainText('Welcome');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.click('[data-testid="login-button"]');
await page.fill('[data-testid="email-input"]', 'invalid@example.com');
await page.fill('[data-testid="password-input"]', 'wrongpassword');
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
await expect(page.locator('[data-testid="error-message"]')).toContainText('Invalid credentials');
});
test('should logout successfully', async ({ page }) => {
// 先登录
await page.click('[data-testid="login-button"]');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
// 等待登录完成
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
// 点击用户菜单
await page.click('[data-testid="user-menu"]');
// 点击登出
await page.click('[data-testid="logout-button"]');
// 验证登出成功
await expect(page.locator('[data-testid="login-button"]')).toBeVisible();
});
});
test.describe('Product Management', () => {
test.beforeEach(async ({ page }) => {
// 登录
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'admin@example.com');
await page.fill('[data-testid="password-input"]', 'admin123');
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
//