Files
cs-note/hhs/REACT/4. 进阶篇/12-HOC 与 Render Props.md
T
2026-05-24 11:42:38 +08:00

341 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [React, HOC, Render Props, Pattern, Frontend]
create time: 2026-04-29 22:11
---
# HOC 与 Render Props
## 概述
> [!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 函数"]
B --> C["增强组件 EnhancedComponent"]
C --> D["获得额外能力:日志/权限/数据"]
style B fill:#F5A87D,color:#000
```
HOC 是一个**函数**,接收组件作为参数,返回增强后的新组件:
```tsx
// 基础模式
function withAuth<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithAuth(props: P) {
const user = useAuth();
if (!user) return <Navigate to="/login" replace />;
return <WrappedComponent {...props} />;
};
}
// 使用:装饰器风格
const ProtectedPage = withAuth(function Dashboard() {
return <h1>Dashboard</h1>;
});
// TS 类型推导:保留原始 props
interface Props { title: string }
const StyledTitle = withTheme(function Title({ title }: Props) {
return <h1>{title}</h1>;
}); // ✅ TS 推断 Props 不变
```
> [!note] 核心原理
> HOC 本质上是**闭包 + 组合**。`WithAuth` 作为一个新的函数组件,可以正常使用 Hooks(因为它是组件),而包裹的 `WrappedComponent` 只负责渲染。这种方式**不修改原组件的任何代码**,仅通过 props 传递增强信息。
#### 关键细节:displayName
调试嵌套 HOC 时,React DevTools 会显示一层层无意义的 wrapper 名。可以用 `displayName` 让调试更清晰:
```tsx
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(`[Mount] ${Comp.name || 'Anonymous'}`), []);
return <Comp {...props} />;
};
}
```
> [!tip] 解释
> 不传任何额外 prop,只"旁路"添加副作用(如日志)。这是 HOC 最轻量的用法——原组件完全不知情。
#### 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);
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 转换
```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);
// ⚠️ transform 返回的 key 可能与原 props 冲突
return <Comp {...(props as any)} {...extra} />;
};
}
```
> [!warning] 注意
> 当 `transform` 返回的 key 和原 props 同名时,后者会覆盖前者。实际项目中建议约定命名空间前缀,例如 `apiData`、`cacheMeta` 等。
### HOC 的局限性
| 问题 | 说明 | 解决方案 |
|------|------|---------|
| **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`),文档说明行为 |
#### 为什么需要 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;
}
function MouseTracker({ render }: MouseTrackerProps) {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
// 🎯 关键:render 函数返回什么就渲染什么
// 状态在父组件,UI 由调用方决定
return <div>{render(pos)}</div>;
}
// 使用
<MouseTracker render={({ x, y }) => (
<div>Cursor at ({x}, {y})</div>
)} />;
```
> [!note] 解释
> `MouseTracker` 是**纯粹的关注点分离**:它只负责追踪鼠标位置并触发重渲染,完全不关心屏幕上画什么。这种"关注点解耦"是 HOC 难以优雅表达的。
### children 作为 Render Prop
当 render prop 只是把数据传给子内容时,可以用 `children`(本身也是函数)替代——语义更自然。
```tsx
// children prop 的类型:接收 data,返回 ReactNode
function DataProvider({ children }: { children: (data: Data) => React.ReactNode }) {
const data = useDatabase();
return <>{children(data)}</>;
}
<DataProvider>
{(data) => <Table items={data.items} />}
</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["三种方案"]
B --> C["HOC<br/>函数包裹组件"]
B --> D["Render Props<br/>函数传递 prop"]
B --> E["Custom Hook<br/>纯函数抽离逻辑"]
C --> C1["⚠️ Wrapper 嵌套深"]
C --> C2["⚠️ 静态方法丢失"]
C --> C3["⚠️ ref 需 forwardRef"]
C --> C4["✅ 不修改原组件 JSX"]
D --> D1["⚠️ 回调嵌套过深"]
D --> D2["⚠️ Prop 命名冲突风险"]
D --> D3["✅ UI 控制权完全交给调用方"]
E --> E1["✅ 扁平可读"]
E --> E2["✅ 直接操作 state / effect"]
E --> E3["✅ 无额外组件树开销"]
E --> E4["❌ 只能在组件内部使用"]
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 取代了它们?
Hooks 的核心理念是**把状态逻辑从组件中抽离出来,但保持调用处扁平**。它结合了 HOC 和 Render Props 的优点:
| 维度 | 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[]>(); // 数据缓存
const pagination = usePagination(); // 分页管理
return <table>{/* ... */}</table>;
}
```
> [!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 模式]]