从类包装器到函数与钩子的更高阶组件
了解 React 高阶组件的工作原理,同一個日志记录器 HOC 在类和函数形式下的差异,以及为什么自定义钩子取代了大多数 HOC 的应用场景。
来源: React 27: Higher Order Components (HOC) — Deep Dive (Class vs Functional Components)
在自定义钩子出现之前,React 开发者就需要一种方法来在多个组件之间共享诸如日志记录、身份验证检查或数据加载等功能。解决方案就是高阶组件(HOC):一种接收组件并为其生成增强版包装器的普通函数。下面将展示同一个用于日志记录的 HOC 以类和函数两种形式实现,然后将其与自定义钩子进行比较。
为何在类时代会出现 HOC
在大多数 React 代码仍以类形式编写时,逻辑复用十分不便。试想 Profile、Dashboard 和 Settings 这些组件都需要在挂载和更新时记录日志、验证用户是否已登录、加载数据以及处理错误。由于缺乏共享的抽象层,每个类都不得不重复编写相同的构造函数、状态初始化代码、生命周期方法以及辅助方法。高阶组件通过将一个类包裹在另一个拥有共享生命周期逻辑的类中,解决了这一问题。
高阶组件并非 React 的特殊 API,只不过是应用于组件的函数组合而已。
用类构建的日志记录高阶组件
编写包装器
以下高阶组件会返回一个新类,该类会在被包装的组件挂载及每次更新时记录日志,随后再将所有传入的属性原封不动地传递给原始组件以进行渲染:
import React, { Component } from "react";
export function withRenderLogger(WrappedComponent) {
return class extends Component {
componentDidMount() {
console.log(`${WrappedComponent.name} mounted`);
}
componentDidUpdate() {
console.log(`${WrappedComponent.name} updated`);
}
render() {
return <WrappedComponent {...this.props} />;
}
};
}
该包装器负责管理生命周期方法,并原封不动地传递 this.props,因此原始组件中不包含任何日志记录代码。
应用方式
应导出经过包装的版本,而非原始类:
import React, { Component } from "react";
import { withRenderLogger } from "./withRenderLogger";
class Profile extends Component {
render() {
return <h2>Profile Component</h2>;
}
}
export default withRenderLogger(Profile);
现在渲染 Profile 时会先渲染包装器,再由包装器渲染真正的 Profile。返回的类是匿名类,因此通过 withRenderLogger(Profile) 这样的方式设置 displayName 可以让 React DevTools 的显示更清晰。
基于类的 HOC 的弊端
虽然这种方法可行,但存在诸多问题:
- 嵌套的类定义以及
this的绑定问题 - 逻辑分散在各个生命周期方法中
- 组件结构更为复杂,调试时需要逐层查看包装器
- 当多个 HOC 注入同名属性时会导致属性名冲突
函数组件中的相同原理
在没有类、this或生命周期方法的情况下,HOC就简化为返回一个函数组件的函数:
function HOC(WrappedComponent) {
return function Enhanced(props) {
return <WrappedComponent {...props} />;
};
}
以下是以该风格重写的日志记录器:
export function withLogger(WrappedComponent) {
return function Enhanced(props) {
console.log(`${WrappedComponent.name} rendered`);
return <WrappedComponent {...props} />;
};
}
使用方式与类版本完全相同:
import React from "react";
import { withLogger } from "./withLogger";
function Dashboard() {
return <h2>Dashboard Component</h2>;
}
export default withLogger(Dashboard);
它会在每次渲染时都进行日志记录,但作为渲染过程中的副作用。React的实际渲染次数可能多于状态更新的次数,而且在严格模式下开发环境会进行两次渲染,因此可能会出现重复日志;如需进行非简单调试的操作,请使用useEffect。
一个小型演示应用
一个按钮可在基于类的 Profile 和基于函数的 Dashboard 之间切换,这样两种高阶组件都能记录日志。通过渲染而非抛出错误来驱动演示,可在快速刷新模式下保持其可预测性:
import React, { useState } from "react";
import Profile from "./Profile";
import Dashboard from "./Dashboard";
export default function App() {
const [toggle, setToggle] = useState(true);
return (
<div style={{ padding: 20 }}>
<h1>HOC Demo</h1>
<button onClick={() => setToggle(!toggle)}>
Re-render Components
</button>
{toggle ? <Profile /> : <Dashboard />}
</div>
);
}
Profile.js 使用了类形式的高阶组件:
import React, { Component } from "react";
import { withRenderLogger } from "./withRenderLogger";
class Profile extends Component {
render() {
return <h2>Profile Component</h2>;
}
}
export default withRenderLogger(Profile);
Dashboard.js 使用了函数形式的高阶组件:
import React from "react";
import { withLogger } from "./withLogger";
function Dashboard() {
return <h2>Dashboard Component</h2>;
}
export default withLogger(Dashboard);
类形式的高阶组件模块:
import React, { Component } from "react";
export function withRenderLogger(WrappedComponent) {
return class extends Component {
componentDidMount() {
console.log(`${WrappedComponent.name} mounted`);
}
componentDidUpdate() {
console.log(`${WrappedComponent.name} updated`);
}
render() {
return <WrappedComponent {...this.props} />;
}
};
}
函数形式的高阶组件模块:
export function withLogger(WrappedComponent) {
return function Enhanced(props) {
console.log(`${WrappedComponent.name} rendered`);
return <WrappedComponent {...props} />;
};
}
这些文件可在任何标准的 React 环境中运行;使用 Vite 时,JSX 文件的扩展名应为 .jsx。
为何自定义钩子取代了大多数高阶组件
现在的自定义钩子已能覆盖大部分原先由高阶组件实现的功能:加载远程数据、身份验证检查、跟踪、DOM 监听器以及订阅操作。日志记录功能也变成了一个钩子:
function useLogger(name) {
useEffect(() => {
console.log(`${name} rendered`);
});
}
function Dashboard() {
useLogger("Dashboard");
return <h2>Dashboard Component</h2>;
}
自定义钩子不会添加额外的包装层,而是通过返回值来替代属性注入,并且能够避免组件之间的冲突。由于没有依赖数组,相关效果会在每次代码提交后执行。
高阶组件仍然适用于条件性包装(例如显示登录界面而非目标组件)、具备高阶组件接口的库以及基于类的代码;React.memo的结构也与此类似。应在模块层面应用高阶组件:如果在渲染时创建包装组件,会导致子树重新挂载并丢失其状态。
关键要点
- 高阶组件会包裹一个组件,并返回其增强后的版本。
- 由于手动实现生命周期逻辑会非常重复,类组件依赖高阶组件来共享这些逻辑。
- 对于函数组件而言,高阶组件仅仅是一个返回函数组件的函数。
- 如今,自定义钩子是复用逻辑的首选;而高阶组件则仍用于组件包装和条件渲染。
displayName,避免在渲染过程中产生副作用,且绝不在渲染时创建高阶组件。