首页 / 文章

从类包装器到函数与钩子的更高阶组件

了解 React 高阶组件的工作原理,同一個日志记录器 HOC 在类和函数形式下的差异,以及为什么自定义钩子取代了大多数 HOC 的应用场景。

发布者:REACTAPP.TOP阅读约 5 分钟1092 词

来源: 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 注入同名属性时会导致属性名冲突
  • HOC与被包装组件的props之间存在隐式耦合
  • 函数组件中的相同原理

    在没有类、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,避免在渲染过程中产生副作用,且绝不在渲染时创建高阶组件。