高阶组件还有人用吗?我翻了几个流行库的源码,发现 Render Props 也没死透
React 社区里有一个流传很广的说法:Hooks 之后,高阶组件和 Render Props 都该进博物馆了。但如果你真的去翻 2024 年还在活跃维护的流行库源码,会发现这两个“遗产 API”不仅没死,在某些场景下甚至是唯一合理的解法。
我最近正好在给团队的设计系统做性能审计,顺手翻了十几个 npm 周下载量过百万的库,结论比想象的更有意思。
先说高阶组件(HOC)。React Redux 的 connect 在 v8 之后确实被 useSelector 全面替代了,React Router 也早就从 withRouter 转向了 hooks。但如果你去看 react-beautiful-dnd(虽然不再维护,但思想被大量 fork 继承)和 react-window 这类库,HOC 依然活得好好的。react-window 的 FixedSizeList 本质上就是一个返回增强组件的工厂函数——它需要接管子组件的渲染生命周期,在虚拟滚动的每一帧里精确控制哪些元素该挂载、哪些该卸载。这种对渲染管线的深度介入,hooks 做不到。useMemo 和 useCallback 只能影响当前组件,无法阻止 React 对子树的 reconciliation 过程。而 HOC 通过包裹目标组件,可以在 shouldComponentUpdate(类组件)或 React.memo 的对比阶段就截断整棵子树的重渲染,这是 hooks 完全触碰不到的层级。
至于 Render Props,翻 downshift 的源码是个很好的例子。这个库在 v6 引入了 useCombobox hook,但同时保留了 Render Props 版本的 Downshift 组件。Kent C. Dodds 在 PR 讨论里解释得很清楚:Render Props 给了调用方在渲染阶段动态决定 DOM 结构的自由,而 hooks 强制你把逻辑提升到父组件,这会让某些复杂组合场景下的 props drilling 变得很丑陋。具体来说,当你有一个组合框,它的下拉菜单需要根据输入值动态渲染完全不同的子组件树时,Render Props 的 children 函数可以直接拿到 { isOpen, highlightedIndex, getItemProps } 这些上下文,在 JSX 内部做分支判断。用 hooks 实现同样的效果,你得把这些状态提升,通过 props 下传,或者用 Context 包一层——前者污染了父组件的关注点,后者在频繁变化的状态下会导致不必要的重渲染。
还有一个被忽视的点:类型推断。TypeScript 下,HOC 的泛型推导链在某些场景下比 hooks 更精准。举个实际例子,react-i18next 的 withTranslation HOC 可以自动从组件 props 中提取 namespace 类型并注入 t 函数的类型签名,这样你的组件内部调用 t('key') 时 IDE 能给出精确的自动补全。而 useTranslation hook 虽然也能做到,但需要手动声明泛型参数,在跨多层组件传递时类型很容易丢失。我去年在一个多语言项目中踩过这个坑,最后不得不为 hooks 版本写了额外的类型包装工具函数,而 HOC 版本开箱即用。
但这不意味着你该在新项目里大量写 HOC 和 Render Props。它们解决的是特定问题,而且代价明显:
HOC 的静态组合问题。多个 HOC 嵌套时,组件层级会变成 withA(withB(withC(MyComponent))),DevTools 里看到的是层层包裹的匿名组件,调试体验灾难。compose 函数能缓解语法上的丑陋,但运行时层级不会消失。更致命的是,如果两个 HOC 注入了同名的 prop,后者会静默覆盖前者,TypeScript 在 HOC 的类型交叉上也经常罢工——Omit<InjectedProps & OriginalProps, keyof InjectedProps> 这种类型体操在复杂组合下很容易变成 never 推导。
Render Props 的回调地狱。当你的组件既需要响应式数据又需要渲染控制时,很容易写成 <Query>{data => <Mutation>{mutate => <Form onSubmit={mutate} data={data} />}</Mutation>}</Query>。这种嵌套在 2018 年就被诟病,现在有了 hooks 确实没必要忍受。@apollo/client 在 v3 全面转向 hooks 后,Render Props 版本虽然还保留在 @apollo/client/react/components 里,但文档已经明确标注为 legacy API。
性能陷阱。Render Props 的 children 作为函数,每次父组件渲染都会创建新的函数引用。如果接收这个函数的子组件没有做 React.memo 优化,会导致子树全部重渲染。我在 react-table v7 的迁移指南里看到过明确的警告:用 hooks 版本的行选择 API 比 Render Props 版本减少了约 30% 的重渲染次数,因为状态可以直接在组件内部用 useRef 稳定引用,而不必通过闭包传递。
那么,什么时候你仍然应该选择它们?我总结了一个决策树,基于我实际遇到过的场景:
-
需要拦截或修改组件的渲染输出:用 HOC。比如给所有表格行添加一个测量 ref 用于虚拟滚动,或者在 React 渲染前注入额外的 context provider。
react-virtuoso就是这么做的——它的GroupedVirtuoso组件通过 HOC 包装用户提供的ItemContent,在渲染层自动注入groupIndex和withinGroupIndex。 -
需要在渲染阶段根据运行时状态动态切换子组件类型:用 Render Props。典型的如
react-routerv5 的<Route>组件,children函数接收match参数让你决定渲染什么。v6 虽然用elementprop 替代了这个模式,但如果你需要基于路由匹配做复杂的布局切换(比如匹配成功渲染侧边栏,失败渲染全屏),Render Props 仍然比useMatch+ 条件渲染更内聚。 -
其他所有场景:用 hooks。状态逻辑复用、副作用管理、订阅外部数据源——这些 hooks 的主场优势不可撼动。
react-query、zustand、jotai这些新生代库清一色 hooks-first 不是没道理的。
有趣的是,我观察到一股“反向迁移”的趋势。react-hook-form 在 v7 引入了 <FormProvider> 和 useFormContext,本质上是把 hooks 的状态通过 Context 下放,让深层子组件可以不用 props drilling 就访问表单实例。这个模式和 Render Props 解决的问题异曲同工,但实现方式更符合现代 React 的并发特性预期。同样,@tanstack/react-table v8 的 flexRender 工具函数,让你可以在 JSX 里动态调用单元格的渲染函数——这其实就是 Render Props 的平替,但以更细粒度的 API 形式出现,避免了整棵树的重渲染问题。
所以回到标题的问题:高阶组件还有人用吗?有,而且集中在需要深度控制渲染管线的场景。Render Props 死透了吗?没有,它在需要运行时动态组合子树的场景下仍然是最简洁的方案。但它们不再是默认选择了——这恰恰是好事。API 的“退居二线”说明 React 生态成熟了,我们知道什么工具该用在什么地方,而不是手里有锤子看什么都像钉子。
常见问题
我该在新项目中禁止使用 HOC 吗?
不应该一刀切。如果你的项目没有虚拟滚动、渲染劫持、或需要大量类型推断的国际化场景,你很可能根本不需要写 HOC。但如果你的设计系统需要给用户提供的组件自动注入测量逻辑或动画上下文,HOC 是比 hooks + Context 更少副作用的方案。原则是:默认用 hooks,只在它触及不到的地方考虑替代方案。
Render Props 和内联函数导致的性能问题有多大?
取决于子树的大小和更新频率。如果一个 Render Props 组件每秒渲染 60 帧(比如动画中的拖拽跟随),内联函数每次创建新引用会让下游的 React.memo 失效,导致整棵树跟着重渲染。在低频场景(如表单输入、菜单展开)下这个开销通常可以忽略。真遇到瓶颈,可以用 useCallback 稳定函数引用,但这样就失去了 Render Props 在渲染阶段动态决策的核心优势——你需要权衡。
为什么不用 Context + hooks 完全替代 Render Props?
Context 的值变化会导致所有消费者重渲染,即使你的组件只用了 Context 中的一个字段。React 19 的 use(Context) 和选择性订阅还没完全普及之前,用 Context 传递高频变化的状态(比如拖拽坐标、输入框光标位置)会造成严重的性能浪费。Render Props 通过作用域插槽让变化只影响包裹的函数子树,粒度更可控。