高阶组件(Higher Order Component)

发布于 2017-11-30  约 20 分钟

高阶组件(HOC)是React开发中的特有名词,一个函数返回一个React组件,指的就是一个React组包裹着另一个React组件。可以理解为一个生产React组件的工厂。

有两种类型的HOC:

  1. Props Proxy(pp) HOC对被包裹组件WrappedComponent的props进行操作。
  2. Inherbitance Inversion(ii)HOC继承被包裹组件WrappedComponent

Props Proxy

一种最简单的Props Proxy实现

function ppHOC(WrappedComponent) {  
  return class PP extends React.Component {    
    render() {      
      return <WrappedComponent {...this.props}/>    
    }  
  } 
}

这里的HOC是一个方法,接受一个WrappedComponent作为方法的参数,返回一个PP class,renderWrappedComponent。使用的时候:

const ListHOCInstance = ppHOC(List)
<ListHOCInstance name='instance' type='hoc' />

这个例子中,我们将本应该传给List的props,传给了ppHoc返回的ListHOCInstance(PP)上,在HOC内部我们将PP的参数直接传给ListWrappedComponent)。这样的就相当于在List外面加了一层代理,这个代理用于处理即将传给WrappedComponent的props,这也是这种HOC为什么叫Props Proxy。

在pp中,我们可以对WrappedComponent进行以下操作:

  1. 操作props(增删改)
  2. 通过refs访问到组件实例
  3. 提取state
  4. 用其他元素包裹WrappedComponent

操作props

比如添加新的props给WrappedComponent

const isLogin = false;

function ppHOC(WrappedComponent) {
  return class PP extends React.Component {
    render() {
      const newProps = {
        isNew: true,
        login: isLogin
      }
      return <WrappedComponent {...this.props} {...newProps}/>
    }
  }
}

WrappedComponent组件新增了两个props:isNew和login。

通过refs访问到组件实例

function refsHOC(WrappedComponent) {
  return class RefsHOC extends React.Component {
    proc(wrappedComponentInstance) {
      wrappedComponentInstance.method()
    }

    render() {
      const props = Object.assign({}, this.props, {ref: this.proc.bind(this)})
      return <WrappedComponent {...props}/>
    }
  }
}

Ref 的回调函数会在 WrappedComponent 渲染时执行,你就可以得到WrappedComponent的引用。这可以用来读取/添加实例的 props ,调用实例的方法。

不过这里有个问题,如果WrappedComponent是个无状态组件,则在proc中的wrappedComponentInstance是null,因为无状态组件没有this,不支持ref。

提取state

你可以通过传入 props 和回调函数把 state 提取出来,

function ppHOC(WrappedComponent) {
  return class PP extends React.Component {
    constructor(props) {
      super(props)
      this.state = {
        name: ''
      }

      this.onNameChange = this.onNameChange.bind(this)
    }
    onNameChange(event) {
      this.setState({
        name: event.target.value
      })
    }
    render() {
      const newProps = {
        name: {
          value: this.state.name,
          onChange: this.onNameChange
        }
      }
      return <WrappedComponent {...this.props} {...newProps}/>
    }
  }
}

使用的时候:

class Test extends React.Component {
    render () {
        return (
            <input name="name" {...this.props.name}/>
        );
    }
}

export default ppHOC(Test);

这样的话,就可以实现将input转化成受控组件。

用其他元素包裹 WrappedComponent

这个比较好理解,就是将WrappedComponent组件外面包一层需要的嵌套结构

function ppHOC(WrappedComponent) {
  return class PP extends React.Component {
    render() {
      return (
        <div style={{display: 'block'}}>
          <WrappedComponent {...this.props}/>
        </div>
      )
    }
  }
}

Inheritance Inversion(反向继承)

先看一个反向继承(ii)的?:

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      return super.render()
    }
  }
}

上面例子可以看出来Enhancer继承了WrappedComponent,但是Enhancer可以通过super关键字获取到父类原型对象上的所有方法(父类实例上的属性或方法则无法获取)。在这种方式中,它们的关系看上去被反转(inverse)了。

我们可以使用Inheritance Inversion实现以下功能:

  1. 渲染劫持(Render Highjacking)
  2. 操作state

渲染劫持

之所以被称为渲染劫持是因为 HOC 控制着 WrappedComponent 的渲染输出,可以用它做各种各样的事。通过渲染劫持我们可以实现:

  1. 在由 render输出的任何 React 元素中读取、添加、编辑、删除 props
  2. 读取和修改由 render 输出的 React 元素树
  3. 有条件地渲染元素树
  4. 把样式包裹进元素树(就像在 Props Proxy 中的那样)

demo1:条件渲染

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      if (!this.props.loading) {
        return super.render()
      } else {
        return <div>loading</div>    
      }
    }
  }
}

demo2:修改渲染

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      const elementsTree = super.render()
      let newProps = {};
      if (elementsTree && elementsTree.type === 'input') {
        newProps = {value: 'may the force be with you'}
      }
      const props = Object.assign({}, elementsTree.props, newProps)
      const newElementsTree = React.cloneElement(elementsTree, props, elementsTree.props.children)
      return newElementsTree
    }
  }
}

在这个例子中,如果 WrappedComponent 的输出在最顶层有一个 input,那么就把它的 value 设为 “may the force be with you”

你可以在这里做各种各样的事,你可以遍历整个元素树,然后修改元素树中任何元素的 props。

操作state

HOC 可以读取、编辑和删除 WrappedComponent 实例的 state,如果你需要,你也可以给它添加更多的 state。记住,这会搞乱 WrappedComponent 的 state,导致你可能会破坏某些东西。要限制 HOC 读取或添加 state,添加 state 时应该放在单独的命名空间里,而不是和 WrappedComponent 的 state 混在一起。

export function IIHOCDEBUGGER(WrappedComponent) {
  return class II extends WrappedComponent {
    render() {
      return (
        <div>
          <h2>HOC Debugger Component</h2>
          <p>Props</p> <pre>{JSON.stringify(this.props, null, 2)}</pre>
          <p>State</p><pre>{JSON.stringify(this.state, null, 2)}</pre>
          {super.render()}
        </div>
      )
    }
  }
}

问题

使用高阶组件的时候,也会遇到一些问题:

静态方法丢失

当使用高阶组件包装组件,原始组件被容器组件包裹,也就意味着新组件会丢失原始组件的所有静态方法。

可以使用hoist-non-react-statics来帮你自动处理,它会自动拷贝所有非React的静态方法:

import hoistNonReactStatic from 'hoist-non-react-statics';
export default (title = '默认标题') => (WrappedComponent) => {
  class HOC extends Component {
    static displayName = `HOC(${getDisplayName(WrappedComponent)})`;
    render() {
      return (
        <fieldset>
          <legend>{title}</legend>
          <WrappedComponent {...this.props} />
        </fieldset>
      );
    }
  }
  // 拷贝静态方法
  hoistNonReactStatic(HOC, WrappedComponent);
  return HOC;
};

Refs属性不能传递

一般来说,高阶组件可以传递所有的props属性给包裹的组件,但是不能传递 refs 引用。因为并不是像 key 一样,refs 是一个伪属性,React 对它进行了特殊处理。
如果你向一个由高级组件创建的组件的元素添加 ref 应用,那么 ref 指向的是最外层容器组件实例的,而不是包裹组件。
但有的时候,我们不可避免要使用 refs,官方给出的解决方案是:

传递一个ref回调函数属性,也就是给ref应用一个不同的名字

const Hello = createReactClass({
  componentDidMount: function() {
    var component = this.hello;
    // ...do something with component
  },
  render() {
    return <div ref={(c) => { this.hello = c; }}>Hello, world.</div>;
  }
});

反向继承不能保证完整的子组件树被解析

这里要注意的是:

  • 元素(element)是一个是用DOM节点或者组件来描述屏幕显示的纯对象,元素可以在属性(props.children)中包含其他的元素,一旦创建就不会改变。我们通过JSXReact.createClass创建的都是元素。
  • 组件(component)可以接受属性(props)作为输入,然后返回一个元素树(element tree)作为输出。有多种实现方式:Class或者函数(Function)。

反向继承不能保证完整的子组件树被解析的意思的解析的元素树中包含了组件(函数类型或者Class类型),就不能再操作组件的子组件了,这就是所谓的不能完全解析。详细介绍可参考官方博客

约定

官方文档中也对使用高阶组件有一些约定

将不相关的props属性传递给包裹组件

高阶组件给组件添加新特性。他们不应该大幅修改原组件的接口(译者注:应该就是props属性)。预期,从高阶组件返回的组件应该与原包裹的组件具有类似的接口。

高阶组件应该传递与它要实现的功能点无关的props属性。大多数高阶组件都包含一个如下的render函数:

render() {
  // 过滤掉与高阶函数功能相关的props属性,
  // 不再传递
  const { extraProp, ...passThroughProps } = this.props;

  // 向包裹组件注入props属性,一般都是高阶组件的state状态
  // 或实例方法
  const injectedProp = someStateOrInstanceMethod;

  // 向包裹组件传递props属性
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

最大化使用组合

并不是所有的高阶组件看起来都是一样的。有时,它们仅仅接收一个参数,即包裹组件:

const NavbarWithRouter = withRouter(Navbar);

一般而言,高阶组件会接收额外的参数。在下面这个来自Relay的示例中,可配置对象用于指定组件的数据依赖关系:

const CommentWithRelay = Relay.createContainer(Comment, config);

大部分常见高阶组件的函数签名如下所示:

// React Redux's `connect`
const ConnectedComment = connect(commentSelector, commentActions)(Comment);

这是什么?! 如果你把它剥开,你就很容易看明白到底是怎么回事了。

// connect是一个返回函数的函数(译者注:就是个高阶函数)
const enhance = connect(commentListSelector, commentListActions);
// 返回的函数就是一个高阶组件,该高阶组件返回一个与Redux store
// 关联起来的新组件
const ConnectedComment = enhance(CommentList);

换句话说,connect 是一个返回高阶组件的高阶函数!

这种形式有点让人迷惑,有点多余,但是它有一个有用的属性。那就是,类似 connect 函数返回的单参数的高阶组件有着这样的签名格式, Component => Component.输入和输出类型相同的函数是很容易组合在一起。

// 不要这样做……
const EnhancedComponent = connect(commentSelector)(withRouter(WrappedComponent))

// ……你可以使用一个功能组合工具
// compose(f, g, h) 和 (...args) => f(g(h(...args)))是一样的
const enhance = compose(
  // 这些都是单参数的高阶组件
  connect(commentSelector),
  withRouter
)
const EnhancedComponent = enhance(WrappedComponent)

connect函数产生的高阶组件和其它增强型高阶组件具有同样的被用作装饰器的能力。)

包括lodash(比如说lodash.flowRight), ReduxRamda在内的许多第三方库都提供了类似compose功能的函数。

包装显示名字以便于调试

高价组件创建的容器组件在React Developer Tools中的表现和其它的普通组件是一样的。为了便于调试,可以选择一个好的名字,确保能够识别出它是由高阶组件创建的新组件还是普通的组件。

最常用的技术就是将包裹组件的名字包装在显示名字中。所以,如果你的高阶组件名字是 withSubscription,且包裹组件的显示名字是 CommentList,那么就是用 withSubscription(CommentList)这样的显示名字:

function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {/* ... */}
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

参考文档

  1. 深入理解 React 高阶组件
  2. Higher-Order Components
  3. React 高阶组件浅析
  4. React 高阶组件(HOC)入门指南
  5. React进阶——使用高阶组件(Higher-order Components)优化你的代码
阅读 4.6k发布于 2017-11-30

推荐阅读
前端小事
用户专栏

前端>React&Vue&经验&CSS&*

72 人关注
28 篇文章
专栏主页
目录