我不知道的 React(08)— 逻辑复用的三代进化
React 组件是构建 UI 的基本单元,但组件之间如何共享逻辑,一直是一个棘手的问题。不同于工具函数的纯计算复用,React 要复用的往往是"带状态的逻辑"——比如追踪鼠标位置、监听窗口大小、管理表单校验。这类逻辑和组件的生命周期、状态管理深度绑定,没法简单地抽成一个函数。
React 组件是构建 UI 的基本单元,但组件之间如何共享逻辑,一直是一个棘手的问题。不同于工具函数的纯计算复用,React 要复用的往往是”带状态的逻辑”——比如追踪鼠标位置、监听窗口大小、管理表单校验。这类逻辑和组件的生命周期、状态管理深度绑定,没法简单地抽成一个函数。
围绕这个问题,React 社区先后探索出三套方案:高阶组件(HOC)、Render Props、自定义 Hooks。它们不是彼此独立的发明,而是前一代的痛点催生了下一代的设计。用一个贯穿全文的鼠标位置追踪的例子,就能把这条进化脉络看清楚。
一、第一代:HOC——函数包函数
高阶组件的思路来自函数式编程的”高阶函数”。说白了,HOC 就是一个函数,接收一个组件,返回一个新组件。新组件在内部封装了共享逻辑,再把结果通过 props 注入到原组件中。
用鼠标位置追踪来演示。先实现一个 withMouse HOC,它内部管理鼠标坐标,然后把坐标作为 props 传给被包裹的组件:
function withMouse(WrappedComponent) {
return class extends React.Component {
state = { x: 0, y: 0 };
handleMouseMove = (e) => {
this.setState({ x: e.clientX, y: e.clientY });
};
render() {
return (
<div onMouseMove={this.handleMouseMove}>
<WrappedComponent {...this.props} mouse={this.state} />
</div>
);
}
};
}
使用时,任何组件只要被 withMouse 包一层,就自动获得了 mouse 这个 prop:
class PositionDisplay extends React.Component {
render() {
const { mouse } = this.props;
return (
<p>
位置:{mouse.x}, {mouse.y}
</p>
);
}
}
const Enhanced = withMouse(PositionDisplay);
这个方案在 React 16.8 之前非常流行,Redux 的 connect、React Router 的 withRouter 都是典型的 HOC。但随着项目规模增长,三个痛点暴露了出来。
第一个痛点是 Props 命名冲突。 如果一个组件同时被 withMouse 和 withTheme 包裹,两个 HOC 碰巧都往 props 里注入了同名的字段,后者会静默覆盖前者。这种 bug 极难排查,因为覆盖发生在 HOC 内部,组件本身看不到任何异常。
第二个痛点是 Props 来源不透明。 当一个组件的 props 里出现了 mouse、theme、router 等字段,光看组件代码根本不知道它们从哪来。要弄清楚就得一层层翻 HOC 的实现。如果 HOC 还有嵌套,溯源就更困难。
第三个痛点是 Wrapper Hell。 每个 HOC 会在组件树中多包一层,三四个 HOC 叠在一起,React DevTools 里的层级就变成了一串难以辨认的嵌套。调试时定位具体组件的难度直线上升。
二、第二代:Render Props——控制反转
Render Props 的核心思路完全反过来:不是 HOC 往组件里”塞” props,而是逻辑组件把数据通过函数参数”交出来”,由调用方决定怎么渲染。这就是控制反转(Inversion of Control)。
同样的鼠标追踪,用 Render Props 改写后是这样的:
class MouseTracker extends React.Component {
state = { x: 0, y: 0 };
handleMouseMove = (e) => {
this.setState({ x: e.clientX, y: e.clientY });
};
render() {
return <div onMouseMove={this.handleMouseMove}>{this.props.children(this.state)}</div>;
}
}
MouseTracker 负责管理鼠标状态,但它不决定界面长什么样。渲染权交给了调用方通过 children 传入的函数:
function App() {
return (
<MouseTracker>
{(mouse) => (
<p>
位置:{mouse.x}, {mouse.y}
</p>
)}
</MouseTracker>
);
}
对比 HOC 的三个痛点,Render Props 都有改进。数据来源完全透明——就是函数参数里的 mouse,不存在注入魔法。命名由调用方自己定,不可能冲突。组件层级也更浅,DevTools 中只多出一个 MouseTracker 节点。
很多人会忽略的细节是,children 作为函数只是 Render Props 的一种变体。理论上任何 prop 都可以是一个函数,比如 render、renderItem。早期的 React Router v4 和 Formik 就大量采用了这种模式。
但 Render Props 也带来了新的问题。当需要组合多个逻辑时,JSX 的嵌套层级会迅速膨胀:
<MouseTracker>
{(mouse) => (
<WindowSize>
{(size) => (
<ThemeContext>
{(theme) => <MyComponent mouse={mouse} size={size} theme={theme} />}
</ThemeContext>
)}
</WindowSize>
)}
</MouseTracker>
这段代码的嵌套程度不亚于 HOC 的 Wrapper Hell,只是从组件树的嵌套变成了 JSX 代码的嵌套。可读性和可维护性依然堪忧。
三、第三代:自定义 Hooks——终极方案
React 16.8 引入 Hooks 后,逻辑复用找到了迄今为止最优解。同样的鼠标追踪,用自定义 Hook 实现:
function useMousePosition() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e) => {
setPos({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handler);
return () => {
window.removeEventListener('mousemove', handler);
};
}, []);
return pos;
}
使用时,一行调用就够了:
function App() {
const mouse = useMousePosition();
return (
<p>
位置:{mouse.x}, {mouse.y}
</p>
);
}
和前两代方案相比,自定义 Hook 的优势是结构性的。
没有额外的组件层级。 Hook 本身不是组件,不会在组件树中增加任何节点。HOC 的 Wrapper Hell 和 Render Props 的 JSX 嵌套同时消失了。
数据来源完全可追踪。 mouse 就是 useMousePosition() 的返回值,不存在隐式注入,也不存在命名冲突——变量名完全由调用方控制。
组合是天然的。 需要同时追踪鼠标位置和窗口大小?直接在同一个组件里调用两个 Hook:
function Dashboard() {
const mouse = useMousePosition();
const size = useWindowSize();
return (
<div>
{mouse.x} / {size.width}
</div>
);
}
不需要嵌套,不需要包裹,代码像写普通函数调用一样直觉。
如果你只记住一句话:自定义 Hook 把逻辑复用从组件层面拉回到了函数层面,这才是 JavaScript 程序员最熟悉的复用方式。
四、遗留项目:类组件如何接入 Hooks
很多项目中还有大量类组件,而 React 的规则很明确:Hooks 只能在函数组件或其他 Hook 中调用,类组件不行。
原因不是技术限制,而是架构设计。Hooks 依赖稳定的调用顺序来关联状态,这个机制建立在函数组件每次 render 都从头执行的前提上。类组件有自己的实例和 this 上下文,和 Hooks 的状态管理模型不兼容。
但现实中确实存在”类组件需要用 Hook 逻辑”的场景。最常用的过渡方案是 HOC 桥梁——创建一个 HOC,内部用函数组件调用 Hook,再把结果作为 props 传给类组件:
function withMousePosition(WrappedComponent) {
function Wrapper(props) {
const mouse = useMousePosition();
return <WrappedComponent {...props} mouse={mouse} />;
}
Wrapper.displayName = `WithMouse(${WrappedComponent.displayName || WrappedComponent.name})`;
return Wrapper;
}
这个方案管用,但 HOC 的老问题——静态成员丢失(需要 hoist-non-react-statics 处理)、Ref 无法直通(需要 React.forwardRef)——一个都没少。说白了,这只是过渡手段,不是终极方案。如果项目条件允许,把类组件重构成函数组件才是根本解法。
五、三代方案对比
三代方案各自的设计取舍,对比一下就很清楚了。
HOC 的复用单位是”增强后的组件”,通过 props 注入逻辑。优点是对原组件非侵入,缺点是来源不明、命名冲突、层级膨胀。
Render Props 的复用单位是”逻辑组件”,通过函数参数暴露数据。解决了来源和命名问题,但引入了 JSX 嵌套。
自定义 Hooks 的复用单位是”函数”,直接在组件内调用。没有额外层级,没有命名冲突,组合自然。
从 HOC 到 Render Props 再到 Hooks,React 的逻辑复用经历了从”组件包组件”到”函数传函数”再到”函数调函数”的进化。 每一代都在削减上一代引入的间接性。Hooks 之所以能成为终局方案,是因为它让复用回归了最朴素的形式——函数调用。
在新项目中,自定义 Hooks 应该是逻辑复用的首选。但理解 HOC 和 Render Props 并非多余:很多主流库的 API 设计仍然带着这两种模式的影子,读懂它们需要知道背后的设计动机。
本系列其他文章:
- 上一篇:组件通信与自定义 Hooks 实战
- 下一篇:Portals 与事件冒泡的真相
相关主题:
- Hooks 底层机制:useState — 批量更新与 Hook 底层机制
- 核心 Hooks 详解:useEffect 等核心 Hooks 详解