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
|
||||
---
|
||||
|
||||
@@ -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 --> C["Context Provider(跨层级)"]
|
||||
A --> D["状态管理库(全局共享)"]
|
||||
|
||||
|
||||
E["子 → 父"] --> F["回调 Prop(事件驱动)"]
|
||||
E --> G["refs(imperative)"]
|
||||
|
||||
E --> G["Refs(imperative)"]
|
||||
|
||||
H["兄弟组件"] --> I["状态提升到共同父组件"]
|
||||
H --> J["通过共同祖先通信"]
|
||||
H --> K["状态管理库 / 发布订阅"]
|
||||
|
||||
|
||||
style B fill:#4FC08D,color:#fff
|
||||
style C fill:#F5A87D,color:#000
|
||||
style F fill:#61DAFB,color:#000
|
||||
@@ -36,30 +41,37 @@ graph TD
|
||||
```tsx
|
||||
function App() {
|
||||
const user = getUser();
|
||||
return <Header user={user} />; // Header 不需要 user,但深层的 Avatar 需要!
|
||||
return <Header user={user} />;
|
||||
// Header 不需要 user,但深层的 Avatar 需要!
|
||||
}
|
||||
|
||||
function Header({ user }) {
|
||||
return (
|
||||
<nav>
|
||||
<Logo /> {/* ❌ 无需 user,却接收了 */}
|
||||
<Avatar user={user} /> {/* ✅ 需要 */}
|
||||
<Logo />
|
||||
{/* ❌ 无需 user,却被迫接收 */}
|
||||
<Avatar user={user} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 何时该容忍 Props Drill?
|
||||
> 传递 2-3 层 Props 是完全合理的,甚至值得鼓励——它让数据流向清晰可见。只有超过 4 层时才考虑替代方案。
|
||||
|
||||
### 解决方案对比
|
||||
|
||||
| 方法 | 适合场景 | 额外依赖 |
|
||||
|------|----------|---------|
|
||||
| **Context** | 少量深层传递(主题、用户信息) | 无 |
|
||||
| **自定义 Hook + Props 重组织** | 中等深度(3-4 层) | 无 |
|
||||
| **状态管理库** | 频繁变化、多组件共享 | Zustand/Redux |
|
||||
| **Render Prop / HOC** | 复用逻辑而非传值 | 无 |
|
||||
| **Context** | 少量深层传递(主题、用户信息、语言设置) | 无 |
|
||||
| **自定义 Hook + Props 重组织** | 中等深度(3-4 层),只需中间层不暴露 | 无 |
|
||||
| **状态管理库** | 频繁变化、多组件共享 | Zustand / Redux Toolkit |
|
||||
| **Render Props / HOC** | 复用逻辑而非单纯传值 | 无 |
|
||||
|
||||
## Callback Prop(子 → 父)
|
||||
|
||||
这是 React 中最基础也最重要的反向通信方式:**父组件传递一个函数作为 Prop,子组件在适当时机调用它**。
|
||||
|
||||
```tsx
|
||||
interface FormProps {
|
||||
onSubmit: (data: FormValues) => Promise<void>;
|
||||
@@ -69,9 +81,9 @@ function LoginForm({ onSubmit }: FormProps) {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = collectFormData();
|
||||
onSubmit(data); // 通知父组件
|
||||
onSubmit(data); // ✅ 通知父组件
|
||||
};
|
||||
|
||||
|
||||
return <form onSubmit={handleSubmit}><Button type="submit">登录</Button></form>;
|
||||
}
|
||||
|
||||
@@ -81,75 +93,203 @@ function App() {
|
||||
await api.login(data);
|
||||
navigate("/dashboard");
|
||||
};
|
||||
|
||||
|
||||
return <LoginForm onSubmit={handleLogin} />;
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] 为什么不用 props.children 做通信?
|
||||
> children 是 UI 内容插槽,不是通信机制。通信用 callback prop 保持关注点分离。
|
||||
> [!note] 为什么不用 children 做通信?
|
||||
> children 是 UI 内容插槽,不是通信机制。回调 Prop 保持关注点分离——子组件只关心"什么时候触发",父组件决定"触发后做什么"。
|
||||
|
||||
> [!important] 性能提示:使用 useCallback
|
||||
> 如果子组件用 `React.memo` 包裹,每次父组件重新渲染都会创建新的函数引用,导致子组件无效重渲染。用 `useCallback` 缓存回调函数:
|
||||
> ```tsx
|
||||
> const handleLogin = useCallback(async (data: FormValues) => { ... }, []);
|
||||
> ```
|
||||
|
||||
## Context 跨层级通信
|
||||
|
||||
当 Props 穿透超过 3-4 层时,Context 是最轻量的替代方案。**它的本质是一个"隐式 Prop"——Provider 上方的任意后代都可以直接消费,无需经过中间组件。**
|
||||
|
||||
```tsx
|
||||
const UserContext = createContext<User | null>(null);
|
||||
|
||||
function UserProfile({ children }: { children: React.ReactNode }) {
|
||||
function UserProfileProvider({ children }: { children: React.ReactNode }) {
|
||||
const user = useDatabaseUser(userId);
|
||||
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={user}>
|
||||
{/* Avatar、Settings、OrderHistory 任意深度都可消费 */}
|
||||
{children}
|
||||
{children} {/* Avatar、Settings 任意深度都可消费 */}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderHistory() {
|
||||
const user = useContext(UserContext); // 直接获取,无需中间层透传
|
||||
return <div>{user?.orders?.map(...)}</div>;
|
||||
const user = useContext(UserContext); // ✅ 直接获取,跳过中间层
|
||||
return <div>{user?.orders?.map(order => <OrderItem key={order.id} order={order} />)}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] Context 的性能陷阱
|
||||
> - Provider value 对象每次渲染都是新引用 → 所有消费者重渲染
|
||||
> - 解决:拆分 Context、或使用 `useReducer` 返回稳定的 `{state, dispatch}` 对象
|
||||
### Context 性能陷阱与最佳实践
|
||||
|
||||
```
|
||||
⚠️ 核心问题:
|
||||
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
|
||||
// Zustand 方案:任意两个组件共享 state,无需祖先后代关系
|
||||
// Zustand:极简 API,按 slice 订阅避免全量重渲染
|
||||
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: [],
|
||||
addItem: (item) => set(state => ({ items: [...state.items, item] })),
|
||||
removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
|
||||
}));
|
||||
|
||||
function ProductCard() {
|
||||
const addItem = useCartStore(s => s.addItem);
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
const addItem = useCartStore(s => s.addItem); // 精确订阅,仅在 addItem 引用变化时重渲染
|
||||
return <button onClick={() => addItem(product)}>加入购物车</button>;
|
||||
}
|
||||
|
||||
function CartIcon() {
|
||||
const items = useCartStore(s => s.items);
|
||||
const items = useCartStore(s => s.items); // 仅 items 变化时重渲染
|
||||
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(命令式通信)
|
||||
|
||||
大多数情况下 React 推崇声明式通信(Props + Callback),但在需要**直接操控子组件实例行为**时使用 refs。典型场景:触发自定义验证、聚焦输入框、播放动画。
|
||||
|
||||
```tsx
|
||||
const formRef = useRef<FormHandle>(null);
|
||||
|
||||
function Parent() {
|
||||
const handleSubmit = () => {
|
||||
formRef.current?.validate(); // 命令子组件执行方法
|
||||
formRef.current?.validate(); // 命令子组件执行
|
||||
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) => {
|
||||
@@ -157,31 +297,44 @@ const ChildForm = forwardRef<FormHandle>((props, ref) => {
|
||||
validate: () => validator.validate(),
|
||||
reset: () => setFields(initialValues),
|
||||
}));
|
||||
|
||||
|
||||
return <form>...</form>;
|
||||
});
|
||||
```
|
||||
|
||||
> [!warning] 谨慎使用 Imperative Refs
|
||||
> Imperative API 打破了 React 的声明式范式。能用 declarative(状态驱动 UI)解决的问题,永远不要上 imperative(直接操控 DOM)。过度使用会导致:难以测试、时序 bug、调试困难。
|
||||
|
||||
## 决策树
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["需要通信?"] -->|"是"| B["通信范围?"]
|
||||
|
||||
B --> C["仅父子两层"]
|
||||
B --> D["多层级穿透"]
|
||||
B --> E["跨层级 + 兄弟之间"]
|
||||
B --> F["需命令式调用子组件方法"]
|
||||
|
||||
C --> G["Props + Callback ✅"]
|
||||
D --> H["Context ✅"]
|
||||
E --> I["Zustand / Redux ✅"]
|
||||
F --> J["forwardRef + useImperativeHandle ✅"]
|
||||
|
||||
style G fill:#4FC08D,color:#fff
|
||||
style H fill:#61DAFB,color:#000
|
||||
style I fill:#F5A87D,color:#000
|
||||
style J fill:#A0AEC0,color:#000
|
||||
START["需要组件通信?"] -->|"否"| DONE["无需处理 ✅"]
|
||||
START -->|"是"| RANGE["通信范围?"]
|
||||
|
||||
RANGE --> PARENT_CHILD["仅父子两层<br/>(1-2 级深度)"]
|
||||
RANGE --> DEEP["多层级穿透<br/>(≥ 3 级深度)"]
|
||||
RANGE --> SIBLING["兄弟组件 / 无关组件"]
|
||||
RANGE --> IMPERATIVE["需命令式调用子组件方法"]
|
||||
|
||||
PARENT_CHILD --> PROP["Props + Callback ✅<br/>简单、直观、可追踪"]
|
||||
DEEP --> CTX["Context ✅<br/>配合 useReducer 保证性能"]
|
||||
SIBLING --> SLIFTING["状态提升到共同父 ✅"]
|
||||
SIBLING --> STORE["状态管理库(复杂场景)"]
|
||||
IMPERATIVE --> REF["forwardRef +<br/>useImperativeHandle ✅"]
|
||||
|
||||
style PROP fill:#4FC08D,color:#fff
|
||||
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(高阶组件)
|
||||
|
||||
### 概念
|
||||
|
||||
> [!question] 类比理解:如果把组件看作"数据 → UI"的转换器,HOC 就是在输入输出之间插入了一层"调料"。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["原始组件 Component"] -->|"注入 props"| B["HOC 函数"]
|
||||
@@ -48,67 +58,135 @@ const StyledTitle = withTheme(function Title({ title }: Props) {
|
||||
}); // ✅ TS 推断 Props 不变
|
||||
```
|
||||
|
||||
### 常见 HOC 模式
|
||||
> [!note] 核心原理
|
||||
> HOC 本质上是**闭包 + 组合**。`WithAuth` 作为一个新的函数组件,可以正常使用 Hooks(因为它是组件),而包裹的 `WrappedComponent` 只负责渲染。这种方式**不修改原组件的任何代码**,仅通过 props 传递增强信息。
|
||||
|
||||
#### 关键细节:displayName
|
||||
|
||||
调试嵌套 HOC 时,React DevTools 会显示一层层无意义的 wrapper 名。可以用 `displayName` 让调试更清晰:
|
||||
|
||||
```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> {
|
||||
return function LoggedComponent(props: P) {
|
||||
useEffect(() => console.log(`${Comp.name} mounted`), []);
|
||||
useEffect(() => console.log(`[Mount] ${Comp.name || 'Anonymous'}`), []);
|
||||
return <Comp {...props} />;
|
||||
};
|
||||
}
|
||||
```
|
||||
> [!tip] 解释
|
||||
> 不传任何额外 prop,只"旁路"添加副作用(如日志)。这是 HOC 最轻量的用法——原组件完全不知情。
|
||||
|
||||
// 2. 加载态封装
|
||||
#### 2. 加载态封装
|
||||
|
||||
```tsx
|
||||
// 自动管理 loading 状态 + 错误处理
|
||||
function withLoading<P extends object>(
|
||||
Comp: React.FC<P>,
|
||||
fetchData: () => Promise<void>
|
||||
): React.FC<P> {
|
||||
return function LoadingWrapper(props: P) {
|
||||
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} />;
|
||||
};
|
||||
}
|
||||
```
|
||||
> [!note] 解释
|
||||
> HOC 在**不改动组件渲染逻辑**的前提下,统一接管了 "加载中 / 出错 / 成功" 三种 UI 状态。多个页面复用同一套加载策略,DRY。
|
||||
|
||||
// 3. Props 转换(驼峰→kebab)
|
||||
#### 3. Props 转换
|
||||
|
||||
```tsx
|
||||
// 将 props 做格式变换后注入子组件
|
||||
function withPropsTransformer<P extends object>(
|
||||
Comp: React.FC<P>,
|
||||
transform: (p: P) => Record<string, unknown>
|
||||
): React.FC {
|
||||
return function Transformed(props: P) {
|
||||
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 的局限性
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| **Static 属性丢失** | `withAuth(Page)` 返回的新组件没有 Page.getInitialProps |
|
||||
| **Wrapper Hell** | `withAuth(withLogging(withData(Page)))`——嵌套过深调试困难 |
|
||||
| **Props 冲突** | 多个 HOC 都注入 `data` prop,后一个覆盖前一个 |
|
||||
| **ref 丢失** | 直接传 ref 给增强组件会报错(需用 forwardRef 包装) |
|
||||
| 问题 | 说明 | 解决方案 |
|
||||
|------|------|---------|
|
||||
| **Static 属性丢失** | `withAuth(Page)` 返回的新组件没有 Page.getInitialProps | 手动拷贝:`EnhancedComponent.getInitialProps = Wrapped.getInitialProps` |
|
||||
| **Wrapper Hell** | `withAuth(withLogging(withData(Page)))`——嵌套过深调试困难 | 用 `compose` 函数扁平化,或直接改用 Custom Hook |
|
||||
| **Props 冲突** | 多个 HOC 都注入 `data` prop,后一个覆盖前一个 | 使用唯一命名空间前缀(如 `_data` → `useData.data`) |
|
||||
| **ref 丢失** | 直接传 ref 给增强组件会报错(需用 forwardRef 包装) | 见下方兼容 ref 的方案 |
|
||||
| **隐式依赖** | HOC 内部调用的 hook 不透明,阅读者不知道注入了什么 | 规范命名(`withXxx`),文档说明行为 |
|
||||
|
||||
> [!tip] HOC 兼容 ref
|
||||
> ```tsx
|
||||
> export function withAuth<P>(Wrapped: React.ComponentType<P>) {
|
||||
> return React.forwardRef<HTMLDivElement, P>((props, ref) => {
|
||||
> const { authenticated } = useAuth();
|
||||
> if (!authenticated) return <Navigate to="/login" />;
|
||||
> return <Wrapped {...props} ref={ref} />;
|
||||
> });
|
||||
> }
|
||||
> ```
|
||||
#### 为什么需要 forwardRef?
|
||||
|
||||
```tsx
|
||||
export function withAuth<P>(Wrapped: React.ComponentType<P>) {
|
||||
return React.forwardRef<HTMLDivElement, P>((props, ref) => {
|
||||
const { authenticated } = useAuth();
|
||||
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
|
||||
|
||||
### 核心思想
|
||||
|
||||
> [!question] 和 HOC 相比,Render Props 到底"额外"给了什么能力?
|
||||
|
||||
通过 prop 传递一个**函数**,该函数返回 JSX——将 UI 渲染逻辑委托给调用方。
|
||||
|
||||
与 HOC 的本质区别:HOC 通过**组件嵌套**注入 props;Render Props 通过**函数传参**直接暴露状态。
|
||||
|
||||
```tsx
|
||||
interface MouseTrackerProps {
|
||||
render: (position: { x: number; y: number }) => React.ReactNode;
|
||||
@@ -124,6 +202,7 @@ function MouseTracker({ render }: MouseTrackerProps) {
|
||||
}, []);
|
||||
|
||||
// 🎯 关键:render 函数返回什么就渲染什么
|
||||
// 状态在父组件,UI 由调用方决定
|
||||
return <div>{render(pos)}</div>;
|
||||
}
|
||||
|
||||
@@ -133,10 +212,15 @@ function MouseTracker({ render }: MouseTrackerProps) {
|
||||
)} />;
|
||||
```
|
||||
|
||||
### 等价于 children 的情况
|
||||
> [!note] 解释
|
||||
> `MouseTracker` 是**纯粹的关注点分离**:它只负责追踪鼠标位置并触发重渲染,完全不关心屏幕上画什么。这种"关注点解耦"是 HOC 难以优雅表达的。
|
||||
|
||||
### children 作为 Render Prop
|
||||
|
||||
当 render prop 只是把数据传给子内容时,可以用 `children`(本身也是函数)替代——语义更自然。
|
||||
|
||||
```tsx
|
||||
// 当 render prop 只是把数据传给 children 时,可以用 children 替代
|
||||
// children prop 的类型:接收 data,返回 ReactNode
|
||||
function DataProvider({ children }: { children: (data: Data) => React.ReactNode }) {
|
||||
const data = useDatabase();
|
||||
return <>{children(data)}</>;
|
||||
@@ -147,42 +231,86 @@ function DataProvider({ children }: { children: (data: Data) => React.ReactNode
|
||||
</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
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A["逻辑复用需求"] --> B["方案对比"]
|
||||
A["逻辑复用需求"] --> B["三种方案"]
|
||||
|
||||
B --> C["HOC"]
|
||||
B --> D["Render Props"]
|
||||
B --> E["Custom Hook"]
|
||||
B --> C["HOC<br/>函数包裹组件"]
|
||||
B --> D["Render Props<br/>函数传递 prop"]
|
||||
B --> E["Custom Hook<br/>纯函数抽离逻辑"]
|
||||
|
||||
C --> F["⚠️ Wrapper 嵌套深"]
|
||||
C --> G["⚠️ 静态方法丢失"]
|
||||
C --> H["✅ 不修改原组件结构"]
|
||||
C --> C1["⚠️ Wrapper 嵌套深"]
|
||||
C --> C2["⚠️ 静态方法丢失"]
|
||||
C --> C3["⚠️ ref 需 forwardRef"]
|
||||
C --> C4["✅ 不修改原组件 JSX"]
|
||||
|
||||
D --> I["⚠️ 回调地狱"]
|
||||
D --> J["⚠️ Prop 命名冲突风险"]
|
||||
D --> K["✅ 灵活的 UI 控制"]
|
||||
D --> D1["⚠️ 回调嵌套过深"]
|
||||
D --> D2["⚠️ Prop 命名冲突风险"]
|
||||
D --> D3["✅ UI 控制权完全交给调用方"]
|
||||
|
||||
E --> L["✅ 简洁直观"]
|
||||
E --> M["✅ 可直接操作 state / effect"]
|
||||
E --> N["✅ 无 wrapper 嵌套"]
|
||||
E --> O["❌ 只能用于组件内部"]
|
||||
E --> E1["✅ 扁平可读"]
|
||||
E --> E2["✅ 直接操作 state / effect"]
|
||||
E --> E3["✅ 无额外组件树开销"]
|
||||
E --> E4["❌ 只能在组件内部使用"]
|
||||
|
||||
style L fill:#4FC08D,color:#fff
|
||||
style M fill:#4FC08D,color:#fff
|
||||
style N fill:#4FC08D,color:#fff
|
||||
style C4 fill:#4FC08D,color:#fff
|
||||
style D3 fill:#4FC08D,color:#fff
|
||||
style E1 fill:#4FC08D,color:#fff
|
||||
style E2 fill:#4FC08D,color:#fff
|
||||
style E3 fill:#4FC08D,color:#fff
|
||||
```
|
||||
|
||||
## 为什么 Hooks 取代了它们?
|
||||
|
||||
```tsx
|
||||
// ❌ HOC 方式
|
||||
const ConnectedUserList = withAuth(withCache(withPagination(UserList)));
|
||||
// 三层嵌套 → 调试困难、性能不可见、type 推导混乱
|
||||
Hooks 的核心理念是**把状态逻辑从组件中抽离出来,但保持调用处扁平**。它结合了 HOC 和 Render Props 的优点:
|
||||
|
||||
// ✅ 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() {
|
||||
useAuth(); // 身份验证
|
||||
const cache = useCache<User[]>(); // 数据缓存
|
||||
@@ -190,12 +318,24 @@ function UserList() {
|
||||
|
||||
return <table>{/* ... */}</table>;
|
||||
}
|
||||
// 扁平可读、天然共享 state、TS 完美推断
|
||||
```
|
||||
|
||||
> [!note] HOC 和 Render Props 真的被淘汰了吗?
|
||||
> - HOC:在需要**包裹**组件但不修改其内部的场景仍有价值(如第三方库封装)
|
||||
> - Render Props:当父组件需要**完全控制子组件的渲染内容**时仍然有用
|
||||
> - 但 90%+ 的场景,Custom Hook 是更好的选择
|
||||
> [!question] 思考
|
||||
> Hooks 真的完全淘汰了 HOC 和 Render Props 吗?回想一下文档开头的两个核心概念——HOC 通过**包裹**增强组件,Render Props 通过**回调**暴露渲染控制。这两种模式在哪些场景中仍然无法被 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 渲染架构演进
|
||||
|
||||
@@ -37,6 +41,28 @@ const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
||||
</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)
|
||||
|
||||
```tsx
|
||||
@@ -60,6 +86,10 @@ function Profile() {
|
||||
|
||||
## useTransition —— 标记低优先级更新
|
||||
|
||||
> [!question] 什么时候该用 Transition?
|
||||
>
|
||||
> 当一次用户交互(如点击、选择)会触发多个状态更新,其中某些更新的 UI 渲染代价高昂时——你可以把「即时反馈」和「延迟渲染」分开。
|
||||
|
||||
```tsx
|
||||
function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -88,16 +118,40 @@ function SearchPage() {
|
||||
}
|
||||
```
|
||||
|
||||
### Transition 与直接 setState 对比
|
||||
### useTransition + Suspense 组合模式
|
||||
|
||||
```mermaid
|
||||
timeline
|
||||
title "输入 "hello" 的渲染行为"
|
||||
|
||||
直接 setState : 每次按键 → re-render\n(h/h/e/l/o 共 5 次)
|
||||
useTransition : h,e,l,l → 跳过中间\no → 最终渲染一次
|
||||
两者配合可以实现更精细的加载策略:Transition 控制哪个更新「可以等待」,Suspense 在数据就绪后展示最终内容。
|
||||
|
||||
```tsx
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const deferredTab = useDeferredValue(activeTab);
|
||||
|
||||
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 —— 延迟副本
|
||||
|
||||
```tsx
|
||||
@@ -117,14 +171,9 @@ function TodoApp() {
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] Transition vs DeferredValue 选择指南
|
||||
> [!note] 内部机制
|
||||
>
|
||||
> | 场景 | 推荐 |
|
||||
> |------|------|
|
||||
> | 表单提交后展示新视图 | `useTransition` |
|
||||
> | 输入框即时搜索 + 延迟加载 | `useDeferredValue` |
|
||||
> | 多个状态联动更新 | `useTransition`(更明确控制粒度) |
|
||||
> | 简单延迟一个值 | `useDeferredValue`(一行搞定) |
|
||||
> `useDeferredValue` 内部等价于一次 `startTransition`。调用后 React 立即用旧值渲染,然后调度一次低优先级的 re-render 来更新为新值。你可以理解为「自动防抖 + 优先级降级」。
|
||||
|
||||
## 时间切片原理
|
||||
|
||||
@@ -174,4 +223,46 @@ useEffect(() => {
|
||||
// 用于发现不纯的 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
|
||||
---
|
||||
|
||||
@@ -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
|
||||
graph TB
|
||||
subgraph "客户端渲染 CSR"
|
||||
A[HTML空白页] --> B["下载 JS Bundle"]
|
||||
B --> C["执行 React hydration"]
|
||||
subgraph CSR["客户端渲染 CSR"]
|
||||
A["HTML空白页"] --> B["下载JS Bundle"]
|
||||
B --> C["执行React hydration"]
|
||||
C --> D["显示内容"]
|
||||
end
|
||||
|
||||
subgraph "服务端渲染 SSR"
|
||||
E[请求页面] --> F["服务器渲染 React → HTML"]
|
||||
F --> G["发送含内容的 HTML"]
|
||||
G --> H["客户端 hydration"]
|
||||
|
||||
subgraph SSR["服务端渲染 SSR"]
|
||||
E["请求页面"] --> F["服务器渲染React -> HTML"]
|
||||
F --> G["发送含内容的HTML"]
|
||||
G --> H["客户端hydration"]
|
||||
H --> I["交互可用"]
|
||||
end
|
||||
|
||||
subgraph "静态生成 SSG"
|
||||
J["构建时渲染"] --> K["生成纯 HTML 文件"]
|
||||
K --> L["CDN 分发"]
|
||||
|
||||
subgraph SSG["静态生成 SSG"]
|
||||
J["构建时渲染"] --> K["生成纯HTML文件"]
|
||||
K --> L["CDN分发"]
|
||||
end
|
||||
|
||||
|
||||
style F fill:#4FC08D,color:#fff
|
||||
style H fill:#F5A87D,color:#000
|
||||
style J fill:#61DAFB,color:#000
|
||||
@@ -44,8 +48,18 @@ graph TB
|
||||
| **SSG**(Static Site Generation) | 博客、文档、营销页 | ⏱️ 构建时生成 | ✅ |
|
||||
| **ISR**(Incremental Static Regeneration) | 新闻列表、商品目录 | 🔄 定时后台更新 | ✅(增量) |
|
||||
|
||||
> [!tip] 核心区别一句话
|
||||
>
|
||||
> - **SSR**:每个用户请求都触发一次服务端的完整渲染。
|
||||
> - **SSG**:构建时生成一次 HTML,所有用户共享同一个静态页面。
|
||||
> - **ISR**:先用 SSG 生成的静态页面响应,后台静默重新生成后替换——对用户无感知。
|
||||
|
||||
## Next.js App Router 架构
|
||||
|
||||
> [!note] Server Component 是默认值
|
||||
>
|
||||
> Next.js App Router 中,所有组件默认就是 **Server Component**——除非你在文件顶部声明 `"use client"`。这意味着你可以放心地在组件里 await 数据库查询、读取环境变量或访问文件系统,这些代码永远不会发送到浏览器。
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx —— 根布局(所有页面共享)
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -63,7 +77,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
async function HomePage() {
|
||||
// ✅ 直接在组件中 await API
|
||||
const posts = await fetchPosts();
|
||||
|
||||
|
||||
return (
|
||||
<main>
|
||||
{posts.map(post => <PostCard key={post.id} post={post} />)}
|
||||
@@ -84,17 +98,17 @@ async function PostPage({ params }: { params: { slug: string } }) {
|
||||
sequenceDiagram
|
||||
participant Client as 浏览器
|
||||
participant Server as 服务器
|
||||
|
||||
|
||||
Client->>Server: GET /dashboard
|
||||
Server->>Server: 并行请求 user/profile/orders
|
||||
Note over Server: 每个请求可有自己的 Suspense boundary
|
||||
|
||||
Server-->>Client: HTML: Navbar + Sidebar<br/>(立即显示,~200ms)
|
||||
|
||||
Server-->>Client: Stream: Profile card<br/>(中等优先级,~800ms)
|
||||
|
||||
Server-->>Client: Stream: Order history<br/>(低优先级,~1500ms)
|
||||
|
||||
Note over Server: 每个请求可有自己的Suspense boundary
|
||||
|
||||
Server-->>Client: HTML: Navbar + Sidebar<br/>(立即显示,约200ms)
|
||||
|
||||
Server-->>Client: Stream: Profile card<br/>(中等优先级,约800ms)
|
||||
|
||||
Server-->>Client: Stream: Order history<br/>(低优先级,约1500ms)
|
||||
|
||||
Note over Client: 用户体验:渐进式展示,而非等全部完成
|
||||
```
|
||||
|
||||
@@ -107,9 +121,9 @@ function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
<Suspense fallback={<SidebarSkeleton />}>
|
||||
<Sidebar />
|
||||
</Suspense>
|
||||
|
||||
|
||||
{children}
|
||||
|
||||
|
||||
{/* 各区域独立 Suspense */}
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<StatsPanel />
|
||||
@@ -122,6 +136,10 @@ function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] Streaming SSR 的工作原理
|
||||
>
|
||||
> 服务端将 HTML 分成多个 chunk,通过网络流逐步发送给浏览器。浏览器边收边渲染——不需要等所有数据就绪。**优先级高的区域先出,低的在后**,用户体验从「全部等」变成「渐进可见」。
|
||||
|
||||
## Data Fetching 策略
|
||||
|
||||
```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
|
||||
// server component(默认,无需声明)
|
||||
@@ -168,7 +195,7 @@ function InteractiveChart() {
|
||||
// ✅ 可以使用 useState/useEffect/DOM API
|
||||
const [zoom, setZoom] = useState(1);
|
||||
useEffect(() => { ... }, []);
|
||||
|
||||
|
||||
return <canvas ref={canvasRef} />;
|
||||
}
|
||||
|
||||
@@ -184,7 +211,219 @@ function Dashboard() {
|
||||
```
|
||||
|
||||
> [!warning] Client → Server 通信限制
|
||||
>
|
||||
> - Client Component 无法直接调用 Server Component 的方法或 props
|
||||
> - Server Component 也不能传给 Client Component 引用了函数、Promise 或 Generator 的值
|
||||
> - 解决方案:通过 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-状态管理]]
|
||||
|
||||
Reference in New Issue
Block a user