React开发规范
Hooks、组件、状态管理
- [Requirement] 组件必须为函数组件,使用 TypeScript 定义 Props 类型;禁止使用 class 组件与
React.FC泛型(会隐式注入children,且与默认参数不兼容)。 - [Requirement] Props 必须显式声明类型并使用
interface命名XxxProps;必填项不设默认值,可选项用?:并在解构时给默认值。 - [Requirement] 组件文件一个文件只导出一个主组件,文件名与组件名一致(大驼峰),私有子组件同文件内以下划线或同文件命名。
- [Requirement] 禁止在组件内部定义另一个组件(每次渲染重新创建导致子树重挂载),子组件提到模块作用域。
- [Standard] 组件遵循单一职责,超过 200 行或嵌套 JSX 超过 4 层时拆分为子组件;提取自定义 Hook 复用逻辑。
- [Standard]
children显式声明为React.ReactNode;不使用React.FC隐式 children。 - [Standard] 列表渲染必须使用稳定的
key(业务 ID),禁止使用数组下标index作为key(列表会重排时)。 - [Standard] 事件处理函数命名
handleXxx,Props 中的回调命名onXxx,如onClick触发handleClick。 - [Default] 受控组件状态就近存放,能在子组件内部维护的状态不上提到父组件。
- [Suggestion] 组件按「数据准备 → 副作用 → 渲染」三段式组织代码,便于阅读。
- [Caution] 禁止在渲染函数中直接调用会改变外部状态的逻辑(如
setState、fetch),必须放入事件回调或useEffect。
// UserCard.tsx
import { useState } from 'react';
interface UserCardProps {
user: { id: string; name: string; avatar: string | null };
onFollow?: (userId: string) => void;
/** 是否显示关注按钮,默认 true */
showFollow?: boolean;
}
export function UserCard({ user, onFollow, showFollow = true }: UserCardProps) {
const [followed, setFollowed] = useState(false);
const handleFollow = () => {
setFollowed(true);
onFollow?.(user.id);
};
return (
<div className="user-card">
<img src={user.avatar ?? defaultAvatar} alt={user.name} />
<span>{user.name}</span>
{showFollow && (
<button onClick={handleFollow} disabled={followed}>
{followed ? '已关注' : '关注'}
</button>
)}
</div>
);
}// 列表渲染使用业务 ID 作为 key
function UserList({ users }: { users: User[] }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}// 反模式:组件内定义子组件
function Parent() {
function Child() { return <div />; } // 每次渲染重建,子树重挂载
return <Child />;
}
// 反模式:用 index 作为 key
{items.map((item, i) => <li key={i}>{item.name}</li>)}
// 反模式:渲染中直接 setState
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1); // 无限循环
return <div>{count}</div>;
}
// 反模式:使用 React.FC
const UserCard: React.FC<UserCardProps> = (props) => { /* ... */ };
// React.FC 隐式包含 children,与显式 Props 冲突且默认参数不生效- [Requirement] 只在函数组件或自定义 Hook 顶层调用 Hook,禁止在循环、条件、嵌套函数中调用;自定义 Hook 必须以
use开头。 - [Requirement]
useEffect必须声明完整依赖数组;禁止故意省略依赖以”少执行”,应通过useRef/useCallback/ 状态提升解决。 - [Requirement]
useEffect的清理函数必须返回,用于订阅、定时器、监听器的释放,避免内存泄漏。 - [Requirement] 自定义 Hook 返回值使用数组或对象,调用方按需解构;Hook 内部状态不暴露给外部直接修改。
- [Standard] 能由现有状态或 Props 推导出来的值使用
useMemo缓存,或直接计算(简单计算不滥用useMemo)。 - [Standard] 传递给子组件的回调使用
useCallback包裹,避免子组件因引用变化重复渲染。 - [Standard]
useState初始值为复杂计算时使用惰性初始化useState(() => computeInitial())。 - [Default] 多个相关状态合并为一个对象或使用
useReducer,避免多个useState导致的状态不一致。 - [Default] 避免在
useEffect中同步派生状态(useEffect(() => setX(props.x), [props.x])),应直接使用 Props 或在渲染中计算。 - [Suggestion] 自定义 Hook 超过 50 行或被 3 处以上复用时,提取到
src/hooks/目录并补充 JSDoc。 - [Caution] 禁止在
useEffect中无条件setState依赖该 state 自身,会导致无限渲染循环。
// 推荐:useEffect 完整依赖 + 清理
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // 必须清理
}, [value, delay]);
return debounced;
}
// 推荐:useReducer 管理复杂状态
interface State { count: number; status: 'idle' | 'loading' | 'error' }
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset'; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment': return { ...state, count: state.count + 1 };
case 'decrement': return { ...state, count: state.count - 1 };
case 'reset': return { count: action.payload, status: 'idle' };
default: return state;
}
}// 反模式:省略依赖
useEffect(() => {
fetchData(userId);
}, []); // userId 变化不会重新执行
// 反模式:不清理副作用
useEffect(() => {
window.addEventListener('resize', handleResize);
// 缺少 removeEventListener,组件卸载后泄漏
}, []);
// 反模式:同步派生状态
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// 应直接:const fullName = `${firstName} ${lastName}`;
// 反模式:无限循环
useEffect(() => {
setCount(c => c + 1); // 依赖 count 自身
}, [count]);- [Requirement] 状态按作用域就近管理:组件局部状态用
useState,跨组件共享用 Context 或状态库,服务端数据用 React Query / SWR。 - [Requirement] 服务端缓存状态(接口数据)与客户端 UI 状态必须分离,禁止把接口返回数据塞入全局 UI store。
- [Requirement] 全局状态库选型后全项目统一,禁止 Redux、Zustand、MobX 混用;本规范默认使用 Zustand(轻量)或 Redux Toolkit(大型应用)。
- [Standard] Context 仅用于低频更新的全局数据(主题、语言、鉴权用户),高频变化数据不要放 Context 否则所有消费者重渲染。
- [Standard] 异步状态(loading/error/data)使用 React Query / SWR 管理,不手写
useEffect + useState三件套。 - [Standard] 表单状态用
react-hook-form或Formik,不自行管理大量useState;字段级校验用zodschema。 - [Default] 状态更新使用函数式更新
setCount(c => c + 1),避免依赖闭包中的旧值。 - [Default] 跨层级共享但更新不频繁的数据优先用 Context +
useReducer,不引入额外状态库。 - [Suggestion] 复杂领域状态建模为有限状态机(XState),明确状态与转移,避免多个布尔 flag 组合。
- [Caution] 禁止把整个大对象放入 Context 后在 Provider 中每次渲染创建新对象,导致所有消费者重渲染。
// 推荐:React Query 管理服务端状态
import { useQuery } from '@tanstack/react-query';
function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000,
});
}
// 推荐:Zustand 管理客户端 UI 状态
import { create } from 'zustand';
interface UIState {
sidebarOpen: boolean;
toggleSidebar: () => void;
}
const useUIStore = create<UIState>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));
// 推荐:Context 仅放低频数据 + memo 化 value
const AuthContext = createContext<{ user: User | null } | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }) {
const user = useCurrentUser();
const value = useMemo(() => ({ user }), [user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}// 反模式:Context value 每次渲染新建
function App() {
const [count, setCount] = useState(0);
return (
<Ctx.Provider value={{ count }}> // 每次渲染新对象
<ExpensiveTree />
</Ctx.Provider>
);
}
// 反模式:服务端数据混入全局 UI store
const useStore = create((set) => ({
users: [],
theme: 'light',
fetchUsers: async () => set({ users: await api.getUsers() }),
// users 是服务端数据,应放 React Query,不应与 theme 混在一个 store
}));
// 反模式:大量 useState 管理表单
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
// ... 10+ 字段,应使用 react-hook-form
}- [Requirement] 性能优化以测量为前提,使用 React DevTools Profiler 定位瓶颈后再优化,禁止提前优化。
- [Requirement] 列表渲染使用虚拟滚动(
react-window/@tanstack/react-virtual)处理超过 100 条的长列表。 - [Requirement] 图片必须设置
width/height或aspect-ratio,使用懒加载loading="lazy",避免布局抖动(CLS)。 - [Standard] 昂贵计算用
useMemo缓存,传递给子组件的稳定引用用useCallback,但简单计算/短回调不滥用(hook 本身有开销)。 - [Standard] 避免在渲染中创建新对象/数组/函数作为 Props 传递给 memo 化子组件,否则 memo 失效。
- [Standard] 大组件使用
React.memo包裹,但需配合稳定的 Props 引用;Props 含对象/函数时必须useMemo/useCallback。 - [Default] 懒加载路由级组件使用
React.lazy+Suspense,拆分首屏包体积。 - [Default] 状态下放到需要它的最近公共父组件,避免顶层状态变化导致整树重渲染。
- [Suggestion] 使用
useDeferredValue/useTransition处理非紧急更新(搜索过滤、大列表重渲染),保持输入响应。 - [Caution] 禁止在
useMemo/useCallback中执行有副作用的操作,它们只用于性能优化。
// 推荐:虚拟滚动长列表
import { FixedSizeList } from 'react-window';
function LongList({ items }: { items: Item[] }) {
return (
<FixedSizeList height={600} width="100%" itemCount={items.length} itemSize={50}>
{({ index, style }) => (
<div style={style}>{items[index].name}</div>
)}
</FixedSizeList>
);
}
// 推荐:useTransition 保持输入响应
function Search() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const results = useMemo(() => filterItems(query), [query]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value); // 紧急更新
startTransition(() => {
setFiltered(filterItems(e.target.value)); // 非紧急更新
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<List items={results} />
</>
);
}// 反模式:渲染中创建新对象作为 props
function Parent() {
const style = { color: 'red' }; // 每次渲染新对象
return <Child style={style} />; // Child 即便 memo 也会重渲染
}
// 反模式:滥用 useMemo 包裹简单计算
const doubled = useMemo(() => count * 2, [count]); // 简单乘法不需要 memo
// 反模式:同步加载所有路由
import { Dashboard } from './pages/Dashboard';
// 应改为 React.lazy(() => import('./pages/Dashboard'))- [Requirement] 组件测试必须使用
@testing-library/react,禁止测试实现细节(如内部 state、私有方法),只测试用户可见行为。 - [Requirement] 测试断言使用
@testing-library/jest-dom提供的toBeInTheDocument等匹配器,不使用querySelector直接查 DOM。 - [Requirement] 异步操作使用
findBy/waitFor,禁止setTimeout或手动await new Promise(r => setTimeout(r))。 - [Requirement] 测试隔离:每个测试用例独立,不依赖执行顺序,
afterEach清理副作用。 - [Standard] 自定义 Hook 使用
@testing-library/react的renderHook测试,不通过包装组件间接测试。 - [Standard] 服务端请求使用
MSW(Mock Service Worker) mock,不 mockfetch本身,保证请求契约真实。 - [Standard] 测试文件与源码同目录或
__tests__目录,命名Xxx.test.tsx;测试用例使用describe/it组织,描述行为而非实现。 - [Default] 关键交互路径(登录、下单、表单提交)必须有集成测试覆盖,组件级单元测试覆盖率不低于 60%。
- [Suggestion] 使用
userEvent模拟用户交互,不使用fireEvent(userEvent更接近真实用户行为)。 - [Caution] 禁止在测试中
console.log调试后提交,调试输出必须在提交前移除。
// UserCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './UserCard';
describe('UserCard', () => {
const mockUser = { id: '1', name: 'Alice', avatar: null };
it('renders user name', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('calls onFollow when follow button clicked', async () => {
const onFollow = jest.fn();
render(<UserCard user={mockUser} onFollow={onFollow} />);
await userEvent.click(screen.getByRole('button', { name: '关注' }));
expect(onFollow).toHaveBeenCalledWith('1');
expect(screen.getByRole('button')).toHaveTextContent('已关注');
});
});// 自定义 Hook 测试
import { renderHook, act } from '@testing-library/react';
import { useDebouncedValue } from './useDebouncedValue';
it('debounces value changes', () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebouncedValue(value, delay),
{ initialProps: { value: 'a', delay: 300 } },
);
expect(result.current).toBe('a');
rerender({ value: 'b', delay: 300 });
expect(result.current).toBe('a'); // 还未到 delay
act(() => jest.advanceTimersByTime(300));
expect(result.current).toBe('b');
});// 反模式:测试实现细节
it('sets followed state to true', () => {
const { instance } = render(<UserCard user={mockUser} />);
// 不应访问内部 state
});
// 反模式:使用 querySelector 直接查 DOM
it('renders name', () => {
const { container } = render(<UserCard user={mockUser} />);
expect(container.querySelector('.user-card span')?.textContent).toBe('Alice');
// 应使用 screen.getByText
});
// 反模式:用 setTimeout 等待异步
it('loads data', async () => {
render(<UserList />);
await new Promise((r) => setTimeout(r, 1000)); // 不稳定
expect(screen.getByText('Alice')).toBeInTheDocument();
});