React 模式之高阶组件

823 阅读6分钟

高阶组件是一种用于代码逻辑复用的 React 模式,学习高阶组件,你可以:

  • 写出更高效更合理的 React 代码。
  • 理解第三方库中的高阶组件用法及其背后原理。

1. 什么是高阶组件

高阶组件 (Higher-Order Component, HOC) 是一种在 React 上实现代码复用的技术。它不是 React 的 API,而是一种编程模式。

具体点,HOC 是一个 Javascript 函数,它接收一个组件,并返回一个新的组件。

const EnhancedComponent = HOC(WrappedComponent);

HOC 对传入的组件进一步封装,添加特定的逻辑,形成新的组件并返回。

下面是一个最简单的 HOC,它不添加任何逻辑,返回一个与原组件相同的组件。

function hoc(WrappedComponent) {
  const EnhancedComponent = (props) => {
    // 这里添加你想要的逻辑
    return <WrappedComponent {...props} />;
  };

  return EnhancedComponent;
}

“高阶组件”这个名称,衍生自函数式编程中的高阶函数(Higher-Order Function,HOF)。高阶函数指的是以函数为参数或者返回值为函数的函数,即可以处理函数的函数。JavaScript 中的 Array.mapArray.reduce 都属于高阶函数。

HOC 与普通组件不同,没有返回一段用于渲染的 React 元素,而是把一个组件转换成另一个组件。从这点看,很难把它理解为“组件”,我倾向于理解成“组件转换函数”。

另一个理解的角度是,把函数调用视为“降阶”,高阶组件经过一次或多次降阶(调用),就可以得到一阶组件(普通组件)。

2. HOC 能做什么

HOC 是一个 JavaScript 函数,理论上能通过函数做到的,都能实现。在实际使用场景中,运用最多有:

  • 引入副作用。
  • 增加状态。
  • props 代理。
  • 控制渲染。

一个 HOC 可以同时做到以上的几点,和一些没有提及的功能。

2.1. 添加副作用

EnhancedComponent 中使用 useEffect,可以在原组件的基础上增加副作用。

这个 HOC 返回的组件会在每次渲染时打印当前的 props。

function logProps(WrappedComponent) {

  const EnhancedComponent = (props) => {
    useEffect(() => {
      console.log('本次渲染的 props:'+ props)
    });

    return <WrappedComponent {...props} />;
  };

  return EnhancedComponent;

2.2. 添加状态

类似地,使用 useState 可以为组件注入状态。

这个例子中,EnhancedComponent 可以监听 window.globalConfig 变量,当它变化时,在 1s 内刷新组件:

function SyncConfig(WrappedComponent) {

  const EnhancedComponent = (props) => {
    const [config, setConfig] = useState(window.globalConfig);
    useEffect(() => {
      const timer=setInterval(() => {
        if(window.globalConfig!==config)
          setConfig(window.globalConfig)
      }, 1000);

      return ()=>clearInterval(timer)
    }, []);

    return <WrappedComponent {...props} />;
  };

  return EnhancedComponent;

2.3. 代理 props

上面的例子中,都是用{...props}把组件 props 原封不动传递给原组件。不过,经常地,HOC 会修改一部分 props,比如:

  • 注入一些额外的 props。
  • 拦截特定的 props。
  • 对 props 进行某些转换或,再传递给原组件。

上面的 SyncConfig 要求 WrappedComponent 依赖于 window.globalConfig,这种设计并不合理,应该尽量让组件的依赖作为 props,我们来重写它,让 globalConfig 通过 props 传递给原组件。

function syncConfig(WrappedComponent) {

  const EnhancedComponent = (props) => {
    const [config, setConfig] = useState(window.globalConfig);
    useEffect(() => {
      const timer=setInterval(() => {
        if(window.globalConfig!==config)
          setConfig(window.globalConfig)
      }, 1000);

      return ()=>clearInterval(timer)
    }, []);

    return <WrappedComponent {...props} config={config} />;
  };

  return EnhancedComponent;

React Router v5 中的 withRouter 函数就是一个 HOC,它会给原组件传递 router 的 locationmatchhistory 对象。

2.4. 控制渲染

EnhancedComponent 也可以在原组件的基础上,增加自己的渲染逻辑。

function withLoad(WrappedComponent) {
  const EnhancedComponent = (props) => {
    // useLoad 是一个封装了异步网络请求的hook
    const { loading, data } = useLoad({
      host:"https://example.com"
      path: "/api/path",
      method: "get",
    });

    if (loading) return <div>Loading ...</div>;

    return <WrappedComponent {...props} data={data} />;
  };

  return EnhancedComponent;
}

上面的例子会在请求 loading 的时候返回一个加载态 UI。

2.5. 可配置的 HOC

HOC 本质上是 JavaScript 函数,可以利用函数实现更多的功能。

比如,可以给函数传递额外的参数,作为 HOC 的配置。上面的 withLoad 中,请求是固定的,其实很难被复用。可以把请求参数作为 withLoad 的参数,在创建的时候传入。

function withLoad(WrappedComponent, loadConfig) {
  const EnhancedComponent = (props) => {
    const { loading, data } = useLoad(loadConfig);

    if (loading) return <div>Loading ...</div>;

    return <WrappedComponent {...props} data={data} />;
  };

  return EnhancedComponent;
}

// 使用
const CardWithLoad = withLoad(Card, {
      host:"https://example.com"
      path: "/api/path",
      method: "get",
    });

完美,但又不完全完美。虽然每次请求的 pathmethod 都是不一样的,但在一个项目中 host 大概率是不变的。为了解决这个问题,我们可以向更高阶转换。

function createLoad(host) {
  const withLoad = (WrappedComponent, loadConfig) => {
    const EnhancedComponent = (props) => {
      const { loading, data } = useLoad({ host, ...loadConfig });

      if (loading) return <div>Loading ...</div>;

      return <WrappedComponent {...props} data={data} />;
    };

    return EnhancedComponent;
  };
  return withLoad;
}

// 使用
const withLoad = createLoad("https://example.com");
const CardWithLoad = withLoad({ path: "/api/path", method: "get" });

3. 什么时候应该用 HOC

HOC 能够抽离出组件内的逻辑,并在需要的时候,把这个逻辑添加到组件中。而且,HOC 有很强的普适性,能应用到各类组件中。所以,HOC 适合用于抽离那些普遍存在于多个组件中的相同逻辑。

React 官方文档说 HOC 适合用于解决横切面关注点(Cross-Cutting Concern)问题。不过这个词有点抽象了,让人难以理解。不过,里面举的例子对 HOC 的使用场景很有启发,值得好好看看。

4. HOC 和其他模式的比较

4.1. HOC vs Hooks

HOC 的诞生早于 hooks。HOC 在 React 推出就存在了,hooks 在 16.8 才推出。

Hooks 也可以实现组件逻辑的抽离和复用,并且更容易编写和理解。 hooks 推出之后,在多场景下替代了 HOC,开发者更纷纷拥抱 hooks,而渐渐淡忘了 HOC。比如上面提到的 withRouter,已经被 useLocation 等 hooks 取代,并于 React Router v6 版本废弃。

那我们还需要 HOC 吗?答案是肯定的,hooks 并不能完全取代 HOC,主要有以下两点:

  • Hooks 不能完全覆盖组件生命周期函数的作用,HOC 可以。在 HOC 中,把 EnhancedComponent 定义成 class 组件,可以使用 shouldComponentUpdatecomponentDidCatch 等 hooks 不支持的生命周期函数。
  • HOC 不会污染原组件。Hooks 直接在原组件内使用,HOC 则是在原组件的基础上创建一个其他版本的组件,原组件不受任何影响。

当然,在 hooks 可用的时候,应该优先使用 hooks。

4.2. HOC vs 组合

一个组件使用了另一个组件,这种模式就叫组合(composition)。组合是 React 最基础的模式,组件就是通过组合构建起 UI 的。

在 HOC 中,EnhancedComponent 使用了 WrappedComponent, 所以 HOC 也是一种组合模式。一些 HOC 也能通过组合模式实现。

这个例子和上面的 syncConfig 效果一样:

function SyncConfig(props) {
  const [config, setConfig] = useState(window.globalConfig);
  useEffect(() => {
    const timer = setInterval(() => {
      if (window.globalConfig !== config) setConfig(window.globalConfig);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return props.children;
}

// 使用
<SyncConfig>
  <Child />
</SyncConfig>;

不过,在父组件中修改子组件非常不方便,远远不如 HOC 灵活。所以,这种组合方式适用于 UI 构建,在逻辑复用上很有限。

5. 一些需要注意的点

HOC 返回的是一个包裹着原组件的新组件,在使用时需要注意:

  • 在最顶层作用域调用 HOC。不要在组件 render 函数内调用 HOC,因为每次返回的都是新组件,会导致组件状态复位和不必要的性能浪费。
  • 手动进行 ref 转发。ref 不能通过 {...props} 转发,需要手动转发
  • 手动复制静态方法和属性。如果需要保留原组件的静态方法和属性,需要手动复制
  • Devtool 上能够看到包裹组件 EnhancedComponent 对应的组件节点。

6. 总结

HOC 是一个接收 React 组件并返回新组件的 JavaScript 函数,是一种实现逻辑复用的设计模式,广泛存在于 React 第三方库中。

使用 HOC 能够复用多个组件普遍存在的逻辑,现在大部分使用 hooks,不过 hooks 并不能完全取代它。