React 应用架构设计最佳实践

困难 🔴React 生态
10 个标签
预计阅读时间:139 分钟
React架构设计模式 scalability状态管理工程化Monorepo代码分割CI/CD可测试性

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 默认引导的结构。

按组件、hooks、utils 等类型划分
适合小型应用和快速原型开发
易于找到特定类型的文件
缺点是功能模块分散,一个功能的代码散落在多个顶层目录,不利于维护
codeCode
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)

按业务功能划分文件夹,每个功能模块独立管理,是中大型应用的推荐结构。

按业务功能划分文件夹,每个功能模块独立管理
每个功能包含相关的组件、hooks、utils、types、styles
提高代码的内聚性,便于功能开发和维护
适合中大型应用,支持团队并行开发(不同功能对应不同 owner,减少 merge 冲突)
codeCode
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.tsx

barrel file(index.ts)的关键作用:它定义了功能模块的"公共 API"。模块外部只能从 features/auth 导入 index.ts 显式导出的内容,内部实现细节(如某个私有组件)不对外暴露。这就是模块封装。

typescriptCode
// 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 规则,可以从工具层面强制"禁止跨功能深层导入":

javascriptCode
// .eslintrc.js —— 禁止绕过 barrel file 直接 import 功能内部文件
module.exports = {
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [
        {
          group: ['@/features/*/*'],
          message: '请从 features/xxx 的 index.ts 导入,禁止依赖内部实现文件',
        },
      ],
    }],
  },
};

1.3 混合组织(Hybrid)

结合功能和类型组织,取两者之长
核心业务功能按功能组织(features/),提高内聚性
通用基础设施按类型组织(shared/、lib/、app/),便于复用
是大多数中大型项目的最佳选择

1.4 三种结构对比

| 维度 | 按类型 | 按功能 | 混合 |

| --- | --- | --- | --- |

| 适用规模 | 小型(少于 20 个组件) | 中大型 | 中大型 |

| 功能内聚性 | 差 | 优 | 优 |

| 复用便利性 | 优 | 一般 | 优 |

| 团队并行开发 | 冲突多 | 冲突少 | 冲突少 |

| 删除功能成本 | 高(散落各处) | 低(删一个目录) | 低 |

| 上手成本 | 低 | 中 | 中 |

1.5 常见坑

shared 目录变成垃圾桶:任何"看起来通用"的东西都往 shared 塞,最后 shared 反而成了最大的耦合源。判断标准:一段代码只有被两个及以上功能真正引用时,才下沉到 shared。
功能之间横向依赖:features/order 直接 import features/user 的内部组件,形成功能间的紧耦合。正确做法是通过 shared 层或事件、路由解耦。
过深的嵌套:features/a/components/sub/deep/inner/... 超过 4 层就应该考虑拆分为独立功能。

二、分层架构与关注点分离

无论目录怎么组织,一个健壮的 React 应用在逻辑上都应该有清晰的分层。经典的分层是:表现层(Presentation)→ 业务逻辑层(Domain / Application)→ 数据访问层(Data / Infrastructure)

表现层:只负责 UI 渲染和用户交互,是"哑组件"(dumb component),不包含业务规则。
业务逻辑层:封装业务规则、状态编排,通常以自定义 Hook 或纯函数存在。
数据访问层:封装网络请求、本地存储、第三方 SDK,屏蔽底层细节。

分层的核心是依赖方向永远从上往下:表现层依赖业务层,业务层依赖数据层,反过来绝对不行。UI 组件里绝不应该出现 fetch('/api/...')。

typescriptCode
// ❌ 反例: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>;
};
typescriptCode
// ✅ 正例:三层分离

// —— 数据访问层: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 或参数注入实现。

typescriptCode
// 定义抽象接口(契约)
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 拆分为五个层级,从小到大逐级组合:

原子(Atoms):最基础的 UI 元素,如 Button、Input、Icon、Label。
分子(Molecules):由原子组合而成,如 SearchForm(Input + Button)、Card。
组织(Organisms):复杂的 UI 区块,如 Header、Sidebar、ProductList。
模板(Templates):页面骨架,定义布局,不含真实数据。
页面(Pages):模板 + 真实数据,是用户最终看到的界面。

React 官方推荐组合优于继承,原子设计正是组合思想的体现。

typescriptCode
// 原子组件: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)

一个经典的组件拆分模式:把"数据与逻辑"和"渲染"分离。

展示组件:只接收 props、只负责渲染,没有 state(除了纯 UI state),高度可复用、易测试。
容器组件:负责获取数据、编排状态,把结果通过 props 传给展示组件。
typescriptCode
// 展示组件:纯函数式,只关心怎么画
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 向下、回调向上,这是最基本也是最推荐的通信方式。
兄弟组件:状态提升(lift state up)到共同父级,或通过共享 store。
跨层级组件:Context API 或状态管理库,避免 props drilling(层层透传)问题。
全局通信:事件总线或全局状态管理,谨慎使用避免过度复杂化。
typescriptCode
// 父子组件通信: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 污染,重构困难。

typescriptCode
// ❌ 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 的回调嵌套。

typescriptCode
// 通用数据请求 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 仍然有用(尤其是需要向下传递作用域数据的库,如虚拟列表、拖拽库)。

typescriptCode
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 来源不透明。

typescriptCode
// 加载态 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 四类状态分层

组件内部状态(Local State):useState / useReducer,适用于组件私有的 UI 状态(输入框内容、弹窗开关)。
跨组件状态(Shared State):Context API,适用于低频变化的共享数据(主题、语言、当前用户)。
全局客户端状态(Global State):Redux Toolkit、Zustand、Jotai,适用于复杂、高频变化、多处读写的客户端状态。
服务器状态(Server State):React Query / SWR,适用于来自后端、需要缓存和同步的异步数据。

最重要的认知:服务器数据(用户列表、订单详情)不应该放进 Redux/Zustand。它天然属于"服务器状态",应该交给 React Query 这类专门的库,它们内建了缓存、去重、失效、重试、后台刷新,能省掉 60% 以上的样板代码。

5.2 各类状态代码示例

typescriptCode
// —— 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 })} />;
};
typescriptCode
// —— 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;
};
typescriptCode
// —— 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>;
};
typescriptCode
// —— 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;
typescriptCode
// —— 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 状态设计原则

保持状态最小化,避免冗余和可派生的状态。
状态就近原则:能放在局部就不放全局,能放页面就不放应用级。
单一数据源:同一份数据只有一个权威来源,避免多处副本不一致。
状态更新使用不可变数据,便于变化检测和时间旅行调试。
typescriptCode
// ❌ 错误:冗余状态,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 单向数据流原则

数据从父组件流向子组件,形成清晰的数据流向。
状态更新通过回调函数向上传递,保持数据流可预测。
避免双向绑定带来的复杂性,调试更加容易,配合 Redux DevTools 甚至可以"时间旅行"。
typescriptCode
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)把"服务器状态"当作一等公民,自动处理缓存、去重、失效、重试、后台刷新。

typescriptCode
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)。

typescriptCode
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 常见坑

把服务器数据同步进 useState:用 useEffect 把 query 结果 setState 到本地,制造第二份数据源,导致不一致。直接用 query.data 即可。
queryKey 设计不当:queryKey 应包含所有影响结果的参数(如 userId、filter、page),否则缓存会串。
staleTime 设为 0:每次挂载都重新请求,浪费流量。根据数据变化频率合理设置。

七、Monorepo 与大型工程组织

当应用增长到多个 App(Web 端、管理后台、移动端 H5)共享大量代码时,Monorepo 是主流选择。

7.1 Monorepo 的价值

代码共享:UI 组件库、工具函数、类型定义在多个 App 间零成本共享。
原子提交:一次 PR 可同时修改库和使用方,保证一致性。
统一工具链:一套 ESLint、TypeScript、构建配置。

主流工具:pnpm workspace + Turborepo / Nx。Turborepo 通过任务缓存把重复构建的时间从几分钟降到几秒。

codeCode
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
jsonCode
// 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
    }
  }
}
yamlCode
# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

App 里像用普通 npm 包一样引用内部包:

typescriptCode
// 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 路由级懒加载

typescriptCode
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 组件级懒加载与预加载

typescriptCode
// 重量级组件(如富文本编辑器、图表库)按需加载
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)把错误局部化,避免整个应用崩溃。

typescriptCode
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 测试金字塔

单元测试(多):测试纯函数、自定义 Hook、独立组件,快、稳、便宜。
集成测试(中):测试组件交互、数据流,用 React Testing Library 模拟用户行为。
端到端测试(少):测完整用户流程,用 Playwright / Cypress,慢但真实。
typescriptCode
// —— 组件集成测试: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();
  });
});
typescriptCode
// —— 自定义 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);
});
typescriptCode
// —— 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)

typescriptCode
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

javascriptCode
// .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 导入' }],
    }],
  },
};
jsonCode
// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100
}

11.2 提交前钩子与规范

用 husky + lint-staged 在提交前自动格式化和校验,把问题挡在提交之前:

jsonCode
// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css}": ["prettier --write"]
  }
}

11.3 组件文档(Storybook)

typescriptCode
/**
 * 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;
}
typescriptCode
// 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 与部署

自动化构建、测试、部署,保证每次发布质量可控。

yamlCode
# 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 时间从几分钟降到几十秒:

yamlCode
      - 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 人时,架构约束必须"工程化",靠自觉是靠不住的。

模块所有权(CODEOWNERS):每个 feature 目录指定 owner,改动自动请求对应人 review。
分支策略:trunk-based 或 GitFlow,配合 PR 模板、必需的 CI 检查、至少一名 reviewer 批准。
架构决策记录(ADR):重大技术决策写成 Markdown 存档,说明"为什么这么选",避免后人重复踩坑。
设计系统单一来源:所有 UI 组件来自 packages/ui,禁止各 App 重复造轮子。
约定式提交(Conventional Commits):feat / fix / chore 前缀,配合自动生成 CHANGELOG 和语义化版本。
codeCode
# .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,让不相关的更新彼此隔离。

typescriptCode
// ❌ 反例:一个巨型 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 变化而重渲染。

typescriptCode
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)。

typescriptCode
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。

typescriptCode
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? 配置式(``)看似简洁,但一旦需要在某个 Tab 里插入自定义元素、调整顺序、加图标,就会不断给 props 打补丁,最终变成难以维护的"配置怪物"。复合组件把布局控制权还给使用者,扩展性更强。

十六、表单架构

表单是前端最复杂的交互之一,涉及受控/非受控、校验、异步提交、错误展示。大型应用推荐用 react-hook-form + zod,把校验规则、类型、UI 解耦。

typescriptCode
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 调用,而应是一个统一的、可配置的客户端,集中处理鉴权、错误、重试、超时。

typescriptCode
// 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,
  };
}
typescriptCode
// 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 记忆化三件套

typescriptCode
// 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 会卡死主线程。虚拟化只渲染可视区域内的元素。

typescriptCode
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 已生产可用)把组件分为"服务端组件"和"客户端组件",从架构层面重新划分数据获取与交互的边界。

服务端组件(默认):在服务器渲染,可直接访问数据库/文件系统,不打包进客户端 JS,零 bundle 成本。适合数据展示。
客户端组件(`'use client'`):需要交互、状态、浏览器 API 的组件。
typescriptCode
// 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>
  );
}
typescriptCode
// 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 额外处理 | 天然友好 |

| 交互状态 | 全部客户端 | 仅客户端组件 |

二十、可观测性与运维

生产环境的架构必须包含监控、日志、错误追踪,否则线上问题只能靠用户投诉才发现。

typescriptCode
// 集成 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>
);
typescriptCode
// 上报核心 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); // 累积布局偏移

环境配置管理

typescriptCode
// 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 + 设计系统 | 约束工程化 |

最后强调三条贯穿始终的心法:

1.依赖方向要清晰:表现层依赖业务层,业务层依赖数据层,永不反向;功能之间通过 shared 层或事件解耦,不横向直连。
2.给不同状态用不同工具:本地、共享、全局客户端、服务器状态各有归属,混用是绝大多数状态 bug 的根源。
3.让架构可被工具强制:ESLint 边界规则、CODEOWNERS、CI 检查、类型系统,把架构约定固化成机器可校验的规则,才能在团队和时间的双重压力下不腐化。

二十二、并发渲染架构

React 18 引入的并发特性(Concurrent Features)从架构层面改变了"渲染是否可中断"这件事。传统的同步渲染一旦开始就必须一次性跑完,期间浏览器无法响应用户输入;并发渲染允许 React 把一次大更新拆成可中断、可让路的小任务,优先响应高优先级交互。

22.1 useTransition:把更新标记为"非紧急"

典型场景:搜索框输入时同时过滤一个上万条的列表。输入(受控 value)是紧急更新,必须立即反映;列表过滤是昂贵的非紧急更新,可以"慢一点"。useTransition 让二者不再互相阻塞。

typescriptCode
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)时更方便。

typescriptCode
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"。

typescriptCode
// 声明式加载:每个数据块独立挂起,先到先渲染,不必等最慢的
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 对应实现)允许一个构建产物在运行时动态加载另一个构建产物暴露的模块,且共享依赖只加载一份。

javascriptCode
// 远程应用 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' },
      },
    }),
  ],
};
javascriptCode
// 宿主应用 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 },
  },
});
typescriptCode
// 宿主中像本地组件一样懒加载远程组件
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 包) | 无 | 天然共享 | 不独立(需一起构建) | 团队协作紧密的中型项目 |

常见坑

React 双实例:宿主与远程各打包一份 React,运行时出现 "Invalid hook call"。必须用 shared + singleton 强制共用一份。
样式全局污染:远程应用的全局 CSS 覆盖宿主。用 CSS Modules、scoped 前缀或 Shadow DOM 隔离。
版本漂移:共享依赖的 requiredVersion 不匹配导致回退到各自版本,包体积翻倍。需在组织层面统一核心依赖版本。
远程宕机拖垮宿主:远程 remoteEntry.js 加载失败必须有错误边界兜底与降级 UI,绝不能让整个 shell 白屏。

代价提示:微前端不是免费的。它引入了额外的构建复杂度、运行时开销(多份 chunk 加载)、跨应用调试困难。经验阈值:团队少于 3 个、代码量不足以让单体构建慢到无法忍受(生产构建仍在 2 分钟内)时,通常不值得上微前端,Monorepo + 代码分割足矣。

二十四、设计系统与组件库分层

前面多次提到 packages/ui 作为设计系统单一来源。一个成熟的设计系统本身也需要清晰的内部分层,否则会退化成又一个大杂烩组件库。

24.1 设计系统的三层结构

Design Tokens 层:颜色、间距、字号、圆角、阴影等最原子的设计变量,与框架无关,通常从 Figma 同步生成。
Primitives / Base 层:无样式或最小样式的行为组件(如 Radix UI、Headless UI 提供的 Dialog、Popover),只管可访问性与交互逻辑,不管长相。
Composed / Branded 层:把 tokens 应用到 primitives 上,形成有品牌视觉的成品组件(Button、Card、Modal)。
typescriptCode
// —— 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;
typescriptCode
// —— 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)之类的工具把变体声明化,同时导出精确类型。

typescriptCode
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 依赖的底层包,破坏性改动会波及所有下游。要点:

语义化版本严格执行:改 API 走 major,加功能走 minor,修 bug 走 patch。
视觉回归测试:用 Chromatic / Playwright 截图对比,任何像素级变化都需人工确认,避免"改了 Button 圆角,全站 200 个页面悄悄变样"。
渐进式废弃:旧 API 标 @deprecated 并保留一个大版本周期,给下游迁移时间,而非直接删除。

| 层级 | 关注点 | 是否含品牌样式 | 代表方案 |

| --- | --- | --- | --- |

| 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 本身就是一个天然的注入容器。关键是把"服务集合"作为一个整体注入。

typescriptCode
// 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 注入假实现。

typescriptCode
// 测试专用的假服务集合
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 基础与全局配置

typescriptCode
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 与网络之间插入了一个"缓存层",承担了原本要手写的一大堆基础设施:

codeCode
UI 组件  ──读──▶  缓存层(SWR / React Query)  ──未命中/过期──▶  API 层  ──▶  后端
   ▲                     │
   └──── 数据变化推送 ─────┘(缓存更新自动触发相关组件重渲染)

这一层要集中处理:缓存键设计、失效策略、去重、重试、后台刷新、乐观更新、分页/无限滚动、依赖查询。把这些能力沉淀在缓存层,业务组件就只剩"声明我要什么数据"。

26.3 SWR 无限加载与本地变更

typescriptCode
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 集中式路由配置与嵌套布局

typescriptCode
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:数据与变更下沉到路由

typescriptCode
// 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 权限模型与能力判定

typescriptCode
// 以"能力(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

typescriptCode
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 路由级权限守卫

typescriptCode
// 与第二十七章的 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

typescriptCode
// —— 领域层:纯 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';
  }
}
typescriptCode
// —— 领域层可以脱离 React、脱离浏览器直接测试,快且稳 ——
describe('Order 领域规则', () => {
  it('已发货订单不可取消', () => {
    const order = Order.create([{ price: 10, quantity: 1 }]);
    // ...推进到 shipped 状态
    expect(() => order.cancel()).toThrow('已发货订单不可取消');
  });
});

29.2 用适配器连接领域层与 React

typescriptCode
// —— 应用层:用 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 架构设计的全部智慧。