React 高阶组件(HOC)

高阶函数


如果一个函数 接受一个或多个函数作为参数或者返回一个函数 就可称之为 高阶函数。

// ES5 
function isSearched(searchTerm) { 
  return function(item) { 
    return item.title.toLowerCase().includes(searchTerm.toLowerCase()); 
  } 
} 

// ES6 
const isSearched = searchTerm => item => 
  item.title.toLowerCase().includes(searchTerm.toLowerCase());

React HOC


在实际项目中,组件总是趋于复杂,会包含很多逻辑。当发现在很多组件都需要处理相同逻辑的时候,就应该想办法抽象复用。在React中,具有复用功能的方式主要有:

  • 公共组件库
  • 公共方法库
  • 类组件:HOC / Render Props
  • 函数组件:Hooks

当你没法把组件中的重复逻辑抽象成公共组件或者公共方法的情况下,在类组件中,就可以考虑引入HOCRender Props看这里

和高阶函数类似,React HOC就是把组件(也可增加一些可选参数)作为输入,然后输出一个新的组件,新组件内部处理一些通用逻辑,并使用输入的组件进行渲染。你可以将其视为参数化容器组件。

我们知道,在React中函数组件本质上就是一个函数。所以高阶函数组件就是一个HOC

高阶组件应用


首先,我们先创建HOC容器:

// 无状态函数组件
function HigherOrderComponent(WrappedComponent) {
    return props => <WrappedComponent {...props} />;
}
// or
// 类组件
function HigherOrderComponent(WrappedComponent) {
    return class extends React.Component {
        render() {
            return <WrappedComponent {...this.props} />;
        }
    };
}
// 类组件特有的通过继承的方式使用
// 也叫 反向继承(Inheritance Inversion)
function HigherOrderComponent(WrappedComponent) {
    return class extends WrappedComponent {
        render() {
            return super.render();
        }
    };
}

通过继承输入组件形式实现的HOC又称为 反向继承(Inheritance Inversion)

以上HOC只是一个容器,和直接使用 WrappedComponent 组件无二。那么,我们可以在HOC中做点什么来达到共享复用的目的呢?主要通过以下三种方式:

  • 操作参数(props / state)
  • 条件渲染
  • 组件包装

操作参数

我们可以通过HOC对一个通用组件的 props 做一些操作达到复用的目的,而不用去修改这个通用组件。

举个简单的例子,一个列表展示组件,在新需求中的某些场景只希望展示列表中 status === '1' 的数据,那我们可以封装一个HOC在这些场景使用,其他场景直接使用。


function HigherOrderComponent(WrappedComponent) {
    return props => {
      let newProps =  props || []
      if (newProps.length > 0){
        newProps = newProps.filter((item) => item.status === '1')
      }
      return <WrappedComponent {...newProps} />
    };
}

当然,你也可以通过修改这个通用组件来适配新场景。但是一直通过修改通用组件来适配所有场景,一方面存在兼容风险,另一方面适配逻辑太多,组件会变得臃肿,很难维护。

在举一个常见的应用:很多页面初始化的时候,都需要通过一个接口获取一些初始化数据。我们也可以通过HOC封装,统一获取,然后作为扩展参数传递给页面组件。

 function HigherOrderComponent(WrappedComponent) {
    return props => {
      const fetchData = async () => {
        const result = await axios(
          'https://hn.algolia.com/api/v1/search?query=redux',
        );
        return result.data;
      };
      const newProps = fetchData();
      return <WrappedComponent {...props} {...newProps} />
    };
}

条件渲染

在实际React项目中,大多采用前端渲染模式。组件的渲染依赖Ajax请求的数据。所以,我们经常会看到(我自己也写过)如下代码:

class BadComp extends React.Component {
    state = {
      isDateReady: false,
      dataA: null,
      dataB: null,
    }
    //...
    render() {
      if (!isDateReady) return false
      const { isDateReady, hasDataA, hasDataB }= this.state
      return (
        <>
          { dataA && <A {...dataA}/> }
          { dataB && <B {...dataB}/> }
        </>
      );
    }
};

上述代码并不是一无是处,至少是严谨的,规避了无数据或者数据异常导致页面奔溃的情况。但是上述组件有两个问题:

  • 通过 isDateReady 阻断页面渲染,接口返回慢的话,白屏时间会很长。
  • 页面充斥着太多类似 { dataB && <B {...dataB}/> } 这种逻辑,不优雅,影响可读性。

解决上述问题,我们一般是通过增加 loading占位 来优化。在页面主接口返回数据之前,增加 loading 效果。需要数据才能渲染的组件模块可以先显示 占位框css),取得数据以后再重新渲染。很明显,这个解决方案比较通用,最好做成可复用的。这个时候,我们就可以通过HOC的条件渲染来实现:

页面loading效果的高阶组件,通过 反向继承 + 条件渲染 实现:


const withLoadingComponent = (WrappedComponent) => {
  return (props) =>  {
    render() {
        if(this.state.isLoading) {
          return <Loading />;
        } else {
          return super.render();
        }
    }
  };
}

组件占位效果的HOC。额外的 options 参数可以决定占位组件 Placeholder 的高度等信息。

const withPlaceholderComponent = (WrappedComponent, options) => {
  return (props) =>  {
    return props ? <WrappedComponent {...props}> : <Placeholder {...options}>
  };
}

组件包装

如果你总是和相同的元素包裹使用一个通用组件,那么你可以通过抽象一个HOC来复用。比如下面的HOC在组件外面包裹一层背景色为 #fafafadiv 元素:

const withOtherComponent = (WrappedComponent) => {
  return (props) =>  {
    return (
      <div style={{ backgroundColor: '#fafafa' }}>
          <WrappedComponent {...this.props} {...newProps} />
      </div>
    )
  };
}

这里只是举了一个简单的例子,实际项目中,也可以通过其他组件来包裹,或是组合使用。

Recompose 库


Recompose 是一个为函数式组件和高阶组件开发的 React 工具库。可以看作是 ReactLodash

复杂场景下,会存在多个 HOC 层层嵌套的情况:

const TodoListWithConditionalRendering = withLoadingIndicator(
  withTodosNull(
    withTodosEmpty(TodoList)
  )
);

可以使用 recompose 库的 compost 方法优化:

import { compose } from 'recompose';
const withTodosNull = (Component) => (props) =>
  ...
const withTodosEmpty = (Component) => (props) =>
  ...
const withLoadingIndicator = (Component) => ({ isLoadingTodos, ...others }) =>
  ...
function TodoList({ todos }) {
  ...
}
const withConditionalRenderings = compose(
  withLoadingIndicator,
  withTodosNull,
  withTodosEmpty
);
const TodoListWithConditionalRendering = withConditionalRenderings(TodoList);
function App(props) {
  return (
    <TodoListWithConditionalRendering
      todos={props.todos}
      isLoadingTodos={props.isLoadingTodos}
    />
  );
}

compose 方法用于组合多个高阶组件。注意, props 流向是自上而下的。

另外,recompose 库的 pure 高阶组件,用于控制只在需要的时候重新呈现组件,即除非 props 发生了更改才重新重现组件。 withStateHandler高阶组件,用于将组件状态和组件本身隔离开来……了解更多

Hoc问题


抽象地狱

也叫包装地狱。过多使用HOC层层嵌套,会导致层级冗余,逻辑难追踪,很难维护。所以要避免Hoc滥用,充分考虑引入HOC的必要性,尤其HOC多层封装的情况。对于复杂的嵌套结构,最好增加充分的注释。

不要在render中使用 HOC

每次render渲染都会创建新的 HOC。diff算法会对新旧子树进行 ==== 比较。如果不相等,子树会进行卸载,和重新挂载的操作。而每次重新渲染创建的 HOC 前后是 !== 的。

render() {
  // 每次调用 render 函数都会创建一个新的 EnhancedComponent
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // 这将导致子树每次渲染都会进行卸载,和重新挂载的操作!
  return <EnhancedComponent />;
}

Refs 不会被传递。

虽然HOC的约定是将所有 props 传递给被包装组件,但这对于 refs 并不适用。那是因为 ref 实际上并不是一个 prop- 就像 key 一样,它是由 React 专门处理的。如果将 ref 添加到 HOC 的返回组件中,则 ref 引用指向容器组件,而不是被包装组件。

这个问题的解决方案是通过使用 React.forwardRefReact 16.3 中引入)。想要了解更多,可以阅览我的另一篇博文拥抱React-Hooks(二)-Refs

静态方法丢失。

因为原始组件被包裹在一个容器组件内,也就意味着新组件会没有原始组件的任何静态方法。为了解决这个问题,你可以在返回之前把这些方法拷贝到容器组件上:

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  // 必须准确知道应该拷贝哪些方法 :(
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

参考文档


官网-HOC
React Higher-order Component
React 中的高阶组件及其应用场景