Init
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
---
|
||||
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 模式]]
|
||||
Reference in New Issue
Block a user