12 KiB
tags, create time
| tags | 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 就是在输入输出之间插入了一层"调料"。
graph LR
A["原始组件 Component"] -->|"注入 props"| B["HOC 函数"]
B --> C["增强组件 EnhancedComponent"]
C --> D["获得额外能力:日志/权限/数据"]
style B fill:#F5A87D,color:#000
HOC 是一个函数,接收组件作为参数,返回增强后的新组件:
// 基础模式
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 让调试更清晰:
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
// 通用:为组件自动记录生命周期事件
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. 加载态封装
// 自动管理 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 转换
// 将 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?
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 可以让可读性更好:
// 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 通过函数传参直接暴露状态。
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(本身也是函数)替代——语义更自然。
// 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,降低心智负担:
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
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,栈更混乱 | 与普通函数无异 |
// ❌ 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 模式