代码质量工程完全指南 🚀
代码质量工程完全指南 🚀
构建可维护、高质量代码库的完整实践方案
TypeScript 高级用法 ⚙️
1. 泛型约束(Generics & Constraints)🎯
为什么需要泛型约束?
在开发可复用组件时,我们经常需要处理多种数据类型,但又不想失去 TypeScript 的类型安全优势。泛型约束允许我们在保持灵活性的同时,对类型参数施加限制。
解决的问题:
- 避免使用
any
类型导致类型信息丢失 - 在通用函数中保持输入输出类型关系
- 提供更好的 IDE 智能提示和自文档化
缺点与限制:
- 过度复杂的约束会让错误信息难以理解
- 嵌套泛型可能导致编译性能下降
- 初学者可能需要时间适应这种抽象思维
详细代码示例
// 🎯 基础泛型函数
function identity<T>(value: T): T {
return value;
}
// 使用显式类型参数
const result1 = identity<string>("Hello"); // 类型: string
// 使用类型推断
const result2 = identity(42); // 类型: number
// 🎯 泛型约束 - 确保类型具有特定属性
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(`Length: ${item.length}`);
}
// 这些调用都是合法的
logLength("hello"); // 字符串有 length 属性
logLength([1, 2, 3]); // 数组有 length 属性
logLength({ length: 5, name: "test" }); // 对象有 length 属性
// 🎯 泛型约束与 keyof 结合
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = {
id: 1,
name: "Alice",
email: "alice@example.com"
};
const userName = getProperty(user, "name"); // 类型: string
const userId = getProperty(user, "id"); // 类型: number
// 🎯 多重约束
interface Serializable {
serialize(): string;
}
interface Identifiable {
id: number;
}
function processEntity<T extends Serializable & Identifiable>(entity: T): void {
console.log(`ID: ${entity.id}`);
console.log(`Serialized: ${entity.serialize()}`);
}
// 🎯 泛型类示例
class Repository<T extends { id: number }> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
getAll(): T[] {
return [...this.items];
}
}
// 使用泛型类
interface Product {
id: number;
name: string;
price: number;
}
const productRepo = new Repository<Product>();
productRepo.add({ id: 1, name: "Laptop", price: 999 });
const laptop = productRepo.findById(1); // 类型: Product | undefined
2. 条件类型与推断(Conditional Types & infer)🧠
为什么需要条件类型?
条件类型允许我们在类型级别进行条件判断,实现基于输入类型的动态类型转换。这在创建灵活的类型工具和库时特别有用。
解决的问题:
- 根据条件动态推导类型
- 从复杂类型中提取子类型
- 减少重复的类型定义
缺点与限制:
- 可读性较差,特别是嵌套条件类型
- 错误信息可能非常复杂难懂
- 需要深入理解 TypeScript 的类型系统
详细代码示例
// 🧠 基础条件类型
type IsString<T> = T extends string ? true : false;
type Test1 = IsString<"hello">; // true
type Test2 = IsString<number>; // false
type Test3 = IsString<string | number>; // boolean
// 🧠 使用 infer 进行类型提取
type ExtractPromiseType<T> = T extends Promise<infer U> ? U : T;
type AsyncString = ExtractPromiseType<Promise<string>>; // string
type JustNumber = ExtractPromiseType<number>; // number
// 🧠 从函数类型中提取参数和返回类型
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type GetParameters<T> = T extends (...args: infer P) => any ? P : never;
type Func = (a: number, b: string) => boolean;
type Return = GetReturnType<Func>; // boolean
type Params = GetParameters<Func>; // [number, string]
// 🧠 分发条件类型(分布式条件类型)
type ToArray<T> = T extends any ? T[] : never;
type StringOrNumberArray = ToArray<string | number>;
// 等价于: string[] | number[]
type NeverArray = ToArray<never>; // never
// 🧠 排除 null 和 undefined
type NonNullable<T> = T extends null | undefined ? never : T;
type ValidString = NonNullable<string | null>; // string
type ValidNumber = NonNullable<number | undefined>; // number
// 🧠 递归条件类型 - DeepPartial
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
interface User {
id: number;
profile: {
name: string;
settings: {
theme: string;
notifications: boolean;
};
};
}
type PartialUser = DeepPartial<User>;
// 等价于:
// {
// id?: number;
// profile?: {
// name?: string;
// settings?: {
// theme?: string;
// notifications?: boolean;
// };
// };
// }
// 🧠 条件类型与模板字面量结合
type GetterName<T extends string> = T extends `_${infer Rest}`
? `get${Capitalize<Rest>}`
: `get${Capitalize<T>}`;
type NameGetter = GetterName<"name">; // "getName"
type PrivateGetter = GetterName<"_email">; // "getEmail"
3. 映射类型(Mapped Types)🔁
为什么需要映射类型?
映射类型允许我们基于现有类型创建新类型,通过转换每个属性来实现类型的批量操作。
解决的问题:
- 批量修改类型属性(只读、可选等)
- 基于现有类型创建变体
- 减少重复的类型定义代码
缺点与限制:
- 映射类型不会自动递归处理嵌套对象
- 复杂的映射类型可能难以理解和调试
- 某些高级用法需要深入的类型系统知识
详细代码示例
// 🔁 基础映射类型
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Partial<T> = {
[P in keyof T]?: T[P];
};
type Required<T> = {
[P in keyof T]-?: T[P];
};
// 🔁 键重映射
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// 等价于:
// {
// getName: () => string;
// getAge: () => number;
// }
// 🔁 过滤属性
type OnlyFunctions<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
interface MixedInterface {
name: string;
age: number;
getName(): string;
setAge(age: number): void;
}
type FunctionsOnly = OnlyFunctions<MixedInterface>;
// 等价于:
// {
// getName: () => string;
// setAge: (age: number) => void;
// }
// 🔁 基于值的类型映射
type EventConfig<T extends { kind: string }> = {
[E in T as E["kind"]]: (event: E) => void;
};
type Event =
| { kind: "click"; x: number; y: number }
| { kind: "keypress"; key: string }
| { kind: "focus"; element: HTMLElement };
type Config = EventConfig<Event>;
// 等价于:
// {
// click: (event: { kind: "click"; x: number; y: number }) => void;
// keypress: (event: { kind: "keypress"; key: string }) => void;
// focus: (event: { kind: "focus"; element: HTMLElement }) => void;
// }
// 🔁 实用映射类型示例
// 1. 将所有属性变为可空
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
// 2. 将函数返回值包装为 Promise
type Promisify<T> = {
[P in keyof T]: T[P] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[P];
};
// 3. 创建严格的不可变类型
type Immutable<T> = {
readonly [P in keyof T]: T[P] extends object ? Immutable<T[P]> : T[P];
};
4. 实用工具类型(Utility Types)🧰
为什么需要工具类型?
工具类型提供了常见的类型转换操作,让类型定义更加简洁和可维护。
解决的问题:
- 减少重复的类型定义
- 提供标准的类型转换模式
- 提高代码的可读性和一致性
缺点与限制:
- 初学者可能需要时间学习各种工具类型
- 过度使用可能让代码看起来更复杂
- 某些工具类型的行为可能不符合直觉
详细代码示例
interface User {
id: number;
name: string;
email: string;
age?: number;
createdAt: Date;
updatedAt?: Date;
}
// 🧰 Partial - 所有属性变为可选
type UserUpdate = Partial<User>;
// 等价于:
// {
// id?: number;
// name?: string;
// email?: string;
// age?: number;
// createdAt?: Date;
// updatedAt?: Date;
// }
// 🧰 Required - 所有属性变为必需
type CompleteUser = Required<User>;
// 等价于:
// {
// id: number;
// name: string;
// email: string;
// age: number;
// createdAt: Date;
// updatedAt: Date;
// }
// 🧰 Pick - 选择特定属性
type UserBasicInfo = Pick<User, 'id' | 'name'>;
// 等价于:
// {
// id: number;
// name: string;
// }
// 🧰 Omit - 排除特定属性
type UserWithoutDates = Omit<User, 'createdAt' | 'updatedAt'>;
// 等价于:
// {
// id: number;
// name: string;
// email: string;
// age?: number;
// }
// 🧰 Record - 创建键值映射
type UserMap = Record<number, User>;
// 等价于:
// {
// [key: number]: User;
// }
type StatusMap = Record<'success' | 'error' | 'loading', boolean>;
// 等价于:
// {
// success: boolean;
// error: boolean;
// loading: boolean;
// }
// 🧰 Extract - 提取匹配的类型
type StringKeys = Extract<keyof User, string>;
// 从 'id' | 'name' | 'email' | 'age' | 'createdAt' | 'updatedAt'
// 提取出所有字符串键(这里全部都是)
// 🧰 Exclude - 排除匹配的类型
type NonFunctionKeys = Exclude<keyof User, Function>;
// 排除函数类型的键(这里没有函数,所以返回所有键)
// 🧰 工具类型组合使用
// 创建用户表单数据类型
type UserFormData = Partial<Pick<User, 'name' | 'email' | 'age'>>;
// 等价于:
// {
// name?: string;
// email?: string;
// age?: number;
// }
// 创建 API 响应类型
type ApiResponse<T> = {
data: T;
success: boolean;
message?: string;
};
type UserResponse = ApiResponse<Omit<User, 'password'>>;
// 🧰 自定义工具类型
// 1. 值类型为特定类型的属性键
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T];
type StringKeysOfUser = KeysOfType<User, string>;
// "name" | "email"
// 2. 深度只读
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};
// 3. 异步函数包装
type AsyncFunction<T extends (...args: any[]) => any> =
(...args: Parameters<T>) => Promise<ReturnType<T>>;
5. 模板字面量类型 ✂️
为什么需要模板字面量类型?
模板字面量类型允许在类型级别进行字符串操作,创建精确的字符串字面量类型。
解决的问题:
- 创建精确的字符串联合类型
- 基于模式生成类型安全的字符串
- 减少运行时字符串验证的需要
缺点与限制:
- 复杂的模板类型可能影响编译性能
- 错误信息可能难以理解
- 某些字符串操作在类型级别有限制
详细代码示例
// ✂️ 基础模板字面量类型
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2' | 'v3';
type ApiEndpoint = `/${ApiVersion}/${string}`;
type FullEndpoint = `${HttpMethod} ${ApiEndpoint}`;
// 使用示例
type UserEndpoint = `GET /v1/users` | `POST /v1/users` | `GET /v1/users/${string}`;
// ✂️ 字符串操作类型
// Uppercase, Lowercase, Capitalize, Uncapitalize
type UpperCaseMethod = Uppercase<HttpMethod>;
// "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
type EventName = 'click' | 'change' | 'submit';
type EventHandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onChange" | "onSubmit"
// ✂️ 路径参数提取
type ExtractPathParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractPathParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type RouteParams = ExtractPathParams<'/users/:userId/posts/:postId'>;
// "userId" | "postId"
// ✂️ 配置键生成
type FeatureFlags = 'darkMode' | 'notifications' | 'analytics';
type ConfigKeys = `feature_${Uppercase<FeatureFlags>}`;
// "feature_DARKMODE" | "feature_NOTIFICATIONS" | "feature_ANALYTICS"
// ✂️ CSS 类名生成
type Color = 'primary' | 'secondary' | 'success' | 'danger';
type Size = 'sm' | 'md' | 'lg';
type ButtonClass = `btn-${Color}-${Size}`;
// "btn-primary-sm" | "btn-primary-md" | "btn-primary-lg" | ...
// ✂️ 高级模式匹配
type ParseQueryString<T extends string> =
T extends `${infer Key}=${infer Value}&${infer Rest}`
? { [K in Key]: Value } & ParseQueryString<Rest>
: T extends `${infer Key}=${infer Value}`
? { [K in Key]: Value }
: {};
type QueryParams = ParseQueryString<'name=John&age=30&city=NY'>;
// 等价于:
// {
// name: "John";
// age: "30";
// city: "NY";
// }
// ✂️ 自动生成 API 客户端类型
type Resource = 'users' | 'posts' | 'comments';
type Action = 'create' | 'read' | 'update' | 'delete';
type ApiAction<T extends Resource> = {
[K in Action as `${K}${Capitalize<T>}`]: () => Promise<void>;
};
type UserApi = ApiAction<'users'>;
// 等价于:
// {
// createUsers: () => Promise<void>;
// readUsers: () => Promise<void>;
// updateUsers: () => Promise<void>;
// deleteUsers: () => Promise<void>;
// }
6. 类型推断与保护 🔎
为什么需要类型保护?
类型保护允许我们在运行时检查值的类型,并让 TypeScript 编译器理解这些检查,从而在特定代码块中缩小类型范围。
解决的问题:
- 安全地处理联合类型
- 减少类型断言的使用
- 提供更好的开发体验和代码安全性
缺点与限制:
- 需要编写额外的运行时检查代码
- 复杂的类型保护可能难以维护
- 某些模式可能无法被 TypeScript 正确推断
详细代码示例
// 🔎 基础类型保护
const isString = (value: unknown): value is string => {
return typeof value === 'string';
};
const isNumber = (value: unknown): value is number => {
return typeof value === 'number' && !isNaN(value);
};
const isArray = <T>(value: unknown): value is T[] => {
return Array.isArray(value);
};
// 🔎 自定义类型保护
interface Cat {
type: 'cat';
meow(): void;
climbTrees(): void;
}
interface Dog {
type: 'dog';
bark(): void;
fetch(): void;
}
type Animal = Cat | Dog;
const isCat = (animal: Animal): animal is Cat => {
return animal.type === 'cat';
};
const isDog = (animal: Animal): animal is Dog => {
return animal.type === 'dog';
};
function handleAnimal(animal: Animal) {
if (isCat(animal)) {
animal.meow(); // TypeScript 知道这是 Cat
animal.climbTrees(); // 可以安全调用
} else {
animal.bark(); // TypeScript 知道这是 Dog
animal.fetch(); // 可以安全调用
}
}
// 🔎 discriminated unions(可区分联合)
type NetworkState =
| { state: 'loading' }
| { state: 'success'; data: string }
| { state: 'error'; error: Error };
function handleNetworkState(state: NetworkState) {
switch (state.state) {
case 'loading':
console.log('Loading...');
break;
case 'success':
console.log('Data:', state.data); // TypeScript 知道有 data 属性
break;
case 'error':
console.log('Error:', state.error.message); // TypeScript 知道有 error 属性
break;
}
}
// 🔎 使用 in 操作符进行类型保护
interface AdminUser {
role: 'admin';
permissions: string[];
manageUsers(): void;
}
interface RegularUser {
role: 'user';
preferences: object;
}
type User = AdminUser | RegularUser;
function handleUser(user: User) {
if ('permissions' in user) {
user.manageUsers(); // TypeScript 知道这是 AdminUser
} else {
console.log(user.preferences); // TypeScript 知道这是 RegularUser
}
}
// 🔎 类型断言的最佳实践
// 方式1: as 语法
const element1 = document.getElementById('my-input') as HTMLInputElement;
// 方式2: 尖括号语法(不推荐在 JSX 中使用)
const element2 = <HTMLInputElement>document.getElementById('my-input');
// 方式3: 非空断言(谨慎使用)
const element3 = document.getElementById('my-input')!;
// 方式4: 安全的类型断言函数
function assertIsHTMLElement(element: unknown): asserts element is HTMLElement {
if (!(element instanceof HTMLElement)) {
throw new Error('Not an HTMLElement');
}
}
const element4 = document.getElementById('my-input');
assertIsHTMLElement(element4);
element4.style.color = 'red'; // 现在可以安全访问
// 🔎 复杂的类型保护示例
interface ApiSuccess<T> {
status: 'success';
data: T;
timestamp: Date;
}
interface ApiError {
status: 'error';
error: string;
code: number;
}
type ApiResponse<T> = ApiSuccess<T> | ApiError;
function isApiSuccess<T>(
response: ApiResponse<T>
): response is ApiSuccess<T> {
return response.status === 'success';
}
async function fetchData<T>(url: string): Promise<T> {
const response: ApiResponse<T> = await fetch(url).then(res => res.json());
if (isApiSuccess(response)) {
return response.data; // TypeScript 知道这是 ApiSuccess<T>
} else {
throw new Error(`API Error ${response.code}: ${response.error}`);
}
}
// 🔎 类型保护与错误处理
class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
type AppError = ValidationError | NetworkError;
const isValidationError = (error: Error): error is ValidationError => {
return error.name === 'ValidationError';
};
function handleError(error: AppError) {
if (isValidationError(error)) {
console.log('Validation error:', error.message);
// 可以访问 ValidationError 特有的属性或方法
} else {
console.log('Network error:', error.message);
// 可以访问 NetworkError 特有的属性或方法
}
}
测试策略 🧪
测试金字塔架构
graph TB
A[单元测试 70%] --> B[集成测试 20%]
B --> C[E2E 测试 10%]
subgraph A [单元测试 - 快速反馈]
A1[工具函数]
A2[React 组件]
A3[自定义 Hooks]
A4[工具类]
end
subgraph B [集成测试 - 模块协作]
B1[组件集成]
B2[API 集成]
B3[状态管理]
B4[路由测试]
end
subgraph C [E2E 测试 - 用户流程]
C1[关键业务流程]
C2[跨页面交互]
C3[性能测试]
C4[兼容性测试]
end
style A fill:#e3f2fd
style B fill:#f3e5f5
style C fill:#e8f5e8
1. 单元测试:Jest + React Testing Library
为什么需要单元测试?
单元测试确保代码的最小单元(函数、组件)按预期工作,提供快速反馈和代码质量保障。
解决的问题:
- 快速发现回归问题
- 提供代码文档和示例
- 支持重构和代码演进
缺点与限制:
- 不能完全模拟真实用户行为
- 过度 mock 可能导致测试与实现耦合
- 维护测试需要额外工作量
详细配置与示例
// 🧪 Jest 配置文件示例
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/reportWebVitals.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
transform: {
'^.+\.(ts|tsx)$': 'ts-jest',
},
};
// 🧪 测试工具函数示例
// src/utils/format.test.ts
import { formatDate, capitalize, debounce } from './format';
describe('format utilities', () => {
describe('formatDate', () => {
it('格式化日期为 YYYY-MM-DD', () => {
const date = new Date('2023-12-25');
expect(formatDate(date)).toBe('2023-12-25');
});
it('处理无效日期', () => {
expect(formatDate(new Date('invalid'))).toBe('Invalid Date');
});
});
describe('capitalize', () => {
it('将字符串首字母大写', () => {
expect(capitalize('hello world')).toBe('Hello world');
});
it('处理空字符串', () => {
expect(capitalize('')).toBe('');
});
});
describe('debounce', () => {
jest.useFakeTimers();
it('防抖函数延迟执行', () => {
const mockFn = jest.fn();
const debouncedFn = debounce(mockFn, 100);
debouncedFn();
debouncedFn();
debouncedFn();
expect(mockFn).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(1);
});
});
});
// 🧪 React 组件测试示例
// src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button Component', () => {
const defaultProps = {
onClick: jest.fn(),
children: 'Click me',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('渲染按钮文本', () => {
render(<Button {...defaultProps} />);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('点击时触发回调', async () => {
const user = userEvent.setup();
render(<Button {...defaultProps} />);
await user.click(screen.getByRole('button'));
expect(defaultProps.onClick).toHaveBeenCalledTimes(1);
});
it('禁用状态下不触发点击', async () => {
const user = userEvent.setup();
render(<Button {...defaultProps} disabled />);
await user.click(screen.getByRole('button'));
expect(defaultProps.onClick).not.toHaveBeenCalled();
});
it('显示加载状态', () => {
render(<Button {...defaultProps} loading />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});
it('应用正确的 CSS 类', () => {
render(<Button {...defaultProps} variant="primary" size="large" />);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-primary', 'btn-large');
});
});
// 🧪 自定义 Hook 测试
// src/hooks/useCounter/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('使用初始值初始化', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('默认初始值为 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('递增计数器', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('递减计数器', () => {
const { result } = renderHook(() => useCounter(2));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(1);
});
it('重置计数器', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
it('设置特定值', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.setCount(42);
});
expect(result.current.count).toBe(42);
});
});
2. 集成测试 🔗
为什么需要集成测试?
集成测试验证多个模块如何协同工作,确保系统各部分正确集成。
解决的问题:
- 发现模块间的集成问题
- 验证数据流和状态管理
- 确保 API 集成正常工作
缺点与限制:
- 执行速度比单元测试慢
- 设置和维护更复杂
- 可能需要真实的外部依赖
详细代码示例
// 🔗 组件集成测试示例
// src/components/UserProfile/UserProfile.integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { UserProvider } from '@/contexts/UserContext';
import { NotificationProvider } from '@/contexts/NotificationContext';
import { server } from '@/mocks/server';
// 设置 API Mock
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
afterAll(() => server.close());
describe('UserProfile Integration', () => {
const renderWithProviders = (component: React.ReactElement) => {
return render(
<UserProvider>
<NotificationProvider>
{component}
</NotificationProvider>
</UserProvider>
);
};
it('加载并显示用户信息', async () => {
renderWithProviders(<UserProfile userId="123" />);
// 验证加载状态
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
// 验证用户信息显示
expect(screen.getByText('zhangsan@example.com')).toBeInTheDocument();
expect(screen.getByText('高级用户')).toBeInTheDocument();
});
it('编辑用户信息', async () => {
const user = userEvent.setup();
renderWithProviders(<UserProfile userId="123" />);
// 等待数据加载
await screen.findByText('张三');
// 点击编辑按钮
await user.click(screen.getByRole('button', { name: /编辑/i }));
// 验证表单显示
expect(screen.getByLabelText(/姓名/i)).toBeInTheDocument();
expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument();
// 修改信息
await user.clear(screen.getByLabelText(/姓名/i));
await user.type(screen.getByLabelText(/姓名/i), '李四');
// 提交表单
await user.click(screen.getByRole('button', { name: /保存/i }));
// 验证成功消息
await waitFor(() => {
expect(screen.getByText('用户信息更新成功')).toBeInTheDocument();
});
// 验证数据更新
expect(screen.getByText('李四')).toBeInTheDocument();
});
it('处理网络错误', async () => {
// 模拟 API 错误
server.use(
rest.get('/api/users/123', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: '服务器错误' }));
})
);
renderWithProviders(<UserProfile userId="123" />);
// 验证错误处理
await waitFor(() => {
expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
});
// 验证重试功能
const retryButton = screen.getByRole('button', { name: /重试/i });
await userEvent.click(retryButton);
// 注意:这里需要重新 mock 成功的响应
});
});
// 🔗 API 集成测试
// src/services/api.integration.test.ts
import { fetchUser, updateUser, deleteUser } from './userApi';
import { server } from '@/mocks/server';
describe('User API Integration', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('成功获取用户信息', async () => {
const user = await fetchUser('123');
expect(user).toEqual({
id: '123',
name: '测试用户',
email: 'test@example.com',
role: 'user',
});
});
it('处理 404 错误', async () => {
server.use(
rest.get('/api/users/999', (req, res, ctx) => {
return res(ctx.status(404));
})
);
await expect(fetchUser('999')).rejects.toThrow('用户不存在');
});
it('更新用户信息', async () => {
const updates = { name: '新名字', email: 'new@example.com' };
const updatedUser = await updateUser('123', updates);
expect(updatedUser.name).toBe('新名字');
expect(updatedUser.email).toBe('new@example.com');
});
});
// 🔗 状态管理集成测试
// src/store/userStore.integration.test.ts
import { renderHook, act } from '@testing-library/react';
import { useUserStore } from './userStore';
import { server } from '@/mocks/server';
describe('User Store Integration', () => {
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
// 重置 store 状态
const { result } = renderHook(() => useUserStore());
act(() => result.current.reset());
});
afterAll(() => server.close());
it('登录流程', async () => {
const { result } = renderHook(() => useUserStore());
expect(result.current.user).toBeNull();
expect(result.current.isLoading).toBe(false);
// 执行登录
await act(async () => {
await result.current.login('test@example.com', 'password');
});
// 验证登录结果
expect(result.current.user).toEqual({
id: '123',
name: '测试用户',
email: 'test@example.com',
});
expect(result.current.isLoading).toBe(false);
});
it('登录失败处理', async () => {
// 模拟登录失败
server.use(
rest.post('/api/login', (req, res, ctx) => {
return res(ctx.status(401), ctx.json({ error: '认证失败' }));
})
);
const { result } = renderHook(() => useUserStore());
await act(async () => {
await expect(
result.current.login('wrong@example.com', 'wrong')
).rejects.toThrow('认证失败');
});
expect(result.current.user).toBeNull();
expect(result.current.error).toBe('认证失败');
});
});
3. E2E 测试:Playwright 🌍
为什么需要 E2E 测试?
E2E 测试模拟真实用户行为,验证整个应用程序从开始到结束的工作流程。
解决的问题:
- 验证完整的用户流程
- 发现集成和环境相关问题
- 确保关键业务功能正常工作
缺点与限制:
- 执行速度最慢
- 测试脆弱,容易受 UI 变化影响
- 调试和维护成本较高
详细配置与示例
// 🌍 Playwright 配置文件
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['json', { outputFile: 'test-results.json' }],
],
use: {
baseURL: process.env.BASE_URL || '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'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
// 🌍 关键用户流程测试
// e2e/critical-flows.spec.ts
import { test, expect } from '@playwright/test';
test.describe('关键用户流程', () => {
test('用户完整注册流程', async ({ page }) => {
// 1. 访问首页
await page.goto('/');
await expect(page).toHaveTitle('我的应用');
// 2. 导航到注册页
await page.click('text=注册');
await expect(page).toHaveURL(/.*\/register/);
// 3. 填写注册表单
await page.fill('[data-testid="email"]', 'test@example.com');
await page.fill('[data-testid="password"]', 'Password123!');
await page.fill('[data-testid="confirmPassword"]', 'Password123!');
await page.fill('[data-testid="fullName"]', '测试用户');
// 4. 提交表单
await page.click('button[type="submit"]');
// 5. 验证重定向和成功消息
await expect(page).toHaveURL(/.*\/dashboard/);
await expect(page.locator('[data-testid="welcome-message"]'))
.toContainText('欢迎,测试用户');
});
test('购物车完整流程', async ({ page }) => {
await page.goto('/products');
// 1. 浏览商品
await expect(page.locator('[data-testid="product-list"]')).toBeVisible();
// 2. 搜索商品
await page.fill('[data-testid="search-input"]', '笔记本电脑');
await page.click('[data-testid="search-button"]');
// 3. 添加商品到购物车
const firstProduct = page.locator('[data-testid="product-item"]').first();
await firstProduct.locator('[data-testid="add-to-cart"]').click();
// 验证购物车数量更新
await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');
// 4. 前往购物车
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL(/.*\/cart/);
// 5. 验证购物车内容
await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1);
await expect(page.locator('[data-testid="total-price"]')).toBeVisible();
// 6. 结账流程
await page.click('text=去结账');
await expect(page).toHaveURL(/.*\/checkout/);
// 7. 填写配送信息
await page.fill('[data-testid="shipping-name"]', '收货人');
await page.fill('[data-testid="shipping-address"]', '收货地址');
await page.fill('[data-testid="shipping-phone"]', '13800138000');
// 8. 选择支付方式并提交订单
await page.click('[data-testid="payment-method-alipay"]');
await page.click('[data-testid="place-order"]');
// 9. 验证订单完成
await expect(page).toHaveURL(/.*\/order-success/);
await expect(page.locator('[data-testid="success-message"]'))
.toContainText('订单提交成功');
});
test('用户登录和权限控制', async ({ page }) => {
// 1. 访问受保护页面
await page.goto('/dashboard');
// 2. 验证重定向到登录页
await expect(page).toHaveURL(/.*\/login/);
// 3. 登录
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password');
await page.click('button[type="submit"]');
// 4. 验证成功登录并重定向
await expect(page).toHaveURL(/.*\/dashboard/);
// 5. 验证用户菜单显示
await page.click('[data-testid="user-menu"]');
await expect(page.locator('[data-testid="user-name"]'))
.toContainText('当前用户');
});
});
// 🌍 页面对象模型 (Page Object Model)
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[data-testid="email"]');
this.passwordInput = page.locator('[data-testid="password"]');
this.submitButton = page.locator('button[type="submit"]');
this.errorMessage = page.locator('[data-testid="error-message"]');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async getErrorMessage() {
return this.errorMessage.textContent();
}
}
// 🌍 使用页面对象的测试
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('登录功能', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('成功登录', async ({ page }) => {
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL(/.*\/dashboard/);
await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
});
test('登录失败显示错误信息', async () => {
await loginPage.login('wrong@example.com', 'wrong');
const errorMessage = await loginPage.getErrorMessage();
expect(errorMessage).toContain('邮箱或密码错误');
});
test('表单验证', async () => {
await loginPage.login('', '');
await expect(loginPage.emailInput).toHaveAttribute('aria-invalid', 'true');
await expect(loginPage.passwordInput).toHaveAttribute('aria-invalid', 'true');
});
});
// 🌍 CI 集成配置
// .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Build application
run: npm run build
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
4. 视觉回归测试 🖼️
为什么需要视觉测试?
视觉测试确保 UI 组件在不同版本间保持一致的视觉外观,捕捉意外的样式变化。
解决的问题:
- 检测意外的视觉回归
- 确保跨浏览器一致性
- 验证响应式设计
缺点与限制:
- 对微小变化敏感,可能产生误报
- 需要维护基线图片
- 执行速度较慢
详细配置与示例
// 🖼️ Storybook 配置
// .storybook/main.js
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-interactions',
'@storybook/addon-viewport',
],
framework: '@storybook/react-vite',
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
},
staticDirs: ['../public'],
};
// 🖼️ 组件 Stories
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
parameters: {
layout: 'centered',
chromatic: {
disable: false,
viewports: [375, 768, 1200],
},
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'danger'],
},
size: {
control: { type: 'select' },
options: ['small', 'medium', 'large'],
},
disabled: {
control: { type: 'boolean' },
},
loading: {
control: { type: 'boolean' },
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: '主要按钮',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: '次要按钮',
},
};
export const Danger: Story = {
args: {
variant: 'danger',
children: '危险操作',
},
};
export const Small: Story = {
args: {
size: 'small',
children: '小按钮',
},
};
export const Large: Story = {
args: {
size: 'large',
children: '大按钮',
},
};
export const Disabled: Story = {
args: {
disabled: true,
children: '禁用按钮',
},
};
export const Loading: Story = {
args: {
loading: true,
children: '加载中',
},
};
// 🖼️ 交互测试 Stories
// src/components/Modal/Modal.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect } from '@storybook/test';
import { Modal } from './Modal';
const meta: Meta<typeof Modal> = {
title: 'UI/Modal',
component: Modal,
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof Modal>;
export const Default: Story = {
args: {
title: '示例弹窗',
children: '这是弹窗的内容',
isOpen: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 验证弹窗标题
await expect(canvas.getByText('示例弹窗')).toBeInTheDocument();
// 验证弹窗内容
await expect(canvas.getByText('这是弹窗的内容')).toBeInTheDocument();
},
};
export const WithInteractions: Story = {
args: {
title: '交互测试',
children: '点击关闭按钮应该关闭弹窗',
isOpen: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// 点击关闭按钮
const closeButton = canvas.getByLabelText('关闭');
await userEvent.click(closeButton);
// 验证 onClose 被调用
await expect(args.onClose).toHaveBeenCalled();
},
};
// 🖼️ Chromatic 配置
// .storybook/chromatic.config.js
import { defineConfig } from 'chromatic';
export default defineConfig({
projectId: 'your-project-id',
storybook: {
build: {
outputDir: 'storybook-static',
},
},
// 只在 main 分支上自动接受更改
autoAcceptChanges: process.env.BRANCH === 'main',
// 设置视觉测试的阈值
diffThreshold: 0.2,
// 需要手动审核的 stories
storiesToReview: [
'UI/Button--Primary',
'UI/Modal--Default',
],
});
// 🖼️ package.json 脚本
{
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"chromatic": "chromatic --exit-zero-on-changes",
"test:visual": "npm run build-storybook && chromatic"
}
}
// 🖼️ CI 集成配置
// .github/workflows/visual.yml
name: Visual Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Publish to Chromatic
uses: chromaui/action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitOnceUploaded: true
autoAcceptChanges: ${{ github.ref == 'refs/heads/main' }}
工程规范 📋
1. ESLint 配置 🛡️
为什么需要 ESLint?
ESLint 通过静态分析识别代码中的问题和模式违规,确保代码质量和一致性。
解决的问题:
- 强制执行编码标准
- 提前发现潜在错误
- 保持代码风格一致性
缺点与限制:
- 配置复杂,学习曲线较陡
- 可能产生误报或漏报
- 严格的规则可能影响开发速度
详细配置示例
// .eslintrc.js
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
ecmaVersion: 2022,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
es2022: true,
node: true,
jest: true,
},
extends: [
// ESLint 推荐规则
'eslint:recommended',
// TypeScript 规则
'@typescript-eslint/recommended',
'@typescript-eslint/recommended-requiring-type-checking',
// React 规则
'plugin:react/recommended',
'plugin:react-hooks/recommended',
// 可访问性规则
'plugin:jsx-a11y/recommended',
// 导入排序规则
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
// Prettier 兼容(必须放在最后)
'prettier',
],
plugins: [
'@typescript-eslint',
'react',
'react-hooks',
'jsx-a11y',
'import',
'prettier',
],
rules: {
// TypeScript 规则
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/prefer-const': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
// React 规则
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'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',
// 代码质量规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
// Prettier 集成
'prettier/prettier': 'error',
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
overrides: [
{
files: ['**/*.test.{js,jsx,ts,tsx}'],
env: {
jest: true,
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['**/*.stories.{js,jsx,ts,tsx}'],
rules: {
'import/no-anonymous-default-export': 'off',
},
},
],
};
// 自定义 ESLint 规则示例
// eslint-plugin-custom-rules/index.js
module.exports = {
rules: {
'no-relative-imports': {
meta: {
type: 'problem',
docs: {
description: '禁止使用相对路径导入',
category: 'Best Practices',
recommended: true,
},
messages: {
noRelativeImports: '请使用绝对路径导入,避免使用相对路径',
},
},
create(context) {
return {
ImportDeclaration(node) {
const importPath = node.source.value;
// 检查是否是相对路径
if (importPath.startsWith('.')) {
context.report({
node,
messageId: 'noRelativeImports',
});
}
},
};
},
},
'no-hardcoded-colors': {
meta: {
type: 'problem',
docs: {
description: '禁止硬编码颜色值',
category: 'Best Practices',
recommended: true,
},
messages: {
noHardcodedColors: '请使用设计系统中的颜色变量,避免硬编码颜色值',
},
},
create(context) {
return {
Literal(node) {
const value = node.value;
const colorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|rgb|hsl|rgba|hsla/i;
if (typeof value === 'string' && colorRegex.test(value)) {
context.report({
node,
messageId: 'noHardcodedColors',
});
}
},
};
},
},
},
};
2. Prettier 配置 🧹
为什么需要 Prettier?
Prettier 自动格式化代码,确保团队代码风格一致,减少格式争议。
解决的问题:
- 自动统一代码风格
- 减少代码审查中的格式讨论
- 提高代码可读性
缺点与限制:
- 某些自定义格式可能无法配置
- 可能与现有代码风格冲突
- 需要团队适应自动化格式
详细配置示例
// .prettierrc.js
module.exports = {
// 每行最大字符数
printWidth: 100,
// 缩进使用空格数
tabWidth: 2,
// 使用空格而不是制表符
useTabs: false,
// 语句末尾添加分号
semi: true,
// 使用单引号
singleQuote: true,
// 对象属性引号使用方式
quoteProps: 'as-needed',
// JSX 中使用单引号
jsxSingleQuote: true,
// 尾随逗号(ES5 标准)
trailingComma: 'es5',
// 对象花括号内的空格
bracketSpacing: true,
// JSX 标签的闭合括号位置
bracketSameLine: false,
// 箭头函数参数括号
arrowParens: 'avoid',
// 格式化范围
rangeStart: 0,
rangeEnd: Infinity,
// 不需要在文件顶部添加 @format 标记
requirePragma: false,
// 不插入 @format 标记
insertPragma: false,
// 折行标准
proseWrap: 'preserve',
// HTML 空白敏感性
htmlWhitespaceSensitivity: 'css',
// Vue 文件脚本和样式标签缩进
vueIndentScriptAndStyle: false,
// 换行符
endOfLine: 'lf',
// 嵌入式语言格式化
embeddedLanguageFormatting: 'auto',
// 单个属性时的括号
singleAttributePerLine: false,
};
// Prettier 忽略文件
// .prettierignore
# 依赖目录
node_modules/
dist/
build/
# 生成的文件
coverage/
*.log
# 配置文件
*.config.js
# 锁文件
package-lock.json
yarn.lock
# 文档
*.md
*.mdx
# 图片和字体
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.woff
*.woff2
// package.json 中的格式化脚本
{
"scripts": {
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
"format:staged": "lint-staged"
}
}
3. Git Hooks 配置 🪝
为什么需要 Git Hooks?
Git Hooks 在代码提交和推送前自动运行检查,防止低质量代码进入仓库。
解决的问题:
- 自动化代码质量检查
- 强制执行代码标准
- 减少 CI 失败次数
缺点与限制:
- 可能减慢开发流程
- 需要团队统一配置
- 复杂的钩子可能难以调试
详细配置示例
// package.json 中的 Husky 配置
{
"scripts": {
"prepare": "husky install",
"lint": "eslint src --ext .ts,.tsx,.js,.jsx --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ci": "vitest run --coverage",
"validate": "npm run lint && npm run typecheck && npm run test:ci"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,mdx,css,scss,yml,yaml}": [
"prettier --write"
],
"*.{ts,tsx}": [
"bash -c 'npm run typecheck'"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "npm run test:ci",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}
// commitlint 配置
// .commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat', // 新功能
'fix', // 修复 bug
'docs', // 文档更新
'style', // 代码格式调整
'refactor', // 代码重构
'test', // 测试相关
'chore', // 构建过程或辅助工具变动
'perf', // 性能优化
'ci', // CI 配置变更
'revert', // 回滚提交
],
],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
'scope-case': [2, 'always', 'lower-case'],
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'header-max-length': [2, 'always', 100],
},
};
// 手动设置 Git Hooks(Husky v8+)
// .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint-staged
// .husky/pre-push
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run test:ci
// .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit "$1"
// 自定义 Git Hook 脚本示例
// scripts/pre-commit-check.sh
#!/bin/bash
# 检查是否有未解决的合并冲突
if git grep -l '<<<<<<<' -- ':(exclude)package-lock.json' | grep -q .; then
echo "错误: 发现未解决的合并冲突"
git grep -l '<<<<<<<' -- ':(exclude)package-lock.json'
exit 1
fi
# 检查调试语句
if git diff --cached --name-only | xargs grep -l 'console.log\|debugger' | grep -q .; then
echo "警告: 发现调试语句"
git diff --cached --name-only | xargs grep -l 'console.log\|debugger'
read -p "是否继续提交? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# 检查文件大小
MAX_FILE_SIZE=5242880 # 5MB
for file in $(git diff --cached --name-only); do
if [ -f "$file" ]; then
size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)
if [ "$size" -gt "$MAX_FILE_SIZE" ]; then
echo "错误: 文件 $file 过大 ($size 字节),最大允许 $MAX_FILE_SIZE 字节"
exit 1
fi
fi
done
echo "预提交检查通过"
exit 0
4. Commitizen 标准化提交 ✍️
为什么需要标准化提交?
标准化提交信息便于自动化生成变更日志、版本管理和代码审查。
解决的问题:
- 统一的提交信息格式
- 自动化版本管理
- 清晰的变更历史
缺点与限制:
- 需要团队成员适应新流程
- 可能增加提交的复杂性
- 某些简单修改可能显得过度正式
详细配置示例
// .cz-config.js
module.exports = {
types: [
{ value: 'feat', name: 'feat: 新功能' },
{ value: 'fix', name: 'fix: 修复 bug' },
{ value: 'docs', name: 'docs: 文档更新' },
{ value: 'style', name: 'style: 代码格式调整(不影响功能)' },
{ value: 'refactor', name: 'refactor: 代码重构(既不是新功能也不是修复 bug)' },
{ value: 'perf', name: 'perf: 性能优化' },
{ value: 'test', name: 'test: 测试相关' },
{ value: 'chore', name: 'chore: 构建过程或辅助工具变动' },
{ value: 'ci', name: 'ci: CI 配置变更' },
{ value: 'revert', name: 'revert: 回滚提交' },
],
scopes: [
{ name: 'ui', description: '用户界面相关' },
{ name: 'api', description: 'API 相关' },
{ name: 'auth', description: '认证授权相关' },
{ name: 'database', description: '数据库相关' },
{ name: 'config', description: '配置相关' },
{ name: 'deps', description: '依赖更新' },
{ name: 'other', description: '其他' },
],
messages: {
type: '选择提交类型:',
scope: '选择影响范围 (可选):',
customScope: '输入自定义范围:',
subject: '简短描述(必填):\n',
body: '详细描述(可选). 使用 "|" 换行:\n',
breaking: '破坏性变化说明(可选):\n',
footer: '关联关闭的 issue(可选). 例如: #31, #34:\n',
confirmCommit: '确认提交?',
},
allowCustomScopes: true,
allowBreakingChanges: ['feat', 'fix'],
skipQuestions: ['body', 'footer'],
subjectLimit: 100,
// 范围验证
scopeOverrides: {
fix: [
{ name: 'merge' },
{ name: 'style' },
{ name: 'e2eTest' },
{ name: 'unitTest' },
],
},
};
// 提交信息验证脚本
// scripts/verify-commit-msg.js
const fs = require('fs');
const path = require('path');
// 获取提交信息
const commitMsgFile = process.argv[2];
const commitMsg = fs.readFileSync(commitMsgFile, 'utf8').trim();
// 提交信息格式正则
const commitRegex = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|revert)(\([^)]+\))?: .{1,100}/;
if (!commitRegex.test(commitMsg)) {
console.error(`
提交信息格式错误!
正确格式: <type>(<scope>): <subject>
示例:
- feat(auth): 添加用户登录功能
- fix(ui): 修复按钮点击无效的问题
- docs: 更新 README 文档
允许的类型:
- feat: 新功能
- fix: 修复 bug
- docs: 文档更新
- style: 代码格式调整
- refactor: 代码重构
- perf: 性能优化
- test: 测试相关
- chore: 构建过程或辅助工具变动
- ci: CI 配置变更
- revert: 回滚提交
`);
process.exit(1);
}
console.log('✅ 提交信息格式正确');
process.exit(0);
// 自动化版本管理和变更日志生成
// .versionrc.js
module.exports = {
types: [
{ type: 'feat', section: '新功能' },
{ type: 'fix', section: 'Bug 修复' },
{ type: 'docs', section: '文档' },
{ type: 'style', section: '代码风格' },
{ type: 'refactor', section: '代码重构' },
{ type: 'perf', section: '性能优化' },
{ type: 'test', section: '测试' },
{ type: 'chore', section: '构建工具' },
{ type: 'ci', section: 'CI 配置' },
{ type: 'revert', section: '回滚' },
],
commitUrlFormat: '{{host}}/{{owner}}/{{repository}}/commit/{{hash}}',
compareUrlFormat: '{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}',
issueUrlFormat: '{{host}}/{{owner}}/{{repository}}/issues/{{id}}',
userUrlFormat: '{{host}}/{{user}}',
};
// package.json 中的相关脚本
{
"scripts": {
"commit": "cz",
"commit:retry": "git add . && cz --retry",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"release": "standard-version",
"release:minor": "standard-version --release-as minor",
"release:major": "standard-version --release-as major"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}
总结 🎯
通过实施完整的代码质量工程体系,团队可以获得以下收益:
核心优势
- 类型安全 - 减少运行时错误,提高代码可靠性
- 测试覆盖 - 确保功能正确性,支持持续重构
- 规范统一 - 提高代码可读性和可维护性
- 自动化流程 - 减少人为错误,提高开发效率
实施建议
- 渐进式采用 - 从最急需的环节开始,逐步推广
- 团队培训 - 确保所有成员理解并认可质量工程的价值
- 持续优化 - 定期回顾和改进质量工程实践
- 工具整合 - 将质量检查集成到开发工作流中
成功指标
- 类型检查通过率 100%
- 测试覆盖率 > 80%
- CI/CD 流水线通过率 > 95%
- 代码审查反馈周期缩短
- 生产环境 bug 数量显著减少
通过系统化地实施这些代码质量工程实践,团队可以构建出更加健壮、可维护且高质量的软件产品。