您的当前位置:首页正文

深入理解 React 高阶组件

来源:图艺博知识网

在目前的前端社区,『推崇组合,不推荐继承(prefer composition than inheritance)』已经成为了比较好的实践,mixin 也因为自身的一些问题而渐渐不被推荐。高阶组件(Higher order components)作为 mixin 之外的一种组件抽象与处理形式,有哪些不同和好处呢?继续阅读来了解一下吧!


摘要

高阶组件是一个非常棒的形式,它已经在多个 React 库中被证明了它的价值。这篇文章中,我们将回顾什么是高阶组件,如何使用高阶组件,它的限制以及如何编写高阶组件。

附录中我们回顾了与高阶组件相关的话题(非核心),但是是我认为应当涉及到的知识。

这篇文章意在写的详尽,如果你发现了任何遗漏,请你报告它,我会做出相应的改变。

这篇文章假设读者拥有 ES6 的相关知识。

让我们开始吧!

什么是高阶组件?

一个高阶组件只是一个包装了另外一个 React 组件的 React 组件

这种形式通常实现为一个函数,本质上是一个类工厂(class factory),它下方的函数标签伪代码启发自 Haskell

hocFactory:: W: => E:

这里 W(WrappedComponent) 指被包装的 Component) 指返回的新的高阶 React 组件。

定义中的『包装』一词故意被定义的比较模糊,因为它可以指两件事情:

  1. 属性代理(Props Proxy):高阶组件操控传递给 WrappedComponent 的 props,
  2. 反向继承(Inheritance Inversion):高阶组件继承(extends)WrappedComponent。

我们将讨论这两种形式的更多细节。

我可以使用高阶组件做什么呢?

概括的讲,高阶组件允许你做:

  • 代码复用,逻辑抽象,抽离底层准备(bootstrap)代码
  • 渲染劫持
  • State 抽象和更改
  • Props 更改

在探讨这些东西的细节之前,我们先学习如何实现一个高阶组件,因为实现方式『允许/限制』你可以通过高阶组件做哪些事情。


高阶组件工厂的实现

在这节中我们将学习两种主流的在 React 中实现高阶组件的方法:属性代理(Props Proxy)和 反向继承(Inheritance Inversion)。两种方法囊括了几种包装 WrappedComponent 的方法。

Props Proxy (PP)

属性代理的实现方法如下:

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

可以看到,这里高阶组件的 render 方法返回了一个 type 为 WrappedComponent 的 React Element(也就是被包装的那个组件),我们把高阶组件收到的 props 传递给它,因此得名 Props Proxy

注意:

<WrappedComponent {...this.props}/>
// is equivalent to
React.createElement(WrappedComponent, this.props, null)
Props Proxy 可以做什么?
  • 更改 props
  • 通过 refs 获取组件实例
  • 抽象 state
  • 把 WrappedComponent 与其它 elements 包装在一起
更改 props

你可以『读取,添加,修改,删除』将要传递给 WrappedComponent 的 props。

在修改或删除重要 props 的时候要小心,你可能应该给高阶组件的 props 指定命名空间(namespace),以防破坏从外传递给 WrappedComponent 的 props。

例子:添加新 props。这个应用目前登陆的一个用户可以在 WrappedComponent 通过 this.props.user 获取

function ppHOC(WrappedComponent) {
  return class PP extends  {
    render() {
      const newProps = {
        user: currentLoggedInUser
      }
      return <WrappedComponent {...this.props} {...newProps}/>
    }
  }
}
通过 refs 获取组件实例

你可以通过 ref 获取关键词 this(WrappedComponent 的实例),但是想要它生效,必须先经历一次正常的渲染过程来让 ref 得到计算,这意味着你需要在高阶组件的 render 方法中返回 WrappedComponent,让 React 进行 reconciliation 过程,这之后你就通过 ref 获取到这个 WrappedComponent 的实例了。

例子:下方例子中,我们实现了通过 ref 获取 WrappedComponent 实例并调用实例方法。

function refsHOC(WrappedComponent) {
  return class RefsHOC extends  {
    proc(wrappedComponentInstance) {
      wrappedComponentInstance.method()
    }
    render() {
      const props = Object.assign({}, this.props, {ref: this.proc.bind(this)})
      return <WrappedComponent {...props}/>
    }
  }
}

当 WrappedComponent 被渲染后,ref 上的回调函数 proc 将会执行,此时就有了这个 WrappedComponent 的实例的引用。这个可以用来『读取,添加』实例的 props 或用来执行实例方法。

抽象 state

例子:在下面这个抽象 state 的例子中,我们幼稚地(原话是naively :D)抽象出了 name input 的 value 和 onChange。我说这是幼稚的是因为这样写并不常见,但是你会理解到点。

function ppHOC(WrappedComponent) {
  return class PP extends  {
    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}/>
    }
  }
}

然后这样使用它:

@ppHOC
class Example extends  {
  render() {
    return <input name="name" {...this.props.name}/>
  }
}
把 WrappedComponent 与其它 elements 包装在一起

出于操作样式、布局或其它目的,你可以将 WrappedComponent 与其它组件包装在一起。一些基本的用法也可以使用正常的父组件来实现(附录 B),但是就像之前所描述的,使用高阶组件你可以获得更多的灵活性。

例子:包装来操作样式

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

Inheritance Inversion(II)

反向继承(II)可以像这样简单地实现:

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

如你所见,返回的高阶组件类(Enhancer)继承了 WrappedComponent。这被叫做反向继承是因为 WrappedComponent 被动地被 Enhancer 继承,而不是 WrappedComponent 去继承 Enhancer。通过这种方式他们之间的关系倒转了。

反向继承允许高阶组件通过 this 关键词获取 WrappedComponent,意味着它可以获取到 state,props,组件生命周期(component lifecycle)钩子,以及渲染方法(render)。

我不会详细介绍你可以使用组件生命周期方法做什么,因为这是 React 的内容,而不是高阶组件的。但是请注意,你可以通过高阶组件来给 WrappedComponent 创建新的生命周期挂钩方法,别忘了调用 super.[lifecycleHook] 防止破坏 WrappedComponent。

Reconciliation 过程

介绍之前先来总结一些理论。

React Element 在 React 执行它的 reconciliation 的过程时描述什么将被渲染。

Function 类型的 React Element 将在 reconciliation 阶段被解析成 DOM 类型的 React Element (最终结果一定都是 DOM 元素)。

这点非常重要,这意味着『反向继承的高阶组件不保证一定解析整个子元素树』。这对渲染劫持非常重要。

可以用反向继承高阶组件做什么?

  • 渲染劫持(Render Highjacking)
  • 操作 state
渲染劫持

它被叫做渲染劫持是因为高阶组件控制了 WrappedComponent 生成的渲染结果,并且可以做各种操作。

通过渲染劫持你可以:

  • 『读取、添加、修改、删除』任何一个将被渲染的 React Element 的 props
  • 在渲染方法中读取或更改 React Elements tree,也就是 WrappedComponent 的 children
  • 根据条件不同,选择性的渲染子树
  • 给子树里的元素变更样式

*渲染 指的是 WrappedComponent.render 方法

无法更改或创建 props 给 WrappedComponent 实例,因为 React 不允许变更一个组件收到的 props,但是你可以在 render 方法里更改子元素/子组件们的 props。

就像之前所说的,反向继承的高阶组件不能保证一定渲染整个子元素树,这同时也给渲染劫持增添了一些限制。通过反向继承,你只能劫持 WrappedComponent 渲染的元素,这意味着如果 WrappedComponent 的子元素里有 Function 类型的 React Element,你不能劫持这个元素里面的子元素树的渲染。

例子1:条件性渲染。如果 this.props.loggedIn 是 true,这个高阶组件会原封不动地渲染 WrappedComponent,如果不是 true 则不渲染(假设此组件会收到 loggedIn 的 prop)

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      if (this.props.loggedIn) {
        return super.render()
      } else {
        return null
      }
    }
  }
}

例子2:通过 render 来变成 React Elements tree 的结果

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,则改变它的值为 “may the force be with you”。

注意:你不能通过 Props Proxy 来做渲染劫持

即使你可以通过 WrappedComponent.prototype.render 获取它的 render 方法,你需要自己手动模拟整个实例以及生命周期方法,而不是依靠 React,这是不值当的,应该使用反向继承来做到渲染劫持。要记住 React 在内部处理组件的实例,而你只通过 thisrefs 来处理实例。

操作 state

高阶组件可以 『读取、修改、删除』WrappedComponent 实例的 state,如果需要也可以添加新的 state。需要记住的是,你在弄乱 WrappedComponent 的 state,可能会导致破坏一些东西。通常不建议使用高阶组件来读取或添加 state,添加 state 需要使用命名空间来防止与 WrappedComponent 的 state 冲突。

例子:通过显示 WrappedComponent 的 props 和 state 来 debug

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>
      )
    }
  }
}

命名

当通过高阶组件来包装一个组件时,你会丢失原先 WrappedComponent 的名字,可能会给开发和 debug 造成影响。

常见的解决方法是在原先的 WrappedComponent 的名字前面添加一个前缀。下面这个方法是从 React-Redux 中拿来的。

HOC.displayName = `HOC(${getDisplayName(WrappedComponent)})`
//or
class HOC extends ... {
  static displayName = `HOC(${getDisplayName(WrappedComponent)})`
  ...
}

方法 getDisplayName 被如下定义:

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || 
         WrappedComponent.name || 
         ‘Component’
}

案例学习

React-Redux

React-Redux 是 Redux 官方的对于 React 的绑定。 其中一个方法 connect 处理了所有关于监听 store 的 bootstrap 代码 以及清理工作,这是通过 Props Proxy 来实现的。

如果你曾经使用过 Flux 你会知道 React 组件需要和一个或多个 store 连接,并且添加/删除对 store 的监听,从中选择需要的那部分 state。而 React-Redux 帮你把它们实现了,自己就不用再去写这些了。

Radium

那么,Radium 是怎么允许 inline css 来实现 CSS 伪选择器的呢(比如 hover)?它实现了一个反向继承来使用渲染劫持,添加适当的事件监听来模拟 CSS 伪选择器。这要求 Radium 读取整个 WrappedComponent 将要渲染的元素树,每当找个某个元素带有 style prop,它就添加对应的时间监听 props。简单地说,Radium 修改了原先元素树的 props(实际上会更复杂,但这么说你可以理解到要点所在)。

Radium 只暴露了一个非常简单的 API 给开发者。这非常惊艳,因为开发者几乎不会注意到它的存在和它是怎么发挥作用的,而实现了想要的功能。这揭露了高阶组件的能力。


附录 A:高阶组件和参数

以下内容不是必须阅读的,你可以略过。

有时,在高阶组件中使用参数是很有用的。这个在以上所有例子中都不是很明显,但是对于中等的 JavaScript 开发者是比较自然的事情。让我们迅速的介绍一下。

例子:一个简单的 Props Proxy 高阶组件搭配参数。重点是这个 HOCFactoryFactory 方法。

function HOCFactoryFactory(...params) {
  // do something with params
  return function HOCFactory(WrappedComponent) {
    return class HOC extends  {
      render() {
        return <WrappedComponent {...this.props}/>
      }
    }
  }
}

你可以这样使用它:

HOCFactoryFactory(params)(WrappedComponent)
//or
@HOCFatoryFactory(params)
class WrappedComponent extends 

附录 B:和父组件的不同之处

以下内容不是必须阅读的,你可以略过。

父组件就是单纯的 React 组件包含了一些子组件(children)。React 提供了获取和操作一个组件的 children 的 APIs。

例子:父组件获取它的 children

class Parent extends  {
  render() {
    return (
      <div>
        {this.props.children}
      </div>
    )
  }
}

render((
  <Parent>
    {children}
  </Parent>
), mountNode)

现在来总结一下父组件能做和不能做的事情(与高阶组件对比):

  • 渲染劫持
  • 操作内部 props
  • 抽象 state。但是有缺点,不能再父组件外获取到它的 state,除非明确地实现了钩子。
  • 与新的 React Element 包装。这似乎是唯一一点,使用父组件要比高阶组件强,但高阶组件也同样可以实现。
  • Children 的操控。如果 children 不是单一 root,则需要多添加一层来包括所有 children,可能会使你的 markup 变得有点笨重。使用高阶组件可以保证单一 root。
  • 父组件可以在元素树立随意使用,它们不像高阶组件一样限制于一个组件。

通常来讲,能使用父组件达到的效果,尽量不要用高阶组件,因为高阶组件是一种更 hack 的方法,但同时也有更高的灵活性。

总结

希望你在读完这篇文章后,能对 React 高阶组件多一丝了解。它们在多个库中被证明非常有效。

React 带来了很多创新,人们维护着像 Radium,React-Redux,React-Router 之类的项目,都是很好的证明。

如果你想联系我,请在 Twitter 关注我并 @franleplant。

Top