REACT · Vol.IV · LESSON 26 · 组件设计与工程化
高阶组件与 render props:历史与现状
HOC:接一个组件,还你一个「强化版」
第 13 篇之后,自定义 Hook 你已用得像呼吸一样自然:useLoading() 一调,加载态逻辑收入囊中。但注意——这套玩法 2019 年(Hooks 诞生)前根本不存在。Class 组件时代,跨组件复用逻辑靠两个「化石模式」:高阶组件(HOC)与 render props。新代码基本不写它们了,可老代码、老库里遍地都是,读不懂就改不动——这一篇是你的「考古指南」。
先看高阶组件。定义一句话:接收一个组件、返回一个新组件的函数。它就是个普通函数,没有关键字、没有 API,名字抄自「高阶函数」(接收函数、返回函数)。看经典款 withLoading——给任意组件加上「取数 + 加载态」:
import { useEffect, useState, type ComponentType } from "react";
// HOC 注入给被包装组件的额外 props
interface LoadingProps {
loading: boolean;
data: unknown;
reload: () => void;
}
// 泛型 P:保留原组件自己的 props,包装不弄丢它们
// fetchUsers 是项目里已封装好的请求函数(略)
function withLoading<P extends object>(
Wrapped: ComponentType<P & LoadingProps>
) {
// 返回的新组件:先干「重复的逻辑」,再把 props 原样转发
return function WithLoading(props: P) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<unknown>(null);
const reload = () =>
fetchUsers().then((d) => { setData(d); setLoading(false); });
useEffect(() => { reload(); }, []);
if (loading) return <div>加载中…</div>; // 逻辑:包装层接管
return <Wrapped {...props} loading={false} data={data} reload={reload} />;
};
}
// 使用:一行包装,UserPage 就「多」了取数与加载态
const UserPageWithLoading = withLoading(UserPage);
结构拆开看:重复的逻辑(取数、loading 状态、加载页)住在包装层,被包装组件只管「有数据之后怎么渲染」。任何页面组件塞进 withLoading,立刻获得同款能力——复用发生了,而且对 Wrapped 零侵入。
withLoading(UserPage) 干的事,和 Spring 给 Bean 织入切面后返回动态代理一模一样:调用方拿到的「还是一个 UserPage」,但真正先干活的是代理——前置逻辑(取数)、条件短路(loading 时不渲染 Wrapped)织入在转发之前,像 @Transactional 在方法外围开事务、提交、回滚。JDK 动态代理、装饰器模式,同一个脑回路的三种实现。
包装地狱:HOC 的四宗罪
包一个 HOC,岁月静好;真实老项目往往是这样:
export default withTheme(
withAuth(
withLoading(UserPage)
)
);
// DevTools 里的组件树,同样一层套一层:
// <WithTheme> → <WithAuth> → <WithLoading> → <UserPage>
// 想看 UserPage 的真实 props?逐层点进去对账
问题从「能用」变成「难养」,集中在四点:
- 包装地狱:N 个复用 = 组件树里多 N 层壳。调试时展开一串 With 开头的同名组件,props 穿层透传,谁注入了什么得逐层对账。
- props 命名冲突、来源不透明:两个 HOC 都注入
data,后包的静默覆盖前包的;更普遍的是「静态看 JSX 根本看不出会收到哪些 props」——全靠运行时揭晓。下面这行放真实代码里,你几乎不可能一眼看出问题:
// withUserBrief 和 withUserDetail 都往里注入 user……
const Enhanced = withUserBrief(withUserDetail(UserPage));
// UserPage 拿到的是哪一个?编译器不报错,测试不一定踩到,线上见真章
- TypeScript 类型难写:
P & LoadingProps这类交叉类型层层叠加,套到三层泛型推断常失效,大家默默as any——类型系统形同虚设。 - ref 转发要额外手续:ref 不是普通 prop,包装层会拦下它,必须用
forwardRef显式转发,每个 HOC 都得补这一课(React 19 起 ref 可当普通 prop 直传,但老代码里到处是 forwardRef 补丁)。
一个请求穿过五层 Filter/拦截器,排错时堆栈半屏都是代理类名;两个切面往同一个 request attribute 里塞同名值,只有运行时才发现。HOC 的痛点与之同源:包装是运行时才展开的黑盒,编译期几乎不提供信息。
render props:把「渲染什么」也参数化
HOC 是「从外面包」,同时代的另一条路是「从里面回调」:组件不替你决定 UI,而是把状态当参数,交给你传进来的函数去渲染。最常见的形式是 children 传函数——children 本就可以是任何可渲染的东西,函数返回值当然也行:
import { useState, type ReactNode } from "react";
interface MousePos {
x: number;
y: number;
}
// children 的类型是函数:接收状态,返回要渲染的 UI
function MouseTracker({
children,
}: {
children: (pos: MousePos) => ReactNode;
}) {
const [pos, setPos] = useState<MousePos>({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
{children(pos)} // 渲染时调用你,把状态递出来
</div>
);
}
// 使用:状态怎么变成 UI,由你说了算
<MouseTracker>
{({ x, y }) => <h1>鼠标位置:{x}, {y}</h1>}
</MouseTracker>
分工一目了然:MouseTracker 管「状态从哪来」(监听鼠标),你管「状态变成什么 UI」。逻辑被复用,UI 完全开放——「逻辑与视图分离」的另一种刀法。React Router v5 的 <Route render={...}>、一些 headless 组件库都是这个模式。
jdbcTemplate.query(sql, rs -> mapToUser(rs))——连接获取、异常翻译、资源释放是 JdbcTemplate 的固定骨架,你只注入「一行结果怎么变成对象」的回调。render props 同构:组件持骨架(状态怎么获取),你注入「状态 → UI」的映射函数。就是你写熟的「模板方法 + 回调」,只是回调返回值是 UI。
Hook 登场:把「包一层」拆成「调一次」
2019 年 Hooks 发布,问题被釜底抽薪。复盘一下:HOC 和 render props 之所以要动组件树(包一层 / 回调一层),是因为当时 state 只能住在组件实例里——想共享逻辑,就得先造一个「装它的组件」。Hook 取消了这个前提:逻辑以函数调用长进组件体内,state 直接 return 出来:
import { useEffect, useState } from "react";
function useLoading<T>(fetcher: () => Promise<T>) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<T | null>(null);
const [tick, setTick] = useState(0); // reload 的计数器:+1 触发重取
useEffect(() => {
let alive = true; // 防竞态:卸载后不再 setData(第 34 篇细讲)
setLoading(true);
fetcher().then((d) => {
if (alive) { setData(d); setLoading(false); }
});
return () => { alive = false; };
// fetcher 不进依赖:约定由调用方保证稳定(第 11 篇 useCallback)
}, [tick]);
return { loading, data, reload: () => setTick((t) => t + 1) };
}
// 使用:不包一层,DevTools 里干干净净(UserList 组件略)
function UserPage() {
const { loading, data, reload } = useLoading(fetchUsers);
if (loading) return <div>加载中…</div>;
return <UserList data={data} onRefresh={reload} />;
}
与 HOC 版逐项对账,四宗罪全部销案:
- 组件树零污染:没有 WithLoading 壳,DevTools 里只有 UserPage。
- 命名自己做主:返回值解构,叫
data还是users随你,冲突无从谈起。 - 类型自然成立:普通函数调用,泛型
<T>顺着参数推断,不用手写交叉类型。 - ref 无需转发:Hook 就跑在组件体内,没有「层」,也就没有「转发」。
还有个不太显眼但本质的差别——状态的归属。HOC 的 loading 住在包装组件实例里,调用方看不见也够不着,想手动干预就得让 HOC 再开 props 接口,接口一多又回到命名冲突。Hook 的状态 return 到你的组件里,来源、去向、干预入口都摆在明面上。
把「取数 + 缓存 + 失效重试」做成 Hook 的集大成者,是第 33 篇的 TanStack Query——useQuery 本质就是一台 withLoading 增强机;老项目里把 connect(第 19 篇 Redux)当 HOC 用的存量代码,也在本篇考古范围内。
三者对账:读懂老代码,新代码用 Hook
最后把三个模式放进同一张表:
| 维度 | HOC | render props | 自定义 Hook |
|---|---|---|---|
| 复用形式 | 包一层组件 | 传一个函数 prop | 函数调用 |
| 组件树代价 | +N 层包装壳 | 不加壳,但 JSX 嵌套加深 | 零 |
| 状态归属 | 包装层实例里 | 数据源组件里 | 调用方组件自己 |
| 命名冲突 | 有(props 注入撞名) | 无 | 无(解构随意命名) |
| TS 类型 | 难(泛型交叉层层叠) | 中等 | 易(普通函数推断) |
| ref | 需 forwardRef 转发 | 正常 | 不涉及 |
| 今天的定位 | 读老代码(connect 时代) | 少量 headless 组件仍在用 | 默认答案 |
「读懂老代码要会,新代码用 Hook」之外,补两个现实注脚:其一,HOC 没死绝——React Redux 的 connect 至今仍是 HOC,存量 Redux 项目(第 19 篇)绕不开;其二,render props 在 headless 组件(只给逻辑不给 UI)里仍有市场,遇到「children 是函数」的库,知道是模式而非炫技就行。
- HOC = 接收组件、返回新组件的函数:逻辑住包装层,渲染权留给被包装组件——AOP 代理的 React 版。
- HOC 四宗罪:包装地狱、props 撞名且来源不透明、TS 类型难写、ref 要转发。
- render props:把「状态 → UI」的映射函数传进组件,JdbcTemplate 回调的 React 版;代价是 memo 击穿与嵌套缩进。
- Hook 釜底抽薪:state 不必再住在组件实例里,函数调用即复用;组件树零污染、命名自主、类型自然。
- 结论:老代码里的 HOC / render props 要读得懂,新代码的逻辑复用一律先想自定义 Hook。
本章回顾
从 HOC 的「包一层」、render props 的「回调一层」,到 Hook 的「调一次」,三次进化回答的是同一个问题:复用的单位到底是组件还是函数。Hooks 用「函数」终结了争论,也让组件重新纯粹——每个组件只负责一件事。可页面越写越大,「一件事」的边界在哪?哪些该合、哪些该拆、状态和逻辑安放在哪一层?下一篇第 27 篇,组件拆分与职责边界,给组件做一次「架构设计」。
Comments · 评论