Files
cs-note/hhs/REACT/4. 进阶篇/12-HOC 与 Render Props.md
T

341 lines
12 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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 模式]]