vault backup: 2026-04-29 23:36:32
This commit is contained in:
+205
-52
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
tags: [React, Component Communication, Props, Context, Frontend]
|
tags: [React, Component Communication, Props, Context, State Lifting, Frontend]
|
||||||
create time: 2026-04-29 22:10
|
create time: 2026-04-29 22:10
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -7,7 +7,12 @@ create time: 2026-04-29 22:10
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
React 中父子组件之间的数据流动有多种方式。理解每种模式的适用场景和性能影响,能够避免过度设计或通信瓶颈。本文档从简单到复杂,梳理完整的通信方案谱系。
|
React 的核心理念是 **"单向数据流"** ——数据从父组件流向子组件,事件从子组件冒泡回父组件。但在真实项目中,组件层级可以很深、结构可以很散,跨组件共享状态成为日常需求。
|
||||||
|
|
||||||
|
本文档梳理 React 中所有主流的组件通信方式,从最基础的 Props 到状态管理库,帮助你根据实际场景选择最合适方案。
|
||||||
|
|
||||||
|
> [!question] 思考题
|
||||||
|
> 假设你有一个 6 层深的组件树,最顶层需要把 `theme` 传给第 6 层的按钮组件。你会怎么做?逐层透传 Props 似乎笨拙,但引入 Context 又可能引发不必要的重渲染——你怎么权衡?
|
||||||
|
|
||||||
## 通信方向全景图
|
## 通信方向全景图
|
||||||
|
|
||||||
@@ -16,14 +21,14 @@ graph TD
|
|||||||
A["父 → 子"] --> B["Props(最基础)"]
|
A["父 → 子"] --> B["Props(最基础)"]
|
||||||
A --> C["Context Provider(跨层级)"]
|
A --> C["Context Provider(跨层级)"]
|
||||||
A --> D["状态管理库(全局共享)"]
|
A --> D["状态管理库(全局共享)"]
|
||||||
|
|
||||||
E["子 → 父"] --> F["回调 Prop(事件驱动)"]
|
E["子 → 父"] --> F["回调 Prop(事件驱动)"]
|
||||||
E --> G["refs(imperative)"]
|
E --> G["Refs(imperative)"]
|
||||||
|
|
||||||
H["兄弟组件"] --> I["状态提升到共同父组件"]
|
H["兄弟组件"] --> I["状态提升到共同父组件"]
|
||||||
H --> J["通过共同祖先通信"]
|
H --> J["通过共同祖先通信"]
|
||||||
H --> K["状态管理库 / 发布订阅"]
|
H --> K["状态管理库 / 发布订阅"]
|
||||||
|
|
||||||
style B fill:#4FC08D,color:#fff
|
style B fill:#4FC08D,color:#fff
|
||||||
style C fill:#F5A87D,color:#000
|
style C fill:#F5A87D,color:#000
|
||||||
style F fill:#61DAFB,color:#000
|
style F fill:#61DAFB,color:#000
|
||||||
@@ -36,30 +41,37 @@ graph TD
|
|||||||
```tsx
|
```tsx
|
||||||
function App() {
|
function App() {
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
return <Header user={user} />; // Header 不需要 user,但深层的 Avatar 需要!
|
return <Header user={user} />;
|
||||||
|
// Header 不需要 user,但深层的 Avatar 需要!
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({ user }) {
|
function Header({ user }) {
|
||||||
return (
|
return (
|
||||||
<nav>
|
<nav>
|
||||||
<Logo /> {/* ❌ 无需 user,却接收了 */}
|
<Logo />
|
||||||
<Avatar user={user} /> {/* ✅ 需要 */}
|
{/* ❌ 无需 user,却被迫接收 */}
|
||||||
|
<Avatar user={user} />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!tip] 何时该容忍 Props Drill?
|
||||||
|
> 传递 2-3 层 Props 是完全合理的,甚至值得鼓励——它让数据流向清晰可见。只有超过 4 层时才考虑替代方案。
|
||||||
|
|
||||||
### 解决方案对比
|
### 解决方案对比
|
||||||
|
|
||||||
| 方法 | 适合场景 | 额外依赖 |
|
| 方法 | 适合场景 | 额外依赖 |
|
||||||
|------|----------|---------|
|
|------|----------|---------|
|
||||||
| **Context** | 少量深层传递(主题、用户信息) | 无 |
|
| **Context** | 少量深层传递(主题、用户信息、语言设置) | 无 |
|
||||||
| **自定义 Hook + Props 重组织** | 中等深度(3-4 层) | 无 |
|
| **自定义 Hook + Props 重组织** | 中等深度(3-4 层),只需中间层不暴露 | 无 |
|
||||||
| **状态管理库** | 频繁变化、多组件共享 | Zustand/Redux |
|
| **状态管理库** | 频繁变化、多组件共享 | Zustand / Redux Toolkit |
|
||||||
| **Render Prop / HOC** | 复用逻辑而非传值 | 无 |
|
| **Render Props / HOC** | 复用逻辑而非单纯传值 | 无 |
|
||||||
|
|
||||||
## Callback Prop(子 → 父)
|
## Callback Prop(子 → 父)
|
||||||
|
|
||||||
|
这是 React 中最基础也最重要的反向通信方式:**父组件传递一个函数作为 Prop,子组件在适当时机调用它**。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
interface FormProps {
|
interface FormProps {
|
||||||
onSubmit: (data: FormValues) => Promise<void>;
|
onSubmit: (data: FormValues) => Promise<void>;
|
||||||
@@ -69,9 +81,9 @@ function LoginForm({ onSubmit }: FormProps) {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = collectFormData();
|
const data = collectFormData();
|
||||||
onSubmit(data); // 通知父组件
|
onSubmit(data); // ✅ 通知父组件
|
||||||
};
|
};
|
||||||
|
|
||||||
return <form onSubmit={handleSubmit}><Button type="submit">登录</Button></form>;
|
return <form onSubmit={handleSubmit}><Button type="submit">登录</Button></form>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,75 +93,203 @@ function App() {
|
|||||||
await api.login(data);
|
await api.login(data);
|
||||||
navigate("/dashboard");
|
navigate("/dashboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
return <LoginForm onSubmit={handleLogin} />;
|
return <LoginForm onSubmit={handleLogin} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!note] 为什么不用 props.children 做通信?
|
> [!note] 为什么不用 children 做通信?
|
||||||
> children 是 UI 内容插槽,不是通信机制。通信用 callback prop 保持关注点分离。
|
> children 是 UI 内容插槽,不是通信机制。回调 Prop 保持关注点分离——子组件只关心"什么时候触发",父组件决定"触发后做什么"。
|
||||||
|
|
||||||
|
> [!important] 性能提示:使用 useCallback
|
||||||
|
> 如果子组件用 `React.memo` 包裹,每次父组件重新渲染都会创建新的函数引用,导致子组件无效重渲染。用 `useCallback` 缓存回调函数:
|
||||||
|
> ```tsx
|
||||||
|
> const handleLogin = useCallback(async (data: FormValues) => { ... }, []);
|
||||||
|
> ```
|
||||||
|
|
||||||
## Context 跨层级通信
|
## Context 跨层级通信
|
||||||
|
|
||||||
|
当 Props 穿透超过 3-4 层时,Context 是最轻量的替代方案。**它的本质是一个"隐式 Prop"——Provider 上方的任意后代都可以直接消费,无需经过中间组件。**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const UserContext = createContext<User | null>(null);
|
const UserContext = createContext<User | null>(null);
|
||||||
|
|
||||||
function UserProfile({ children }: { children: React.ReactNode }) {
|
function UserProfileProvider({ children }: { children: React.ReactNode }) {
|
||||||
const user = useDatabaseUser(userId);
|
const user = useDatabaseUser(userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={user}>
|
<UserContext.Provider value={user}>
|
||||||
{/* Avatar、Settings、OrderHistory 任意深度都可消费 */}
|
{children} {/* Avatar、Settings 任意深度都可消费 */}
|
||||||
{children}
|
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrderHistory() {
|
function OrderHistory() {
|
||||||
const user = useContext(UserContext); // 直接获取,无需中间层透传
|
const user = useContext(UserContext); // ✅ 直接获取,跳过中间层
|
||||||
return <div>{user?.orders?.map(...)}</div>;
|
return <div>{user?.orders?.map(order => <OrderItem key={order.id} order={order} />)}</div>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!warning] Context 的性能陷阱
|
### Context 性能陷阱与最佳实践
|
||||||
> - Provider value 对象每次渲染都是新引用 → 所有消费者重渲染
|
|
||||||
> - 解决:拆分 Context、或使用 `useReducer` 返回稳定的 `{state, dispatch}` 对象
|
```
|
||||||
|
⚠️ 核心问题:
|
||||||
|
Context value 改变 → Provider 下所有消费者重渲染,无论是否真的使用了这个值
|
||||||
|
```
|
||||||
|
|
||||||
|
**陷阱一:value 对象每次都是新引用**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 每次渲染都创建新对象,所有消费者必重渲染
|
||||||
|
<UserContext.Provider value={{ user, theme }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
```
|
||||||
|
|
||||||
|
**陷阱二:单个大 Context 包含多个独立状态**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ settings 变了,即使用户信息模块的组件也会重渲染
|
||||||
|
<UserAndSettingsContext.Provider value={{ user, settings, locale }}>
|
||||||
|
{children}
|
||||||
|
</UserAndSettingsContext.Provider>
|
||||||
|
```
|
||||||
|
|
||||||
|
**最佳实践:拆分成小 Context + useReducer 稳定引用**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 拆分 Context,粒度更细
|
||||||
|
const UserContext = createContext<User | null>(null);
|
||||||
|
const SettingsContext = createContext<Settings>({ theme: "light" });
|
||||||
|
|
||||||
|
// ✅ 用 useReducer 返回稳定的 { state, dispatch } 对象
|
||||||
|
function SettingsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [settings, dispatch] = useReducer(settingsReducer, initialSettings);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{ settings, dispatch }}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消费者只取需要的 slice
|
||||||
|
function ThemeToggle() {
|
||||||
|
const { settings, dispatch } = useContext(SettingsContext);
|
||||||
|
return (
|
||||||
|
<button onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
|
||||||
|
{settings.theme === "light" ? "☀️" : "🌙"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 兄弟组件通信
|
||||||
|
|
||||||
|
两个没有祖先后代关系的组件如何共享数据?答案是:**将状态提升到它们的共同父组件中**。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type ContactId = string;
|
||||||
|
|
||||||
|
function ChatApp() {
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<ContactId | null>(null);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
|
||||||
|
const selectedContact = useMemo(
|
||||||
|
() => contacts.find(c => c.id === selectedId),
|
||||||
|
[contacts, selectedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-layout">
|
||||||
|
{/* 左侧:联系人列表 */}
|
||||||
|
<ContactList
|
||||||
|
contacts={contacts}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右侧:聊天窗口 */}
|
||||||
|
<ChatWindow
|
||||||
|
contact={selectedContact}
|
||||||
|
messages={messages.filter(m => m.contactId === selectedId)}
|
||||||
|
onSend={(text) => setMessages(prev => [...prev, { text, contactId: selectedId!, timestamp: Date.now() }])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!diagram] 状态提升流程
|
||||||
|
> ```mermaid
|
||||||
|
> graph LR
|
||||||
|
> A[ContactList] -- "onSelect(id)" --> P[ChatApp - State]
|
||||||
|
> P -- "contact" --> B[ChatWindow]
|
||||||
|
> P -- "onSend(text)" --> P
|
||||||
|
> B -- "sendMessage(text)" --> P
|
||||||
|
> style P fill:#4FC08D,color:#fff
|
||||||
|
> ```
|
||||||
|
|
||||||
|
> [!tip] 何时应该使用状态提升?
|
||||||
|
> 当兄弟组件数量少(≤ 3 个)、关系固定、状态不频繁变化时,状态提升比引入状态管理库更简单、更可维护。
|
||||||
|
|
||||||
## 状态管理库方案
|
## 状态管理库方案
|
||||||
|
|
||||||
|
当状态需要在整个应用中流转、涉及复杂操作逻辑或多条副作用时,Zustand / Redux Toolkit 等库提供了集中式状态容器。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Zustand 方案:任意两个组件共享 state,无需祖先后代关系
|
// Zustand:极简 API,按 slice 订阅避免全量重渲染
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
const useCartStore = create((set) => ({
|
interface CartState {
|
||||||
|
items: CartItem[];
|
||||||
|
addItem: (item: CartItem) => void;
|
||||||
|
removeItem: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCartStore = create<CartState>((set) => ({
|
||||||
items: [],
|
items: [],
|
||||||
addItem: (item) => set(state => ({ items: [...state.items, item] })),
|
addItem: (item) => set(state => ({ items: [...state.items, item] })),
|
||||||
|
removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function ProductCard() {
|
function ProductCard({ product }: { product: Product }) {
|
||||||
const addItem = useCartStore(s => s.addItem);
|
const addItem = useCartStore(s => s.addItem); // 精确订阅,仅在 addItem 引用变化时重渲染
|
||||||
return <button onClick={() => addItem(product)}>加入购物车</button>;
|
return <button onClick={() => addItem(product)}>加入购物车</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CartIcon() {
|
function CartIcon() {
|
||||||
const items = useCartStore(s => s.items);
|
const items = useCartStore(s => s.items); // 仅 items 变化时重渲染
|
||||||
return <Badge>{items.length}</Badge>;
|
return <Badge>{items.length}</Badge>;
|
||||||
}
|
}
|
||||||
// 两者毫无关联,但共享同一份状态
|
// 两者毫无关联,但共享同一份状态
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!note] Zustand vs Redux Toolkit 选型建议
|
||||||
|
> - **Zustand**:API 简洁、样板代码极少、支持 immer 中间件,适合中小型项目和个人项目
|
||||||
|
> - **Redux Toolkit**:生态成熟、DevTools 体验好、middleware 体系完善(thunk/saga),适合大型团队协作项目
|
||||||
|
> - 详见 [[hhs/REACT/3. 生态工具篇/09-状态管理.md]]
|
||||||
|
|
||||||
## Ref Imperative API(命令式通信)
|
## Ref Imperative API(命令式通信)
|
||||||
|
|
||||||
|
大多数情况下 React 推崇声明式通信(Props + Callback),但在需要**直接操控子组件实例行为**时使用 refs。典型场景:触发自定义验证、聚焦输入框、播放动画。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const formRef = useRef<FormHandle>(null);
|
const formRef = useRef<FormHandle>(null);
|
||||||
|
|
||||||
function Parent() {
|
function Parent() {
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
formRef.current?.validate(); // 命令子组件执行方法
|
formRef.current?.validate(); // 命令子组件执行
|
||||||
formRef.current?.reset();
|
formRef.current?.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
return <><ChildForm ref={formRef} /><button onClick={handleSubmit}>提交</button></>;
|
return (
|
||||||
|
<>
|
||||||
|
<ChildForm ref={formRef} />
|
||||||
|
<button onClick={handleSubmit}>提交</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChildForm = forwardRef<FormHandle>((props, ref) => {
|
const ChildForm = forwardRef<FormHandle>((props, ref) => {
|
||||||
@@ -157,31 +297,44 @@ const ChildForm = forwardRef<FormHandle>((props, ref) => {
|
|||||||
validate: () => validator.validate(),
|
validate: () => validator.validate(),
|
||||||
reset: () => setFields(initialValues),
|
reset: () => setFields(initialValues),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <form>...</form>;
|
return <form>...</form>;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!warning] 谨慎使用 Imperative Refs
|
||||||
|
> Imperative API 打破了 React 的声明式范式。能用 declarative(状态驱动 UI)解决的问题,永远不要上 imperative(直接操控 DOM)。过度使用会导致:难以测试、时序 bug、调试困难。
|
||||||
|
|
||||||
## 决策树
|
## 决策树
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
A["需要通信?"] -->|"是"| B["通信范围?"]
|
START["需要组件通信?"] -->|"否"| DONE["无需处理 ✅"]
|
||||||
|
START -->|"是"| RANGE["通信范围?"]
|
||||||
B --> C["仅父子两层"]
|
|
||||||
B --> D["多层级穿透"]
|
RANGE --> PARENT_CHILD["仅父子两层<br/>(1-2 级深度)"]
|
||||||
B --> E["跨层级 + 兄弟之间"]
|
RANGE --> DEEP["多层级穿透<br/>(≥ 3 级深度)"]
|
||||||
B --> F["需命令式调用子组件方法"]
|
RANGE --> SIBLING["兄弟组件 / 无关组件"]
|
||||||
|
RANGE --> IMPERATIVE["需命令式调用子组件方法"]
|
||||||
C --> G["Props + Callback ✅"]
|
|
||||||
D --> H["Context ✅"]
|
PARENT_CHILD --> PROP["Props + Callback ✅<br/>简单、直观、可追踪"]
|
||||||
E --> I["Zustand / Redux ✅"]
|
DEEP --> CTX["Context ✅<br/>配合 useReducer 保证性能"]
|
||||||
F --> J["forwardRef + useImperativeHandle ✅"]
|
SIBLING --> SLIFTING["状态提升到共同父 ✅"]
|
||||||
|
SIBLING --> STORE["状态管理库(复杂场景)"]
|
||||||
style G fill:#4FC08D,color:#fff
|
IMPERATIVE --> REF["forwardRef +<br/>useImperativeHandle ✅"]
|
||||||
style H fill:#61DAFB,color:#000
|
|
||||||
style I fill:#F5A87D,color:#000
|
style PROP fill:#4FC08D,color:#fff
|
||||||
style J fill:#A0AEC0,color:#000
|
style CTX fill:#F5A87D,color:#000
|
||||||
|
style SLIFTING fill:#61DAFB,color:#000
|
||||||
|
style STORE fill:#C084FC,color:#fff
|
||||||
|
style REF fill:#A0AEC0,color:#000
|
||||||
```
|
```
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
|
||||||
|
- [[hhs/REACT/1. 基础篇/03-组件与 Props.md]] — Props 类型校验、组件拆分原则
|
||||||
|
- [[hhs/REACT/2. Hooks 篇/05-核心 Hooks.md]] — useContext / useRef 原理
|
||||||
|
- [[hhs/REACT/2. Hooks 篇/06-性能优化 Hooks.md]] — 配合 Context 的性能优化手段
|
||||||
|
- [[hhs/REACT/4. 进阶篇/12-HOC 与 Render Props.md]] — 高阶组件与 Render Props 进阶
|
||||||
|
- [[hhs/REACT/3. 生态工具篇/09-状态管理.md]] — Zustand / Redux Toolkit 选型对比
|
||||||
|
- [[hhs/REACT/5. 工程实践篇/15-性能优化.md]] — React.memo、虚拟列表、Bundle 分析
|
||||||
|
|||||||
@@ -7,12 +7,22 @@ create time: 2026-04-29 22:11
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
Hooks 出现之前,HOC(高阶组件)和 Render Props 是 React 中复用它逻辑的两种主要模式。理解它们的原理、适用场景和局限,有助于阅读遗留代码并理解为什么 Hooks 成为更优解。
|
> [!question] 思考:如果两个组件需要共享同一套数据获取逻辑,该怎么设计?
|
||||||
|
|
||||||
|
Hooks 出现之前,HOC(高阶组件)和 Render Props 是 React 中**复用状态逻辑**的两种主要模式。理解它们的原理、适用场景和局限,不仅有助于阅读遗留代码,更能帮你深刻理解为什么 Hooks 成为更优解——以及哪些场景下旧模式依然不可替代。
|
||||||
|
|
||||||
|
| 维度 | HOC | Render Props | Custom Hook |
|
||||||
|
|------|-----|-------------|-------------|
|
||||||
|
| **本质** | 函数包裹组件 | prop 传递渲染回调 | 纯函数抽离逻辑 |
|
||||||
|
| **侵入性** | 不改动原组件 JSX | 需修改 JSX 调用处 | 零侵入,仅在 hooks 内使用 |
|
||||||
|
| **性能影响** | 额外 wrapper 层 | 同上 | **无额外组件树开销** |
|
||||||
|
|
||||||
## HOC(高阶组件)
|
## HOC(高阶组件)
|
||||||
|
|
||||||
### 概念
|
### 概念
|
||||||
|
|
||||||
|
> [!question] 类比理解:如果把组件看作"数据 → UI"的转换器,HOC 就是在输入输出之间插入了一层"调料"。
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph LR
|
graph LR
|
||||||
A["原始组件 Component"] -->|"注入 props"| B["HOC 函数"]
|
A["原始组件 Component"] -->|"注入 props"| B["HOC 函数"]
|
||||||
@@ -48,67 +58,135 @@ const StyledTitle = withTheme(function Title({ title }: Props) {
|
|||||||
}); // ✅ TS 推断 Props 不变
|
}); // ✅ TS 推断 Props 不变
|
||||||
```
|
```
|
||||||
|
|
||||||
### 常见 HOC 模式
|
> [!note] 核心原理
|
||||||
|
> HOC 本质上是**闭包 + 组合**。`WithAuth` 作为一个新的函数组件,可以正常使用 Hooks(因为它是组件),而包裹的 `WrappedComponent` 只负责渲染。这种方式**不修改原组件的任何代码**,仅通过 props 传递增强信息。
|
||||||
|
|
||||||
|
#### 关键细节:displayName
|
||||||
|
|
||||||
|
调试嵌套 HOC 时,React DevTools 会显示一层层无意义的 wrapper 名。可以用 `displayName` 让调试更清晰:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// 1. 日志/HUD
|
export function withAuth<P>(Wrapped: React.ComponentType<P>) {
|
||||||
|
const WithAuth: React.FC<P> = (props) => {
|
||||||
|
// ... 认证逻辑
|
||||||
|
return <Wrapped {...props} />;
|
||||||
|
};
|
||||||
|
WithAuth.displayName = `withAuth(${Wrapped.name || 'Component'})`;
|
||||||
|
return WithAuth;
|
||||||
|
}
|
||||||
|
// DevTools 中显示:withAuth(Dashboard) → 一目了然
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见 HOC 模式
|
||||||
|
|
||||||
|
#### 1. 日志 / HUD
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 通用:为组件自动记录生命周期事件
|
||||||
function withLogger<P extends object>(Comp: React.FC<P>): React.FC<P> {
|
function withLogger<P extends object>(Comp: React.FC<P>): React.FC<P> {
|
||||||
return function LoggedComponent(props: P) {
|
return function LoggedComponent(props: P) {
|
||||||
useEffect(() => console.log(`${Comp.name} mounted`), []);
|
useEffect(() => console.log(`[Mount] ${Comp.name || 'Anonymous'}`), []);
|
||||||
return <Comp {...props} />;
|
return <Comp {...props} />;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
> [!tip] 解释
|
||||||
|
> 不传任何额外 prop,只"旁路"添加副作用(如日志)。这是 HOC 最轻量的用法——原组件完全不知情。
|
||||||
|
|
||||||
// 2. 加载态封装
|
#### 2. 加载态封装
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 自动管理 loading 状态 + 错误处理
|
||||||
function withLoading<P extends object>(
|
function withLoading<P extends object>(
|
||||||
Comp: React.FC<P>,
|
Comp: React.FC<P>,
|
||||||
fetchData: () => Promise<void>
|
fetchData: () => Promise<void>
|
||||||
): React.FC<P> {
|
): React.FC<P> {
|
||||||
return function LoadingWrapper(props: P) {
|
return function LoadingWrapper(props: P) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
useEffect(() => { fetchData().then(() => setLoading(false)); }, []);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData().then(() => setLoading(false)).catch(setError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <ErrorMessage err={error} />;
|
||||||
return loading ? <Spinner /> : <Comp {...props} />;
|
return loading ? <Spinner /> : <Comp {...props} />;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
> [!note] 解释
|
||||||
|
> HOC 在**不改动组件渲染逻辑**的前提下,统一接管了 "加载中 / 出错 / 成功" 三种 UI 状态。多个页面复用同一套加载策略,DRY。
|
||||||
|
|
||||||
// 3. Props 转换(驼峰→kebab)
|
#### 3. Props 转换
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 将 props 做格式变换后注入子组件
|
||||||
function withPropsTransformer<P extends object>(
|
function withPropsTransformer<P extends object>(
|
||||||
Comp: React.FC<P>,
|
Comp: React.FC<P>,
|
||||||
transform: (p: P) => Record<string, unknown>
|
transform: (p: P) => Record<string, unknown>
|
||||||
): React.FC {
|
): React.FC {
|
||||||
return function Transformed(props: P) {
|
return function Transformed(props: P) {
|
||||||
const extra = transform(props);
|
const extra = transform(props);
|
||||||
return <Comp {...props as any} {...extra} />;
|
// ⚠️ transform 返回的 key 可能与原 props 冲突
|
||||||
|
return <Comp {...(props as any)} {...extra} />;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
> [!warning] 注意
|
||||||
|
> 当 `transform` 返回的 key 和原 props 同名时,后者会覆盖前者。实际项目中建议约定命名空间前缀,例如 `apiData`、`cacheMeta` 等。
|
||||||
|
|
||||||
### HOC 的局限性
|
### HOC 的局限性
|
||||||
|
|
||||||
| 问题 | 说明 |
|
| 问题 | 说明 | 解决方案 |
|
||||||
|------|------|
|
|------|------|---------|
|
||||||
| **Static 属性丢失** | `withAuth(Page)` 返回的新组件没有 Page.getInitialProps |
|
| **Static 属性丢失** | `withAuth(Page)` 返回的新组件没有 Page.getInitialProps | 手动拷贝:`EnhancedComponent.getInitialProps = Wrapped.getInitialProps` |
|
||||||
| **Wrapper Hell** | `withAuth(withLogging(withData(Page)))`——嵌套过深调试困难 |
|
| **Wrapper Hell** | `withAuth(withLogging(withData(Page)))`——嵌套过深调试困难 | 用 `compose` 函数扁平化,或直接改用 Custom Hook |
|
||||||
| **Props 冲突** | 多个 HOC 都注入 `data` prop,后一个覆盖前一个 |
|
| **Props 冲突** | 多个 HOC 都注入 `data` prop,后一个覆盖前一个 | 使用唯一命名空间前缀(如 `_data` → `useData.data`) |
|
||||||
| **ref 丢失** | 直接传 ref 给增强组件会报错(需用 forwardRef 包装) |
|
| **ref 丢失** | 直接传 ref 给增强组件会报错(需用 forwardRef 包装) | 见下方兼容 ref 的方案 |
|
||||||
|
| **隐式依赖** | HOC 内部调用的 hook 不透明,阅读者不知道注入了什么 | 规范命名(`withXxx`),文档说明行为 |
|
||||||
|
|
||||||
> [!tip] HOC 兼容 ref
|
#### 为什么需要 forwardRef?
|
||||||
> ```tsx
|
|
||||||
> export function withAuth<P>(Wrapped: React.ComponentType<P>) {
|
```tsx
|
||||||
> return React.forwardRef<HTMLDivElement, P>((props, ref) => {
|
export function withAuth<P>(Wrapped: React.ComponentType<P>) {
|
||||||
> const { authenticated } = useAuth();
|
return React.forwardRef<HTMLDivElement, P>((props, ref) => {
|
||||||
> if (!authenticated) return <Navigate to="/login" />;
|
const { authenticated } = useAuth();
|
||||||
> return <Wrapped {...props} ref={ref} />;
|
if (!authenticated) return <Navigate to="/login" />;
|
||||||
> });
|
return <Wrapped {...props} ref={ref} />;
|
||||||
> }
|
});
|
||||||
> ```
|
}
|
||||||
|
```
|
||||||
|
> [!tip] 解释
|
||||||
|
> 普通函数组件不能接收 `ref`。当 HOC 需要透传 `ref` 给子组件时,必须用 `React.forwardRef` 包裹,否则 React 会抛出 "Function components cannot be given refs" 错误。这增加了额外的样板代码——Hooks 天然解决了这个问题。
|
||||||
|
|
||||||
|
#### compose 工具
|
||||||
|
|
||||||
|
当多层 HOC 叠加时,`compose` 可以让可读性更好:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// before: 从内到外,阅读时要先找最内层
|
||||||
|
const Enhanced = withAuth(withCache(withPagination(UserList)));
|
||||||
|
|
||||||
|
// after: 从左到右,符合阅读习惯
|
||||||
|
const Enhanced = compose(
|
||||||
|
withAuth,
|
||||||
|
withCache,
|
||||||
|
withPagination,
|
||||||
|
)(UserList);
|
||||||
|
```
|
||||||
|
> [!question] 思考
|
||||||
|
> compose 真的改善了可读性吗?如果每个 HOC 的作用一目了然,你是否还需要它?
|
||||||
|
|
||||||
## Render Props
|
## Render Props
|
||||||
|
|
||||||
### 核心思想
|
### 核心思想
|
||||||
|
|
||||||
|
> [!question] 和 HOC 相比,Render Props 到底"额外"给了什么能力?
|
||||||
|
|
||||||
通过 prop 传递一个**函数**,该函数返回 JSX——将 UI 渲染逻辑委托给调用方。
|
通过 prop 传递一个**函数**,该函数返回 JSX——将 UI 渲染逻辑委托给调用方。
|
||||||
|
|
||||||
|
与 HOC 的本质区别:HOC 通过**组件嵌套**注入 props;Render Props 通过**函数传参**直接暴露状态。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
interface MouseTrackerProps {
|
interface MouseTrackerProps {
|
||||||
render: (position: { x: number; y: number }) => React.ReactNode;
|
render: (position: { x: number; y: number }) => React.ReactNode;
|
||||||
@@ -124,6 +202,7 @@ function MouseTracker({ render }: MouseTrackerProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🎯 关键:render 函数返回什么就渲染什么
|
// 🎯 关键:render 函数返回什么就渲染什么
|
||||||
|
// 状态在父组件,UI 由调用方决定
|
||||||
return <div>{render(pos)}</div>;
|
return <div>{render(pos)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +212,15 @@ function MouseTracker({ render }: MouseTrackerProps) {
|
|||||||
)} />;
|
)} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 等价于 children 的情况
|
> [!note] 解释
|
||||||
|
> `MouseTracker` 是**纯粹的关注点分离**:它只负责追踪鼠标位置并触发重渲染,完全不关心屏幕上画什么。这种"关注点解耦"是 HOC 难以优雅表达的。
|
||||||
|
|
||||||
|
### children 作为 Render Prop
|
||||||
|
|
||||||
|
当 render prop 只是把数据传给子内容时,可以用 `children`(本身也是函数)替代——语义更自然。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// 当 render prop 只是把数据传给 children 时,可以用 children 替代
|
// children prop 的类型:接收 data,返回 ReactNode
|
||||||
function DataProvider({ children }: { children: (data: Data) => React.ReactNode }) {
|
function DataProvider({ children }: { children: (data: Data) => React.ReactNode }) {
|
||||||
const data = useDatabase();
|
const data = useDatabase();
|
||||||
return <>{children(data)}</>;
|
return <>{children(data)}</>;
|
||||||
@@ -147,42 +231,86 @@ function DataProvider({ children }: { children: (data: Data) => React.ReactNode
|
|||||||
</DataProvider>;
|
</DataProvider>;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!tip] children vs render
|
||||||
|
> - **用 children**:渲染逻辑简单、只需要传入数据 → 代码最简洁
|
||||||
|
> - **用 render prop**:需要多个渲染入口(如 `renderEmpty` / `renderLoading`)、或需要对渲染做参数校验 → 类型约束更精确
|
||||||
|
|
||||||
|
### 常见模式:多回调拆分
|
||||||
|
|
||||||
|
当渲染逻辑复杂时,可以将一个大 `render` 拆成多个小 prop,降低心智负担:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CarouselProps {
|
||||||
|
slides: Slide[];
|
||||||
|
renderItem: (slide: Slide, index: number) => React.ReactNode;
|
||||||
|
renderIndicator?: (index: number, active: boolean) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({ slides, renderItem, renderIndicator }: CarouselProps) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{slides.map((s, i) => renderItem(s, i))}
|
||||||
|
<div className="indicators">
|
||||||
|
{slides.map((_, i) => renderIndicator?.(i, i === active) ?? (
|
||||||
|
<span key={i} onClick={() => setActive(i)}
|
||||||
|
className={i === active ? 'dot-active' : 'dot'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> [!note] 为什么拆比合好?
|
||||||
|
> 一个巨大的 `render` 回调会让调用处变得冗长。拆成独立的小 prop(`renderItem`、`renderIndicator`),调用方**按需实现**,未实现的可以省略——这是 React 库设计的经典模式。
|
||||||
|
|
||||||
## HOC vs Render Props vs Custom Hook
|
## HOC vs Render Props vs Custom Hook
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
A["逻辑复用需求"] --> B["方案对比"]
|
A["逻辑复用需求"] --> B["三种方案"]
|
||||||
|
|
||||||
B --> C["HOC"]
|
B --> C["HOC<br/>函数包裹组件"]
|
||||||
B --> D["Render Props"]
|
B --> D["Render Props<br/>函数传递 prop"]
|
||||||
B --> E["Custom Hook"]
|
B --> E["Custom Hook<br/>纯函数抽离逻辑"]
|
||||||
|
|
||||||
C --> F["⚠️ Wrapper 嵌套深"]
|
C --> C1["⚠️ Wrapper 嵌套深"]
|
||||||
C --> G["⚠️ 静态方法丢失"]
|
C --> C2["⚠️ 静态方法丢失"]
|
||||||
C --> H["✅ 不修改原组件结构"]
|
C --> C3["⚠️ ref 需 forwardRef"]
|
||||||
|
C --> C4["✅ 不修改原组件 JSX"]
|
||||||
|
|
||||||
D --> I["⚠️ 回调地狱"]
|
D --> D1["⚠️ 回调嵌套过深"]
|
||||||
D --> J["⚠️ Prop 命名冲突风险"]
|
D --> D2["⚠️ Prop 命名冲突风险"]
|
||||||
D --> K["✅ 灵活的 UI 控制"]
|
D --> D3["✅ UI 控制权完全交给调用方"]
|
||||||
|
|
||||||
E --> L["✅ 简洁直观"]
|
E --> E1["✅ 扁平可读"]
|
||||||
E --> M["✅ 可直接操作 state / effect"]
|
E --> E2["✅ 直接操作 state / effect"]
|
||||||
E --> N["✅ 无 wrapper 嵌套"]
|
E --> E3["✅ 无额外组件树开销"]
|
||||||
E --> O["❌ 只能用于组件内部"]
|
E --> E4["❌ 只能在组件内部使用"]
|
||||||
|
|
||||||
style L fill:#4FC08D,color:#fff
|
style C4 fill:#4FC08D,color:#fff
|
||||||
style M fill:#4FC08D,color:#fff
|
style D3 fill:#4FC08D,color:#fff
|
||||||
style N fill:#4FC08D,color:#fff
|
style E1 fill:#4FC08D,color:#fff
|
||||||
|
style E2 fill:#4FC08D,color:#fff
|
||||||
|
style E3 fill:#4FC08D,color:#fff
|
||||||
```
|
```
|
||||||
|
|
||||||
## 为什么 Hooks 取代了它们?
|
## 为什么 Hooks 取代了它们?
|
||||||
|
|
||||||
```tsx
|
Hooks 的核心理念是**把状态逻辑从组件中抽离出来,但保持调用处扁平**。它结合了 HOC 和 Render Props 的优点:
|
||||||
// ❌ HOC 方式
|
|
||||||
const ConnectedUserList = withAuth(withCache(withPagination(UserList)));
|
|
||||||
// 三层嵌套 → 调试困难、性能不可见、type 推导混乱
|
|
||||||
|
|
||||||
// ✅ Hook 方式
|
| 维度 | HOC / Render Props | Custom Hook |
|
||||||
|
|------|-------------------|-------------|
|
||||||
|
| **嵌套层级** | 多层 wrapper 或回调嵌套 | **零嵌套,直接平铺** |
|
||||||
|
| **状态共享** | 需要 prop 层层传递,容易混乱 | 每个 hook 管理自己的 state,互不干扰 |
|
||||||
|
| **TypeScript** | 泛型约束复杂,组合后类型可能丢失 | **完美推断**,不需要额外 type wrangling |
|
||||||
|
| **调试** | DevTools 多一层 wrapper,栈更混乱 | 与普通函数无异 |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ HOC 方式:三层嵌套 → 调试困难、性能不可见、type 推导混乱
|
||||||
|
const ConnectedUserList = withAuth(withCache(withPagination(UserList)));
|
||||||
|
|
||||||
|
// ✅ Hook 方式:扁平可读,逻辑一目了然
|
||||||
function UserList() {
|
function UserList() {
|
||||||
useAuth(); // 身份验证
|
useAuth(); // 身份验证
|
||||||
const cache = useCache<User[]>(); // 数据缓存
|
const cache = useCache<User[]>(); // 数据缓存
|
||||||
@@ -190,12 +318,24 @@ function UserList() {
|
|||||||
|
|
||||||
return <table>{/* ... */}</table>;
|
return <table>{/* ... */}</table>;
|
||||||
}
|
}
|
||||||
// 扁平可读、天然共享 state、TS 完美推断
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!note] HOC 和 Render Props 真的被淘汰了吗?
|
> [!question] 思考
|
||||||
> - HOC:在需要**包裹**组件但不修改其内部的场景仍有价值(如第三方库封装)
|
> Hooks 真的完全淘汰了 HOC 和 Render Props 吗?回想一下文档开头的两个核心概念——HOC 通过**包裹**增强组件,Render Props 通过**回调**暴露渲染控制。这两种模式在哪些场景中仍然无法被 Hook 替代?
|
||||||
> - Render Props:当父组件需要**完全控制子组件的渲染内容**时仍然有用
|
|
||||||
> - 但 90%+ 的场景,Custom Hook 是更好的选择
|
#### 何时仍应使用旧模式?
|
||||||
|
|
||||||
|
| 场景 | 推荐方案 | 原因 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 第三方库封装(不接触用户代码) | **HOC** | 库方只操作组件类,无需知道用户内部结构 |
|
||||||
|
| UI 框架需要提供渲染占位符 | **Render Props** | 父组件需要定义多个渲染回调(如 `renderEmpty`、`renderLoading`) |
|
||||||
|
| 通用业务逻辑复用 | **Custom Hook** | 首选方案:简洁、可组合、无 DOM 开销 |
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
|
||||||
|
- [[00.Readme]]
|
||||||
|
- [[3. Hooks 篇/01-为什么需要 Hooks]]
|
||||||
|
- [[3. Hooks 篇/02-useEffect 深度解析]]
|
||||||
|
- [[3. Hooks 篇/08-自定义 Hook 最佳实践]]
|
||||||
|
- [[4. 进阶篇/07-forwardRef 与 useImperativeHandle]]
|
||||||
|
- [[4. 进阶篇/11-组件 Composition 模式]]
|
||||||
+106
-15
@@ -7,7 +7,11 @@ create time: 2026-04-29 22:12
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
React 18 引入了并发渲染(Concurrent Rendering)架构,将 UI 更新划分为可中断、可恢复、可优先级调度的任务。理解并发的核心概念——Suspend、Transition、时间切片——能帮助你写出更流畅的用户体验。
|
React 18 引入了并发渲染(Concurrent Rendering)架构,将 UI 更新划分为**可中断、可恢复、可优先级调度**的任务。理解并发的核心概念——Suspend、Transition、时间切片——能帮助你写出更流畅的用户体验。
|
||||||
|
|
||||||
|
> [!question] 思考:如果一次 setState 触发了大量 DOM 更新,为什么页面不会卡死?
|
||||||
|
>
|
||||||
|
> 答案就在 React 的 Fiber 架构里——它将渲染工作切分成小单元,每个单元完成后让出主线程给浏览器处理高优任务(如点击响应)。这就是「并发」的本质。
|
||||||
|
|
||||||
## React 渲染架构演进
|
## React 渲染架构演进
|
||||||
|
|
||||||
@@ -37,6 +41,28 @@ const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Suspense + SSR(流式渲染)
|
||||||
|
|
||||||
|
> [!tip] React 18 的 Streaming SSR
|
||||||
|
>
|
||||||
|
> Suspense 在 SSR 中的核心价值:服务端可以「分块」返回 HTML,用户先看到首屏骨架,数据就绪后逐步替换。这比传统的「等所有数据都就绪再输出完整 HTML」快得多。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 服务端组件中嵌套 Suspense boundary
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Header /> {/* 同步渲染 */}
|
||||||
|
<Suspense fallback={<SearchSkeleton />}>
|
||||||
|
<SearchResults /> {/* 等待 fetch 完成后插入 */}
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端输出变成**渐进式 HTML 流**:`Header → SearchSkeleton → SearchResults(loaded)`
|
||||||
|
|
||||||
### Suspense + Data Fetching(实验性 API)
|
### Suspense + Data Fetching(实验性 API)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -60,6 +86,10 @@ function Profile() {
|
|||||||
|
|
||||||
## useTransition —— 标记低优先级更新
|
## useTransition —— 标记低优先级更新
|
||||||
|
|
||||||
|
> [!question] 什么时候该用 Transition?
|
||||||
|
>
|
||||||
|
> 当一次用户交互(如点击、选择)会触发多个状态更新,其中某些更新的 UI 渲染代价高昂时——你可以把「即时反馈」和「延迟渲染」分开。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -88,16 +118,40 @@ function SearchPage() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transition 与直接 setState 对比
|
### useTransition + Suspense 组合模式
|
||||||
|
|
||||||
```mermaid
|
两者配合可以实现更精细的加载策略:Transition 控制哪个更新「可以等待」,Suspense 在数据就绪后展示最终内容。
|
||||||
timeline
|
|
||||||
title "输入 "hello" 的渲染行为"
|
```tsx
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
直接 setState : 每次按键 → re-render\n(h/h/e/l/o 共 5 次)
|
const deferredTab = useDeferredValue(activeTab);
|
||||||
useTransition : h,e,l,l → 跳过中间\no → 最终渲染一次
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
|
{isPending && <PageSkeleton />}
|
||||||
|
<Suspense fallback={<SectionLoading />}>
|
||||||
|
<ActiveSection tab={deferredTab} />
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Transition vs DeferredValue 选择指南
|
||||||
|
|
||||||
|
| 场景 | 推荐 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| 路由切换后展示新视图 | `useTransition` | 明确标记整页过渡状态,配合 `<Suspense>` |
|
||||||
|
| 输入框即时搜索 + 延迟加载 | `useDeferredValue` | 一行搞定,自动处理防抖逻辑 |
|
||||||
|
| 多个状态联动更新 | `useTransition` | 更精确控制哪些 setState 属于低优先级 |
|
||||||
|
| 简单延迟一个值(如过滤条件) | `useDeferredValue` | 侵入性最小,不改变组件交互模式 |
|
||||||
|
| Tab 切换时内容区域的异步加载 | 两者组合 | `startTransition` 控制页面级 Pending,`useDeferredValue` 控制数据流 |
|
||||||
|
|
||||||
|
> [!tip] 核心区别一句话
|
||||||
|
>
|
||||||
|
> - **`useTransition`**:控制**「哪个 setState」**是低优先级的 → 你直接包裹需要降级的那段 setState 调用。
|
||||||
|
> - **`useDeferredValue`**:控制**「哪个值」**是延迟更新的 → 你用 `const delayed = useDeferredValue(value)` 拿到一个滞后 ~16ms 的副本。
|
||||||
|
|
||||||
## useDeferredValue —— 延迟副本
|
## useDeferredValue —— 延迟副本
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -117,14 +171,9 @@ function TodoApp() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!tip] Transition vs DeferredValue 选择指南
|
> [!note] 内部机制
|
||||||
>
|
>
|
||||||
> | 场景 | 推荐 |
|
> `useDeferredValue` 内部等价于一次 `startTransition`。调用后 React 立即用旧值渲染,然后调度一次低优先级的 re-render 来更新为新值。你可以理解为「自动防抖 + 优先级降级」。
|
||||||
> |------|------|
|
|
||||||
> | 表单提交后展示新视图 | `useTransition` |
|
|
||||||
> | 输入框即时搜索 + 延迟加载 | `useDeferredValue` |
|
|
||||||
> | 多个状态联动更新 | `useTransition`(更明确控制粒度) |
|
|
||||||
> | 简单延迟一个值 | `useDeferredValue`(一行搞定) |
|
|
||||||
|
|
||||||
## 时间切片原理
|
## 时间切片原理
|
||||||
|
|
||||||
@@ -174,4 +223,46 @@ useEffect(() => {
|
|||||||
// 用于发现不纯的 render 函数和清理逻辑缺失
|
// 用于发现不纯的 render 函数和清理逻辑缺失
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
> [!tip] 实战经验总结
|
||||||
|
|
||||||
|
### 代码示例:安全的 Effect Cleanup
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 问题:异步 render → effect 执行时 dep 可能已变化
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchData(url, { signal: controller.signal }).then(setData);
|
||||||
|
return () => controller.abort(); // cleanup 依赖的 url 可能已过时
|
||||||
|
}, [dep]);
|
||||||
|
|
||||||
|
// ✅ 推荐:用 ref 缓存最新值,保证 cleanup 拿到正确的信号
|
||||||
|
const latestSignalRef = useRef<AbortController>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
latestSignalRef.current = controller;
|
||||||
|
|
||||||
|
fetchData(url, { signal: controller.signal }).then(setData);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (latestSignalRef.current === controller) {
|
||||||
|
controller.abort(); // 确认是同一个请求才 abort
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [dep]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 要点清单
|
||||||
|
|
||||||
|
1. **优先使用 Suspense,谨慎手动 startTransition**:Suspense 配合数据获取模式是 React 官方推荐的方向,而 `startTransition` 适合「一个交互触发多个 setState」的场景。
|
||||||
|
2. **避免在 render 中做副作用**:并发模式下 render 函数可能被多次调用、随时中断——render 应该是纯粹的「视图描述函数」。
|
||||||
|
3. **合理使用 `<Suspense>` boundary 层级**:不要把所有组件包在一个大 Suspense 里,也不要每个小组件都加——根据网络请求和数据依赖划分边界。
|
||||||
|
4. **用 `use()` + Suspense 替代 useEffect 中的数据获取**:实验性但代表未来方向,能消除竞态条件(race condition)和 loading 状态管理样板代码。
|
||||||
|
5. **警惕 `useEffect` 的异步执行时机**:如果 effect 依赖于某个 state 的值来做 cleanup,考虑改用 `useSyncExternalStore` 或使用 ref 存储最新值。
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
- [[11-组件通信模式]]
|
||||||
|
- [[12-HOC 与 Render Props]]
|
||||||
|
- [[14-Serverside Rendering]]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
tags: [React, Next.js, SSR, SSG, ISR, Frontend]
|
tags: [React, Next.js, SSR, SSG, ISR, Frontend, Server Components]
|
||||||
create time: 2026-04-29 22:13
|
create time: 2026-04-29 22:13
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -7,30 +7,34 @@ create time: 2026-04-29 22:13
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
服务端渲染(SSR)让 React 组件在服务器端预渲染为 HTML,显著改善首屏加载速度和 SEO。本文档以 Next.js App Router 为核心,介绍 SSR/SSG/ISR 的渲染策略与最佳实践。
|
服务端渲染(SSR)让 React 组件在服务器端预渲染为 HTML,显著改善首屏加载速度和 SEO。本文档以 Next.js App Router 为核心,介绍 SSR/SSG/ISR 的渲染策略、Server Components 体系与最佳实践。
|
||||||
|
|
||||||
|
> [!question] 思考:用户从输入 URL 到看到页面,中间经历了哪些步骤?
|
||||||
|
>
|
||||||
|
> 传统 CSR 模式下,浏览器先拿到一个几乎空的 HTML,然后下载 JS bundle,执行 React 来生成内容——用户需要等待两件事都完成才能看到页面。SSR 把「生成 HTML」这一步搬到服务器做,用户请求回来时就已经有可读的内容了。
|
||||||
|
|
||||||
## 渲染模式对比
|
## 渲染模式对比
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph "客户端渲染 CSR"
|
subgraph CSR["客户端渲染 CSR"]
|
||||||
A[HTML空白页] --> B["下载 JS Bundle"]
|
A["HTML空白页"] --> B["下载JS Bundle"]
|
||||||
B --> C["执行 React hydration"]
|
B --> C["执行React hydration"]
|
||||||
C --> D["显示内容"]
|
C --> D["显示内容"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "服务端渲染 SSR"
|
subgraph SSR["服务端渲染 SSR"]
|
||||||
E[请求页面] --> F["服务器渲染 React → HTML"]
|
E["请求页面"] --> F["服务器渲染React -> HTML"]
|
||||||
F --> G["发送含内容的 HTML"]
|
F --> G["发送含内容的HTML"]
|
||||||
G --> H["客户端 hydration"]
|
G --> H["客户端hydration"]
|
||||||
H --> I["交互可用"]
|
H --> I["交互可用"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "静态生成 SSG"
|
subgraph SSG["静态生成 SSG"]
|
||||||
J["构建时渲染"] --> K["生成纯 HTML 文件"]
|
J["构建时渲染"] --> K["生成纯HTML文件"]
|
||||||
K --> L["CDN 分发"]
|
K --> L["CDN分发"]
|
||||||
end
|
end
|
||||||
|
|
||||||
style F fill:#4FC08D,color:#fff
|
style F fill:#4FC08D,color:#fff
|
||||||
style H fill:#F5A87D,color:#000
|
style H fill:#F5A87D,color:#000
|
||||||
style J fill:#61DAFB,color:#000
|
style J fill:#61DAFB,color:#000
|
||||||
@@ -44,8 +48,18 @@ graph TB
|
|||||||
| **SSG**(Static Site Generation) | 博客、文档、营销页 | ⏱️ 构建时生成 | ✅ |
|
| **SSG**(Static Site Generation) | 博客、文档、营销页 | ⏱️ 构建时生成 | ✅ |
|
||||||
| **ISR**(Incremental Static Regeneration) | 新闻列表、商品目录 | 🔄 定时后台更新 | ✅(增量) |
|
| **ISR**(Incremental Static Regeneration) | 新闻列表、商品目录 | 🔄 定时后台更新 | ✅(增量) |
|
||||||
|
|
||||||
|
> [!tip] 核心区别一句话
|
||||||
|
>
|
||||||
|
> - **SSR**:每个用户请求都触发一次服务端的完整渲染。
|
||||||
|
> - **SSG**:构建时生成一次 HTML,所有用户共享同一个静态页面。
|
||||||
|
> - **ISR**:先用 SSG 生成的静态页面响应,后台静默重新生成后替换——对用户无感知。
|
||||||
|
|
||||||
## Next.js App Router 架构
|
## Next.js App Router 架构
|
||||||
|
|
||||||
|
> [!note] Server Component 是默认值
|
||||||
|
>
|
||||||
|
> Next.js App Router 中,所有组件默认就是 **Server Component**——除非你在文件顶部声明 `"use client"`。这意味着你可以放心地在组件里 await 数据库查询、读取环境变量或访问文件系统,这些代码永远不会发送到浏览器。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// app/layout.tsx —— 根布局(所有页面共享)
|
// app/layout.tsx —— 根布局(所有页面共享)
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -63,7 +77,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
async function HomePage() {
|
async function HomePage() {
|
||||||
// ✅ 直接在组件中 await API
|
// ✅ 直接在组件中 await API
|
||||||
const posts = await fetchPosts();
|
const posts = await fetchPosts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
{posts.map(post => <PostCard key={post.id} post={post} />)}
|
{posts.map(post => <PostCard key={post.id} post={post} />)}
|
||||||
@@ -84,17 +98,17 @@ async function PostPage({ params }: { params: { slug: string } }) {
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Client as 浏览器
|
participant Client as 浏览器
|
||||||
participant Server as 服务器
|
participant Server as 服务器
|
||||||
|
|
||||||
Client->>Server: GET /dashboard
|
Client->>Server: GET /dashboard
|
||||||
Server->>Server: 并行请求 user/profile/orders
|
Server->>Server: 并行请求 user/profile/orders
|
||||||
Note over Server: 每个请求可有自己的 Suspense boundary
|
Note over Server: 每个请求可有自己的Suspense boundary
|
||||||
|
|
||||||
Server-->>Client: HTML: Navbar + Sidebar<br/>(立即显示,~200ms)
|
Server-->>Client: HTML: Navbar + Sidebar<br/>(立即显示,约200ms)
|
||||||
|
|
||||||
Server-->>Client: Stream: Profile card<br/>(中等优先级,~800ms)
|
Server-->>Client: Stream: Profile card<br/>(中等优先级,约800ms)
|
||||||
|
|
||||||
Server-->>Client: Stream: Order history<br/>(低优先级,~1500ms)
|
Server-->>Client: Stream: Order history<br/>(低优先级,约1500ms)
|
||||||
|
|
||||||
Note over Client: 用户体验:渐进式展示,而非等全部完成
|
Note over Client: 用户体验:渐进式展示,而非等全部完成
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,9 +121,9 @@ function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|||||||
<Suspense fallback={<SidebarSkeleton />}>
|
<Suspense fallback={<SidebarSkeleton />}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* 各区域独立 Suspense */}
|
{/* 各区域独立 Suspense */}
|
||||||
<Suspense fallback={<StatsSkeleton />}>
|
<Suspense fallback={<StatsSkeleton />}>
|
||||||
<StatsPanel />
|
<StatsPanel />
|
||||||
@@ -122,6 +136,10 @@ function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!note] Streaming SSR 的工作原理
|
||||||
|
>
|
||||||
|
> 服务端将 HTML 分成多个 chunk,通过网络流逐步发送给浏览器。浏览器边收边渲染——不需要等所有数据就绪。**优先级高的区域先出,低的在后**,用户体验从「全部等」变成「渐进可见」。
|
||||||
|
|
||||||
## Data Fetching 策略
|
## Data Fetching 策略
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -151,7 +169,16 @@ function Page() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## SSR vs Client Component 边界
|
### Cache vs Revalidate vs no-store 决策指南
|
||||||
|
|
||||||
|
| 策略 | 行为 | 适合场景 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `fetch(url)` 不加配置 | 基于 HTTP 协议的永久缓存(直到下次部署失效) | 不常变化的配置数据 |
|
||||||
|
| `{ next: { revalidate: N } }` | CDN 级别缓存,N 秒后后台重新验证 | 商品信息、文章列表等 |
|
||||||
|
| `{ cache: "no-store" }` | 完全不走缓存,每次都回源 | 用户面板、实时仪表盘 |
|
||||||
|
| `{ next: { tags: ["posts"] } }` + `revalidateTag("posts")` | 按标签精确失效 | CMS 系统发布新文章时触发更新 |
|
||||||
|
|
||||||
|
## Server Component vs Client Component 边界
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// server component(默认,无需声明)
|
// server component(默认,无需声明)
|
||||||
@@ -168,7 +195,7 @@ function InteractiveChart() {
|
|||||||
// ✅ 可以使用 useState/useEffect/DOM API
|
// ✅ 可以使用 useState/useEffect/DOM API
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
useEffect(() => { ... }, []);
|
useEffect(() => { ... }, []);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} />;
|
return <canvas ref={canvasRef} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +211,219 @@ function Dashboard() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!warning] Client → Server 通信限制
|
> [!warning] Client → Server 通信限制
|
||||||
|
>
|
||||||
> - Client Component 无法直接调用 Server Component 的方法或 props
|
> - Client Component 无法直接调用 Server Component 的方法或 props
|
||||||
|
> - Server Component 也不能传给 Client Component 引用了函数、Promise 或 Generator 的值
|
||||||
> - 解决方案:通过 URL 参数、cookies、或后端 API 传递数据
|
> - 解决方案:通过 URL 参数、cookies、或后端 API 传递数据
|
||||||
|
|
||||||
|
### 何时该用 Server Component?何时该用 Client Component?
|
||||||
|
|
||||||
|
> [!question] 如何判断一个组件应该放在哪一边?
|
||||||
|
>
|
||||||
|
> 记住一条黄金法则:**能放服务器的就放服务器**。Server Component 是零 bundle size 的——它们不会增加客户端 JavaScript 体积。只有当你确实需要浏览器专属 API(事件监听、状态管理、DOM 操作)时才降级到 Client Component。
|
||||||
|
|
||||||
|
| 需要浏览器能力吗? | 推荐方案 |
|
||||||
|
|-------------------|---------|
|
||||||
|
| ❌ 不需要(只渲染数据) | Server Component ✅ |
|
||||||
|
| ✅ 需要 `useState` / `useEffect` / `onClick` | Client Component (`"use client"`) |
|
||||||
|
| ✅ 需要第三方交互库(地图、图表) | Client Component |
|
||||||
|
| ✅ 需要浏览器 API(localStorage、Geolocation) | Client Component |
|
||||||
|
| ❌ 只需要从 API 获取数据并展示 | Server Component ✅ |
|
||||||
|
|
||||||
|
## Server Actions
|
||||||
|
|
||||||
|
Server Action 允许你在服务端定义可直接从 Client Component 调用的异步函数——无需手动写 API 路由。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/actions.ts —— 独立的 Server Action 模块
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function createPost(formData: FormData) {
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const content = formData.get("content") as string;
|
||||||
|
|
||||||
|
// 数据库写入
|
||||||
|
await db.post.create({ data: { title, content } });
|
||||||
|
|
||||||
|
// 成功后刷新对应页面的缓存
|
||||||
|
revalidatePath("/blog");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/blog/new/page.tsx —— 表单页面(Client Component)
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createPost } from "@/app/actions";
|
||||||
|
|
||||||
|
function NewPostForm() {
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
await createPost(formData);
|
||||||
|
// 提交后跳转到列表页
|
||||||
|
router.push("/blog");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={handleSubmit}>
|
||||||
|
<input name="title" placeholder="标题" required />
|
||||||
|
<textarea name="content" placeholder="内容" required />
|
||||||
|
<button type="submit">发布</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!tip] Server Actions 的优势
|
||||||
|
>
|
||||||
|
> 1. **零样板代码**:不再需要手动编写 API Route + fetch 调用链
|
||||||
|
> 2. **类型安全**:函数签名天然携带类型信息
|
||||||
|
> 3. **内置序列化和校验**:FormData 自动解析,配合 Zod 做 schema 校验
|
||||||
|
> 4. **直接操作服务端状态**:数据库读写、文件操作、认证上下文一步到位
|
||||||
|
|
||||||
|
## Metadata API & 动态 SEO
|
||||||
|
|
||||||
|
Next.js 提供了声明式的元数据 API,自动生成 `<head>` 中的标签。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/blog/[slug]/page.tsx —— 动态元数据
|
||||||
|
import { getPostBySlug } from "@/lib/posts";
|
||||||
|
|
||||||
|
// 静态 generateMetadata(构建时可确定)
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const post = await getPostBySlug(params.slug);
|
||||||
|
return {
|
||||||
|
title: `${post.title} | My Blog`,
|
||||||
|
description: post.excerpt,
|
||||||
|
openGraph: { title, images: [post.coverImage] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态 generateViewport / robots / sitemap
|
||||||
|
export function generateStaticParams() {
|
||||||
|
const posts = getAllPosts();
|
||||||
|
return posts.map(post => ({ slug: post.slug }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!note] generateStaticParams vs dynamicParams
|
||||||
|
>
|
||||||
|
> 如果未列出的动态路由被访问到,Next.js 默认会返回 404。你可以通过 `next.config.js` 设置 `dangerouslyAllowHostnameMismatch = true` 或在路由目录中添加 `not-found.tsx` 来自定义 404 行为。对于 API 驱动的项目,可以设置 `dynamicParams = false` 确保安全性。
|
||||||
|
|
||||||
|
## Cookies、Headers 与 Auth
|
||||||
|
|
||||||
|
Server Component 可以直接读取和修改 cookies/headers,这是构建认证系统的基石。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/dashboard/page.tsx —— Server Component
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
async function DashboardPage() {
|
||||||
|
// 读取 cookie
|
||||||
|
const sessionCookie = (await cookies()).get("session");
|
||||||
|
|
||||||
|
// 验证 token
|
||||||
|
if (!sessionCookie) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取请求头
|
||||||
|
const userAgent = (await headers()).get("user-agent");
|
||||||
|
|
||||||
|
// 使用 session 数据渲染页面
|
||||||
|
return <DashboardContent session={sessionCookie.value} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
SSR 环境下的错误处理需要考虑服务端和网络异常的双重场景。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/error.tsx —— 捕获渲染阶段的错误
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>出错了</h2>
|
||||||
|
<p>{error.message}</p>
|
||||||
|
<button onClick={() => reset()}>重试</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/not-found.tsx —— 自定义 404 页面
|
||||||
|
export default function NotFound() {
|
||||||
|
return <h2>页面不存在</h2>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步组件中的优雅降级
|
||||||
|
async function CommentsSection({ postId }: { postId: string }) {
|
||||||
|
// 评论组件失败不影响整个页面
|
||||||
|
let comments;
|
||||||
|
try {
|
||||||
|
comments = await fetchComments(postId);
|
||||||
|
} catch {
|
||||||
|
comments = []; // 降级为空数组
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能调优
|
||||||
|
|
||||||
|
### 关键指标与优化方向
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A["TTFB<br/>Time to First Byte"] -->|"降低服务器处理时间"| B["边缘缓存<br/>ISR / CDN"]
|
||||||
|
C["LCP<br/>Largest Contentful Paint"] -->|"减小首屏 JS 体积"| D["Server Components ✅"]
|
||||||
|
E["CLS<br/>Cumulative Layout Shift"] -->|"预留空间防抖动"| F["固定尺寸 + Aspect Ratio"]
|
||||||
|
|
||||||
|
style A fill:#F5A87D,color:#000
|
||||||
|
style C fill:#F5A87D,color:#000
|
||||||
|
style E fill:#F5A87D,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Optimization & Font Optimization
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] }); // 零 CLS 字体加载
|
||||||
|
|
||||||
|
// next/image 自动做 WebP 转换 + responsive srcset + lazy loading
|
||||||
|
<Image
|
||||||
|
src="/hero.jpg"
|
||||||
|
alt="Hero image"
|
||||||
|
width={1200}
|
||||||
|
height={600}
|
||||||
|
placeholder="blur" // 懒加载时用模糊缩略图占位
|
||||||
|
priority // 首屏图片提前加载
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
> [!tip] 实战经验总结
|
||||||
|
|
||||||
|
### 要点清单
|
||||||
|
|
||||||
|
1. **Server Component 优先**:默认情况下所有组件都是 Server Component,充分利用其零 bundle size 和数据直取的能力。只有在需要交互时才添加 `"use client"`。
|
||||||
|
2. **Suspense 粒度要合理**:不要把所有东西包在一个大 Suspense 里(起不到流式效果),也不要每个小组件都加(overhead)。按数据依赖层级划分边界。
|
||||||
|
3. **合理使用数据缓存策略**:`revalidate` 适合大多数内容型数据,`no-store` 用于用户相关实时数据,`tags` 用于精确实时失效控制。
|
||||||
|
4. **Server Actions 替代手写 API**:减少样板代码的同时保持类型安全,但注意不要滥用——简单 CRUD 以外仍建议走标准的 API Route 模式。
|
||||||
|
5. **做好错误降级**:SSR 中一个组件出错会导致整页白屏,对非关键组件要用 try-catch 兜底或 `<Suspense>` 隔离。
|
||||||
|
6. **善用 Metadata API**:SEO 相关的工作都应该在 `generateMetadata` 等 API 中完成,避免在 JSX 中手动操作 head。
|
||||||
|
7. **关注 Core Web Vitals**:Server Components 天然利好 LCP(减少 JS),ISR + CDN 利好 TTFB,Image/Font Optimization 利好 CLS。
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
|
||||||
|
- [[13-并发特性]]
|
||||||
|
- [[15-性能优化]]
|
||||||
|
- [[09-状态管理]]
|
||||||
|
|||||||
+214
-20
@@ -9,6 +9,42 @@ create time: 2026-04-29 22:14
|
|||||||
|
|
||||||
React 性能优化的核心原则是 **"减少不必要的渲染"**。本文档从诊断工具到具体手段,提供完整的调优方法论。
|
React 性能优化的核心原则是 **"减少不必要的渲染"**。本文档从诊断工具到具体手段,提供完整的调优方法论。
|
||||||
|
|
||||||
|
> [!question] 思考:为什么 React 会慢?
|
||||||
|
> 大多数情况下,应用变慢不是因为 React 本身慢,而是因为 **它重新渲染了不该渲染的组件**。
|
||||||
|
> 一个常见的陷阱:父组件 state 变化 → 所有子组件重新执行 render(即使它们的 props 没变)。
|
||||||
|
> 我们的目标:让 React 只渲染真正需要更新的部分。
|
||||||
|
|
||||||
|
### 优化决策流程图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[页面卡顿?] --> B{使用 Profiler 定位}
|
||||||
|
B --> C[频繁重渲染?]
|
||||||
|
B --> D[首屏加载慢?]
|
||||||
|
B --> E[滚动掉帧?]
|
||||||
|
|
||||||
|
C --> F[props 不变却渲染?]
|
||||||
|
C --> G[状态更新频率过高?]
|
||||||
|
F --> H["React.memo / useMemo"]
|
||||||
|
G --> I["useCallback / 防抖节流"]
|
||||||
|
|
||||||
|
D --> J[打包体积过大?]
|
||||||
|
D --> K[某些功能很少用?]
|
||||||
|
J --> L["Code Splitting / Tree Shaking"]
|
||||||
|
K --> M["Dynamic Import"]
|
||||||
|
|
||||||
|
E --> N[列表项很多?]
|
||||||
|
N --> O["虚拟列表 Virtual Scroll"]
|
||||||
|
|
||||||
|
style H fill:#61DAFB,color:#000
|
||||||
|
style L fill:#61DAFB,color:#000
|
||||||
|
style O fill:#61DAFB,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!warning] 第一条原则:先测量,再优化
|
||||||
|
> 没有 Profiler 数据的优化都是猜。先用 React DevTools 或 Chrome Performance 确认瓶颈在哪,
|
||||||
|
> 然后**针对性地解决**,而不是盲目地在每个组件上加 memo。
|
||||||
|
|
||||||
## 性能诊断工具箱
|
## 性能诊断工具箱
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -22,7 +58,7 @@ graph TB
|
|||||||
|
|
||||||
C --> G["定位重渲染的组件和原因"]
|
C --> G["定位重渲染的组件和原因"]
|
||||||
D --> H["分析主线程阻塞时段"]
|
D --> H["分析主线程阻塞时段"]
|
||||||
E --> I["整体 LCP/FID/CLS 评分"]
|
E --> I["整体 LCP/INP/CLS 评分"]
|
||||||
F --> J["生产环境真实用户数据"]
|
F --> J["生产环境真实用户数据"]
|
||||||
|
|
||||||
style C fill:#61DAFB,color:#000
|
style C fill:#61DAFB,color:#000
|
||||||
@@ -54,6 +90,64 @@ const ExpensiveList = React.memo(({ items }: { items: Item[] }) => {
|
|||||||
> - 父组件每次传新对象/函数作 prop → memo 无效
|
> - 父组件每次传新对象/函数作 prop → memo 无效
|
||||||
> - 简单列表(几十项以内)不需要 memo
|
> - 简单列表(几十项以内)不需要 memo
|
||||||
|
|
||||||
|
## 缓存计算与回调 — useMemo / useCallback
|
||||||
|
|
||||||
|
React.memo 解决的是"组件不重新渲染",而 `useMemo` 和 `useCallback` 解决的是**"值不重新生成"**。
|
||||||
|
|
||||||
|
### useMemo —— 缓存昂贵的计算结果
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchPage({ items, keyword }: { items: string[]; keyword: string }) {
|
||||||
|
// 过滤逻辑只在 items 或 keyword 变化时重新执行
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
console.log("filtering..."); // 仅依赖变化时才打印
|
||||||
|
return items.filter(item => item.includes(keyword));
|
||||||
|
}, [items, keyword]);
|
||||||
|
|
||||||
|
return <List data={filtered} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!tip] useMemo 的本质
|
||||||
|
> 它不是"性能捷径",而是**跳过不必要的计算**。对于 O(n) 以下的轻量操作,useMemo 反而增加内存开销。
|
||||||
|
> **适用场景**:大数据量处理、深度嵌套对象的派生计算、API 请求参数校验。
|
||||||
|
|
||||||
|
### useCallback —— 缓存函数引用
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Parent() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
// count 变化时才创建新函数,Child 用 React.memo 包裹时不会被重渲染
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setCount(c => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
|
||||||
|
<Child onAction={handleClick} /> {/* 不受 count 变化影响 */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!important] 什么时候需要 useCallback?
|
||||||
|
> 仅在以下两种场景中使用:
|
||||||
|
> 1. 函数传给 `React.memo` 包裹的子组件,避免父状态变化导致子组件重渲染
|
||||||
|
> 2. 作为其他 Hook(如 `useEffect`)的依赖项
|
||||||
|
>
|
||||||
|
> 否则——不加更简单,也更容易维护。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!question] 思考:三个 memo 的关系
|
||||||
|
> - **React.memo**: 阻止组件树 re-render(组件级别)
|
||||||
|
> - **useMemo**: 缓存返回值(值级别)
|
||||||
|
> - **useCallback**: 缓存函数引用(等价于 `useMemo(() => fn, deps)`)
|
||||||
|
>
|
||||||
|
> 它们共同目标是:**缩小变更传播范围**。当 state 更新时,让影响尽可能少地扩散到子树。
|
||||||
|
|
||||||
## 虚拟列表(Virtual Scrolling)
|
## 虚拟列表(Virtual Scrolling)
|
||||||
|
|
||||||
当列表项超过数百条时,虚拟滚动通过只渲染可视区域内的 DOM 元素来大幅降低内存占用:
|
当列表项超过数百条时,虚拟滚动通过只渲染可视区域内的 DOM 元素来大幅降低内存占用:
|
||||||
@@ -103,39 +197,69 @@ function VirtualList({ items }: { items: string[] }) {
|
|||||||
|
|
||||||
## Code Splitting
|
## Code Splitting
|
||||||
|
|
||||||
|
Code Splitting 的核心理念:**用户不需要一次性下载所有代码**。按需加载可以显著降低首屏时间。
|
||||||
|
|
||||||
### 路由级拆分
|
### 路由级拆分
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Next.js App Router(内置)
|
import { lazy, Suspense } from "react";
|
||||||
const AdminPage = lazy(() => import("./pages/Admin"));
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
|
||||||
// vite + React Router
|
// 每个路由对应一个 chunk,用户只下载当前页面对应的代码
|
||||||
const Settings = lazy(() => import(/* vite: preload */ "./pages/Settings"));
|
const Home = lazy(() => import("./pages/Home"));
|
||||||
|
const Admin = lazy(() => import("./pages/Admin"));
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/admin" element={<Admin />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!note] Next.js 的特殊之处
|
||||||
|
> Next.js 基于文件系统自动实现路由级 Code Splitting,**无需手动 lazy()**。
|
||||||
|
> 在 `/app` 目录中,每个页面文件就是一个独立的 route segment → 自动拆包。
|
||||||
|
|
||||||
### 组件级拆分
|
### 组件级拆分
|
||||||
|
|
||||||
|
对体积大、使用频率低的第三方库进行动态导入:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
// 不加载图表库直到真正需要
|
// SSR 关闭——图表库依赖 window,服务端没有
|
||||||
const Chart = dynamic(() => import("recharts"), { ssr: false });
|
const Chart = dynamic(() => import("recharts"), { ssr: false });
|
||||||
// 或带 loading fallback
|
|
||||||
const HeavyEditor = dynamic(() => import("@monaco-editor/react"), {
|
// 带 loading fallback——提升感知体验
|
||||||
loading: () => <p>Loading editor...</p>,
|
const HeavyEditor = dynamic(
|
||||||
ssr: false,
|
() => import("@monaco-editor/react"),
|
||||||
});
|
{ loading: () => <p>正在加载编辑器...</p>, ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// React 原生写法——配合 Suspense
|
||||||
|
const MapComponent = lazy(() => import("./MapComponent"));
|
||||||
|
// 可指定 preloadStrategy——预加载策略
|
||||||
|
const PrefetchModal = lazy(
|
||||||
|
() => import("./HeavyModal")
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bundle 分析与优化
|
### Bundle 分析与优化
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装插件
|
# Next.js 项目
|
||||||
npm install --save-dev rollup-plugin-visualizer
|
npx next-bundle-analyzer
|
||||||
# 或在 Next.js 中使用 next-bundle-analyzer
|
|
||||||
|
|
||||||
# 生成可视化报告
|
# Vite / Webpack 通用方案
|
||||||
npx run build && npx visualizer
|
npm install --save-dev rollup-plugin-visualizer
|
||||||
|
npm run build && npx visualizer
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!tip] Bundle 大小目标
|
> [!tip] Bundle 大小目标
|
||||||
@@ -145,13 +269,83 @@ npx run build && npx visualizer
|
|||||||
> | 单个 chunk | < 300KB |
|
> | 单个 chunk | < 300KB |
|
||||||
> | JS Total | < 500KB(SPA)/ < 200KB(PWA) |
|
> | JS Total | < 500KB(SPA)/ < 200KB(PWA) |
|
||||||
|
|
||||||
|
> [!tip] 减小 bundle 的三个高效手段
|
||||||
|
> 1. **替换大库**——`lodash` → `lodash-es`(tree-shaking 友好),或直接用原生 API
|
||||||
|
> 2. **检查重复依赖**——`npm ls react` 看有没有多份 React 实例
|
||||||
|
> 3. **静态资源不进 bundle**——图片、视频等上传到 CDN,URL 写在代码里
|
||||||
|
|
||||||
## Lighthouse 关键指标调优
|
## Lighthouse 关键指标调优
|
||||||
|
|
||||||
| 指标 | 含义 | 优化方向 |
|
| 指标 | 含义 | 优化方向 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| **FCP**(First Contentful Paint) | 首次内容绘制 | 减小首屏 HTML/JS 体积 |
|
| **FCP**(First Contentful Paint) | 首次内容绘制 | 减小首屏 HTML/JS 体积、使用 CDN |
|
||||||
| **LCP**(Largest Contentful Paint) | 最大内容绘制 | 图片懒加载、预加载关键资源 |
|
| **LCP**(Largest Contentful Paint) | 最大内容绘制 | 图片懒加载、预加载关键资源、优先渲染 |
|
||||||
| **INP**(Interaction to Next Paint) | 交互响应延迟 | useTransition、删除同步 heavy work |
|
| **INP**(Interaction to Next Paint) | 交互响应延迟(取代 FID) | useTransition、删除同步 heavy work、Web Worker 分流 |
|
||||||
| **CLS**(Cumulative Layout Shift) | 布局偏移 | 预留图片宽高、避免字体闪烁 |
|
| **CLS**(Cumulative Layout Shift) | 布局偏移 | 预留图片宽高、避免字体闪烁、占位广告容器 |
|
||||||
|
|
||||||
|
## React 并发优化 —— startTransition / useDeferredValue
|
||||||
|
|
||||||
|
React 18 引入的并发特性是解决 **"交互卡顿"** 最直接的手段。它们让 React 能够**优先级调度**。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, startTransition } from "react";
|
||||||
|
|
||||||
|
function SearchApp() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
|
||||||
|
// ❌ 普通 setState:阻塞 UI,用户输入时感知到延迟
|
||||||
|
// setSearchResults(heavySearch(query));
|
||||||
|
|
||||||
|
// ✅ startTransition:标记为低优先级,不阻塞当前输入
|
||||||
|
function handleChange(e: string) {
|
||||||
|
setQuery(e); // 高优先级——立即更新
|
||||||
|
startTransition(() => {
|
||||||
|
setResults(heavySearch(e)); // 低优先级——可以中断
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <input value={query} onChange={handleChange} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 与 Suspense 配合的最佳实践
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 路由级懒加载 + 过渡状态
|
||||||
|
<Suspense fallback={<Spinner />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/dashboard" element={
|
||||||
|
<Dashboard />
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!tip] startTransition vs useDeferredValue 的选择
|
||||||
|
> | 场景 | 推荐方案 |
|
||||||
|
> |------|----------|
|
||||||
|
> | 表单输入 → 搜索结果 | `startTransition` |
|
||||||
|
> | 长列表滚动 → 非关键区域降级 | `useDeferredValue` |
|
||||||
|
> | 异步数据加载(路由级) | `Suspense` + `lazy` |
|
||||||
|
> | 复杂计算(不在渲染中) | Web Worker / TanStack Query |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!summary] 性能优化 checklist
|
||||||
|
> - [ ] 用 Profiler 确认瓶颈位置
|
||||||
|
> - [ ] 高频重渲染组件加 `React.memo`
|
||||||
|
> - [ ] 派生计算用 `useMemo`,回调用 `useCallback`
|
||||||
|
> - [ ] 大数据列表用虚拟滚动
|
||||||
|
> - [ ] 大库和低频页面做 Code Splitting
|
||||||
|
> - [ ] 耗时操作放进 `startTransition`
|
||||||
|
> - [ ] Lighthouse 评分达标?Web Vitals 监控上线?
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
|
||||||
|
- [[06-性能优化 Hooks]] — useMemo、useCallback、useTransition 等 Hook 的详细用法
|
||||||
|
- [[13-并发特性]] — React 18 并发渲染机制深入解析
|
||||||
|
- [[04-State 与不可变性]] — State 更新模式对性能的影响
|
||||||
|
- [[09-状态管理]] — 全局状态管理的性能考量(Zustand / Redux / Jotai)
|
||||||
|
- [[14-Serverside Rendering]] — SSR / RSC 的首屏性能优势
|
||||||
|
- [[16-测试]] — 性能回归测试:React Testing Library + Performance Assertions
|
||||||
|
|||||||
+187
-5
@@ -146,11 +146,84 @@ describe("<UserCard />", () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 快照测试
|
||||||
|
|
||||||
|
> [!tip] Snapshot 的定位
|
||||||
|
> 快照不是单元测试的替代品——它检测的是**UI 结构意外变化**。
|
||||||
|
> 适用于不会频繁变化的展示型组件(如仪表盘卡片、文章详情页)。
|
||||||
|
> 不适用于动态数据多的列表或表单。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { UserDetail } from "./UserDetail";
|
||||||
|
|
||||||
|
it("渲染与之前一致", () => {
|
||||||
|
const { container } = render(<UserDetail user={mockUser} />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!warning] 快照的陷阱
|
||||||
|
> - 时间戳、随机 ID 等动态内容会导致每次快照不同 → 用 `sanitize` 处理
|
||||||
|
> - 盲目更新快照(`-u`)等于放弃测试价值 → **每次 diff 都要人工审查**
|
||||||
|
> - 配合确定性断言一起使用,不要只做快照断言
|
||||||
|
|
||||||
|
### 查询方法速查表
|
||||||
|
|
||||||
|
> [!question] 思考:getBy、queryBy、findBy 我该用哪个?
|
||||||
|
> React Testing Library 提供三类查询,它们的**行为差异直接决定测试的健壮性**。
|
||||||
|
|
||||||
|
| 前缀 | 匹配不到时 | 典型场景 |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| `getBy*` | **抛异常**(测试失败) | 验证元素**必须存在** |
|
||||||
|
| `queryBy*` | 返回 `null` | 验证元素**不应存在**(配合 `.not.toBeInTheDocument()`) |
|
||||||
|
| `findBy*` | **超时后抛异常** | 等待**异步出现**的元素(内部自动 await) |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// getBy — 必须有,没有就失败(同步)
|
||||||
|
const submitBtn = screen.getByRole("button", { name: /submit/i });
|
||||||
|
|
||||||
|
// queryBy — 验证"不存在"(同步,推荐优先于 findBy 做否定断言)
|
||||||
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// findBy — 等待异步出现的元素(内部封装了 waitFor + polling)
|
||||||
|
const successMsg = await screen.findByText("Saved!");
|
||||||
|
|
||||||
|
// ✅ 避免:手动写 waitFor + getBy(findBy 更简洁)
|
||||||
|
// await waitFor(() => expect(screen.getByText("Saved!")).toBeInTheDocument());
|
||||||
|
|
||||||
|
// getAllBy — 匹配多个元素
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(checkboxes).toHaveLength(3);
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!tip] 查询优先级原则
|
||||||
|
> 按以下顺序选择查询方式,越靠前的优先级越高:
|
||||||
|
>
|
||||||
|
> `role` → `label` → `text` → `testID` → `screen`(最后手段)
|
||||||
|
>
|
||||||
|
> **永远不要**用 `container.querySelector(".css-class")`——样式变了测试就挂了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Mock 异步操作
|
## Mock 异步操作
|
||||||
|
|
||||||
|
### vi.mock() — 模块级 Mock
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 内联 Mock,隔离 API 依赖
|
||||||
|
vi.mock("../api/user", () => ({
|
||||||
|
getUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
|
||||||
|
updateUser: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock Fetch / Axios
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
it("loading 态在请求完成后消失", async () => {
|
it("loading 态在请求完成后消失", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
// 推荐:用 vi.spyOn 包装全局 fetch
|
||||||
|
vi.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => [{ id: 1, name: "Test" }],
|
json: async () => [{ id: 1, name: "Test" }],
|
||||||
} as Response);
|
} as Response);
|
||||||
@@ -167,7 +240,7 @@ it("loading 态在请求完成后消失", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("网络错误显示错误提示", async () => {
|
it("网络错误显示错误提示", async () => {
|
||||||
vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error"));
|
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
render(<TodoList />);
|
render(<TodoList />);
|
||||||
|
|
||||||
@@ -177,6 +250,13 @@ it("网络错误显示错误提示", async () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!note] API Mock 方案选型
|
||||||
|
> - **vi.mock() / vi.spyOn**:适合单元测试,快速隔离依赖,但无法模拟完整网络流程
|
||||||
|
> - **MSW (Mock Service Worker)**:通过 Service Worker 拦截真实网络请求,适用于集成测试;与生产行为最接近
|
||||||
|
> - **建议**:组件层用 vi.mock;集成流用 MSW
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Hook 测试
|
## Hook 测试
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -188,9 +268,10 @@ describe("useDebounce", () => {
|
|||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it("值不变时返回原始值", () => {
|
it("值不变时返回原始值", () => {
|
||||||
const { result } = renderHook(({ value }) => useDebounce(value, 300), {
|
const { result } = renderHook(
|
||||||
initialProps: { value: "hello", delay: 300 },
|
({ value }) => useDebounce(value, 300),
|
||||||
});
|
{ initialProps: { value: "hello" } }
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.current).toBe("hello");
|
expect(result.current).toBe("hello");
|
||||||
});
|
});
|
||||||
@@ -210,6 +291,100 @@ describe("useDebounce", () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!tip] Hook 测试的关键模式
|
||||||
|
> - **时间控制**:用 `vi.useFakeTimers()` + `act(() => vi.advanceTimersByTime(n))` 精确控制异步时序,不依赖真实等待
|
||||||
|
> - **cleanup**:每个 `beforeEach` 对应独立的渲染上下文,`rerender` 模拟 props 更新时的行为
|
||||||
|
> - **不要断言内部变量**:只检查 `result.current`(返回值),就像组件只暴露 props 一样
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 集成测试模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 模拟一个完整的用户操作流程
|
||||||
|
describe("<SignupForm /> — 集成测试", () => {
|
||||||
|
it("完整注册流程:填写 → 提交 → 跳转", async () => {
|
||||||
|
// 1. Mock API 响应
|
||||||
|
vi.mock("../api/auth", () => ({
|
||||||
|
register: vi.fn().mockResolvedValue({ token: "abc123" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. 渲染表单
|
||||||
|
render(<SignupForm />);
|
||||||
|
|
||||||
|
// 3. 模拟用户输入(userEvent 更符合真实行为)
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
|
||||||
|
await userEvent.type(emailInput, "alice@example.com");
|
||||||
|
await userEvent.type(passwordInput, "SecurePass123!");
|
||||||
|
|
||||||
|
// 4. 提交
|
||||||
|
const submitBtn = screen.getByRole("button", { name: /sign up/i });
|
||||||
|
await userEvent.click(submitBtn);
|
||||||
|
|
||||||
|
// 5. 验证结果
|
||||||
|
await screen.findByText("Account created!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("重复邮箱提示错误", async () => {
|
||||||
|
vi.mock("../api/auth", () => ({
|
||||||
|
register: vi.fn().mockRejectedValue(new Error("Email already exists")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<SignupForm />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
await userEvent.type(emailInput, "exists@example.com");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /sign up/i }));
|
||||||
|
|
||||||
|
await screen.findByText("Email already exists");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!important] 集成测试的设计要点
|
||||||
|
> - **一次测一个完整流程**,不要拆成原子步骤——这样即使中间环节变动,只要最终行为一致就不需要改测试
|
||||||
|
> - **Mock 所有外部依赖**(API、localStorage、WebSocket),但不 mock 内部逻辑
|
||||||
|
> 集成测试的目标是:**确认多个组件协同工作后,用户得到了正确的反馈**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误边界测试
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
|
|
||||||
|
describe("<ErrorBoundary />", () => {
|
||||||
|
// 让被包裹的组件抛出错误
|
||||||
|
function BrokenComponent() {
|
||||||
|
throw new Error("Render failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("捕获渲染错误并显示 fallback UI", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
||||||
|
<BrokenComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未发生错误时正常渲染子内容", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<span>Safe content</span>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Safe content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## E2E 测试选择
|
## E2E 测试选择
|
||||||
|
|
||||||
| 工具 | 适用场景 | 特点 |
|
| 工具 | 适用场景 | 特点 |
|
||||||
@@ -222,4 +397,11 @@ describe("useDebounce", () => {
|
|||||||
> - E2E 只应覆盖**关键用户旅程**(登录、下单、支付)
|
> - E2E 只应覆盖**关键用户旅程**(登录、下单、支付)
|
||||||
> - 不要为每个页面的每个字段写 E2E——那属于集成测试的范畴
|
> - 不要为每个页面的每个字段写 E2E——那属于集成测试的范畴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
|
||||||
|
- [[07-自定义 Hooks]] — 自定义 Hook 的设计与测试模式
|
||||||
|
- [[09-状态管理]] — 全局状态管理(Zustand / Redux Toolkit)的测试策略
|
||||||
|
- [[13-并发特性]] — Suspense、Transitions 的测试注意事项
|
||||||
|
- [[15-性能优化]] — 性能回归测试:React Testing Library + Performance Assertions
|
||||||
|
|||||||
+308
-57
@@ -38,8 +38,18 @@ graph TB
|
|||||||
|
|
||||||
## 语义化 HTML(最重要的一条)
|
## 语义化 HTML(最重要的一条)
|
||||||
|
|
||||||
|
> [!tip] 黄金法则
|
||||||
|
> **能用原生元素,绝不用 div + onClick。** 原生 HTML 元素天生具备键盘交互、屏幕阅读器支持和 SEO 友好能力。这是可访问性的基石,比任何 ARIA 属性都重要。
|
||||||
|
|
||||||
|
思考:为什么一个 `<div>` 加上 `onClick` 事件后,仍然不能代替 `<button>`?
|
||||||
|
|
||||||
|
答案涉及三个层面:
|
||||||
|
1. **键盘** — div 不在 Tab 焦点流中,Enter/Space 不会触发点击
|
||||||
|
2. **屏幕阅读器** — 读作"按钮,未绑定", 用户不知道它是干什么的
|
||||||
|
3. **搜索引擎** — 无法识别为可交互元素
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// ❌ 滥用 div + onClick
|
// ❌ 滥用 div + onClick——视觉上是按钮,实质上什么都不是
|
||||||
<div className="btn" onClick={() => navigate("/home")}>Home</div>
|
<div className="btn" onClick={() => navigate("/home")}>Home</div>
|
||||||
<div className="link" onClick={() => goTo("/about")}>About</div>
|
<div className="link" onClick={() => goTo("/about")}>About</div>
|
||||||
|
|
||||||
@@ -51,67 +61,142 @@ graph TB
|
|||||||
|
|
||||||
### 常用语义标签对照表
|
### 常用语义标签对照表
|
||||||
|
|
||||||
| 功能 | 错误写法 | 正确写法 |
|
| 功能 | 错误写法 | 正确写法 | 原因 |
|
||||||
|------|---------|---------|
|
|------|---------|---------|------|
|
||||||
| 按钮行为 | `<div onClick>` | `<button>` |
|
| 按钮行为 | `<div onClick>` | `<button>` | 原生键盘交互 |
|
||||||
| 链接跳转 | `<span onClick=navigate>` | `<a href>` |
|
| 链接跳转 | `<span onClick=navigate>` | `<a href>` | 右键打开新标签、Ctrl+点击 |
|
||||||
| 表单输入 | `<div contentEditable>` | `<input>`/`<textarea>` |
|
| 表单输入 | `<div contentEditable>` | `<input>`/`<textarea>` | 输入法兼容、自动填充 |
|
||||||
| 弹窗 | `<div className="modal">` | `<dialog>` 或 role="dialog" |
|
| 弹窗 | `<div className="modal">` | `<dialog>` 或 role="dialog" | 焦点管理、Escape 关闭 |
|
||||||
| 导航区 | `<div class="nav">` | `<nav>` |
|
| 导航区 | `<div class="nav">` | `<nav>` | 跳过导航快捷入口 |
|
||||||
| 侧边栏 | `<aside class="sidebar">` | `<aside>` |
|
| 侧边栏 | `<aside class="sidebar">` | `<aside>` | 页面结构角色识别 |
|
||||||
|
| 主内容 | `<main className="content">` | `<main>` | 直达主内容快捷键 |
|
||||||
|
|
||||||
## aria 属性体系
|
## ARIA 属性体系
|
||||||
|
|
||||||
|
> [!warning] ARIA 铁律(ARIA Gods)
|
||||||
|
> 1. **不要使用 ARIA** —— 用原生语义化元素解决一切
|
||||||
|
> 2. **不要将原生交互改为 `role="presentation"`** —— 会破坏已有无障碍支持
|
||||||
|
> 3. **所有 ARIA 可用状态和属性必须有对应原生支持** —— 不要用 ARIA 模拟原生行为
|
||||||
|
|
||||||
|
ARIA 是最后一道防线,用于当原生 HTML 无法满足需求时。核心原则:**优先使用原生语义,ARIA 作为补充**。
|
||||||
|
|
||||||
|
### aria-label:为无声元素赋予名称
|
||||||
|
|
||||||
|
给没有可见文本的图标、装饰性按钮添加屏幕阅读器可读的描述。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// 1. aria-label —— 给无文本内容的图标添加描述
|
// 关闭按钮——只有图标,需要 label 告诉用户功能
|
||||||
<button aria-label="关闭对话框">
|
<button aria-label="关闭对话框">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
// 2. aria-describedby —— 关联说明文字
|
// 搜索图标按钮
|
||||||
|
<button aria-label="搜索商品">
|
||||||
|
<SearchIcon />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`aria-label` 会完全替代元素的可见文本(如果有),屏幕阅读器只朗读 label 内容。如果已有清晰可见文本,优先用 `aria-labelledby` 关联。
|
||||||
|
|
||||||
|
### aria-describedby:提供补充说明
|
||||||
|
|
||||||
|
当字段需要额外解释文字时使用,屏幕阅读器会在朗读输入框名称后继续朗读描述。
|
||||||
|
|
||||||
|
```tsx
|
||||||
<input
|
<input
|
||||||
aria-label="邮箱地址"
|
aria-label="邮箱地址"
|
||||||
aria-describedby="email-hint"
|
aria-describedby="email-hint"
|
||||||
/>
|
/>
|
||||||
<p id="email-hint">请使用注册时使用的邮箱</p>
|
<p id="email-hint">请使用注册时使用的邮箱</p>
|
||||||
|
|
||||||
// 3. aria-live —— 动态内容变化通知屏幕阅读器
|
{/* 效果:屏幕阅读器会读 "邮箱地址,请使用注册时使用的邮箱" */}
|
||||||
|
```
|
||||||
|
|
||||||
|
### aria-live:动态内容变化通知
|
||||||
|
|
||||||
|
让屏幕阅读器自动感知并朗读动态变化的内容区域。适用于 Toast 消息、表单验证错误、实时搜索结果等场景。
|
||||||
|
|
||||||
|
```tsx
|
||||||
<div aria-live="polite">
|
<div aria-live="polite">
|
||||||
{formErrors.length > 0 && (
|
{formErrors.length > 0 && (
|
||||||
<p>{formErrors.join("、")}</p>
|
<ul>
|
||||||
|
{formErrors.map((err, i) => <li key={i}>{err}</li>)}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* polite = 等待空闲再朗读;assertive = 立即打断 */}
|
```
|
||||||
|
|
||||||
// 4. aria-expanded —— 折叠面板展开状态
|
> [!note] polite vs assertive
|
||||||
<button aria-expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)}>
|
> - **polite**(默认)— 等待用户当前操作空闲后再朗读,不打断用户
|
||||||
|
> - **assertive** — 立即中断当前朗读,优先播报紧急信息
|
||||||
|
>
|
||||||
|
> 非紧急提示(如"保存成功")用 polite;安全警告、支付确认用 assertive。
|
||||||
|
|
||||||
|
### aria-expanded:折叠面板展开状态
|
||||||
|
|
||||||
|
对可展开/收起的元素(手风琴、下拉菜单、折叠面板),标记当前展开状态。用户打开或关闭时,屏幕阅读器会自动读出 "已展开" 或 "已折叠"。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
aria-controls="panel-content"
|
||||||
|
>
|
||||||
{isExpanded ? "收起" : "展开"}
|
{isExpanded ? "收起" : "展开"}
|
||||||
<ChevronIcon rotated={isExpanded ? 180 : 0} />
|
<ChevronIcon rotated={isExpanded ? 180 : 0} />
|
||||||
</button>
|
</button>
|
||||||
|
<div id="panel-content" hidden={!isExpanded}>
|
||||||
// 5. aria-hidden — 装饰性元素隐藏给屏幕阅读器
|
{/* 面板内容 */}
|
||||||
<img src="decorative-pattern.png" alt="" aria-hidden="true" />
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **技巧**:配合 `aria-controls` 指向被控制的内容区域 ID,帮助屏幕阅读器建立两者之间的关系。
|
||||||
|
|
||||||
|
### aria-hidden:隐藏装饰性元素
|
||||||
|
|
||||||
|
将纯装饰性的 SVG 图标或对视觉重要但屏幕阅读器不应朗读的图片隐藏。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
/* 装饰性背景图——不想让屏幕阅读器朗读文件名 */
|
||||||
|
<img src="decorative-pattern.png" alt="" aria-hidden="true" />
|
||||||
|
|
||||||
|
/* SVG 图标中,textContent 已经通过 aria-label 表达了 */
|
||||||
|
<svg aria-hidden="true" focusable="false">
|
||||||
|
<path d="M..." />
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!question] 思考:什么时候应该用 `aria-hidden="true"`,什么时候应该用 `alt=""`?
|
||||||
|
> - `aria-hidden`:告诉辅助技术"忽略这个元素"(用于重复信息,如文本旁已标注功能的图标)
|
||||||
|
> - `alt=""`(空 alt):告诉屏幕阅读器"这是一张装饰图片"(用于图片本身是装饰性的)
|
||||||
|
> - 两者有时可以配合使用
|
||||||
|
|
||||||
## 键盘导航
|
## 键盘导航
|
||||||
|
|
||||||
|
> [!warning] 核心原则
|
||||||
|
> **默认 Tab 顺序 = DOM 顺序。** 绝大多数情况下,不要通过 JavaScript 改变自然的页面阅读顺序。手动设置 `tabIndex` 是例外而非常规。
|
||||||
|
|
||||||
### Tab Order 管理
|
### Tab Order 管理
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// 默认 Tab 顺序 = DOM 顺序,不要随意改变!
|
// ✅ 在 Tab 流中(正常用户应该能用 Tab 到达的元素)
|
||||||
|
<NavButton tabIndex={0} />
|
||||||
|
|
||||||
// 需要自定义时:
|
// ✅ 仅程序化聚焦(浮动按钮、Toast 等不需要 Tab 的元素)
|
||||||
// ✅ 用 tabIndex 控制焦点位置(仅必要场景)
|
<FloatingActionButton tabIndex={-1} />
|
||||||
<NavButton tabIndex={0} /> {/* 在 Tab 流中 */}
|
|
||||||
<FloatingActionButton tabIndex={-1} /> {/* 仅程序化聚焦 */}
|
|
||||||
|
|
||||||
// ❌ 不要设置负 tabIndex 阻止所有键盘操作
|
// ❌ 永远不要全局禁用键盘——这剥夺了所有用户的操作能力
|
||||||
|
<div tabIndex={-1} onKeyDown={handler}>...</div>
|
||||||
|
```
|
||||||
|
|
||||||
// 自定义键盘快捷键
|
### 自定义快捷键 Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
function useKeyboardShortcut(key: string, handler: () => void) {
|
function useKeyboardShortcut(key: string, handler: () => void) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === key && !isInputFocused()) { // 避免在输入框中触发
|
// 避免在输入框、文本域等中触发快捷键
|
||||||
|
if (e.key === key && !isInputFocused()) {
|
||||||
handler();
|
handler();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -121,47 +206,71 @@ function useKeyboardShortcut(key: string, handler: () => void) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **解释**:这段代码的关键在于 `!isInputFocused()` 检查。如果不加这个判断,用户在输入密码或搜索词时按下快捷键,会导致意外行为。例如用户在搜索框中输入 "s",如果同时触发了某个搜索提交快捷键,会打断用户的正常输入。
|
||||||
|
|
||||||
### Focus Trap(模态框焦点陷阱)
|
### Focus Trap(模态框焦点陷阱)
|
||||||
|
|
||||||
|
打开模态对话框时,必须确保 Tab 键的焦点不会跑到背景内容上。焦点应该在弹窗内的可交互元素之间循环。
|
||||||
|
|
||||||
|
> [!note] 为什么需要 Focus Trap?
|
||||||
|
> 想象你打开了一个确认删除弹窗,此时按 Tab 焦点跑到了背景的菜单链接上——用户完全不知道自己在操作什么。Focus Trap 将焦点锁在弹窗内部形成闭环:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Tab 在弹窗内"] --> B["到达最后元素"]
|
||||||
|
B -->|Shift+Tab| C["回到第一个元素"]
|
||||||
|
B -->|Tab| D["回到第一个元素\n(焦点循环)"]
|
||||||
|
C -->|"Tab"| A
|
||||||
|
style A fill:#4FC08D,color:#000
|
||||||
|
style D fill:#F5A87D,color:#000
|
||||||
|
style C fill:#61DAFB,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
function Modal({ isOpen, onClose, children }: Props) {
|
function Modal({ isOpen, onClose, children }: Props) {
|
||||||
const modalRef = useRef<HTMLDialogElement>(null);
|
const modalRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
// 收集弹窗内所有可聚焦元素
|
||||||
const focusableElements = modalRef.current?.querySelectorAll(
|
const focusableElements = modalRef.current?.querySelectorAll(
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
);
|
);
|
||||||
const firstEl = focusableElements?.[0] as HTMLElement;
|
const firstEl = focusableElements?.[0] as HTMLElement;
|
||||||
const lastEl = focusableElements?.[focusableElements.length - 1] as HTMLElement;
|
const lastEl = focusableElements?.[focusableElements.length - 1] as HTMLElement;
|
||||||
|
|
||||||
|
// Tab 键循环逻辑
|
||||||
const handleTab = (e: KeyboardEvent) => {
|
const handleTab = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Tab") return;
|
if (e.key !== "Tab") return;
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
|
// Shift+Tab:从第一个元素回到最后一个
|
||||||
if (document.activeElement === firstEl) {
|
if (document.activeElement === firstEl) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
lastEl.focus();
|
lastEl.focus();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Tab:从最后一个元素回到第一个
|
||||||
if (document.activeElement === lastEl) {
|
if (document.activeElement === lastEl) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
firstEl.focus();
|
firstEl.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleTab);
|
document.addEventListener("keydown", handleTab);
|
||||||
firstEl?.focus();
|
firstEl?.focus();
|
||||||
|
|
||||||
return () => document.removeEventListener("keydown", handleTab);
|
return () => document.removeEventListener("keydown", handleTab);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog ref={modalRef} open onClose={onClose}>
|
<dialog ref={modalRef} open onClose={onClose}>
|
||||||
{children}
|
{children}
|
||||||
@@ -170,6 +279,13 @@ function Modal({ isOpen, onClose, children }: Props) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **解释**:这个 Hook 做了三件事:
|
||||||
|
> 1. **自动聚焦首个元素** —— 打开弹窗后立即将焦点放到第一个可交互元素,让用户无需按 Tab 即可操作
|
||||||
|
> 2. **Tab 循环** —— Shift+Tab 从第一个回到最后一个,Tab 从最后一个回到第一个
|
||||||
|
> 3. **清理监听器** —— 关闭弹窗时移除事件监听,防止内存泄漏
|
||||||
|
>
|
||||||
|
> 实际项目中推荐直接使用 [`@radix-ui/react-dialog`](https://www.radix-ui.com/primitives/docs/dialog/dialog) 或 MUI/Chakra 等组件库,它们已经内置了 Focus Trap 和大量 edge case 处理。
|
||||||
|
|
||||||
## 颜色与视觉
|
## 颜色与视觉
|
||||||
|
|
||||||
> [!tip] 设计检查清单
|
> [!tip] 设计检查清单
|
||||||
@@ -191,48 +307,183 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## React 特定点
|
## React 中的关键注意点
|
||||||
|
|
||||||
|
### 表单字段关联
|
||||||
|
|
||||||
|
屏幕阅读器依赖 `<label>` 元素来告知用户当前输入框的作用。这是最基础的无障碍要求,但也是最容易被忽视的。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// 1. Suspense fallback 要提供有意义的 loading 文案
|
// ✅ 显式关联:通过 htmlFor / id 配对(适合复杂布局)
|
||||||
<Suspense fallback={<p>Loading profile...</p>}>
|
|
||||||
<Profile />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
// 2. Form 字段必须有 label
|
|
||||||
<label htmlFor="username">用户名:</label>
|
<label htmlFor="username">用户名:</label>
|
||||||
<input id="username" name="username" />
|
<input id="username" name="username" />
|
||||||
|
|
||||||
{/* 或隐式关联 */}
|
// ✅ 隐式关联:input 嵌套在 label 内部(最简单可靠)
|
||||||
<label>
|
<label>
|
||||||
用户名:
|
用户名:
|
||||||
<input name="username" />
|
<input name="username" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
// 3. 图标按钮必须有 aria-label
|
// ❌ 缺少 label——屏幕阅读器只会读 "编辑文本,未命名"
|
||||||
|
<input name="username" placeholder="请输入用户名" />
|
||||||
|
|
||||||
|
{/* placeholder ≠ label!placeholder 消失后用户不知道该填什么 */}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **解释**:`placeholder` 属性仅作为提示文字使用,当用户开始输入时它就会消失。而 `label` 始终可见且与控件绑定,是屏幕阅读器的主要信息来源。两者可以配合使用,但不能互相替代。
|
||||||
|
|
||||||
|
### 路由切换后的焦点管理
|
||||||
|
|
||||||
|
SPA 路由切换时,页面内容虽然变了,但 URL 也变了——屏幕阅读器不会自动将焦点移到新页面的顶部。需要手动处理:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
function FocusManager() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 回到顶部 + 将焦点移到主内容区域
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
document.getElementById("main-content")?.focus();
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return null; // 这是一个纯逻辑组件,不渲染任何 UI
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 中设置锚点
|
||||||
|
<main id="main-content" tabIndex={-1}>
|
||||||
|
{/* 路由内容会在这里替换 */}
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **解释**:这个模式对 SPA 项目尤为重要。当用户在侧边栏点击链接跳转到新页面时,屏幕阅读器用户可能仍在听旧页面的内容——因为他们不知道焦点实际上还留在原地。将焦点主动移到 `<main>` 元素上,等于告诉屏幕阅读器 "页面已经切换了,请开始朗读新内容"。
|
||||||
|
>
|
||||||
|
> Next.js 等框架内置了此行为,无需手动实现。
|
||||||
|
|
||||||
|
### Suspense fallback 可读性
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Suspense fallback={<p>Loading profile...</p>}>
|
||||||
|
<Profile />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* ✅ 有意义的加载文案,而非空白的 spinner */}
|
||||||
|
<Suspense fallback={<p>Loading user profile data...</p>}>
|
||||||
|
<Profile />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
/* ❌ 无意义的 loading —— 无法帮助等待中的用户理解发生了什么 */
|
||||||
|
<Suspense fallback={<div className="spinner" />} >
|
||||||
|
<Profile />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图标按钮的可访问性
|
||||||
|
|
||||||
|
所有只有图标的按钮都必须提供描述性文本。视觉用户看到图标就明白功能,但屏幕阅读器用户只能听到一串字符。
|
||||||
|
|
||||||
|
```tsx
|
||||||
<Button aria-label="删除这条消息">
|
<Button aria-label="删除这条消息">
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
// 4. 路由切换后,屏幕阅读器应知道页面变了
|
{/* 如果同时有可见文本,不需要 aria-label */}
|
||||||
// Next.js Router 自动处理;SPA 项目中可手动聚焦顶部
|
<Button onClick={() => deleteMessage(id)}>
|
||||||
useEffect(() => {
|
<TrashIcon />
|
||||||
window.scrollTo(0, 0);
|
删除
|
||||||
document.getElementById("main-content")?.focus();
|
</Button>
|
||||||
}, [location.pathname]);
|
```
|
||||||
|
|
||||||
|
### 跳过导航链接
|
||||||
|
|
||||||
|
长页面或复杂的导航结构应该提供一个"跳过到主内容"的快捷入口。这是 WCAG 2.4.1 的要求。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 放在页面最顶部,通常用 CSS 隐藏,获得焦点时才显示
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="skip-link"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-40px",
|
||||||
|
left: "0",
|
||||||
|
background: "#000",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "8px 16px",
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.top = "0")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.top = "-40px")}
|
||||||
|
>
|
||||||
|
跳到主内容
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<main id="main-content" tabIndex={-1}>
|
||||||
|
{/* 页面内容 */}
|
||||||
|
</main>
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试辅助技术兼容性
|
## 测试辅助技术兼容性
|
||||||
|
|
||||||
| 工具 | 用途 |
|
> [!tip] 分层测试策略
|
||||||
|------|------|
|
> 自动化测试只是第一道防线——快速捕捉低 hanging fruit。但要真正保证无障碍体验,需要三层组合:
|
||||||
| axe DevTools | Chrome/Firefox 扩展,自动检测 a11y 问题 |
|
|
||||||
| Lighthouse | 生成 a11y 评分报告 |
|
```mermaid
|
||||||
| NVDA / VoiceOver | 免费屏幕阅读器(Win / Mac) |
|
graph LR
|
||||||
| wAI11y | Jest/Vitest 断言库 |
|
A["🛡️ 自动化 lint 规则\n(eslint-plugin-jsx-a11y)"] -->|覆盖 ~30%| B["🤖 自动化测试\n(wAI11y / axe-core)"]
|
||||||
|
B -->|覆盖 ~40%| C["⌨️ 键盘 + 屏幕阅读器\n手动测试"]
|
||||||
|
C -->|发现剩余 ~30%| D["👥 真实残障用户测试"]
|
||||||
|
style A fill:#A0AEC0,color:#000
|
||||||
|
style B fill:#61DAFB,color:#000
|
||||||
|
style C fill:#4FC08D,color:#000
|
||||||
|
style D fill:#ED8936,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动化检测工具
|
||||||
|
|
||||||
|
| 工具 | 用途 | 集成方式 |
|
||||||
|
|------|------|---------|
|
||||||
|
| axe DevTools | Chrome/Firefox 扩展,手动审计页面 | 浏览器安装 |
|
||||||
|
| Lighthouse | CI 中生成 a11y 评分报告 | `npx lighthouse --view` |
|
||||||
|
| eslint-plugin-jsx-a11y | 编码时实时报错 | ESLint 插件 |
|
||||||
|
| wAI11y | Jest/Vitest 中的无障碍断言库 | npm 包 |
|
||||||
|
|
||||||
|
### wAI11y 基础用法
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 单元测试示例
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { accessibilityChecks } from "wai-aria";
|
||||||
|
|
||||||
|
it("关闭按钮应有关闭描述", () => {
|
||||||
|
render(<Modal onClose={() => {}} isOpen />);
|
||||||
|
const closeBtn = screen.getByRole("button", { name: /关闭/i });
|
||||||
|
|
||||||
|
// 检查元素是否有正确的 aria 角色和状态
|
||||||
|
expect(closeBtn).toHaveAttribute("aria-label", "关闭对话框");
|
||||||
|
expect(closeBtn).toHaveAccessibleName();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动测试清单
|
||||||
|
|
||||||
|
在发布前,务必用以下组合手动验证:
|
||||||
|
|
||||||
|
> [!example] 人工测试步骤
|
||||||
|
> 1. **纯键盘遍历** — 关掉鼠标,只用 Tab / Shift+Tab / Arrow Keys / Enter / Escape 操作完整流程
|
||||||
|
> 2. **屏幕阅读器体验** — 开启 NVDA(Windows)或 VoiceOver(Mac),浏览核心页面
|
||||||
|
> 3. **缩放测试** — 浏览器缩放至 200%,确认无内容被遮挡或水平滚动
|
||||||
|
> 4. **对比度检查** — 使用 axe DevTools 验证所有文本对比度达标
|
||||||
|
> 5. **深色模式** — 切换系统深色模式后重新走一遍流程
|
||||||
|
|
||||||
> [!question] 思考:为什么自动化工具只能覆盖约 30% 的可访问性问题?
|
> [!question] 思考:为什么自动化工具只能覆盖约 30% 的可访问性问题?
|
||||||
> - 自动化能检测缺少 alt、颜色对比不足等技术问题
|
> - 自动化能检测缺少 alt、颜色对比不足等技术问题
|
||||||
> - 但无法判断交互逻辑是否合理、ARIA 含义是否正确表达、Tab 顺序是否符合直觉——这些需要人工测试
|
> - 但无法判断交互逻辑是否合理、ARIA 含义是否正确表达、Tab 顺序是否符合直觉——这些需要人工测试
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
|
||||||
|
- [[3. 生态工具篇/08-路由管理]] — 路由切换时的焦点管理策略
|
||||||
|
- [[5. 工程实践篇/16-测试]] — 使用 wAI11y 编写可访问性断言测试
|
||||||
|
- [[5. 工程实践篇/15-性能优化]] — 无障碍组件的性能考量
|
||||||
|
|||||||
+341
-65
@@ -23,74 +23,86 @@ create time: 2026-04-29 22:17
|
|||||||
| `ref = React.createRef()` | `useRef` |
|
| `ref = React.createRef()` | `useRef` |
|
||||||
| `shouldComponentUpdate` | `React.memo` / `useMemo` / `useCallback` |
|
| `shouldComponentUpdate` | `React.memo` / `useMemo` / `useCallback` |
|
||||||
| Context.Consumer | `useContext` |
|
| Context.Consumer | `useContext` |
|
||||||
|
| 派生计算值 | `useMemo(() => expensiveCalc(data), [data])` |
|
||||||
|
| 传递函数避免重渲染 | `useCallback(fn, deps)` |
|
||||||
|
|
||||||
### 实际迁移示例
|
### 性能相关 Hooks 迁移
|
||||||
|
|
||||||
|
在实际迁移中,Class 组件往往隐含了 `shouldComponentUpdate` 或手动优化逻辑。用 Hooks 重写时需要显式声明:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// ❌ Before: Class Component
|
// ❌ Before: Class Component — 隐式全量渲染
|
||||||
class UserProfile extends React.Component<{ userId: string }> {
|
class TodoList extends React.Component {
|
||||||
state = { user: null, loading: true };
|
state = { filter: "", todos: [] };
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
fetchUser(this.props.userId).then(user => {
|
|
||||||
this.setState({ user, loading: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (prevProps.userId !== this.props.userId) {
|
|
||||||
this.setState({ loading: true });
|
|
||||||
fetchUser(this.props.userId).then(user => this.setState({ user, loading: false }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
// nothing to clean up here
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// shouldComponentUpdate 需要手动写,否则每次都全量渲染
|
||||||
render() {
|
render() {
|
||||||
const { user, loading } = this.state;
|
return (
|
||||||
return loading ? <Spinner /> : <div>{user.name}</div>;
|
<div>
|
||||||
|
<input onChange={e => this.setState({ filter: e.target.value })} />
|
||||||
|
{/* todos 每次都会重新创建新数组引用,子列表无法优化 */}
|
||||||
|
<TodoItems todos={this.state.todos.filter(t => t.text.includes(this.state.filter))} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ After: Functional Component + Hooks
|
// ✅ After: useMemo + useCallback 显式控制
|
||||||
function UserProfile({ userId }: { userId: string }) {
|
function TodoList() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [filter, setFilter] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const todos = useTodos(); // custom hook
|
||||||
|
|
||||||
useEffect(() => {
|
// 只在 todos/filter 变化时重新过滤
|
||||||
let cancelled = false;
|
const filteredTodos = useMemo(
|
||||||
setLoading(true);
|
() => todos.filter(t => t.text.includes(filter)),
|
||||||
|
[todos, filter]
|
||||||
fetchUser(userId).then(u => {
|
);
|
||||||
if (!cancelled) setUser(u);
|
|
||||||
}).finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { cancelled = true; }; // cleanup
|
|
||||||
}, [userId]); // userId 变化时自动重新 fetch
|
|
||||||
|
|
||||||
if (loading) return <Spinner />;
|
// setFilter 引用稳定,不会导致子组件无效重渲染
|
||||||
return <div>{user?.name}</div>;
|
const handleFilter = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilter(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input onChange={handleFilter} />
|
||||||
|
<TodoItems todos={filteredTodos} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!tip] 何时使用 useMemo vs useCallback
|
||||||
|
> - **memorize value** → `useMemo`: 用于昂贵计算结果或防止子组件 props 引用变化
|
||||||
|
> - **memorize function** → `useCallback`: 用于传递给子组件回调或作为 effect 依赖
|
||||||
|
> - **不要过早优化**: 没有 profiling 数据支持时,默认不用 memo——可读性优先
|
||||||
|
|
||||||
|
### 迁移工具推荐
|
||||||
|
|
||||||
|
> [!note] react-codemod 自动化迁移
|
||||||
|
> Facebook 官方提供的 codemod 可以批量转换大部分 Class → Function 代码:
|
||||||
|
> ```bash
|
||||||
|
> npx jscodeshift -t node_modules/react-codemod/transforms/class-to-hook.js src/
|
||||||
|
> ```
|
||||||
|
> ⚠️ Codemod 只做机械转换,`useEffect` cleanup、`useCallback` 依赖数组等仍需人工审查。
|
||||||
|
> 建议将 `git diff --name-only` 结果加入 Code Review。
|
||||||
|
|
||||||
> [!warning] 迁移 Checklist
|
> [!warning] 迁移 Checklist
|
||||||
> - [ ] 所有 `componentDidMount/Update/Unmount` 转译为 useEffect
|
> - [ ] 所有 `componentDidMount/Update/Unmount` 转译为 useEffect
|
||||||
> - [ ] State 合并:将互不相关的 state 拆分,减少不必要的重渲染
|
> - [ ] State 合并:将互不相关的 state 拆分,减少不必要的重渲染
|
||||||
> - [ ] 移除 `this.` —— 检查所有闭包中的变量引用
|
> - [ ] 移除 `this.` —— 检查所有闭包中的变量引用
|
||||||
> - [ ] Props 类型从 class props 改为 interface 解构
|
> - [ ] Props 类型从 class props 改为 interface 解构
|
||||||
|
> - [ ] 审查 `useEffect` 依赖数组,避免 stale closure
|
||||||
|
> - [ ] 为传给子组件的函数添加 `useCallback`(如经 profiling 确认需要)
|
||||||
|
|
||||||
## React Router v6 → v7 迁移
|
## React Router v6 → v7 迁移
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph LR
|
graph LR
|
||||||
A["v6 BrowserRouter"] -->|"Switch → Routes"| B["v7 Routes"]
|
A["v6 BrowserRouter + Switch"] -->|"Routes + createBrowserRouter"| B["v7 Data Router"]
|
||||||
C["v6 <Route path='/' element={} />"] -->|"path prop 简化"| D["v7 { path: '/', element: {} }"]
|
C["v6 <br/>Route path prop"] -->|"简化"| D["v7 route config object"]
|
||||||
E["v6 <Redirect/>"] -->|"navigate(-1)/replace"| F["v7 <Navigate replace />"]
|
E["v6 <br/>Redirect"] -->|"Navigate replace"| F["v7 Navigate"]
|
||||||
G["v6 Outlet"] -->|"保留"| H["v7 Outlet — 位置不变"]
|
G["v6 Outlet"] -->|"保留,位置不变"| H["v7 Outlet"]
|
||||||
|
|
||||||
style A fill:#F4DBD6,color:#000
|
style A fill:#F4DBD6,color:#000
|
||||||
style B fill:#61DAFB,color:#000
|
style B fill:#61DAFB,color:#000
|
||||||
@@ -106,15 +118,80 @@ graph LR
|
|||||||
| `useHistory` | `useNavigate` | 名称统一 |
|
| `useHistory` | `useNavigate` | 名称统一 |
|
||||||
| `<Route component={Page}>` | `<Route element={<Page />} >` | JSX 方式 |
|
| `<Route component={Page}>` | `<Route element={<Page />} >` | JSX 方式 |
|
||||||
| `match.params` | `useParams()` | hook 方式 |
|
| `match.params` | `useParams()` | hook 方式 |
|
||||||
|
| `useLocation()` | 同 v6 | `location.key` 不再等于 `"default"` |
|
||||||
|
|
||||||
|
### Data Router 配置示例
|
||||||
|
|
||||||
|
v7 引入 **Data Router** 概念,路由配置从 JSX 变为纯数据驱动:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ v6: JSX 声明式路由
|
||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
<Route path="users/:id" element={<Profile />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ v7: createBrowserRouter 数据驱动
|
||||||
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <Layout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Home /> },
|
||||||
|
{ path: "users/:id", element: <Profile /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!note] 为什么 Data Router?
|
||||||
|
> Data Router 将路由状态与 React 状态分离,使得导航、加载、错误处理可以脱离组件树独立管理。这为后续 Loader / Action / Error Boundary 等数据功能奠定了基础。如果需要保留 JSX 写法,可以使用 `createRoutesFromElements` 桥接。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 折中方案:用 JSX 创建 route config
|
||||||
|
import { createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom";
|
||||||
|
|
||||||
|
const router = createBrowserRouter(
|
||||||
|
createRoutesFromElements(
|
||||||
|
<Route path="/" element={<App />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
<Route path="about" element={<About />} />
|
||||||
|
</Route>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!warning] 常见陷阱
|
||||||
|
> - `useParams()` 在 route 未匹配时返回空对象 `{}` 而非 undefined,需要显式判断
|
||||||
|
> - `useSearchParams()` 行为与 v6 一致,但 URL 解析基于 Data Router
|
||||||
|
> - 嵌套路由的 Outlet 必须渲染在父元素中,否则子路由不会显示
|
||||||
|
|
||||||
## React 17 → 18 兼容性
|
## React 17 → 18 兼容性
|
||||||
|
|
||||||
|
React 18 是一个 **渐进式** 升级,不需要整体重写。核心变更如下:
|
||||||
|
|
||||||
| 注意事项 | 说明 |
|
| 注意事项 | 说明 |
|
||||||
|----------|------|
|
|----------|------|
|
||||||
| `ReactDOM.render` 已废弃 | 改用 `createRoot().render()` |
|
| `ReactDOM.render` 已废弃 | 改用 `createRoot().render()` |
|
||||||
| `ReactDOM.unmountComponentAtNode` 已废弃 | 改用 `root.unmount()` |
|
| `ReactDOM.unmountComponentAtNode` 已废弃 | 改用 `root.unmount()` |
|
||||||
| 事件处理在微任务中执行 | `event.persist()` 已移除 |
|
| 事件处理在微任务中执行 | 批处理行为改变;`event.persist()` 已移除 |
|
||||||
| StrictMode 双重渲染 | 开发环境 effect 先 mount → unmount → remount(用于检测) |
|
| StrictMode 双重渲染 | 开发环境 effect 先 mount → unmount → remount(用于检测不安全 effect) |
|
||||||
|
| 自动批处理扩展 | React 事件、promise、setTimeout 等全部自动批处理 |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// React 17
|
// React 17
|
||||||
@@ -127,57 +204,256 @@ const root = createRoot(document.getElementById("root"));
|
|||||||
root.render(<App />);
|
root.render(<App />);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Suspense on Waterfall & Streaming SSR
|
||||||
|
|
||||||
|
React 18 的 Suspense 可以配合 `SuspenseList` 和 streaming SSR 实现渐进式加载:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Server Component 场景(Next.js App Router)
|
||||||
|
// 外层 UI 立即呈现,内部大组件用 Suspense 包裹懒加载
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Header /> // 立即渲染
|
||||||
|
<Navigation /> // 立即渲染
|
||||||
|
<Suspense fallback={<DashboardSkeleton />}>
|
||||||
|
<HeavyChart data={data} /> // 等待数据加载后渲染
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### useTransition & useDeferredValue
|
||||||
|
|
||||||
|
React 18 引入两个新的 concurrency primitives,用于优化用户体验:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useTransition, useDeferredValue } from "react";
|
||||||
|
|
||||||
|
function SearchPage() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// 输入更新是"普通"优先级,结果渲染是"低"优先级
|
||||||
|
const handleInput = (value: string) => {
|
||||||
|
setQuery(value); // 保持输入响应
|
||||||
|
startTransition(() => {
|
||||||
|
doExpensiveSearch(value); // 不阻塞输入
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input value={query} onChange={e => handleInput(e.target.value)} />
|
||||||
|
{isPending && <Spinner />}
|
||||||
|
<SearchResults query={query} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### createPortal 变更
|
||||||
|
|
||||||
|
v18 起 `createPortal` 的宿主容器不再要求必须是 DOM 树中的元素——这意味着可以在 off-screen 容器中创建 portal,延迟挂载到 DOM:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// v17: 必须先将容器添加到 DOM
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const portal = createRoot(container);
|
||||||
|
|
||||||
|
// v18: 可以直接创建,稍后再挂载
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const portal = createRoot(container);
|
||||||
|
// ... later ...
|
||||||
|
document.body.appendChild(container.children[0]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### React 18 升级 Checklist
|
||||||
|
|
||||||
|
> [!note] React 17 → 18 升级清单
|
||||||
|
> - [ ] `ReactDOM.render` → `createRoot().render()`
|
||||||
|
> - [ ] `StrictMode` 下确认无副作用泄漏(effect unmount/remount 测试)
|
||||||
|
> - [ ] 检查第三方库是否声明了 `react@^18` peerDep
|
||||||
|
> - [ ] `useTransition` / `useDeferredValue` 可逐步引入到高延迟场景
|
||||||
|
> - [ ] 流式 SSR 项目需验证 hydration 兼容性
|
||||||
|
> - [ ] `act()` 警告更严格,确保测试中 await act()
|
||||||
|
|
||||||
## Next.js Pages → App Router 迁移
|
## Next.js Pages → App Router 迁移
|
||||||
|
|
||||||
|
### 目录与 API 对照表
|
||||||
|
|
||||||
| Pages Router | App Router |
|
| Pages Router | App Router |
|
||||||
|-------------|-----------|
|
|-------------|-----------|
|
||||||
| `pages/` 目录 | `app/` 目录 |
|
| `pages/` 目录 | `app/` 目录 |
|
||||||
| `_document.tsx` | `app/layout.tsx` |
|
| `_document.tsx` | **不再需要**,由框架自动生成 HTML |
|
||||||
| `_app.tsx` | `app/layout.tsx`(根布局) |
|
| `_app.tsx` | `app/layout.tsx`(根布局) |
|
||||||
| `getServerSideProps` | async Page 组件 |
|
| `getServerSideProps` | async Page 组件 |
|
||||||
| `getStaticProps` | async Page + `revalidate` |
|
| `getStaticProps` | async Page + `revalidate` |
|
||||||
| `next/router` | `next/navigation` |
|
| `next/router` | `next/navigation` |
|
||||||
|
| `getInitialProps` | **不支持**,需用 Server Component 替代 |
|
||||||
| `_middleware.ts` | `middleware.ts` |
|
| `_middleware.ts` | `middleware.ts` |
|
||||||
|
| Dynamic Route `[slug]` | `[slug]/page.tsx` |
|
||||||
|
|
||||||
|
### 核心差异:Server Components vs Client Components
|
||||||
|
|
||||||
|
App Router 中默认页面组件是 **React Server Component (RSC)**:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Pages Router
|
// ❌ Pages Router: getServerSideProps — 必须显式导出
|
||||||
export async function getServerSideProps() {
|
export async function getServerSideProps() {
|
||||||
const data = await api.getData();
|
const data = await api.getData();
|
||||||
return { props: { data } };
|
return { props: { data } };
|
||||||
}
|
}
|
||||||
|
function Page({ data }) {
|
||||||
// App Router
|
|
||||||
async function Page() {
|
|
||||||
const data = await api.getData();
|
|
||||||
return <View data={data} />;
|
return <View data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ App Router: async 组件直接运行在服务端
|
||||||
|
async function Page() {
|
||||||
|
const data = await api.getData();
|
||||||
|
// data 不会序列化发送到客户端,减少 bundle size
|
||||||
|
return <View data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚡ 需要交互的组件用 "use client" 标记为 Client Component
|
||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
function Counter() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
return <button onClick={() => setCount(count + 1)}>{count}</button>;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Loading UI & Error Boundary
|
||||||
|
|
||||||
|
Pages Router 需要手动实现 loading 和 error 页面;App Router 内置支持:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/dashboard/loading.tsx — 过渡 UI 自动展示
|
||||||
|
export default function Loading() {
|
||||||
|
return <div className="animate-pulse">Loading dashboard...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/dashboard/error.tsx — boundary 捕获错误,无需 try/catch
|
||||||
|
"use client";
|
||||||
|
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Something went wrong!</h2>
|
||||||
|
<button onClick={() => reset()}>Try again</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Middleware 变化
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Pages Router: _middleware.ts — 匹配规则有限
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
export function middleware(req) {
|
||||||
|
if (!req.cookies.get("auth")) {
|
||||||
|
return NextResponse.redirect("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ App Router: middleware.ts — 更强大的匹配和响应能力
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
export function middleware(req) {
|
||||||
|
const token = req.cookies.get("auth")?.value;
|
||||||
|
|
||||||
|
if (!token && !req.nextUrl.pathname.startsWith("/login")) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next(); // 放行
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = { matcher: "/dashboard/:path*" }; // 只匹配需要的路由
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移路径建议
|
||||||
|
|
||||||
|
> [!tip] 渐进式迁移策略
|
||||||
|
> - **方案 A(推荐)**:在 `next.config.js` 中设置 `experimental.appDir = true`,双模式并行,按页面逐个迁移到 `app/`
|
||||||
|
> - **方案 B**:新项目直接使用 App Router,老项目保持 Pages Router 不动
|
||||||
|
> - 注意:`getInitialProps` **无法**迁移到 App Router,需要用 fetch + 缓存替代
|
||||||
|
|
||||||
## Major Version 升级通用流程
|
## Major Version 升级通用流程
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["发现新版本发布"] --> B["阅读 Changelog / Breaking Changes"]
|
A["New Version Released"] --> B["Read Changelog / Breaking Changes"]
|
||||||
B --> C{"影响范围评估"}
|
B --> C{"Impact Assessment"}
|
||||||
|
|
||||||
C --> D["仅 minor version bump"]
|
C --> D["Minor version bump"]
|
||||||
C --> E["API 变更 / 新配置项"]
|
C --> E["API changes / new config"]
|
||||||
C --> F["架构性改动"]
|
F["Architecture change"]
|
||||||
|
|
||||||
D --> G["直接升级 + CI 验证"]
|
D --> G["Upgrade + CI verify"]
|
||||||
E --> H["逐个文件修复"]
|
E --> H["Fix per file"]
|
||||||
F --> I["分阶段迁移 + 并行验证"]
|
F --> I["Phased migration + parallel test"]
|
||||||
|
|
||||||
G --> J["更新依赖 + 运行测试"]
|
G --> J["Update deps + run tests"]
|
||||||
H --> J
|
H --> J
|
||||||
I --> J
|
I --> J
|
||||||
|
|
||||||
J --> K["Staging 环境验证"]
|
J --> K["Staging verification"]
|
||||||
K --> L["灰度发布 / Feature Flag"]
|
K --> L["Canary release / Feature Flag"]
|
||||||
L --> M["全量上线"]
|
L --> M["Full rollout"]
|
||||||
|
|
||||||
style F fill:#F5A87D,color:#000
|
style F fill:#F5A87D,color:#000
|
||||||
style I fill:#F5A87D,color:#000
|
style I fill:#F5A87D,color:#000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 通用升级最佳实践
|
||||||
|
|
||||||
|
> [!quote] 经验法则
|
||||||
|
> **永远不要在同一次 PR 中升级依赖 + 重构业务逻辑。** 如果测试失败,你无法判断是版本破坏还是你的代码有问题。分步进行:先升级跑通 CI,再逐步优化。
|
||||||
|
|
||||||
|
| 实践 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **锁定大版本** | `package.json` 中使用 `"^18.2.0"` 而非 `"*"`,防止意外拉取 patch 外的变更 |
|
||||||
|
| **CI 前置** | 升级后首先确保所有测试通过(含 snapshot test) |
|
||||||
|
| **逐个升级** | 先修 core(React),再修生态(Router、Redux 等),最后修 UI 库 |
|
||||||
|
| **Feature Flag** | 对新路由或新页面用 feature flag 控制,观察指标后再全量开放 |
|
||||||
|
| **监控告警** | 上线后关注 Error Boundary 捕获的异常数量和性能指标(LCP、FCP) |
|
||||||
|
|
||||||
|
## 常见陷阱汇总
|
||||||
|
|
||||||
|
> [!danger] Class → Function 常见陷阱
|
||||||
|
> - **stale closure**: `useEffect` 中没有把依赖放入数组,导致闭包中读取到旧值
|
||||||
|
> ```tsx
|
||||||
|
> // ❌ name 变化时 effect 不会重新执行
|
||||||
|
> useEffect(() => { console.log(name); }, []);
|
||||||
|
> // ✅ 正确写法
|
||||||
|
> useEffect(() => { console.log(name); }, [name]);
|
||||||
|
> ```
|
||||||
|
> - **过度使用 memo**: `React.memo` / `useMemo` / `useCallback` 本身有比较成本, profiling 确认瓶颈后再加
|
||||||
|
> - **忽略 cleanup**: `useEffect` 返回的 cleanup 函数必须清除定时器、取消 fetch、移除 listener
|
||||||
|
|
||||||
|
> [!danger] Router 常见陷阱
|
||||||
|
> - Data Router 下 `navigate()` 不会自动停止正在进行的 loader fetch,需要 `AbortController`
|
||||||
|
> - v7 的 `future.v7_relativeSplatPath` 改变了 splat route 的路径解析规则
|
||||||
|
> - 嵌套路由中忘记在父元素渲染 `<Outlet />` 导致子路由不显示
|
||||||
|
|
||||||
|
> [!danger] React 18 常见陷阱
|
||||||
|
> - StrictMode 双重渲染不是 bug,是有意为之的检测机制——利用它发现 effect cleanup 问题
|
||||||
|
> - `ReactDOM.render` 移除后老代码直接报错,需要用 codemod 批量替换
|
||||||
|
> - `act()` 包装缺失会导致测试异步状态更新丢失,务必 `await act(async () => {...})`
|
||||||
|
|
||||||
|
> [!danger] Next.js 常见陷阱
|
||||||
|
> - Client Component 中不能直接 `import` Server Component
|
||||||
|
> - 动态路由组 `(group)` 只影响 URL 路径,不影响目录结构
|
||||||
|
> - App Router 中的 layout 会跨页面保持状态,需用 `key` 强制重建
|
||||||
|
|
||||||
## 关联笔记
|
## 关联笔记
|
||||||
|
|
||||||
|
- [[hhs/REACT/2. Hooks 篇/05-核心 Hooks]]
|
||||||
|
- [[hhs/REACT/3. 生态工具篇/08-路由管理]]
|
||||||
|
- [[hhs/REACT/4. 进阶篇/13-并发特性]]
|
||||||
|
- [[hhs/REACT/4. 进阶篇/14-Serverside Rendering]]
|
||||||
|
- [[hhs/REACT/5. 工程实践篇/15-性能优化]]
|
||||||
|
- [[hhs/REACT/5. 工程实践篇/16-测试]]
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ React 是 Meta 维护的前端 UI 库,通过组件化开发和虚拟 DOM 实
|
|||||||
|
|
||||||
### 4. 进阶篇
|
### 4. 进阶篇
|
||||||
|
|
||||||
- **[11-组件通信模式](./4. 进阶篇/11-组件通信模式.md)** — Props Drill → Context → 状态管理库,跨层级通信方案对比
|
- **[11-组件通信模式](./4. 进阶篇/11-组件通信模式.md)** — Props Drill → Context(含性能陷阱)→ 兄弟状态提升 → 状态管理库 → Imperative Refs
|
||||||
- **[12-HOC 与 Render Props](./4. 进阶篇/12-HOC 与 Render Props.md)** — 高阶组件模式、Render Props 模式、为什么 Hook 更受欢迎
|
- **[12-HOC 与 Render Props](./4. 进阶篇/12-HOC 与 Render Props.md)** — 高阶组件模式、Render Props 模式、为什么 Hook 更受欢迎
|
||||||
- **[13-并发特性](./4. 进阶篇/13-并发特性.md)** — Suspense、useSuspenseQuery、Transitions、时间切片原理
|
- **[13-并发特性](./4. 进阶篇/13-并发特性.md)** — Suspense、useSuspenseQuery、Transitions、时间切片原理
|
||||||
- **[14-Serverside Rendering](./4. 进阶篇/14-Serverside Rendering.md)** — Next.js App Router、SSR/SSG/ISR 渲染策略、Stream SSR
|
- **[14-Serverside Rendering](./4. 进阶篇/14-Serverside Rendering.md)** — Next.js App Router、SSR/SSG/ISR 渲染策略、Stream SSR
|
||||||
|
|||||||
Reference in New Issue
Block a user