React 应用架构设计最佳实践
React 应用架构设计最佳实践
良好的架构是 React 应用可维护性和可扩展性的关键。一个优秀的 React 应用架构应该遵循关注点分离、单一职责、高内聚低耦合等原则,同时考虑团队协作、代码可维护性和长期演进能力。
架构不是一次性设计出来的,而是随着业务规模、团队规模、性能需求不断演进的结果。一个只有 3 个页面的原型项目,和一个由 40 名工程师维护、包含 200 个路由、每天部署 10 次的大型 SaaS 应用,需要的架构完全不同。过早的抽象和过度的架构设计,与完全没有架构一样有害:前者带来无谓的复杂度和心智负担,后者带来技术债的雪崩。本篇会从文件夹结构、组件设计、状态分层、数据流、复用模式、依赖倒置、Monorepo、代码分割、错误边界、可测试性、CI/CD、大型团队协作规范等维度,系统讲清楚 React 应用架构的取舍逻辑。
为什么架构如此重要
在展开细节之前,先用一组真实数据说明架构的价值。下表是社区与企业实践中反复出现的一组经验对比(数字为典型量级,非绝对值),说明"有意识的架构"与"野蛮生长"之间的差距:
| 维度 | 无架构(野蛮生长) | 有架构(分层 + feature) | 差距 |
| --- | --- | --- | --- |
| 新人上手时间 | 2 到 4 周 | 3 到 5 天 | 约 4 倍 |
| 平均改一个功能波及文件数 | 15 到 30 个 | 3 到 6 个 | 约 5 倍 |
| 单元测试覆盖率 | 低于 20% | 60% 到 80% | 数倍 |
| 首屏 JS 体积 | 2MB 以上 | 300KB 以下 | 约 6 倍 |
| 回归 bug 率(每次发布) | 频繁 | 显著下降 | 明显 |
| 并行开发冲突(merge conflict) | 高频 | 低频 | 明显 |
架构的核心目标可以浓缩成一句话:让"改动的成本"随着代码量增长保持近似线性,而不是指数级爆炸。当一个应用没有清晰的边界时,任何一个改动都可能引发不可预测的连锁反应,这就是所谓的"意大利面代码"(spaghetti code)。
一、文件夹结构
文件夹结构是架构最直观的体现。它决定了代码"住在哪里",也决定了团队成员"去哪里找代码"。好的目录结构应当让人在不阅读代码的情况下,仅凭路径就能猜到文件的职责。
1.1 按类型组织(Type-based)
按组件、hooks、utils 等类型划分是最朴素的方式,也是 create-react-app 默认引导的结构。
src/
├── components/
│ ├── Button.tsx
│ ├── Modal.tsx
│ └── Header.tsx
├── hooks/
│ ├── useAuth.ts
│ └── useApi.ts
├── utils/
│ └── helpers.ts
└── App.tsx为什么它会失败? 当项目增长到 50 个组件、30 个 hook 时,一个"用户资料"功能的代码可能分布在 components/ProfileCard.tsx、hooks/useProfile.ts、utils/formatProfile.ts、api/profileApi.ts 四个不同目录。修改这个功能需要在四个目录间来回跳转,而且你无法通过删除一个文件夹来"删掉整个功能"——耦合关系是隐式的。
1.2 按功能组织(Feature-based)
按业务功能划分文件夹,每个功能模块独立管理,是中大型应用的推荐结构。
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── RegisterForm.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── api/
│ │ │ └── authApi.ts
│ │ ├── types/
│ │ │ └── auth.types.ts
│ │ ├── utils/
│ │ │ └── validators.ts
│ │ └── index.ts // 唯一对外出口(barrel file)
│ ├── dashboard/
│ └── profile/
├── shared/
│ ├── components/ // 跨功能复用的 UI 组件
│ ├── hooks/
│ └── utils/
├── lib/ // 第三方库封装(axios 实例、dayjs 配置等)
├── app/ // 应用级配置(路由、Provider、store)
└── App.tsxbarrel file(index.ts)的关键作用:它定义了功能模块的"公共 API"。模块外部只能从 features/auth 导入 index.ts 显式导出的内容,内部实现细节(如某个私有组件)不对外暴露。这就是模块封装。
// features/auth/index.ts —— 只导出对外契约
export { LoginForm } from './components/LoginForm';
export { RegisterForm } from './components/RegisterForm';
export { useAuth } from './hooks/useAuth';
export type { User, AuthState } from './types/auth.types';
// 注意:authApi、validators 是内部实现,不导出,外部无法直接依赖配合 ESLint 的 no-restricted-imports 规则,可以从工具层面强制"禁止跨功能深层导入":
// .eslintrc.js —— 禁止绕过 barrel file 直接 import 功能内部文件
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@/features/*/*'],
message: '请从 features/xxx 的 index.ts 导入,禁止依赖内部实现文件',
},
],
}],
},
};1.3 混合组织(Hybrid)
1.4 三种结构对比
| 维度 | 按类型 | 按功能 | 混合 |
| --- | --- | --- | --- |
| 适用规模 | 小型(少于 20 个组件) | 中大型 | 中大型 |
| 功能内聚性 | 差 | 优 | 优 |
| 复用便利性 | 优 | 一般 | 优 |
| 团队并行开发 | 冲突多 | 冲突少 | 冲突少 |
| 删除功能成本 | 高(散落各处) | 低(删一个目录) | 低 |
| 上手成本 | 低 | 中 | 中 |
1.5 常见坑
二、分层架构与关注点分离
无论目录怎么组织,一个健壮的 React 应用在逻辑上都应该有清晰的分层。经典的分层是:表现层(Presentation)→ 业务逻辑层(Domain / Application)→ 数据访问层(Data / Infrastructure)。
分层的核心是依赖方向永远从上往下:表现层依赖业务层,业务层依赖数据层,反过来绝对不行。UI 组件里绝不应该出现 fetch('/api/...')。
// ❌ 反例:UI 组件里直接写 fetch,三层耦合在一起,无法测试、无法复用
const UserPage = () => {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('https://api.example.com/user/1', {
headers: { Authorization: 'Bearer ' + localStorage.getItem('token') },
})
.then((r) => r.json())
.then((data) => setUser({ ...data, displayName: data.first + ' ' + data.last }));
}, []);
return <div>{user?.displayName}</div>;
};// ✅ 正例:三层分离
// —— 数据访问层:features/user/api/userApi.ts ——
import { httpClient } from '@/lib/http';
import type { UserDTO } from '../types/user.types';
export const userApi = {
getById: (id: string) => httpClient.get<UserDTO>(`/user/${id}`),
};
// —— 业务逻辑层:features/user/hooks/useUser.ts ——
import { useQuery } from '@tanstack/react-query';
import { userApi } from '../api/userApi';
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: () => userApi.getById(id),
// 业务规则:把后端 DTO 转换为前端领域模型
select: (dto) => ({
id: dto.id,
displayName: `${dto.first} ${dto.last}`,
isVip: dto.level >= 3,
}),
});
}
// —— 表现层:features/user/components/UserPage.tsx ——
export const UserPage = ({ id }: { id: string }) => {
const { data: user, isLoading } = useUser(id);
if (isLoading) return <Spinner />;
return <div>{user?.displayName}{user?.isVip && <VipBadge />}</div>;
};2.1 依赖倒置原则(DIP)在 React 中的应用
依赖倒置的核心:高层模块不应依赖低层模块的具体实现,二者都应依赖抽象。在 React 里,这通常表现为"依赖接口而非具体类",并通过 Context 或参数注入实现。
// 定义抽象接口(契约)
export interface AnalyticsClient {
track(event: string, payload?: Record<string, unknown>): void;
}
// 具体实现可以有多个:生产用 Segment,测试用 mock
class SegmentAnalytics implements AnalyticsClient {
track(event: string, payload?: Record<string, unknown>) {
window.analytics?.track(event, payload);
}
}
class NoopAnalytics implements AnalyticsClient {
track() { /* 测试环境什么都不做 */ }
}
// 通过 Context 注入抽象,组件只依赖接口
const AnalyticsContext = createContext<AnalyticsClient>(new NoopAnalytics());
export const useAnalytics = () => useContext(AnalyticsContext);
// 业务组件不知道底层是 Segment 还是别的,只依赖 track 契约
const CheckoutButton = () => {
const analytics = useAnalytics();
return <button onClick={() => analytics.track('checkout_clicked')}>结算</button>;
};
// 生产环境注入真实实现,测试环境注入 Noop,实现了控制反转
// <AnalyticsContext.Provider value={new SegmentAnalytics()}>依赖倒置带来的最大收益是可测试性:测试时可以注入 mock 实现,无需真实发请求、无需真实打点。
三、组件设计
3.1 原子设计(Atomic Design)
原子设计由 Brad Frost 提出,把 UI 拆分为五个层级,从小到大逐级组合:
React 官方推荐组合优于继承,原子设计正是组合思想的体现。
// 原子组件:Button
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
children,
onClick,
disabled = false,
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};
// 原子组件:Input
interface InputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
type?: string;
}
const Input: React.FC<InputProps> = ({ value, onChange, placeholder, type = 'text' }) => (
<input className="input" value={value} onChange={onChange} placeholder={placeholder} type={type} />
);
// 分子组件:SearchForm(组合 Input + Button)
const SearchForm: React.FC<{ onSearch: (query: string) => void }> = ({ onSearch }) => {
const [query, setQuery] = useState('');
return (
<form onSubmit={(e) => { e.preventDefault(); onSearch(query); }}>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<Button>Search</Button>
</form>
);
};
// 组织组件:Header(组合多个分子/原子)
const Header: React.FC<{ onSearch: (q: string) => void }> = ({ onSearch }) => {
return (
<header className="header">
<Logo />
<Navigation />
<SearchForm onSearch={onSearch} />
<UserMenu />
</header>
);
};
// 模板组件:定义布局骨架,用 slot(children/props)填充
interface DashboardTemplateProps {
header: React.ReactNode;
sidebar: React.ReactNode;
main: React.ReactNode;
}
const DashboardTemplate: React.FC<DashboardTemplateProps> = ({ header, sidebar, main }) => (
<div className="dashboard-layout">
<div className="dashboard-header">{header}</div>
<div className="dashboard-body">
<aside className="dashboard-sidebar">{sidebar}</aside>
<main className="dashboard-main">{main}</main>
</div>
</div>
);3.2 容器组件与展示组件(Container / Presentational)
一个经典的组件拆分模式:把"数据与逻辑"和"渲染"分离。
// 展示组件:纯函数式,只关心怎么画
interface UserCardProps {
name: string;
avatar: string;
online: boolean;
onMessage: () => void;
}
const UserCard: React.FC<UserCardProps> = ({ name, avatar, online, onMessage }) => (
<div className="user-card">
<img src={avatar} alt={name} />
<span>{name}</span>
{online && <span className="dot-online" />}
<Button onClick={onMessage}>发消息</Button>
</div>
);
// 容器组件:负责取数据、处理业务,委托展示组件渲染
const UserCardContainer: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user } = useUser(userId);
const { mutate: openChat } = useOpenChat();
if (!user) return <UserCardSkeleton />;
return (
<UserCard
name={user.displayName}
avatar={user.avatar}
online={user.online}
onMessage={() => openChat(userId)}
/>
);
};现代 React 中,自定义 Hook 已经在很大程度上取代了容器组件的角色(把逻辑抽到 Hook 里,组件既是容器又是展示),但"逻辑与视图分离"的思想始终有效。
3.3 组件通信策略
// 父子组件通信:props 向下,回调向上
interface ChildProps {
data: string;
onUpdate: (newValue: string) => void;
}
const Child: React.FC<ChildProps> = ({ data, onUpdate }) => (
<div>
<span>{data}</span>
<button onClick={() => onUpdate('new value')}>Update</button>
</div>
);
const Parent: React.FC = () => {
const [value, setValue] = useState('initial');
return <Child data={value} onUpdate={setValue} />;
};props drilling 问题:当数据需要从最外层传到很深的子组件时,中间每一层都要透传 props,即使它们根本不用。这会导致中间组件被无关 props 污染,重构困难。
// ❌ props drilling:theme 层层透传
const App = () => {
const [theme, setTheme] = useState('light');
return <Page theme={theme} setTheme={setTheme} />;
};
const Page = ({ theme, setTheme }) => <Layout theme={theme} setTheme={setTheme} />;
const Layout = ({ theme, setTheme }) => <Toolbar theme={theme} setTheme={setTheme} />;
const Toolbar = ({ theme, setTheme }) => <ThemeButton theme={theme} setTheme={setTheme} />;
// ✅ 用 Context 消除透传
const ThemeContext = createContext<{ theme: string; setTheme: (t: string) => void }>({
theme: 'light',
setTheme: () => {},
});
const ThemedButton: React.FC = () => {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
className={`btn btn-${theme}`}
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
>
Toggle Theme
</button>
);
};四、组件复用模式
React 的复用能力经历了从 Mixin → HOC → Render Props → 自定义 Hook 的演进。现代 React 首选自定义 Hook。
4.1 自定义 Hook(首选)
自定义 Hook 是复用有状态逻辑的最佳方式,它没有 HOC 的组件嵌套地狱,也没有 Render Props 的回调嵌套。
// 通用数据请求 Hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => { if (!cancelled) setData(json); })
.catch((e) => { if (!cancelled) setError(e); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; // 清理,避免卸载后 setState
}, [url]);
return { data, loading, error };
}
// 防抖 Hook
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// 本地存储同步 Hook
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// 组合使用:搜索框 + 防抖 + 请求
const SearchPage = () => {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 400);
const { data, loading } = useFetch<Result[]>(`/api/search?q=${debouncedKeyword}`);
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{loading ? <Spinner /> : <ResultList results={data ?? []} />}
</div>
);
};4.2 Render Props 模式
当逻辑需要"把中间结果交给调用方决定怎么渲染"时,Render Props 仍然有用(尤其是需要向下传递作用域数据的库,如虚拟列表、拖拽库)。
interface DataFetcherProps<T> {
url: string;
children: (state: { data: T | null; loading: boolean; error: Error | null }) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const { data, loading, error } = useFetch<T>(url);
return <>{children({ data, loading, error })}</>;
}
// 使用:调用方完全掌控渲染
<DataFetcher<User[]> url="/api/users">
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <ErrorView message={error.message} />;
return <UserList users={data ?? []} />;
}}
</DataFetcher>4.3 高阶组件(HOC)
HOC 是"接收组件、返回增强组件"的函数,适合横切关注点(权限、日志、埋点)。缺点是嵌套过多会形成"包装地狱",且 props 来源不透明。
// 加载态 HOC
function withLoading<P extends object>(Wrapped: React.ComponentType<P>) {
return function WithLoading(props: P & { isLoading: boolean }) {
const { isLoading, ...rest } = props;
if (isLoading) return <Spinner />;
return <Wrapped {...(rest as P)} />;
};
}
// 权限控制 HOC
function withAuth<P extends object>(Wrapped: React.ComponentType<P>, requiredRole: string) {
return function WithAuth(props: P) {
const { user } = useUser('me');
if (!user) return <Redirect to="/login" />;
if (!user.roles.includes(requiredRole)) return <Forbidden />;
return <Wrapped {...props} />;
};
}
// 组合使用
const ProtectedDashboard = withAuth(withLoading(Dashboard), 'admin');4.4 三种复用模式对比
| 模式 | 复用对象 | 优点 | 缺点 | 现代推荐度 |
| --- | --- | --- | --- | --- |
| 自定义 Hook | 有状态逻辑 | 无嵌套、可组合、类型友好 | 只能在组件/Hook 中调用 | 首选 |
| Render Props | 渲染逻辑 | 灵活、作用域清晰 | 回调嵌套、可读性差 | 特定场景 |
| HOC | 组件增强 | 适合横切关注点 | 包装地狱、props 来源不透明 | 逐渐淘汰 |
五、状态管理与状态分层
状态管理是 React 架构中最容易出错的部分。核心原则是给不同性质的状态用不同的工具,而不是把所有状态都塞进一个全局 store。
5.1 四类状态分层
最重要的认知:服务器数据(用户列表、订单详情)不应该放进 Redux/Zustand。它天然属于"服务器状态",应该交给 React Query 这类专门的库,它们内建了缓存、去重、失效、重试、后台刷新,能省掉 60% 以上的样板代码。
5.2 各类状态代码示例
// —— 1. 组件内部状态:useState ——
const Counter: React.FC = () => {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
};
// —— 复杂内部状态:useReducer ——
type Action =
| { type: 'add'; text: string }
| { type: 'toggle'; id: string }
| { type: 'clear' };
function todoReducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add':
return [...state, { id: crypto.randomUUID(), text: action.text, done: false }];
case 'toggle':
return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case 'clear':
return state.filter((t) => !t.done);
default:
return state;
}
}
const TodoApp = () => {
const [todos, dispatch] = useReducer(todoReducer, []);
return <TodoList todos={todos} onToggle={(id) => dispatch({ type: 'toggle', id })} />;
};// —— 2. 跨组件状态:Context API ——
interface User {
id: string;
name: string;
email: string;
}
interface UserContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
const UserContext = createContext<UserContextType | null>(null);
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
// 用 useMemo 稳定 value 引用,避免 Provider 每次渲染都触发所有消费者重渲染
const value = useMemo(
() => ({
user,
login: (u: User) => setUser(u),
logout: () => setUser(null),
}),
[user],
);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
};
export const useUserContext = () => {
const context = useContext(UserContext);
if (!context) throw new Error('useUserContext must be used within UserProvider');
return context;
};// —— 3a. 全局状态:Zustand(轻量、无样板) ——
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartState {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
const useCartStore = create<CartState>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}),
{ name: 'cart-storage' }, // 自动持久化到 localStorage
),
),
);
// 组件中按需订阅,只有 items 变化才重渲染
const CartBadge = () => {
const count = useCartStore((s) => s.items.length);
return <span>{count}</span>;
};// —— 3b. 全局状态:Redux Toolkit(大型团队、需要严格规范) ——
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';
interface AuthState {
token: string | null;
status: 'idle' | 'loading' | 'authenticated';
}
const authSlice = createSlice({
name: 'auth',
initialState: { token: null, status: 'idle' } as AuthState,
reducers: {
setToken: (state, action: PayloadAction<string>) => {
state.token = action.payload; // Immer 让你"直接改",底层生成不可变副本
state.status = 'authenticated';
},
logout: (state) => {
state.token = null;
state.status = 'idle';
},
},
});
export const { setToken, logout } = authSlice.actions;
export const store = configureStore({
reducer: { auth: authSlice.reducer },
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;// —— 3c. 全局状态:Jotai(原子化、自底向上) ——
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2); // 派生 atom,自动追踪依赖
const JotaiCounter = () => {
const [count, setCount] = useAtom(countAtom);
const [doubled] = useAtom(doubledAtom);
return (
<div>
<p>{count} × 2 = {doubled}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
};5.3 主流状态库对比
| 库 | 心智模型 | 样板代码 | 包体积(约) | DevTools | 适用场景 |
| --- | --- | --- | --- | --- | --- |
| useState/useReducer | 内置 | 无 | 0 | React DevTools | 组件局部状态 |
| Context API | 内置 | 少 | 0 | React DevTools | 低频共享(主题、语言) |
| Zustand | 单一 store,hook 订阅 | 极少 | 约 1KB | 支持 | 中大型客户端状态 |
| Jotai | 原子组合,自底向上 | 少 | 约 3KB | 支持 | 细粒度、派生状态多 |
| Redux Toolkit | 单向 flux,严格规范 | 中 | 约 12KB | 强大 | 大型团队、复杂状态流转 |
| React Query | 服务器状态专用 | 少 | 约 12KB | 强大 | 所有异步/后端数据 |
5.4 状态设计原则
// ❌ 错误:冗余状态,fullName 与 firstName/lastName 会不同步
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // 冗余!
// ✅ 正确:最小化状态 + 派生值
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // 渲染时派生,永远同步
// ✅ 昂贵派生用 useMemo 缓存
const sortedFilteredList = useMemo(() => {
return rawData.filter((x) => x.active).sort((a, b) => a.rank - b.rank);
}, [rawData]);六、数据流与服务器状态
6.1 单向数据流原则
interface TodoListProps {
todos: Todo[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
const TodoList: React.FC<TodoListProps> = ({ todos, onToggle, onDelete }) => (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={onToggle} onDelete={onDelete} />
))}
</ul>
);
// 父组件是唯一的状态权威,子组件通过回调"申请"变更
const TodoApp: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const handleToggle = (id: string) =>
setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
const handleDelete = (id: string) =>
setTodos((prev) => prev.filter((t) => t.id !== id));
return <TodoList todos={todos} onToggle={handleToggle} onDelete={handleDelete} />;
};6.2 React Query 缓存与数据获取
React Query(TanStack Query)把"服务器状态"当作一等公民,自动处理缓存、去重、失效、重试、后台刷新。
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// 基础查询:带缓存策略
const useUserPosts = (userId: string) =>
useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
staleTime: 5 * 60 * 1000, // 5 分钟内数据视为新鲜,不重新请求
gcTime: 10 * 60 * 1000, // 组件卸载后缓存保留 10 分钟
refetchOnWindowFocus: true, // 窗口重新聚焦时后台刷新
retry: 3, // 失败自动重试 3 次
});
// 依赖查询:等前一个查询完成才发起
const useUserAndPosts = (userId: string) => {
const userQuery = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!userQuery.data, // 只有拿到 user 后才查 posts
});
return { userQuery, postsQuery };
};
// 分页查询
const usePaginatedPosts = (page: number) =>
useQuery({
queryKey: ['posts', 'page', page],
queryFn: () => fetchPosts(page),
placeholderData: (prev) => prev, // 翻页时保留上一页数据,避免闪烁
});6.3 乐观更新(Optimistic Update)
乐观更新指"先假设成功、立即更新 UI,失败再回滚",能显著提升交互流畅度(用户感知延迟从数百毫秒降到接近 0)。
const useToggleLike = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) => api.toggleLike(postId),
onMutate: async (postId) => {
// 1. 取消正在进行的相关查询,避免覆盖乐观更新
await queryClient.cancelQueries({ queryKey: ['posts'] });
// 2. 快照旧数据,用于回滚
const previous = queryClient.getQueryData<Post[]>(['posts']);
// 3. 乐观地立即更新缓存
queryClient.setQueryData<Post[]>(['posts'], (old) =>
(old ?? []).map((p) =>
p.id === postId ? { ...p, liked: !p.liked, likes: p.likes + (p.liked ? -1 : 1) } : p,
),
);
return { previous };
},
onError: (_err, _postId, context) => {
// 4. 失败:回滚到快照
if (context?.previous) queryClient.setQueryData(['posts'], context.previous);
},
onSettled: () => {
// 5. 无论成败,最终与服务器同步一次
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
};6.4 常见坑
七、Monorepo 与大型工程组织
当应用增长到多个 App(Web 端、管理后台、移动端 H5)共享大量代码时,Monorepo 是主流选择。
7.1 Monorepo 的价值
主流工具:pnpm workspace + Turborepo / Nx。Turborepo 通过任务缓存把重复构建的时间从几分钟降到几秒。
my-monorepo/
├── apps/
│ ├── web/ // 主站
│ ├── admin/ // 管理后台
│ └── mobile-h5/ // 移动 H5
├── packages/
│ ├── ui/ // 共享组件库
│ ├── hooks/ // 共享 hooks
│ ├── utils/ // 共享工具
│ ├── api-client/ // 共享 API 客户端
│ ├── tsconfig/ // 共享 TS 配置
│ └── eslint-config/ // 共享 lint 配置
├── pnpm-workspace.yaml
├── turbo.json
└── package.json// turbo.json —— 定义任务依赖与缓存
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"lint": {},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'App 里像用普通 npm 包一样引用内部包:
// apps/web/package.json 里声明依赖 "@myorg/ui": "workspace:*"
import { Button, Modal } from '@myorg/ui';
import { useDebounce } from '@myorg/hooks';
import { formatCurrency } from '@myorg/utils';八、代码分割与懒加载
首屏 JS 体积直接影响加载性能。代码分割让用户只下载当前页面需要的代码。
8.1 路由级懒加载
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
// 每个页面单独打包,访问时才加载
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
const router = createBrowserRouter([
{
path: '/dashboard',
element: (
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
),
},
{
path: '/settings',
element: (
<Suspense fallback={<PageSkeleton />}>
<Settings />
</Suspense>
),
},
]);8.2 组件级懒加载与预加载
// 重量级组件(如富文本编辑器、图表库)按需加载
const RichEditor = lazy(() => import('./RichEditor'));
const ArticleForm = () => {
const [editing, setEditing] = useState(false);
return (
<div>
<button
onClick={() => setEditing(true)}
// 鼠标悬停即预加载,点击时几乎无等待
onMouseEnter={() => import('./RichEditor')}
>
开始编辑
</button>
{editing && (
<Suspense fallback={<Spinner />}>
<RichEditor />
</Suspense>
)}
</div>
);
};8.3 代码分割效果对比
| 策略 | 首屏 JS | 首屏加载时间(3G) | 说明 |
| --- | --- | --- | --- |
| 无分割(单 bundle) | 2.1MB | 约 8 秒 | 所有代码打进一个文件 |
| 路由级分割 | 320KB | 约 2 秒 | 每个路由独立 chunk |
| 路由 + 组件级 + 预加载 | 210KB | 约 1.3 秒 | 重组件懒加载 + 悬停预取 |
九、错误边界与健壮性
React 组件树中任何未捕获的渲染错误都会导致整棵树卸载(白屏)。错误边界(Error Boundary)把错误局部化,避免整个应用崩溃。
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
fallback: (error: Error, reset: () => void) => ReactNode;
children: ReactNode;
}
interface State {
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error }; // 渲染出错时切换到 fallback UI
}
componentDidCatch(error: Error, info: ErrorInfo) {
// 上报到监控平台(Sentry 等)
reportError(error, { componentStack: info.componentStack });
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) return this.props.fallback(this.state.error, this.reset);
return this.props.children;
}
}
// 分区包裹:某个 widget 崩溃不影响整页
const DashboardPage = () => (
<div>
<ErrorBoundary fallback={(e, reset) => <WidgetError onRetry={reset} />}>
<RevenueWidget />
</ErrorBoundary>
<ErrorBoundary fallback={(e, reset) => <WidgetError onRetry={reset} />}>
<UserActivityWidget />
</ErrorBoundary>
</div>
);注意:错误边界只能捕获渲染期、生命周期、构造函数中的错误,捕获不了事件处理器、异步代码(setTimeout、Promise)、SSR 中的错误——这些要用 try/catch 或 window.onerror 单独处理。生产实践中,通常在应用最外层放一个"兜底"边界,在每个独立 widget/路由再放局部边界。
十、可测试性
架构好不好,很大程度看它可不可测。前面讲的分层、依赖倒置、纯函数展示组件,最终都服务于可测试性。
10.1 测试金字塔
// —— 组件集成测试:React Testing Library ——
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('renders email and password inputs', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it('calls onSubmit with form data', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('shows validation error for invalid email', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await userEvent.type(screen.getByLabelText(/email/i), 'invalid-email');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
});// —— 自定义 Hook 测试:renderHook ——
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
it('increments count', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});// —— API mock:MSW(Mock Service Worker)在网络层拦截,测试更真实 ——
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users', () =>
HttpResponse.json([{ id: '1', name: 'Alice' }]),
),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());10.2 端到端测试(Playwright)
import { test, expect } from '@playwright/test';
test('用户能完成登录流程', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('user@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('欢迎回来')).toBeVisible();
});十一、代码规范与团队协作
11.1 ESLint 与 Prettier
// .eslintrc.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// 强制功能模块边界,禁止跨功能深层导入
'no-restricted-imports': ['error', {
patterns: [{ group: ['@/features/*/*'], message: '请从 feature 的 index.ts 导入' }],
}],
},
};// .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}11.2 提交前钩子与规范
用 husky + lint-staged 在提交前自动格式化和校验,把问题挡在提交之前:
// package.json
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css}": ["prettier --write"]
}
}11.3 组件文档(Storybook)
/**
* Button 组件 - 基础按钮组件
*
* @example
* // 基础用法
* <Button onClick={handleClick}>Click me</Button>
*
* // 不同变体
* <Button variant="primary">Primary</Button>
* <Button variant="secondary">Secondary</Button>
* <Button variant="danger">Danger</Button>
*
* // 不同尺寸
* <Button size="sm">Small</Button>
* <Button size="md">Medium</Button>
* <Button size="lg">Large</Button>
*/
interface ButtonProps {
/** 按钮变体样式 */
variant?: 'primary' | 'secondary' | 'danger';
/** 按钮尺寸 */
size?: 'sm' | 'md' | 'lg';
/** 点击事件处理 */
onClick?: () => void;
/** 子元素 */
children: React.ReactNode;
}// Button.stories.tsx —— Storybook 交互文档
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Atoms/Button',
component: Button,
args: { children: '按钮' },
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = { args: { variant: 'primary' } };
export const Danger: Story = { args: { variant: 'danger' } };
export const Small: Story = { args: { size: 'sm' } };十二、CI/CD 与部署
自动化构建、测试、部署,保证每次发布质量可控。
# GitHub Actions CI/CD 示例
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Test
run: npm run test:coverage
- name: Build
run: npm run build
- name: Upload bundle size report
run: npx size-limit
- name: Deploy to Vercel
if: github.ref == 'refs/heads/main'
run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}在 Monorepo 中,配合 Turborepo 的远程缓存,只重新构建受影响的包,把 CI 时间从几分钟降到几十秒:
- name: Build affected packages only
run: npx turbo run build lint test --filter=...[origin/main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}十三、大型团队协作规范
当团队规模超过 10 人时,架构约束必须"工程化",靠自觉是靠不住的。
# .github/CODEOWNERS
/src/features/auth/ @team-identity
/src/features/payment/ @team-billing
/packages/ui/ @team-design-system
*.config.js @team-platform十四、Context 的组织与性能优化
Context 是 React 内置的跨层级通信手段,但它有一个著名的性能陷阱:只要 Provider 的 value 变化,所有消费该 Context 的组件都会重渲染,无论它实际用到 value 的哪一部分。大型应用里滥用单一大 Context,会导致牵一发而动全身的性能雪崩。
14.1 拆分 Context:按变化频率与关注点
一个常见错误是把所有全局数据塞进一个 AppContext。正确做法是按变化频率和关注点拆分成多个 Context,让不相关的更新彼此隔离。
// ❌ 反例:一个巨型 Context,theme 变了连带 user 消费者一起重渲染
interface AppContextType {
user: User | null;
theme: 'light' | 'dark';
locale: string;
notifications: Notification[];
}
const AppContext = createContext<AppContextType>(/* ... */);
// ✅ 正例:拆分为独立 Context,各自独立更新
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<'light' | 'dark'>('light');
const LocaleContext = createContext<string>('zh-CN');
// 组合 Provider,但内部状态互不干扰
const AppProviders: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<UserProvider>
<ThemeProvider>
<LocaleProvider>{children}</LocaleProvider>
</ThemeProvider>
</UserProvider>
);14.2 拆分 state 与 dispatch
把"数据"和"更新数据的方法"拆成两个 Context。只读方法的组件不会因为 state 变化而重渲染。
const TodoStateContext = createContext<Todo[]>([]);
const TodoDispatchContext = createContext<React.Dispatch<Action>>(() => {});
export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoStateContext.Provider value={todos}>
{/* dispatch 引用永远稳定,只用 dispatch 的组件永不因 todos 变化重渲染 */}
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
};
export const useTodos = () => useContext(TodoStateContext);
export const useTodoDispatch = () => useContext(TodoDispatchContext);
// AddButton 只需要 dispatch,不订阅 todos,列表更新时它不重渲染
const AddButton = () => {
const dispatch = useTodoDispatch();
return <button onClick={() => dispatch({ type: 'add', text: '新任务' })}>添加</button>;
};14.3 Context Selector 模式
当确实需要一个大 Context 但只想订阅其中一部分时,可以用 use-context-selector 库实现细粒度订阅(React 官方也在讨论内建 selector)。
import { createContext, useContextSelector } from 'use-context-selector';
const StoreContext = createContext<{ user: User; cart: Cart } | null>(null);
// 只订阅 user.name,cart 变化不会触发本组件重渲染
const UserName = () => {
const name = useContextSelector(StoreContext, (s) => s?.user.name);
return <span>{name}</span>;
};14.4 Context 使用对比
| 方案 | 更新粒度 | 额外依赖 | 适用场景 |
| --- | --- | --- | --- |
| 单一大 Context | 全部消费者重渲染 | 无 | 极少变化的全局配置 |
| 拆分多 Context | 按 Context 隔离 | 无 | 主题/用户/语言等独立关注点 |
| state/dispatch 分离 | 只读方不重渲染 | 无 | 频繁更新的列表类状态 |
| Context Selector | 字段级 | use-context-selector | 大对象、局部订阅 |
| 外部 store(Zustand) | 字段级 | zustand | 高频、复杂全局状态 |
十五、复合组件模式(Compound Components)
复合组件模式让一组关联组件共享隐式状态,对外提供声明式、灵活的 API。典型代表是原生 select/option,以及各大 UI 库的 Tabs、Accordion、Menu。
import { createContext, useContext, useState, ReactNode } from 'react';
interface TabsContextType {
active: string;
setActive: (id: string) => void;
}
const TabsContext = createContext<TabsContextType | null>(null);
const useTabs = () => {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs 子组件必须在 <Tabs> 内使用');
return ctx;
};
// 父组件:持有共享状态
function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 子组件通过 Context 隐式共享状态,用户无需手动传 props
function TabList({ children }: { children: ReactNode }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
function Tab({ value, children }: { value: string; children: ReactNode }) {
const { active, setActive } = useTabs();
return (
<button
role="tab"
aria-selected={active === value}
className={active === value ? 'tab tab-active' : 'tab'}
onClick={() => setActive(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }: { value: string; children: ReactNode }) {
const { active } = useTabs();
if (active !== value) return null;
return <div role="tabpanel">{children}</div>;
}
// 挂载子组件,形成命名空间 API
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// 使用:声明式、结构一目了然,用户完全掌控布局
const Demo = () => (
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Tab value="profile">资料</Tabs.Tab>
<Tabs.Tab value="settings">设置</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile"><ProfilePanel /></Tabs.Panel>
<Tabs.Panel value="settings"><SettingsPanel /></Tabs.Panel>
</Tabs>
);为什么优于配置式 API? 配置式(`
十六、表单架构
表单是前端最复杂的交互之一,涉及受控/非受控、校验、异步提交、错误展示。大型应用推荐用 react-hook-form + zod,把校验规则、类型、UI 解耦。
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. 用 zod 定义 schema,校验规则与类型是同一份来源(single source of truth)
const registerSchema = z
.object({
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 位'),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: '两次密码不一致',
path: ['confirm'],
});
// 2. 从 schema 自动推导 TS 类型,无需手写 interface
type RegisterForm = z.infer<typeof registerSchema>;
const RegisterPage = () => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({ resolver: zodResolver(registerSchema) });
const onSubmit = async (data: RegisterForm) => {
await api.register(data); // 校验通过才会走到这里
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="邮箱" />
{errors.email && <span className="error">{errors.email.message}</span>}
<input type="password" {...register('password')} placeholder="密码" />
{errors.password && <span className="error">{errors.password.message}</span>}
<input type="password" {...register('confirm')} placeholder="确认密码" />
{errors.confirm && <span className="error">{errors.confirm.message}</span>}
<button disabled={isSubmitting}>{isSubmitting ? '提交中...' : '注册'}</button>
</form>
);
};受控 vs 非受控性能对比:react-hook-form 默认走非受控模式,输入时不触发整表单重渲染。在一个 50 字段的大表单里,受控方案(每次输入 setState)每次击键渲染 50 个组件,而 react-hook-form 只更新当前字段,输入延迟从明显卡顿降到无感。
| 方案 | 每次输入重渲染范围 | 大表单(50 字段)体验 | 校验集成 |
| --- | --- | --- | --- |
| 纯受控 useState | 整个表单 | 明显卡顿 | 手写 |
| Formik(受控) | 整个表单 | 一般 | 内建 |
| react-hook-form(非受控) | 单个字段 | 流畅 | zod/yup |
十七、API 层设计
数据访问层不应该是散落各处的 fetch 调用,而应是一个统一的、可配置的客户端,集中处理鉴权、错误、重试、超时。
// lib/http.ts —— 统一的 HTTP 客户端封装
import axios, { AxiosError } from 'axios';
export const httpClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
});
// 请求拦截器:统一注入鉴权头
httpClient.interceptors.request.use((config) => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 响应拦截器:统一错误处理与 401 刷新
httpClient.interceptors.response.use(
(response) => response.data, // 直接返回 data,业务层不用每次 .data
async (error: AxiosError) => {
if (error.response?.status === 401) {
const refreshed = await tryRefreshToken();
if (refreshed) return httpClient(error.config!); // 刷新成功后重放
redirectToLogin();
}
// 归一化错误结构,业务层拿到统一的 error shape
return Promise.reject(normalizeError(error));
},
);
// 统一错误模型
export interface ApiError {
code: string;
message: string;
status: number;
}
function normalizeError(error: AxiosError): ApiError {
return {
code: (error.response?.data as any)?.code ?? 'UNKNOWN',
message: (error.response?.data as any)?.message ?? '网络异常,请稍后重试',
status: error.response?.status ?? 0,
};
}// features/order/api/orderApi.ts —— 领域 API 只声明契约
import { httpClient } from '@/lib/http';
import type { Order, CreateOrderInput } from '../types';
export const orderApi = {
list: (params: { page: number; status?: string }) =>
httpClient.get<Order[]>('/orders', { params }),
detail: (id: string) => httpClient.get<Order>(`/orders/${id}`),
create: (input: CreateOrderInput) => httpClient.post<Order>('/orders', input),
cancel: (id: string) => httpClient.post<void>(`/orders/${id}/cancel`),
};十八、性能架构
架构层面的性能优化,重点在"减少不必要的渲染"和"减少一次渲染的成本"。
18.1 记忆化三件套
// React.memo:props 未变则跳过重渲染,适合纯展示的重组件
const ExpensiveList = React.memo(({ items }: { items: Item[] }) => (
<ul>{items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
));
const Parent = () => {
const [count, setCount] = useState(0);
const [items] = useState<Item[]>(bigList);
// useCallback:稳定回调引用,配合 memo 才生效
const handleSelect = useCallback((id: string) => {
console.log('selected', id);
}, []);
// useMemo:缓存昂贵计算结果
const sorted = useMemo(() => [...items].sort((a, b) => a.rank - b.rank), [items]);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{/* count 变化时,ExpensiveList 因 props 未变而不重渲染 */}
<ExpensiveList items={sorted} onSelect={handleSelect} />
</>
);
};注意:不要无脑到处 memo。记忆化本身有比较成本,只在"重组件 + 频繁父级更新 + props 稳定"时才有正收益。React 19 的编译器(React Compiler)会自动完成大部分记忆化,届时手动 memo 会大幅减少。
18.2 长列表虚拟化
一次渲染上万条 DOM 会卡死主线程。虚拟化只渲染可视区域内的元素。
import { useVirtualizer } from '@tanstack/react-virtual';
const VirtualList = ({ rows }: { rows: Row[] }) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // 每行预估高度
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => (
<div
key={vItem.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${vItem.start}px)`,
height: vItem.size,
}}
>
{rows[vItem.index].name}
</div>
))}
</div>
</div>
);
};| 列表规模 | 普通渲染 DOM 节点 | 虚拟化 DOM 节点 | 首次渲染耗时 |
| --- | --- | --- | --- |
| 1 万条 | 10000 | 约 20 | 从约 1200ms 降到约 30ms |
| 10 万条 | 直接卡死 | 约 20 | 依然流畅 |
十九、Server Components 与现代渲染架构
React Server Components(RSC,Next.js App Router 已生产可用)把组件分为"服务端组件"和"客户端组件",从架构层面重新划分数据获取与交互的边界。
// app/products/page.tsx —— 服务端组件:直接读数据库,不进客户端 bundle
import { db } from '@/lib/db';
import { AddToCartButton } from './AddToCartButton';
export default async function ProductsPage() {
const products = await db.product.findMany(); // 直接查库,无需 API 层
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} - ¥{p.price}
{/* 交互部分下沉到客户端组件 */}
<AddToCartButton productId={p.id} />
</li>
))}
</ul>
);
}// app/products/AddToCartButton.tsx —— 客户端组件:需要 onClick 交互
'use client';
import { useCartStore } from '@/stores/cart';
export function AddToCartButton({ productId }: { productId: string }) {
const addItem = useCartStore((s) => s.addItem);
return <button onClick={() => addItem(productId)}>加入购物车</button>;
}架构影响:RSC 让"服务器状态"很多时候不再需要 React Query——数据在服务端直接取好渲染。客户端状态库的职责收缩到纯交互状态。这是 React 架构的一次范式迁移。
| 维度 | 传统 CSR(客户端渲染) | RSC(服务端组件) |
| --- | --- | --- |
| 数据获取位置 | 客户端(useEffect/Query) | 服务端(直接访问) |
| 首屏 JS | 大 | 小(服务端组件不打包) |
| SEO | 需 SSR 额外处理 | 天然友好 |
| 交互状态 | 全部客户端 | 仅客户端组件 |
二十、可观测性与运维
生产环境的架构必须包含监控、日志、错误追踪,否则线上问题只能靠用户投诉才发现。
// 集成 Sentry:错误追踪 + 性能监控
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
tracesSampleRate: 0.1, // 采样 10% 的性能数据
replaysOnErrorSampleRate: 1, // 出错时录制回放,便于复现
});
// 用 Sentry 增强的错误边界包裹应用
const App = () => (
<Sentry.ErrorBoundary fallback={<AppCrashScreen />} showDialog>
<AppProviders>
<RouterProvider router={router} />
</AppProviders>
</Sentry.ErrorBoundary>
);// 上报核心 Web 指标(Core Web Vitals)
import { onCLS, onINP, onLCP } from 'web-vitals';
function reportWebVitals(metric: { name: string; value: number }) {
navigator.sendBeacon('/analytics/vitals', JSON.stringify(metric));
}
onLCP(reportWebVitals); // 最大内容绘制
onINP(reportWebVitals); // 交互到下次绘制
onCLS(reportWebVitals); // 累积布局偏移环境配置管理
// config/env.ts —— 集中校验环境变量,缺失即启动失败(fail fast)
import { z } from 'zod';
const envSchema = z.object({
VITE_API_BASE_URL: z.string().url(),
VITE_SENTRY_DSN: z.string().optional(),
MODE: z.enum(['development', 'staging', 'production']),
});
export const env = envSchema.parse(import.meta.env);
// 类型安全的 env,任何拼写错误在启动时就暴露,而非运行时二十一、总结
React 架构没有"银弹",一切都是取舍。核心是根据当前规模选择恰当的复杂度,并为未来演进留出空间。下表汇总本篇的关键决策点:
| 决策维度 | 小型项目推荐 | 中大型项目推荐 | 核心原则 |
| --- | --- | --- | --- |
| 文件夹结构 | 按类型 | 混合(feature + shared) | 高内聚、边界清晰 |
| 组件设计 | 简单组合 | 原子设计 + 容器/展示分离 | 组合优于继承、单一职责 |
| 逻辑复用 | 自定义 Hook | 自定义 Hook 为主 | 避免 HOC 包装地狱 |
| 局部状态 | useState | useState / useReducer | 状态就近 |
| 共享状态 | Context | Context(低频) | 用 useMemo 稳定 value |
| 全局状态 | 通常不需要 | Zustand / Redux Toolkit / Jotai | 按复杂度选型 |
| 服务器状态 | React Query | React Query / SWR | 不塞进全局 store |
| 分层 | 可省略 | 表现/业务/数据三层 | 依赖方向自上而下 |
| 工程组织 | 单仓库 | Monorepo(pnpm + Turborepo) | 代码共享、原子提交 |
| 性能 | 按需 | 路由 + 组件级代码分割 | 首屏最小化 |
| 健壮性 | 顶层错误边界 | 分区错误边界 + 监控上报 | 局部失败不拖垮全局 |
| 质量保障 | 关键路径测试 | 测试金字塔 + CI/CD | 可测试性驱动架构 |
| 团队协作 | 约定即可 | CODEOWNERS + ADR + 设计系统 | 约束工程化 |
最后强调三条贯穿始终的心法:
二十二、并发渲染架构
React 18 引入的并发特性(Concurrent Features)从架构层面改变了"渲染是否可中断"这件事。传统的同步渲染一旦开始就必须一次性跑完,期间浏览器无法响应用户输入;并发渲染允许 React 把一次大更新拆成可中断、可让路的小任务,优先响应高优先级交互。
22.1 useTransition:把更新标记为"非紧急"
典型场景:搜索框输入时同时过滤一个上万条的列表。输入(受控 value)是紧急更新,必须立即反映;列表过滤是昂贵的非紧急更新,可以"慢一点"。useTransition 让二者不再互相阻塞。
import { useState, useTransition, useDeferredValue, useMemo } from 'react';
const SearchableList = ({ allItems }: { allItems: Item[] }) => {
const [query, setQuery] = useState('');
const [result, setResult] = useState<Item[]>(allItems);
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value); // 紧急更新:输入框必须立即响应,不被过滤计算阻塞
// 非紧急更新:过滤一万条数据,可被后续输入打断、丢弃过时结果
startTransition(() => {
const next = allItems.filter((i) =>
i.name.toLowerCase().includes(value.toLowerCase()),
);
setResult(next);
});
};
return (
<div>
<input value={query} onChange={handleChange} placeholder="搜索..." />
{/* isPending 提供过渡态视觉反馈,而非白屏卡顿 */}
<div style={{ opacity: isPending ? 0.5 : 1 }}>
<VirtualList rows={result} />
</div>
</div>
);
};性能对比:在一个 2 万条数据的列表上,不使用并发特性时每次击键主线程被过滤计算占用约 180ms,输入明显卡顿掉字;使用 useTransition 后,输入框保持 60fps 无掉帧,过滤结果延迟约 200ms 呈现但不阻塞交互,用户主观流畅度大幅提升。
22.2 useDeferredValue:延迟派生值
useDeferredValue 是 useTransition 的"值版本",当你无法控制 setState 的调用点(比如值来自 props 或第三方 Hook)时更方便。
const ProductGrid = ({ keyword }: { keyword: string }) => {
// 当 keyword 快速变化时,deferredKeyword 会"落后"一拍,让紧急渲染先完成
const deferredKeyword = useDeferredValue(keyword);
const isStale = keyword !== deferredKeyword;
// 昂贵渲染基于延迟值,避免每次击键都重算
const filtered = useMemo(
() => heavyFilter(deferredKeyword),
[deferredKeyword],
);
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
{filtered.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
};22.3 Suspense 与流式数据加载
Suspense 让"加载中"成为声明式的架构原语:组件在数据未就绪时"挂起",由最近的 Suspense 边界统一展示 fallback。配合 RSC 与流式 SSR,可以做到"边取数边流式传输 HTML"。
// 声明式加载:每个数据块独立挂起,先到先渲染,不必等最慢的
const Dashboard = () => (
<div className="dashboard">
<Suspense fallback={<CardSkeleton />}>
<RevenueCard /> {/* 200ms 返回,先渲染 */}
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<SlowAnalyticsCard /> {/* 1500ms 返回,晚渲染,不阻塞上面 */}
</Suspense>
</div>
);| 特性 | 解决的问题 | 触发方式 | 典型收益 |
| --- | --- | --- | --- |
| useTransition | 昂贵更新阻塞交互 | startTransition 包裹 setState | 输入延迟从 180ms 降到无感 |
| useDeferredValue | 无法改 setState 调用点 | 延迟派生值 | 高频输入下渲染次数下降 |
| Suspense 流式 | 慢接口拖累整页 | 组件挂起 + 边界 fallback | 首个内容可见时间提前数百毫秒 |
常见坑:把所有 setState 都塞进 startTransition。过渡更新会被降级、可能被打断重跑,输入框 value 这类必须即时的状态绝不能放进 transition,否则会出现"打字延迟回显"的诡异体验。
二十三、微前端与模块联邦
当组织扩张到多个团队各自负责一大块业务,且希望"独立开发、独立部署、独立技术栈演进"时,单体 SPA 的边界开始成为瓶颈。微前端(Micro Frontends)把一个应用拆成多个可独立交付的子应用,在运行时组合。
23.1 模块联邦(Module Federation)
Webpack 5 的 Module Federation(Vite 有 @originjs/vite-plugin-federation 对应实现)允许一个构建产物在运行时动态加载另一个构建产物暴露的模块,且共享依赖只加载一份。
// 远程应用 remote (团队 B) webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'orderApp',
filename: 'remoteEntry.js',
// 对外暴露的模块
exposes: {
'./OrderList': './src/features/order/OrderList',
},
// 共享依赖:宿主与远程共用同一份 React 实例,避免 hooks 报错
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
};// 宿主应用 host (团队 A) webpack.config.js
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// 运行时从团队 B 的部署地址加载
orderApp: 'orderApp@https://order.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
});// 宿主中像本地组件一样懒加载远程组件
import { lazy, Suspense } from 'react';
// @ts-expect-error 远程模块类型需通过 d.ts 声明补充
const RemoteOrderList = lazy(() => import('orderApp/OrderList'));
const OrdersRoute = () => (
<ErrorBoundary fallback={() => <RemoteLoadFailed app="订单" />}>
<Suspense fallback={<PageSkeleton />}>
<RemoteOrderList />
</Suspense>
</ErrorBoundary>
);23.2 微前端方案对比
| 方案 | 隔离级别 | 共享依赖 | 部署独立性 | 适用场景 |
| --- | --- | --- | --- | --- |
| iframe | 最强(进程级) | 无法共享 | 完全独立 | 完全异构、强隔离需求 |
| Module Federation | 中(同一运行时) | 可共享单例 | 独立 | 同技术栈、需共享 React |
| single-spa | 中(生命周期约定) | 依约定共享 | 独立 | 多框架混合(React+Vue) |
| Web Components 封装 | 较强(Shadow DOM) | 有限 | 独立 | 跨框架 UI 复用 |
| 构建时集成(npm 包) | 无 | 天然共享 | 不独立(需一起构建) | 团队协作紧密的中型项目 |
常见坑:
代价提示:微前端不是免费的。它引入了额外的构建复杂度、运行时开销(多份 chunk 加载)、跨应用调试困难。经验阈值:团队少于 3 个、代码量不足以让单体构建慢到无法忍受(生产构建仍在 2 分钟内)时,通常不值得上微前端,Monorepo + 代码分割足矣。
二十四、设计系统与组件库分层
前面多次提到 packages/ui 作为设计系统单一来源。一个成熟的设计系统本身也需要清晰的内部分层,否则会退化成又一个大杂烩组件库。
24.1 设计系统的三层结构
// —— tokens 层:与框架无关的设计变量 ——
export const tokens = {
color: {
primary: { 50: '#eff6ff', 500: '#3b82f6', 700: '#1d4ed8' },
danger: { 500: '#ef4444' },
neutral: { 0: '#fff', 900: '#111827' },
},
spacing: { xs: '4px', sm: '8px', md: '16px', lg: '24px' },
radius: { sm: '4px', md: '8px', full: '9999px' },
fontSize: { sm: '14px', md: '16px', lg: '20px' },
} as const;// —— composed 层:基于 tokens + primitive 封装成品组件 ——
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { tokens } from '../tokens';
// primitive 提供焦点陷阱、ESC 关闭、aria 属性等无障碍能力
export const Modal = ({ open, onClose, title, children }: ModalProps) => (
<DialogPrimitive.Root open={open} onOpenChange={onClose}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="ds-overlay" />
<DialogPrimitive.Content
className="ds-modal"
style={{ borderRadius: tokens.radius.md, padding: tokens.spacing.lg }}
>
<DialogPrimitive.Title>{title}</DialogPrimitive.Title>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);24.2 变体管理与类型安全
组件的 variant 组合容易失控。用 cva(class-variance-authority)之类的工具把变体声明化,同时导出精确类型。
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva('ds-btn', {
variants: {
variant: {
primary: 'ds-btn--primary',
secondary: 'ds-btn--secondary',
ghost: 'ds-btn--ghost',
},
size: { sm: 'ds-btn--sm', md: 'ds-btn--md', lg: 'ds-btn--lg' },
},
defaultVariants: { variant: 'primary', size: 'md' },
});
// 变体类型自动推导,传错值编译期报错
type ButtonProps = VariantProps<typeof buttonVariants> &
React.ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = ({ variant, size, className, ...rest }: ButtonProps) => (
<button className={buttonVariants({ variant, size, className })} {...rest} />
);24.3 设计系统版本与发布
设计系统作为被多个 App 依赖的底层包,破坏性改动会波及所有下游。要点:
| 层级 | 关注点 | 是否含品牌样式 | 代表方案 |
| --- | --- | --- | --- |
| Tokens | 设计变量 | 是(值本身) | Style Dictionary、Figma Tokens |
| Primitives | 行为与无障碍 | 否 | Radix UI、Headless UI、Ark UI |
| Composed | 成品视觉组件 | 是 | 自建 packages/ui |
二十五、依赖注入与 IoC 的落地
第二章提到依赖倒置,这里进一步讨论如何在 React 中系统性地做依赖注入(DI),实现控制反转(IoC),让业务代码不与具体实现绑定,从而获得极佳的可测试性与可替换性。
25.1 用 Context 搭建轻量 IoC 容器
无需引入 InversifyJS 这类重型 DI 框架,React 的 Context 本身就是一个天然的注入容器。关键是把"服务集合"作为一个整体注入。
// 1. 定义各服务的抽象接口
interface Services {
auth: AuthService;
analytics: AnalyticsClient;
storage: StorageService;
featureFlags: FeatureFlagService;
}
// 2. 创建容器 Context
const ServicesContext = createContext<Services | null>(null);
export const useService = <K extends keyof Services>(key: K): Services[K] => {
const services = useContext(ServicesContext);
if (!services) throw new Error('组件必须包裹在 ServicesProvider 内');
return services[key];
};
// 3. 组合根(Composition Root):应用启动时在唯一入口装配所有实现
export const ServicesProvider = ({ children }: { children: React.ReactNode }) => {
const services = useMemo<Services>(
() => ({
auth: new HttpAuthService(httpClient),
analytics: new SegmentAnalytics(),
storage: new LocalStorageService(),
featureFlags: new RemoteFeatureFlagService(httpClient),
}),
[],
);
return <ServicesContext.Provider value={services}>{children}</ServicesContext.Provider>;
};
// 4. 业务组件只依赖抽象,通过 key 取服务
const LoginButton = () => {
const auth = useService('auth');
const analytics = useService('analytics');
const handleLogin = async () => {
await auth.login();
analytics.track('login_success');
};
return <button onClick={handleLogin}>登录</button>;
};25.2 测试时注入 mock 实现
DI 的最大红利在测试环节:无需 mock 全局、无需拦截网络,直接在 Provider 注入假实现。
// 测试专用的假服务集合
const mockServices: Services = {
auth: { login: jest.fn().mockResolvedValue(true), logout: jest.fn() },
analytics: { track: jest.fn() },
storage: { get: jest.fn(), set: jest.fn() },
featureFlags: { isEnabled: () => true },
};
const renderWithServices = (ui: React.ReactElement) =>
render(
<ServicesContext.Provider value={mockServices}>{ui}</ServicesContext.Provider>,
);
it('登录成功后上报埋点', async () => {
renderWithServices(<LoginButton />);
await userEvent.click(screen.getByRole('button', { name: '登录' }));
expect(mockServices.auth.login).toHaveBeenCalled();
expect(mockServices.analytics.track).toHaveBeenCalledWith('login_success');
});25.3 组合根原则
DI 的精髓是"组合根"(Composition Root):整个应用只在一个地方(离入口最近处)决定"谁的实现是什么",其余所有代码都只认接口。这样切换环境(生产/测试/本地 mock)、替换供应商(Segment 换成 Amplitude)时,改动都集中在组合根一处,业务代码零改动。
| 方式 | 侵入性 | 类型安全 | 适用规模 |
| --- | --- | --- | --- |
| props 手动传 | 无(但透传烦) | 好 | 极小型、层级浅 |
| Context 容器 | 低 | 好 | 中大型(推荐) |
| InversifyID/tsyringe | 中(需装饰器、reflect-metadata) | 好 | 逻辑极重、后端风格前端 |
二十六、SWR 与缓存层设计
第六章重点讲了 React Query,这里补充 SWR 及"数据缓存层"的架构设计思路。SWR(stale-while-revalidate)的名字本身就是一种缓存策略:先返回缓存(stale),同时后台重新验证(revalidate),拿到新数据再更新。
26.1 SWR 基础与全局配置
import useSWR, { SWRConfig } from 'swr';
// 全局配置:统一 fetcher、错误重试、去重窗口
const AppSWRProvider = ({ children }: { children: React.ReactNode }) => (
<SWRConfig
value={{
fetcher: (url: string) => httpClient.get(url),
dedupingInterval: 2000, // 2 秒内相同 key 的请求去重合并
errorRetryCount: 3,
revalidateOnFocus: true, // 窗口聚焦时重新验证
keepPreviousData: true, // 切 key 时保留旧数据,避免闪烁
}}
>
{children}
</SWRConfig>
);
// 使用:key 即缓存键,相同 key 全应用共享同一份缓存
const useProfile = (id: string) => {
const { data, error, isLoading, mutate } = useSWR<Profile>(`/user/${id}`);
return { profile: data, error, isLoading, refresh: mutate };
};26.2 缓存层的架构定位
无论用 SWR 还是 React Query,它们本质上都在 UI 与网络之间插入了一个"缓存层",承担了原本要手写的一大堆基础设施:
UI 组件 ──读──▶ 缓存层(SWR / React Query) ──未命中/过期──▶ API 层 ──▶ 后端
▲ │
└──── 数据变化推送 ─────┘(缓存更新自动触发相关组件重渲染)这一层要集中处理:缓存键设计、失效策略、去重、重试、后台刷新、乐观更新、分页/无限滚动、依赖查询。把这些能力沉淀在缓存层,业务组件就只剩"声明我要什么数据"。
26.3 SWR 无限加载与本地变更
import useSWRInfinite from 'swr/infinite';
const usePosts = () => {
const { data, size, setSize, isValidating } = useSWRInfinite<Post[]>(
// getKey:根据页码生成每页的缓存键,返回 null 表示到底了
(pageIndex, prev) => {
if (prev && prev.length === 0) return null;
return `/posts?page=${pageIndex + 1}&limit=20`;
},
);
const posts = data ? data.flat() : [];
const isEnd = data && data[data.length - 1]?.length < 20;
return { posts, loadMore: () => setSize(size + 1), isEnd, isValidating };
};26.4 SWR vs React Query 选型
| 维度 | SWR | React Query |
| --- | --- | --- |
| 包体积(约) | 4KB | 12KB |
| 心智模型 | key + fetcher,极简 | queryKey + queryFn,功能全 |
| 缓存操作 API | 较基础(mutate) | 丰富(invalidate/setQueryData 等) |
| Mutation 支持 | 需自行封装 | 一等公民 useMutation |
| DevTools | 社区插件 | 官方强大 |
| 适用 | 读多写少、追求轻量 | 复杂读写、乐观更新多 |
常见坑:缓存键(key / queryKey)设计成"不包含全部影响因素",比如按 userId 查详情却只用 '/user' 作 key,导致切换用户时读到上一个人的缓存。缓存键必须能唯一确定一份数据。
二十七、路由架构与数据加载
路由不只是"URL 映射到组件",现代路由(React Router 6.4+ 的 data router、Next.js App Router)把"数据加载"提升为路由的一等职责,从架构上消灭了"进入页面才在 useEffect 里取数导致的瀑布式请求"。
27.1 集中式路由配置与嵌套布局
import { createBrowserRouter } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />, // 共享导航/页脚
errorElement: <RouteError />, // 路由级错误边界
children: [
{ index: true, element: <Home /> },
{
path: 'dashboard',
element: <DashboardLayout />, // 嵌套布局:侧边栏 + Outlet
loader: requireAuth, // 进入前的权限守卫
children: [
{
path: 'orders/:id',
element: <OrderDetail />,
// loader 在渲染前并行取数,避免组件内 useEffect 瀑布
loader: ({ params }) => orderApi.detail(params.id!),
},
],
},
],
},
]);27.2 loader / action:数据与变更下沉到路由
// loader:路由匹配时自动执行,组件用 useLoaderData 直接拿到数据
export const orderLoader = async ({ params }: LoaderFunctionArgs) => {
const [order, items] = await Promise.all([
orderApi.detail(params.id!),
orderApi.items(params.id!),
]); // 并行取数,而非组件挂载后串行
return { order, items };
};
const OrderDetail = () => {
const { order, items } = useLoaderData() as OrderLoaderData;
return <OrderView order={order} items={items} />;
};
// action:表单提交等变更操作,配合 <Form> 声明式提交
export const cancelOrderAction = async ({ params }: ActionFunctionArgs) => {
await orderApi.cancel(params.id!);
return redirect('/dashboard/orders');
};27.3 请求瀑布对比
| 数据加载方式 | 请求时序 | 首屏数据就绪时间 | 说明 |
| --- | --- | --- | --- |
| 组件内 useEffect | 渲染后才发起,层层瀑布 | 慢(约 3 段串行 900ms) | 父取完渲染子,子再取数 |
| 路由 loader 并行 | 匹配即并行发起 | 快(约 300ms) | 进入前就把该页数据取齐 |
| loader + defer 流式 | 关键数据等,次要数据流式 | 首屏更快 | 关键内容先出,次要内容 Suspense |
架构收益:把数据获取从组件内部移到路由层,组件退化为"纯展示 + useLoaderData",可测试性、可预取性、SSR 友好度都显著提升。这与 RSC 的"数据在渲染前就绪"思路一脉相承。
二十八、权限(RBAC)跨切面架构
权限是典型的横切关注点,散落在各处的 if (user.role === 'admin') 是维护噩梦。应当把权限抽象成独立的一层,提供声明式的守卫能力。
28.1 权限模型与能力判定
// 以"能力(permission)"而非"角色(role)"为判定单位,更灵活
type Permission =
| 'order:read' | 'order:write' | 'order:cancel'
| 'user:manage' | 'report:export';
interface AuthUser {
id: string;
roles: string[];
permissions: Permission[];
}
// 角色到能力的映射集中管理,改权限不用改业务代码
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: ['order:read', 'order:write', 'order:cancel', 'user:manage', 'report:export'],
operator: ['order:read', 'order:write'],
viewer: ['order:read'],
};28.2 声明式权限组件与 Hook
const PermissionContext = createContext<Set<Permission>>(new Set());
export const useCan = () => {
const granted = useContext(PermissionContext);
return (perm: Permission) => granted.has(perm);
};
// 声明式守卫组件:无权限则不渲染(或渲染兜底)
export const Can = ({
perform,
children,
fallback = null,
}: {
perform: Permission;
children: React.ReactNode;
fallback?: React.ReactNode;
}) => {
const can = useCan();
return <>{can(perform) ? children : fallback}</>;
};
// 使用:权限判定与业务 UI 解耦,一目了然
const OrderToolbar = () => (
<div>
<Can perform="order:write"><EditButton /></Can>
<Can perform="order:cancel"><CancelButton /></Can>
<Can perform="report:export" fallback={<UpgradeHint />}>
<ExportButton />
</Can>
</div>
);28.3 路由级权限守卫
// 与第二十七章的 loader 结合,在进入路由前拦截
export const requirePermission =
(perm: Permission) =>
async () => {
const user = await getCurrentUser();
if (!user) throw redirect('/login');
if (!user.permissions.includes(perm)) throw redirect('/403');
return null;
};
// 路由配置中挂载
// { path: 'admin/users', loader: requirePermission('user:manage'), element: <UserAdmin /> }常见坑:前端权限只是"体验优化",绝不能作为安全边界。隐藏按钮不等于禁止操作——后端必须对每个接口独立鉴权。前端权限层的价值是"不让用户看到点了会失败的东西",而非"保证安全"。
二十九、领域驱动设计在前端的落地
对于业务规则极其复杂的大型应用(金融、ERP、医疗),可以借鉴后端的领域驱动设计(DDD),把业务逻辑从 UI 与框架中彻底剥离,形成独立、可单测、框架无关的领域层。
29.1 分层:领域模型独立于 React
// —— 领域层:纯 TypeScript,不依赖任何 React/网络,可独立单测 ——
// domain/order/Order.ts
export class Order {
private constructor(
public readonly id: string,
public readonly items: OrderItem[],
private status: OrderStatus,
) {}
static create(items: OrderItem[]): Order {
if (items.length === 0) throw new DomainError('订单至少包含一件商品');
return new Order(crypto.randomUUID(), items, 'pending');
}
// 业务规则内聚在领域模型里,而非散落在组件
get total(): number {
return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
canCancel(): boolean {
return this.status === 'pending' || this.status === 'paid';
}
cancel(): void {
if (!this.canCancel()) throw new DomainError('已发货订单不可取消');
this.status = 'cancelled';
}
}// —— 领域层可以脱离 React、脱离浏览器直接测试,快且稳 ——
describe('Order 领域规则', () => {
it('已发货订单不可取消', () => {
const order = Order.create([{ price: 10, quantity: 1 }]);
// ...推进到 shipped 状态
expect(() => order.cancel()).toThrow('已发货订单不可取消');
});
});29.2 用适配器连接领域层与 React
// —— 应用层:用 Hook 把领域模型接入 React ——
export const useOrderActions = (orderId: string) => {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['order', orderId],
queryFn: () => orderRepository.findById(orderId), // 仓储把 DTO 还原成领域对象
});
const cancel = useMutation({
mutationFn: async () => {
data!.cancel(); // 调用领域规则(会抛领域异常)
await orderRepository.save(data!); // 持久化
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['order', orderId] }),
});
return { order: data, canCancel: data?.canCancel() ?? false, cancel: cancel.mutate };
};29.3 DDD 前端分层职责
| 层 | 职责 | 依赖 | 可测试性 |
| --- | --- | --- | --- |
| 领域层(domain) | 业务规则、实体、值对象 | 零依赖 | 极高(纯函数/类) |
| 应用层(application) | 用例编排、Hook 适配 | 领域 + 仓储接口 | 高 |
| 基础设施层(infra) | 仓储实现、HTTP、存储 | 具体第三方库 | 中(可 mock) |
| 表现层(presentation) | React 组件、UI | 应用层 | 中(RTL) |
何时该上 DDD:只有当业务规则本身复杂(大量状态机、校验、计算、不变量)时,DDD 的收益才盖过其结构成本。对以"表单增删改查 + 展示"为主的应用,DDD 是过度设计——直接用 feature 分层 + React Query 即可。判断标准:如果你的业务逻辑写下来的伪代码比 UI 代码还多、还难,才值得独立领域层。
三十、架构演进路线与结语
架构是随规模演进的连续过程,不存在"一步到位"。下表给出一条务实的演进路线,说明"在什么规模引入什么",避免过早优化,也避免积重难返:
| 阶段 | 团队/规模 | 引入的架构能力 | 不该做的事 |
| --- | --- | --- | --- |
| 原型期 | 1 到 2 人 / 少于 10 页 | 按类型目录、useState、fetch | 别上 Redux、别搞微前端 |
| 成长期 | 3 到 8 人 / 数十页 | feature 分层、React Query、错误边界、代码分割 | 别过度抽象领域层 |
| 成熟期 | 8 到 20 人 / 上百页 | Monorepo、设计系统、DI 容器、CI/CD、测试金字塔 | 别为炫技上 DDD |
| 规模化 | 多团队 / 数百页 | 微前端/模块联邦、RBAC 层、可观测性、ADR、CODEOWNERS | 别让共享层变垃圾桶 |
从数据角度看架构投入的回报(社区与企业实践的典型量级):
| 能力 | 一次性投入 | 长期回报 |
| --- | --- | --- |
| feature 分层 + 边界 lint | 数天 | 改动波及文件数下降约 5 倍 |
| React Query 缓存层 | 1 到 2 天 | 数据同步样板代码减少约 60% |
| 路由级代码分割 | 半天 | 首屏 JS 从 2MB 级降到 300KB 级 |
| 并发渲染优化 | 按需 | 大列表输入延迟从约 180ms 降到无感 |
| DI + 分层带来的可测试性 | 持续 | 覆盖率从低于 20% 升到 60% 以上 |
| Turborepo 缓存 | 1 天 | CI 构建从几分钟降到几十秒 |
最终回到那句贯穿全篇的话:架构的目标是让"改动的成本"随代码量保持近似线性增长。所有的分层、边界、注入、缓存、工具约束,服务的都是同一件事——让第 100 个功能和第 1 个功能一样好加,让第 40 个工程师和第 1 个工程师一样快上手。选择恰当的复杂度,用工具固化约定,为演进留白,就是 React 架构设计的全部智慧。