首页 / React 学习笔记 / 26

REACT · Vol.IV · LESSON 26 · 组件设计与工程化

高阶组件与 render props:历史与现状

进阶#组件#模式

HOC:接一个组件,还你一个「强化版」

第 13 篇之后,自定义 Hook 你已用得像呼吸一样自然:useLoading() 一调,加载态逻辑收入囊中。但注意——这套玩法 2019 年(Hooks 诞生)前根本不存在。Class 组件时代,跨组件复用逻辑靠两个「化石模式」:高阶组件(HOC)render props。新代码基本不写它们了,可老代码、老库里遍地都是,读不懂就改不动——这一篇是你的「考古指南」。

先看高阶组件。定义一句话:接收一个组件、返回一个新组件的函数。它就是个普通函数,没有关键字、没有 API,名字抄自「高阶函数」(接收函数、返回函数)。看经典款 withLoading——给任意组件加上「取数 + 加载态」:

withLoading.tsx · 经典 HOC:逻辑住包装层,渲染权归你
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 零侵入。

后端类比:AOP 代理 / 装饰器

withLoading(UserPage) 干的事,和 Spring 给 Bean 织入切面后返回动态代理一模一样:调用方拿到的「还是一个 UserPage」,但真正先干活的是代理——前置逻辑(取数)、条件短路(loading 时不渲染 Wrapped)织入在转发之前,像 @Transactional 在方法外围开事务、提交、回滚。JDK 动态代理、装饰器模式,同一个脑回路的三种实现。

包装地狱:HOC 的四宗罪

包一个 HOC,岁月静好;真实老项目往往是这样:

UserPage.tsx · 老项目里的「三明治」
export default withTheme(
  withAuth(
    withLoading(UserPage)
  )
);

// DevTools 里的组件树,同样一层套一层:
// <WithTheme> → <WithAuth> → <WithLoading> → <UserPage>
// 想看 UserPage 的真实 props?逐层点进去对账

问题从「能用」变成「难养」,集中在四点:

  1. 包装地狱:N 个复用 = 组件树里多 N 层壳。调试时展开一串 With 开头的同名组件,props 穿层透传,谁注入了什么得逐层对账。
  2. props 命名冲突、来源不透明:两个 HOC 都注入 data,后包的静默覆盖前包的;更普遍的是「静态看 JSX 根本看不出会收到哪些 props」——全靠运行时揭晓。下面这行放真实代码里,你几乎不可能一眼看出问题:
conflict.tsx · 静默覆盖:后包的赢
// withUserBrief 和 withUserDetail 都往里注入 user……
const Enhanced = withUserBrief(withUserDetail(UserPage));
// UserPage 拿到的是哪一个?编译器不报错,测试不一定踩到,线上见真章
  1. TypeScript 类型难写P & LoadingProps 这类交叉类型层层叠加,套到三层泛型推断常失效,大家默默 as any——类型系统形同虚设。
  2. ref 转发要额外手续:ref 不是普通 prop,包装层会拦下它,必须用 forwardRef 显式转发,每个 HOC 都得补这一课(React 19 起 ref 可当普通 prop 直传,但老代码里到处是 forwardRef 补丁)。
后端类比:五层过滤器链

一个请求穿过五层 Filter/拦截器,排错时堆栈半屏都是代理类名;两个切面往同一个 request attribute 里塞同名值,只有运行时才发现。HOC 的痛点与之同源:包装是运行时才展开的黑盒,编译期几乎不提供信息。

平心而论,HOC 并非一无是处——React Redux 的 connect、React Router v5 的 withRouter 靠它服务了一整个时代。它的错不在模式本身,而在「为复用一段逻辑,不惜改变组件树结构」:逻辑是垂直切片,包装是水平叠加,维度对不上。

render props:把「渲染什么」也参数化

HOC 是「从外面包」,同时代的另一条路是「从里面回调」:组件不替你决定 UI,而是把状态当参数,交给你传进来的函数去渲染。最常见的形式是 children 传函数——children 本就可以是任何可渲染的东西,函数返回值当然也行:

MouseTracker.tsx · render props 经典款:children as function
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 的 RowMapper

jdbcTemplate.query(sql, rs -> mapToUser(rs))——连接获取、异常翻译、资源释放是 JdbcTemplate 的固定骨架,你只注入「一行结果怎么变成对象」的回调。render props 同构:组件持骨架(状态怎么获取),你注入「状态 → UI」的映射函数。就是你写熟的「模板方法 + 回调」,只是回调返回值是 UI。

render props 解决了 HOC 的命名冲突与包装层(不再多套组件),但自己也有雷:传入的函数每次渲染都是新引用,子组件一套 memo 就被击穿优化(第 37 篇);多个 render props 嵌套时 JSX 缩进层层右移——前端版回调地狱。

Hook 登场:把「包一层」拆成「调一次」

2019 年 Hooks 发布,问题被釜底抽薪。复盘一下:HOC 和 render props 之所以要动组件树(包一层 / 回调一层),是因为当时 state 只能住在组件实例里——想共享逻辑,就得先造一个「装它的组件」。Hook 取消了这个前提:逻辑以函数调用长进组件体内,state 直接 return 出来

useLoading.ts · withLoading 的等价 Hook 迁移
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 到你的组件里,来源、去向、干预入口都摆在明面上

HOC:复用一次,包一层 自定义 Hook:复用即调用 WithTheme WithAuth WithLoading UserPage DevTools 里四层壳,props 穿层对账 UserPage useLoading() useAuth() 树里只有一层,逻辑平铺进组件体,来源去向都在明面上
图 1HOC 每复用一次就多一层壳;自定义 Hook 把逻辑平铺进组件体内,组件树纹丝不动。

把「取数 + 缓存 + 失效重试」做成 Hook 的集大成者,是第 33 篇的 TanStack Query——useQuery 本质就是一台 withLoading 增强机;老项目里把 connect(第 19 篇 Redux)当 HOC 用的存量代码,也在本篇考古范围内。

三者对账:读懂老代码,新代码用 Hook

最后把三个模式放进同一张表:

维度HOCrender 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 · 评论