本文档旨在为 OSPAY 系统的前端组件开发提供规范和指南,确保团队成员能够遵循一致的开发标准,提高代码质量和开发效率。文档涵盖组件设计原则、目录结构、开发流程、状态管理、测试要求以及最佳实践等方面。
OSPAY 前端采用以下技术栈:
OSPAY 前端组件分为以下几类:
基础组件 (Base Components):
src/components/base
目录业务组件 (Business Components):
src/components/business
目录布局组件 (Layout Components):
src/components/layout
目录页面组件 (Page Components):
src/pages
目录HOC (高阶组件):
src/hocs
目录自定义 Hooks:
src/hooks
目录单一职责原则:
可复用性:
可测试性:
关注点分离:
渐进增强:
组件应遵循以下目录结构:
ComponentName/
├── index.tsx # 组件入口和主要逻辑
├── styles.ts # 样式定义 (Styled Components)
├── types.ts # TypeScript 类型定义
├── constants.ts # 常量定义 (可选)
├── utils.ts # 工具函数 (可选)
├── hooks.ts # 组件特定的 hooks (可选)
└── __tests__/ # 测试文件目录
├── index.test.tsx # 单元测试
└── ...
src/
├── api/ # API 请求函数
├── assets/ # 静态资源
├── components/ # 组件
│ ├── base/ # 基础组件
│ ├── business/ # 业务组件
│ └── layout/ # 布局组件
├── constants/ # 全局常量
├── contexts/ # React Context
├── hooks/ # 自定义 Hooks
├── hocs/ # 高阶组件
├── locales/ # 国际化文本
├── pages/ # 页面组件
├── routes/ # 路由配置
├── services/ # 业务服务
│ ├── websocket/ # WebSocket 服务
│ └── ...
├── store/ # Redux 状态管理
│ ├── slices/ # Redux Toolkit slices
│ ├── selectors/ # Redux selectors
│ └── index.ts # Store 配置
├── styles/ # 全局样式
├── types/ # 全局类型定义
├── utils/ # 工具函数
├── App.tsx # 应用入口组件
└── main.tsx # 应用入口文件
文件命名:
组件命名:
每个组件都应定义清晰的 props 类型:
// types.ts
export interface ButtonProps {
/** 按钮类型 */
type?: 'primary' | 'secondary' | 'danger';
/** 按钮大小 */
size?: 'small' | 'medium' | 'large';
/** 是否禁用 */
disabled?: boolean;
/** 按钮内容 */
children: React.ReactNode;
/** 点击事件处理函数 */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
组件代码示例:
// index.tsx
import React from 'react';
import { ButtonContainer } from './styles';
import { ButtonProps } from './types';
export const Button: React.FC<ButtonProps> = ({
type = 'primary',
size = 'medium',
disabled = false,
children,
onClick,
...restProps
}) => {
return (
<ButtonContainer
type={type}
size={size}
disabled={disabled}
onClick={disabled ? undefined : onClick}
{...restProps}
>
{children}
</ButtonContainer>
);
};
export default Button;
使用 Styled Components 定义组件样式:
// styles.ts
import styled, { css } from 'styled-components';
import { ButtonProps } from './types';
type ButtonContainerProps = Pick<ButtonProps, 'type' | 'size' | 'disabled'>;
export const ButtonContainer = styled.button<ButtonContainerProps>`
border-radius: 4px;
font-weight: 500;
transition: all 0.2s ease;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
opacity: ${({ disabled }) => (disabled ? 0.6 : 1)};
/* Type Styles */
${({ type }) => {
switch (type) {
case 'primary':
return css`
background-color: ${({ theme }) => theme.colors.primary};
color: white;
border: none;
`;
case 'secondary':
return css`
background-color: white;
color: ${({ theme }) => theme.colors.primary};
border: 1px solid ${({ theme }) => theme.colors.primary};
`;
case 'danger':
return css`
background-color: ${({ theme }) => theme.colors.danger};
color: white;
border: none;
`;
default:
return '';
}
}}
/* Size Styles */
${({ size }) => {
switch (size) {
case 'small':
return css`
padding: 6px 12px;
font-size: 12px;
`;
case 'medium':
return css`
padding: 8px 16px;
font-size: 14px;
`;
case 'large':
return css`
padding: 12px 20px;
font-size: 16px;
`;
default:
return '';
}
}}
`;
组件应该包含详细的文档注释,描述其功能、props 和使用示例:
/**
* 基础按钮组件
*
* 用于表单提交、操作触发等场景
*
* @example
* ```tsx
* <Button type="primary" onClick={handleClick}>
* 提交
* </Button>
* ```
*/
export const Button: React.FC<ButtonProps> = ({ ... }) => { ... }
状态分层:
Redux 使用场景:
组件内状态:
使用 Redux Toolkit 创建 Slice:
// store/slices/authSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '../../types/user';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
}
const initialState: AuthState = {
user: null,
token: localStorage.getItem('token'),
isLoading: false,
error: null,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginStart(state) {
state.isLoading = true;
state.error = null;
},
loginSuccess(state, action: PayloadAction<{ user: User; token: string }>) {
state.isLoading = false;
state.user = action.payload.user;
state.token = action.payload.token;
localStorage.setItem('token', action.payload.token);
},
loginFailure(state, action: PayloadAction<string>) {
state.isLoading = false;
state.error = action.payload;
},
logout(state) {
state.user = null;
state.token = null;
localStorage.removeItem('token');
},
},
});
export const { loginStart, loginSuccess, loginFailure, logout } = authSlice.actions;
export default authSlice.reducer;
使用 Redux Toolkit 的 createAsyncThunk 处理异步操作:
// store/actions/authActions.ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { loginStart, loginSuccess, loginFailure } from '../slices/authSlice';
import { authApi } from '../../api/auth';
export const loginUser = createAsyncThunk(
'auth/login',
async ({ username, password }: { username: string; password: string }, { dispatch }) => {
try {
dispatch(loginStart());
const { user, token } = await authApi.login(username, password);
dispatch(loginSuccess({ user, token }));
return { user, token };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '登录失败';
dispatch(loginFailure(errorMessage));
throw error;
}
}
);
每个组件应该包含以下类型的测试:
// __tests__/index.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { theme } from '../../../styles/theme';
import Button from '../index';
// 测试包装器
const renderWithTheme = (ui: React.ReactElement) => {
return render(
<ThemeProvider theme={theme}>
{ui}
</ThemeProvider>
);
};
describe('Button Component', () => {
// 渲染测试
test('renders correctly with default props', () => {
renderWithTheme(<Button>Click Me</Button>);
const button = screen.getByText('Click Me');
expect(button).toBeInTheDocument();
expect(button).toHaveStyle('background-color: ' + theme.colors.primary);
});
// 交互测试
test('calls onClick handler when clicked', () => {
const handleClick = jest.fn();
renderWithTheme(<Button onClick={handleClick}>Click Me</Button>);
const button = screen.getByText('Click Me');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
// 条件渲染测试
test('does not call onClick when disabled', () => {
const handleClick = jest.fn();
renderWithTheme(<Button disabled onClick={handleClick}>Click Me</Button>);
const button = screen.getByText('Click Me');
fireEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
expect(button).toHaveStyle('opacity: 0.6');
expect(button).toHaveStyle('cursor: not-allowed');
});
// 不同变体测试
test('renders with correct styles for secondary type', () => {
renderWithTheme(<Button type="secondary">Secondary</Button>);
const button = screen.getByText('Secondary');
expect(button).toHaveStyle('background-color: white');
expect(button).toHaveStyle('color: ' + theme.colors.primary);
});
});
使用 React.memo 优化函数组件:
export default React.memo(Button);
避免不必要的重渲染:
列表优化:
路由级别代码分割:
// routes/index.tsx
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('../pages/Dashboard'));
const Orders = lazy(() => import('../pages/Orders'));
const routes = [
{
path: '/dashboard',
element: (
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
),
},
// ...
];
组件级别代码分割:
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const MyComponent = () => {
const [showHeavy, setShowHeavy] = useState(false);
return (
<div>
<button onClick={() => setShowHeavy(true)}>Show Heavy Component</button>
{showHeavy && (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
)}
</div>
);
};
图片优化:
样式优化:
// locales/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import enTranslation from './en/translation.json';
import zhTranslation from './zh/translation.json';
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslation },
zh: { translation: zhTranslation },
},
lng: localStorage.getItem('language') || 'zh',
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
});
export default i18n;
locales/
├── en/
│ └── translation.json
└── zh/
└── translation.json
import { useTranslation } from 'react-i18next';
const MyComponent: React.FC = () => {
const { t } = useTranslation();
return (
<div>
<h1>{t('welcome')}</h1>
<p>{t('description')}</p>
<button>{t('buttons.submit')}</button>
</div>
);
};
使用 Framer Motion 实现动画:
import { motion } from 'framer-motion';
const fadeIn = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
const SlideIn: React.FC = ({ children }) => {
return (
<motion.div
initial="hidden"
animate="visible"
variants={fadeIn}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
};
语义化 HTML:
键盘可访问性:
ARIA 属性:
颜色对比度:
需求讨论:
开发计划:
组件开发:
代码审查:
合并与发布:
版本控制:
废弃流程:
// components/base/BaseForm/index.tsx
import React from 'react';
import { Form as AntForm, FormProps as AntFormProps } from 'antd';
import { FormContainer } from './styles';
import { BaseFormProps } from './types';
export const BaseForm: React.FC<BaseFormProps> = ({
children,
title,
subtitle,
loading = false,
layout = 'vertical',
...restProps
}) => {
return (
<FormContainer $loading={loading}>
{(title || subtitle) && (
<div className="form-header">
{title && <h3 className="form-title">{title}</h3>}
{subtitle && <p className="form-subtitle">{subtitle}</p>}
</div>
)}
<AntForm
layout={layout}
className="base-form"
{...restProps}
>
{children}
</AntForm>
</FormContainer>
);
};
export default BaseForm;
// components/business/OrderCard/index.tsx
import React from 'react';
import { Card, Tag, Button } from 'antd';
import { useTranslation } from 'react-i18next';
import {
OrderCardContainer,
OrderHeader,
OrderDetails,
OrderActions
} from './styles';
import { OrderCardProps, OrderStatus } from './types';
import { formatCurrency, formatDate } from '../../../utils/formatters';
// 订单状态对应的标签颜色
const statusColorMap: Record<OrderStatus, string> = {
pending: 'orange',
processing: 'blue',
completed: 'green',
failed: 'red',
cancelled: 'gray',
};
export const OrderCard: React.FC<OrderCardProps> = ({
order,
onViewDetails,
onCancelOrder,
}) => {
const { t } = useTranslation();
const { id, status, amount, currency, createdAt, itemCount } = order;
// 是否可取消订单
const canCancel = ['pending', 'processing'].includes(status);
return (
<OrderCardContainer>
<Card>
<OrderHeader>
<div className="order-id">
<span className="label">{t('order.id')}:</span>
<span className="value">{id}</span>
</div>
<Tag color={statusColorMap[status]}>
{t(`order.status.${status}`)}
</Tag>
</OrderHeader>
<OrderDetails>
<div className="detail-item">
<span className="label">{t('order.amount')}:</span>
<span className="value">{formatCurrency(amount, currency)}</span>
</div>
<div className="detail-item">
<span className="label">{t('order.date')}:</span>
<span className="value">{formatDate(createdAt)}</span>
</div>
<div className="detail-item">
<span className="label">{t('order.items')}:</span>
<span className="value">{itemCount}</span>
</div>
</OrderDetails>
<OrderActions>
<Button type="primary" onClick={() => onViewDetails(id)}>
{t('order.viewDetails')}
</Button>
{canCancel && (
<Button danger onClick={() => onCancelOrder(id)}>
{t('order.cancel')}
</Button>
)}
</OrderActions>
</Card>
</OrderCardContainer>
);
};
export default React.memo(OrderCard);
本指南提供了 OSPAY 前端组件开发的基本规范和最佳实践。随着项目的发展,我们将不断完善和更新本文档。所有开发人员应遵循这些规范,确保代码的一致性、可维护性和高质量。
如有任何问题或建议,请联系前端技术负责人。
ESLint 配置示例:
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"plugins": [
"react",
"react-hooks",
"@typescript-eslint"
],
"rules": {
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
Prettier 配置示例:
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "avoid"
}