前端工程化

0 阅读5分钟

📦 一、模块化

1. CommonJS、ES Module、AMD、UMD 的区别?

CommonJS (Node.js)

// 导出
module.exports = { name: 'Alice' };
// 或
exports.name = 'Alice';

// 导入
const user = require('./user');

// 特点:
// 1. 同步加载,适合服务端
// 2. 运行时加载,加载的是值的拷贝
// 3. 可以动态引入:require(condition ? './a' : './b')

ES Module (浏览器标准)

// 导出
export const name = 'Alice';
export default function() {}

// 导入
import user from './user.js';
import { name } from './user.js';

// 特点:
// 1. 异步加载,适合浏览器
// 2. 编译时加载(静态分析),加载的是值的引用
// 3. 支持 Tree Shaking
// 4. 导入是只读的,不能修改

AMD (RequireJS)

// 定义模块
define(['jquery'], function($) {
  return { name: 'Alice' };
});

// 使用
require(['./user'], function(user) {
  console.log(user.name);
});

// 特点:异步加载,依赖前置,主要用于浏览器

UMD (通用模块)

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery'], factory);
  } else if (typeof module === 'object' && module.exports) {
    // CommonJS
    module.exports = factory(require('jquery'));
  } else {
    // 浏览器全局变量
    root.MyModule = factory(root.jQuery);
  }
}(this, function ($) {
  return { name: 'Alice' };
}));

// 特点:兼容 AMD、CommonJS、全局变量

核心区别对比表:

特性CommonJSES Module
加载方式运行时同步编译时异步
输出值的拷贝值的引用
this指向当前模块undefined
循环依赖加载部分已执行代码报错或undefined
Tree Shaking不支持支持

🔧 二、构建工具

2. Webpack 核心概念和工作原理?

核心概念:

// webpack.config.js
module.exports = {
  // 1. Entry:入口
  entry: './src/index.js',
  
  // 2. Output:输出
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js'
  },
  
  // 3. Loader:转换器
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'] // 从右往左执行
      },
      {
        test: /.jsx?$/,
        use: 'babel-loader',
        exclude: /node_modules/
      }
    ]
  },
  
  // 4. Plugin:插件
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    }),
    new MiniCssExtractPlugin()
  ],
  
  // 5. Mode:模式
  mode: 'production' // development | production
};

工作原理(简化版):

// 1. 初始化参数
const options = require('./webpack.config.js');

// 2. 创建 Compiler 对象
const compiler = new Webpack(options);

// 3. 从 entry 开始递归分析依赖
compiler.run((err, stats) => {
  // 4. 调用 loader 转换模块
  // 5. 解析依赖关系,构建依赖图
  // 6. 根据依赖图生成 chunk
  // 7. 输出到文件系统
});

打包流程详解:

1. 初始化阶段
   ├─ 读取配置文件
   ├─ 合并 shell 参数
   └─ 初始化 compiler

2. 编译阶段
   ├─ 从 entry 开始
   ├─ 调用 loader 处理模块
   ├─ 使用 acorn 解析 AST
   ├─ 找出依赖模块(import/require)
   └─ 递归处理所有依赖

3. 输出阶段
   ├─ 根据依赖关系组装 chunk
   ├─ 把 chunk 转换成文件
   └─ 输出到 output 目录

3. Loader 和 Plugin 的区别?如何自定义?

区别:

Loader:
- 文件转换器,处理单个文件
- 在 module.rules 中配置
- 本质是一个函数,接收源文件,返回转换后的结果

Plugin:
- 功能扩展器,可以访问整个编译生命周期
- 在 plugins 数组中配置
- 本质是一个类,有 apply 方法

自定义 Loader:

// my-loader.js
module.exports = function(source) {
  // source 是文件内容
  // this 是 loader 上下文
  
  // 同步 loader
  return source.replace(/console.log/g, '');
  
  // 异步 loader
  const callback = this.async();
  setTimeout(() => {
    callback(null, source.replace(/console.log/g, ''));
  }, 1000);
};

// 使用
module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        use: path.resolve(__dirname, 'my-loader.js')
      }
    ]
  }
};

自定义 Plugin:

// my-plugin.js
class MyPlugin {
  apply(compiler) {
    // compiler 是 webpack 实例
    
    // 在编译开始时执行
    compiler.hooks.compile.tap('MyPlugin', (params) => {
      console.log('开始编译...');
    });
    
    // 在生成资源到 output 目录之前
    compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
      // compilation 包含所有模块信息
      
      // 遍历所有即将输出的文件
      for (let filename in compilation.assets) {
        let content = compilation.assets[filename].source();
        
        // 修改文件内容
        compilation.assets[filename] = {
          source: () => content + '\n// Added by MyPlugin',
          size: () => content.length
        };
      }
      
      callback();
    });
  }
}

module.exports = MyPlugin;

// 使用
plugins: [new MyPlugin()]

4. Webpack 性能优化手段?

构建速度优化:

module.exports = {
  // 1. 缩小搜索范围
  resolve: {
    modules: [path.resolve(__dirname, 'node_modules')], // 指定查找目录
    extensions: ['.js', '.json'], // 减少后缀尝试
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  
  module: {
    rules: [
      {
        test: /.js$/,
        use: 'babel-loader',
        include: path.resolve(__dirname, 'src'), // 只处理 src
        exclude: /node_modules/ // 排除 node_modules
      }
    ]
  },
  
  // 2. 多线程打包
  module: {
    rules: [
      {
        test: /.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: { workers: 3 }
          },
          'babel-loader'
        ]
      }
    ]
  },
  
  // 3. 缓存
  cache: {
    type: 'filesystem', // 使用文件系统缓存
    cacheDirectory: path.resolve(__dirname, '.webpack_cache')
  },
  
  // 4. DLL 预编译(不常变的库)
  // 已被 cache 替代,但了解思想
};

打包体积优化:

module.exports = {
  optimization: {
    // 1. 代码分割
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          priority: 10
        },
        common: {
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    },
    
    // 2. Tree Shaking(production 默认开启)
    usedExports: true,
    
    // 3. 压缩
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true // 删除 console
          }
        }
      }),
      new CssMinimizerPlugin()
    ]
  },
  
  // 4. 动态导入(懒加载)
  // 代码中使用 import() 语法
  
  // 5. 外部扩展(CDN)
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM'
  }
};

// 组件懒加载示例
const Home = lazy(() => import('./pages/Home'));

运行时优化:

module.exports = {
  output: {
    // 1. 文件名哈希(利用缓存)
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js'
  },
  
  // 2. Source Map 优化
  devtool: 'source-map', // production
  // devtool: 'eval-cheap-module-source-map', // development
  
  optimization: {
    // 3. 模块 ID 稳定
    moduleIds: 'deterministic',
    
    // 4. Runtime chunk 分离
    runtimeChunk: 'single'
  }
};

5. Vite 为什么比 Webpack 快?原理是什么?

核心区别:

Webpack(打包器):
├─ 开发环境:先打包所有模块 → 启动服务器
├─ 修改文件:重新打包相关模块 → HMR
└─ 冷启动慢(大项目 10s+)

Vite(无打包开发服务器):
├─ 开发环境:直接启动服务器 → 按需编译
├─ 修改文件:只重新编译单个模块 → HMR
└─ 冷启动快(1-2s)

Vite 原理:

// 1. 开发环境利用浏览器原生 ES Module
// index.html
<script type="module" src="/src/main.js"></script>

// 2. 浏览器请求 main.js
// GET /src/main.js

// 3. Vite 拦截请求,实时编译
import { createApp } from 'vue'; // 浏览器不认识裸模块
// ↓ Vite 转换
import { createApp } from '/@modules/vue.js'; // 指向 node_modules

// 4. 预构建依赖(首次)
// Vite 用 esbuild 预构建 node_modules
// vue、react 等打包成单文件,避免请求瀑布

// 5. HMR 原理
// WebSocket 通信,模块热替换
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 只更新当前模块
  });
}

对比总结:

// Webpack 开发流程
entry → 递归解析 → loader 转换 → 打包成 bundle → 启动服务 (慢)

// Vite 开发流程
启动服务 (快) → 浏览器请求 → 实时编译单个文件 → 返回

// 生产环境
Vite 用 Rollup 打包(和 Webpack 类似)

🎯 三、代码质量

6. ESLint、Prettier、Husky、lint-staged 的作用和配置?

完整配置流程:

// package.json
{
  "scripts": {
    "lint": "eslint src --ext .js,.jsx,.ts,.tsx",
    "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
    "format": "prettier --write "src/**/*.{js,jsx,ts,tsx,json,css,md}"",
    "prepare": "husky install"
  },
  "devDependencies": {
    "eslint": "^8.0.0",
    "prettier": "^2.8.0",
    "husky": "^8.0.0",
    "lint-staged": "^13.0.0"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,css,md}": [
      "prettier --write"
    ]
  }
}

ESLint 配置:

// .eslintrc.js
module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true
  },
  extends: [
    'eslint:recommended',
    'plugin:react/recommended',
    'plugin:@typescript-eslint/recommended',
    'prettier' // 必须放最后,关闭 ESLint 中与 Prettier 冲突的规则
  ],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true
    }
  },
  plugins: ['react', '@typescript-eslint'],
  rules: {
    'no-console': 'warn',
    'no-unused-vars': 'error',
    'react/prop-types': 'off'
  }
};

Prettier 配置:

// .prettierrc.js
module.exports = {
  semi: true,              // 分号
  singleQuote: true,       // 单引号
  tabWidth: 2,             // 缩进
  trailingComma: 'es5',    // 尾逗号
  printWidth: 80,          // 每行宽度
  arrowParens: 'avoid'     // 箭头函数参数括号
};

Husky + lint-staged:

# 1. 安装
npm install husky lint-staged -D

# 2. 初始化 husky
npx husky install

# 3. 添加 pre-commit hook
npx husky add .husky/pre-commit "npx lint-staged"

# 4. 添加 commit-msg hook (可选,配合 commitlint)
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'
// .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged

Commitlint 配置(可选):

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']
    ],
    'subject-case': [0]
  }
};

// 提交格式:
// feat: 新功能
// fix: 修复 bug
// docs: 文档修改
// style: 代码格式(不影响功能)
// refactor: 重构
// test: 测试
// chore: 构建/工具

工作流程:

1. 开发者修改代码
2. git add .
3. git commit -m "feat: 添加登录功能"
   ↓
4. Husky 触发 pre-commit hook
   ↓
5. lint-staged 只检查暂存区文件
   ├─ 运行 eslint --fix
   ├─ 运行 prettier --write
   └─ 自动修复并重新 add6. Husky 触发 commit-msg hook
   └─ commitlint 检查提交信息格式
   ↓
7. 通过则提交成功,否则中断

🔄 四、CI/CD

7. 前端 CI/CD 流程和工具?

完整流程:

代码提交 → 自动化测试 → 构建打包 → 部署上线 → 监控回滚

GitHub Actions 示例:

# .github/workflows/deploy.yml
name: Deploy to Production

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

jobs:
  # 1. 测试和构建
  build:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout 代码
        uses: actions/checkout@v3
      
      - name: 设置 Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: 安装依赖
        run: npm ci
      
      - name: 代码检查
        run: npm run lint
      
      - name: 单元测试
        run: npm run test
      
      - name: 构建
        run: npm run build
      
      - name: 上传构建产物
        uses: actions/upload-artifact@v3
        with:
          name: dist
          path: dist/
  
  # 2. 部署到服务器
  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
      - name: 下载构建产物
        uses: actions/download-artifact@v3
        with:
          name: dist
      
      - name: 部署到阿里云 OSS
        uses: fangbinwei/aliyun-oss-website-action@v1
        with:
          accessKeyId: ${{ secrets.ACCESS_KEY_ID }}
          accessKeySecret: ${{ secrets.ACCESS_KEY_SECRET }}
          bucket: my-bucket
          endpoint: oss-cn-hangzhou.aliyuncs.com
          folder: dist/
      
      - name: 清除 CDN 缓存
        run: |
          curl -X POST https://api.cdn.com/purge \
            -H "Authorization: Bearer ${{ secrets.CDN_TOKEN }}"

GitLab CI 示例:

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

variables:
  NODE_VERSION: "18"

# 缓存依赖
cache:
  paths:
    - node_modules/

# 测试阶段
test:
  stage: test
  image: node:$NODE_VERSION
  script:
    - npm ci
    - npm run lint
    - npm run test:unit
    - npm run test:e2e
  coverage: '/Statements\s+:\s+(\d+.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

# 构建阶段
build:
  stage: build
  image: node:$NODE_VERSION
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week
  only:
    - main
    - develop

# 部署到测试环境
deploy:test:
  stage: deploy
  script:
    - scp -r dist/* user@test-server:/var/www/html/
  environment:
    name: test
    url: https://test.example.com
  only:
    - develop

# 部署到生产环境
deploy:prod:
  stage: deploy
  script:
    - scp -r dist/* user@prod-server:/var/www/html/
    - ssh user@prod-server "pm2 reload app"
  environment:
    name: production
    url: https://www.example.com
  when: manual  # 手动触发
  only:
    - main

Docker 部署:

# Dockerfile
# 多阶段构建
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 生产镜像
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# docker-compose.yml
version: '3.8'
services:
  web:
    build: .
    ports:
      - "80:80"
    environment:
      - NODE_ENV=production
    restart: always

部署策略:

// 1. 蓝绿部署
// 维护两套环境,快速切换

// 2. 金丝雀发布(灰度发布)
// Nginx 配置示例
upstream backend {
  server v1.example.com weight=9;  # 90% 流量到旧版本
  server v2.example.com weight=1;  # 10% 流量到新版本
}

// 3. 滚动更新
// Kubernetes 示例
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1  # 最多 1 个不可用
      maxSurge: 1        # 最多超出 1 个

📊 五、性能监控

8. 前端监控体系如何搭建?

监控分类:

// 1. 性能监控
const performanceData = {
  // FP: First Paint
  FP: performance.getEntriesByType('paint')[0].startTime,
  
  // FCP: First Contentful Paint
  FCP: performance.getEntriesByType('paint')[1].startTime,
  
  // LCP: Largest Contentful Paint
  LCP: new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    console.log('LCP:', lastEntry.renderTime || lastEntry.loadTime);
  }).observe({ entryTypes: ['largest-contentful-paint'] }),
  
  // FID: First Input Delay
  FID: new PerformanceObserver((list) => {
    list.getEntries().forEach(entry => {
      console.log('FID:', entry.processingStart - entry.startTime);
    });
  }).observe({ entryTypes: ['first-input'] }),
  
  // CLS: Cumulative Layout Shift
  CLS: new PerformanceObserver((list) => {
    let cls = 0;
    list.getEntries().forEach(entry => {
      if (!entry.hadRecentInput) {
        cls += entry.value;
      }
    });
    console.log('CLS:', cls);
  }).observe({ entryTypes: ['layout-shift'] }),
  
  // TTFB: Time to First Byte
  TTFB: performance.timing.responseStart - performance.timing.requestStart,
  
  // 页面加载总时间
  loadTime: performance.timing.loadEventEnd - performance.timing.navigationStart
};

// 上报数据
function reportPerformance(data) {
  navigator.sendBeacon('/api/monitor/performance', JSON.stringify(data));
}

错误监控:

// 2. JS 错误监控
window.addEventListener('error', (event) => {
  const errorInfo = {
    type: 'jsError',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    stack: event.error?.stack,
    userAgent: navigator.userAgent,
    url: window.location.href,
    timestamp: Date.now()
  };
  
  reportError(errorInfo);
}, true);

// Promise 错误
window.addEventListener('unhandledrejection', (event) => {
  const errorInfo = {
    type: 'promiseError',
    message: event.reason?.message || event.reason,
    stack: event.reason?.stack,
    timestamp: Date.now()
  };
  
  reportError(errorInfo);
});

// 资源加载错误
window.addEventListener('error', (event) => {
  if (event.target !== window) {
    const errorInfo = {
      type: 'resourceError',
      tagName: event.target.tagName,
      src: event.target.src || event.target.href,
      timestamp: Date.now()
    };
    
    reportError(errorInfo);
  }
}, true);

// React 错误边界
class ErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    reportError({
      type: 'reactError',
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack
    });
  }
  
  render() {
    return this.props.children;
  }
}

用户行为监控:

// 3. PV/UV 统计
function trackPageView() {
  const data = {
    type: 'pv',
    url: window.location.href,
    referrer: document.referrer,
    userId: getUserId(),
    timestamp: Date.now()
  };
  
  reportBehavior(data);
}

// 页面停留时间
let startTime = Date.now();
window.addEventListener('beforeunload', () => {
  const duration = Date.now() - startTime;
  navigator.sendBeacon('/api/monitor/duration', JSON.stringify({
    url: window.location.href,
    duration
  }));
});

// 点击事件埋点
document.addEventListener('click', (event) => {
  const target = event.target;
  const dataTrack = target.getAttribute('data-track');
  
  if (dataTrack) {
    reportBehavior({
      type: 'click',
      element: dataTrack,
      timestamp: Date.now()
    });
  }
});

// 自定义埋点
function trackEvent(eventName, params) {
  reportBehavior({
    type: 'customEvent',
    eventName,
    params,
    timestamp: Date.now()
  });
}

// 使用
trackEvent('购买商品', { productId: '123', price: 99 });

接口监控:

// 4. 封装 fetch/axios
const originalFetch = window.fetch;
window.fetch = function(...args) {
  const startTime = Date.now();
  const url = args[0];
  
  return originalFetch.apply(this, args)
    .then(response => {
      const duration = Date.now() - startTime;
      
      reportAPI({
        url,
        method: args[1]?.method || 'GET',
        status: response.status,
        duration,
        success: response.ok
      });
      
      return response;
    })
    .catch(error => {
      reportAPI({
        url,
        method: args[1]?.method || 'GET',
        error: error.message,
        success: false
      });
      
      throw error;
    });
};

上报策略:

// 批量上报(节流)
class Monitor {
  constructor() {
    this.queue = [];
    this.timer = null;
  }
  
  // 添加到队列
  addLog(data) {
    this.queue.push(data);
    
    // 超过 10 条立即上报
    if (this.queue.length >= 10) {
      this.flush();
      return;
    }
    
    // 否则 3 秒后上报
    if (!this.timer) {
      this.timer = setTimeout(() => {
        this.flush();
      }, 3000);
    }
  }
  
  // 上报
  flush() {
    if (this.queue.length === 0) return;
    
    // 使用 sendBeacon 保证数据发送(页面卸载时也能发)
    navigator.sendBeacon(
      '/api/monitor/log',
      JSON.stringify(this.queue)
    );
    
    this.queue = [];
    this.timer = null;
  }
}

const monitor = new Monitor();

// 页面卸载时上报剩余数据
window.addEventListener('beforeunload', () => {
  monitor.flush();
});

完整监控 SDK:

class MonitorSDK {
  constructor(options) {
    this.appId = options.appId;
    this.apiUrl = options.apiUrl;
    this.userId = this.getUserId();
    
    this.initPerformance();
    this.initError();
    this.initBehavior();
  }
  
  // 生成用户 ID
  getUserId() {
    let userId = localStorage.getItem('monitor_user_id');
    if (!userId) {
      userId = `user_${Date.now()}_${Math.random()}`;
      localStorage.setItem('monitor_user_id', userId);
    }
    return userId;
  }
  
  // 初始化性能监控
  initPerformance() {
    if (window.PerformanceObserver) {
      // 监控 LCP
      new PerformanceObserver((list) => {
        const entries = list.getEntries();
        const lastEntry = entries[entries.length - 1];
        this.report({
          type: 'performance',
          metric: 'LCP',
          value: lastEntry.renderTime || lastEntry.loadTime
        });
      }).observe({ entryTypes: ['largest-contentful-paint'] });
    }
  }
  
  // 初始化错误监控
  initError() {
    window.addEventListener('error', (event) => {
      this.report({
        type: 'error',
        subType: 'jsError',
        message: event.message,
        stack: event.error?.stack
      });
    }, true);
  }
  
  // 初始化行为监控
  initBehavior() {
    // PV
    this.report({
      type: 'behavior',
      subType: 'pv',
      url: window.location.href
    });
  }
  
  // 上报
  report(data) {
    const logData = {
      ...data,
      appId: this.appId,
      userId: this.userId,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
      url: window.location.href
    };
    
    navigator.sendBeacon(this.apiUrl, JSON.stringify(logData));
  }
}

// 使用
const monitor = new MonitorSDK({
  appId: 'my-app',
  apiUrl: 'https://api.example.com/monitor'
});

🧪 六、测试

9. 前端测试的分类和工具?

测试金字塔:

        /\
       /E2E\        端到端测试 (少量)
      /------\
     /集成测试 \     组件集成 (适量)
    /----------\
   /  单元测试   \   函数/组件 (大量)
  /--------------\

单元测试(Jest + Testing Library):

// utils.js
export function add(a, b) {
  return a + b;
}

export function formatPrice(price) {
  return ${price.toFixed(2)}`;
}

// utils.test.js
import { add, formatPrice } from './utils';

describe('工具函数测试', () => {
  test('add 函数', () => {
    expect(add(1, 2)).toBe(3);
    expect(add(-1, 1)).toBe(0);
  });
  
  test('formatPrice 函数', () => {
    expect(formatPrice(100)).toBe('¥100.00');
    expect(formatPrice(99.9)).toBe('¥99.90');
  });
});

// Button.jsx
function Button({ onClick, children }) {
  return <button onClick={onClick}>{children}</button>;
}

// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button 组件', () => {
  test('渲染正确的文本', () => {
    render(<Button>点击我</Button>);
    expect(screen.getByText('点击我')).toBeInTheDocument();
  });
  
  test('点击事件触发', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>点击</Button>);
    
    fireEvent.click(screen.getByText('点击'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

集成测试:

// TodoList.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import TodoList from './TodoList';

test('添加和删除待办事项', () => {
  render(<TodoList />);
  
  // 添加待办
  const input = screen.getByPlaceholderText('输入待办事项');
  const addButton = screen.getByText('添加');
  
  fireEvent.change(input, { target: { value: '买菜' } });
  fireEvent.click(addButton);
  
  expect(screen.getByText('买菜')).toBeInTheDocument();
  
  // 删除待办
  const deleteButton = screen.getByText('删除');
  fireEvent.click(deleteButton);
  
  expect(screen.queryByText('买菜')).not.toBeInTheDocument();
});

E2E 测试(Cypress):

// cypress/e2e/login.cy.js
describe('登录流程', () => {
  beforeEach(() => {
    cy.visit('http://localhost:3000/login');
  });
  
  it('成功登录', () => {
    // 输入用户名和密码
    cy.get('input[name="username"]').type('admin');
    cy.get('input[name="password"]').type('123456');
    
    // 点击登录按钮
    cy.get('button[type="submit"]').click();
    
    // 验证跳转到首页
    cy.url().should('include', '/home');
    cy.contains('欢迎回来').should('be.visible');
  });
  
  it('登录失败提示', () => {
    cy.get('input[name="username"]').type('wrong');
    cy.get('input[name="password"]').type('wrong');
    cy.get('button[type="submit"]').click();
    
    cy.contains('用户名或密码错误').should('be.visible');
  });
});

覆盖率配置:

// jest.config.js
module.exports = {
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.test.{js,jsx}',
    '!src/index.js'
  ],
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 80,
      functions: 80,
      lines: 80
    }
  }
};

🎁 七、综合实战题

10. 从0到1搭建一个完整的前端工程?

完整清单:

# 1. 初始化项目
npm create vite@latest my-project -- --template react-ts
cd my-project

# 2. 安装依赖
npm install
npm install -D eslint prettier husky lint-staged @commitlint/cli @commitlint/config-conventional

# 3. 配置代码规范
npm init @eslint/config
echo "module.exports = { semi: true, singleQuote: true };" > .prettierrc.js

# 4. 配置 Git Hooks
npx husky install
npx husky add .husky/pre-commit "npx lint-staged"
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'

# 5. 配置环境变量
echo "VITE_API_URL=http://localhost:3000" > .env.development
echo "VITE_API_URL=https://api.example.com" > .env.production

# 6. 目录结构
mkdir -p src/{components,pages,utils,hooks,store,styles,types,api}

# 7. 配置路径别名
# vite.config.ts 中添加 resolve.alias

# 8. 配置 CI/CD
mkdir .github/workflows
# 创建 deploy.yml

# 9. 配置监控
# 引入 Sentry 或自建监控

# 10. 文档
echo "# Project Documentation" > README.md

目录结构:

my-project/
├── .github/
│   └── workflows/
│       └── deploy.yml
├── .husky/
│   ├── pre-commit
│   └── commit-msg
├── public/
├── src/
│   ├── api/              # API 请求
│   ├── components/       # 通用组件
│   ├── hooks/            # 自定义 Hooks
│   ├── pages/            # 页面组件
│   ├── store/            # 状态管理
│   ├── styles/           # 全局样式
│   ├── types/            # TS 类型定义
│   ├── utils/            # 工具函数
│   ├── App.tsx
│   └── main.tsx
├── .env.development
├── .env.production
├── .eslintrc.js
├── .prettierrc.js
├── commitlint.config.js
├── tsconfig.json
├── vite.config.ts
└── package.json