React高阶组件(HOC)解析及实践

1,174 阅读10分钟

  基于上一遍关于React组件通信文章中,有关任意组件间数据通信:Redux、Redux-mobx、Redux-saga的解读,对于Redux生态圈中,中间件是如何拓展到Redux中的机制,勾起个人的兴趣。

what ? 高阶组件(HOC)的基本概念

  • 高阶组件(HOC higher-Order Component) 是React 中用于复用组件逻辑的一种高级技巧。它接收一个组件作为参数,然后返回经过改造的新组件
 const EnhancedComponent = higherOrderComponent(WrappedComponent);
  • 组件是将props 转换为UI;而高阶组件是将组件转换为一个新组件
  • 高阶组件自身不是React API 的一部分,而是一种基于React的组合特性而形成的设计模式,关于其原理的详细说明可参看 官方文档

Why? 为啥使用高阶组件

  • 高阶组件能解决以下三点:
    首先:抽取重复代码,实现组件复用,比如说:组件复用
    其次:条件渲染,控制组件的渲染逻辑,比如说:noData、权限控制
    最后:捕获/劫持被扩展组件的生命周期,比如说,logger日志,性能追踪、代码调试
  • 能够灵活使用高阶组件,不仅可以增加代码的复用性和灵活性,而且提高了开发效率,让代码更加华丽
  • 便于去了解React 其他的第三方库的原理

HOW?怎样实现高阶组件

 高阶组件实现方式:

  属性代理(props proxy)

   一个Hoc函数接收一个WrappedComponent组件作为参数,并返回一个继承了React.Component组件的类,且在该类的render()的方法中返回被传入的WrappedComponent组件。其本质上使用组合的方式,通过将组件包装在容器组件中实现功能,因此,高阶组件与原组件的生命周期的关系完全是React父子组件的生命周期关系,从而高阶组件会影响原组件某些生命周期等方法。

  操作props

    通过属性代理方式实现的高阶组件包装后的组件,可以拦截原组件的props,进行props的拓展

// 返回一个无状态的函数组件
function Hoc(WrappedComponent){
   const newprops = {type:'HOC'}
   return props => <WrappedComponent {...props} {..newprops} />
}
// 返回一个有状态的 class组件
function Hoc(WrappedComponent){
   return class extends React.Component {
       render(){
           const newProps = {type:'HOC'}
           return <WrappedComponent {...props} {..newprops} />
       }
   }
}
  抽象state

     属性代理方式实现的高阶组件 无法 直接操作原组件的state,但可以通过props和callback函数对state进行抽象,实现 非受控组件受控组件的转变

function Hoc(WrappedComponet){
  return class extends React.Component{
      constructor(props){
          super(props)
          this.state = {name:''}
          this.onChange = this.onChange.bind(this)
      }
      onChange = e =>{
          this.setState({name:e.target.value})
      }
      render(){
          const newProps = {name:{value:this.state.title,onChange:this.onChange}}
          return <WrappedComponent {...this.props} {..newprops} />
      }
  }
}

@Hoc
class InputTitle extends Component{
  return  <input name='name' {...this.props.name} />
}
  获取refs

     属性代理方式实现的高阶组件 无法 直接获取原组件的 refs 引用,但可以通过在原组件的ref的回调函数中调用 父组件传入的ref回调函数来获取到原组件的refs引用。注意:ref属性只能申明在class类型的组件上,而无法申明在函数类型的组件上(无状态组件没有实例)

import React from 'react'
class User extends React.Component{
  private inputElement
  static sayHello(){
      console.log('jsx')
  }
  constructor(props){
      super(props)
      this.focus = this.focus.bind(this)
      this.onChange = this.onChange.bind(this)
      this.state = {name:'',age:0}
  }
  componentDidMount(){
      this.setState({
          name:this.props.name,
          age:this.props.age
      })
  }
  onChange = (e) =>{
      this.setState({age.e.target.value})
  }
  focus = () =>{
      this.inputElement.focus()
  }
  render(){
    return (
      <div>
            <div>姓名:{this.state.name}</div>
            <div>年龄:
                <input value={this.state.age} onChange={this.onChange} type='number'
                    ref = {input =>{
                        if(this.props.inputRef) this.props.inputRef(input)
                        this.inputElement = input
                    }}
                    />
             </div>
             <div><button onClick={this.focus} >获取输入框焦点</button></div>
      </div> 
    ) 
  }
}
export default User
import React from 'react'
function Hoc(WrappedComponent){
  let inputElement = null
  function handleClick(){
      inputElement&&inputElement.focus()
  }
  function WrappedComponentStatic(){
      WrappedComponent.sayHello()
  }
  return (props) =>{
      return (
              <div>
  				<WrappedComponent inputRef= {(el) => {inputElement = el}} {...props} />
                  <button onClick={handleClick}>获取子组件输入框焦点</button>
                  <button onClick={WrappedComponentStatic}>调用子组件static</button>
              </div>
      )
  }
}
export default Hoc
import React from 'react'
import Hoc from './hoc'
import User from './User'
const EnhanceUser = hoc(user)

class UserRefs extends React.Component{
    render(){
        return <EnhanceUser name='jsx' age={18} />
    }
}
export default UserRefs
  获取原组件的static方法

    对于子组件为class组件时,通过属性代理实现的高阶组件(返回函数组件还是class组件),都可以获取到原组件的static方法。
具体核心代码如下 获取refs 里的 WrappedComponentStatic

  通过props实现条件渲染

     属性代理实现的高阶组件 无法 直接实现 对原组件内部Render 进行渲染劫持,但通过props来控制是否渲染子组件以及传入数据

···
render(){
 return ({
      props.isShow?(<WrappedComponent inputRef= {(el) => {inputElement = el}} {...props} />):<div>空数据</div>
  })
}
  用其他元素包裹传入的组件

    通过将原组件包裹起来,实现布局或者样式的目的

···
render(){
       return (
           <div style={{ backgroundColor: '#ccc' }}>
          	<WrappedComponent inputRef= {(el) => {inputElement = el}} {...props} />
      	</div>
       )
  }
}

 反向继承

   一个hoc函数接收一个WrappedComponet组件作为参数,返回一个继承传入的WrappedComponent组件的类,且在该类的render()的方法返回super.render()的方法。

function Hoc(WrappedComponent){
  return class extends WrappedComponent{
      render(){
          return super.render()
      }
  }
}
  • 相比较属性代理方式,反向继承允许高阶组件通过this 访问到原组件,直接读取和操作原组件的state、ref、生命周期方法
  • 反向继承通过super.render() 方法获取到传入的组件实例的render的结果,可对传入的组件进行渲染劫持
  操作state

    反向继承实现的高阶组件,可以通过this 直接读取,编辑,删除 原组件实例中的state,甚至能拓展state(但非常不建议这么做,难以维护state)

function Hoc(WrappedComponent){
  const didMount = WrappedComponent.prototype.componentDidMount
  return class extends WrappedComponent{
      async componentDidMount(){
          if(didMount) await.didMount.apply(this)
          this.setState({num:2})
      }
      render(){
          return (
           <div>
                  <div>state:{JSON.stringify(this.state)}</div>
                  <div>props:{JSON.stringify(this.props)}</div>
                  {super.render()}
           </div>
          )
      }
  }
}
##### &emsp;&emsp;劫持原组件生命周期方法

    反向继承实现的高阶组件返回的新组件是继承传入的组件,因此当新组件定义同样的方法时,会将原先的实例方法覆盖掉。

function Hoc(WrappedComponent){
    const didMount = WrappedComponent.prototype.componentDidMount
    return class extends WrappedComponent{
        async componentDidMount(){
            // 劫持 WrappedComponent 组件的生命周期
            if(didMount) await.didMount.apply(this)
            this.setState({num:2})
        }
        render(){
            return (
             <div>
                    <div>state:{JSON.stringify(this.state)}</div>
                    <div>props:{JSON.stringify(this.props)}</div>
                    {super.render()}
             </div>
            )
        }
    }
}
  渲染劫持

    条件渲染:根据部分参数 检测宣传劫持

function Hoc(WrappedComponent){
  return class extends WrappedComponent{
      render(){
          return (
           <div>
                  {this.props.isShow?super.render():(<div>no data</div>)}
           </div>
          )
      }
  }
}
  修改由 render() 输出的 React 元素树
function Hoc(WrappedComponent){
   return class extends WrappedComponent{
       render(){
           const tree = super.render()
           const newProps = {}
           if(tree && tree.type === 'input'){
               newProps.value = 'jsx'
           }
           const props = {...this.props,...newProps}
           const newTree = React.CloneElement(tree,props,tree.props.chilidren)
           return newTree
       }
   }
}

Bad? 高阶组件存在的问题

  属性代理是从“组合”的角度出发,有利于从外部去操作WrappedComponent,可以操作props或者在WrappedComponent外面添加一些拦截器,控制器等
向继承是从“继承”的角度出发,是从内部去操作WrappedComponent,可操作组件内部的state、ref、render、生命周期等。还是存在不少问题:

静态方法丢失

   由于原组件被包裹于一个容器的组件内,因此,新组件会没有原始组件的任何静态方法

function hoc(WrappedComponent) {
   class Enhance extends React.Component {···}
   // 必须得知道要拷贝的方法
   Enhance.staticMethod = WrappedComponent.staticMethod;
   return Enhance;
}

自动拷贝所有非React的静态方法 hoist-non-react-static

import hoistNonReactStatic from 'hoist-non-react-static'

function hoc(WrappedComponent) {
  class Enhance extends React.Component {···}
  hoistNonReactStatic(Enhance,WrappedComponent);
  return Enhance;
}

refs属性不能透传

    高阶组件可以通过包裹组件WrappedComponent, 传递所有的props, 但是ref 由于React 对其特殊处理,而不能传递;若向一个高阶组件创建的组件的元素添加ref 引用,ref 只是指向最外层容器组件实例,而不是被包裹的原组件
注意:若一定要传递ref ,可以通过react.forwardRef 来解决

function hoc(WrappedComponent) {
    class Enhance extends React.Component {
        componentWillReceiveProps(){
            console.log('current props', this.props)
            console.log('next props',nexProps)
        }
        render(){
            const {forwardedRef,...rest} this.props
            // 把 forwardedRef 赋值给 ref
            return <WrappedComponent {...rest} ref={forwardedRef} />;
        }
    }
    
    //React.forwardRef 方法会传入 props 和 ref 两个参数给其回调函数
    // 所以这边的 ref 是由 React.forwardRef 提供的
    function forwardRef(props, ref) {
        return <Enhance {...props} forwardRef={ref} />
    }
    
     return React.forwardRef(forwardRef);
}
const EnhancedComponent = hoc(SomeComponent);

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

    反向继承的渲染劫持可以控制 WrappedComponent 的渲染过程,可以对 elements treestatepropsrender() 的结果做各种操作,但是渲染 elements tree 中包含了 function 类型的组件的话,这时候就不能操作组件的子组件了

具体场景Demo

页面复用

//渲染评论列表
class CommentList extends React.Component {
constructor(props) {
  super(props);
  this.handleChange = this.handleChange.bind(this);
  this.state = {
    // 假设 "DataSource" 是个全局范围内的数据源变量
    comments: DataSource.getComments()
  };
}

componentDidMount() {
  // 订阅更改
  DataSource.addChangeListener(this.handleChange);
}

componentWillUnmount() {
  // 清除订阅
  DataSource.removeChangeListener(this.handleChange);
}

handleChange() {
  // 当数据源更新时,更新组件状态
  this.setState({
    comments: DataSource.getComments()
  });
}

render() {
  return (
    <div>
      {this.state.comments.map((comment) => (
        <Comment comment={comment} key={comment.id} />
      ))}
    </div>
  );
}
}
//渲染博客帖子
class BlogPost extends React.Component {
constructor(props) {
  super(props);
  this.handleChange = this.handleChange.bind(this);
  this.state = {
    blogPost: DataSource.getBlogPost(props.id)
  };
}

componentDidMount() {
  DataSource.addChangeListener(this.handleChange);
}

componentWillUnmount() {
  DataSource.removeChangeListener(this.handleChange);
}

handleChange() {
  this.setState({
    blogPost: DataSource.getBlogPost(this.props.id)
  });
}

render() {
  return <TextBlock text={this.state.blogPost} />;
}
}

CommentListBlogPost 不同 - 它们在 DataSource 上调用不同的方法,且渲染不同的结果。但它们的大部分实现都是一样的:

  • 在挂载时,向 DataSource 添加一个更改侦听器。
  • 在侦听器内部,当数据源发生变化时,调用 setState
  • 在卸载时,删除侦听器。

对于订阅了 DataSource 的组件,比如 CommentListBlogPost,我们可以编写一个创建组件函数。该函数将接受一个子组件作为它的其中一个参数,该子组件将订阅数据作为 prop。让我们调用函数 withSubscription

function withSubscription(WrappedComponent, selectData) {
  // ...并返回另一个组件...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ...负责订阅相关的操作...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ... 并使用新数据渲染被包装的组件!
      // 请注意,我们可能还会传递其他属性
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}
const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);

权限控制

假设现在有这样一个场景:最近有一个新功能要上线,包含了一系列新开发的页面。现在需要对其中几个页面增加白名单功能,如果不在白名单中的用户访问这些页面只进行文案提示,不展示相关业务数据。一周(功能验收完成)后去掉白名单,对全部用户开放。

以上场景中有几个条件:

  • 多个页面鉴权:鉴权代码不能重复写在页面组件中;
  • 不在白名单用户只进行文案提示:鉴权过程业务数据请求之前;
  • 一段时间后去掉白名单:鉴权应该完全与业务解耦,增加或去除鉴权应该最小化影响原有逻辑。

思路:封装鉴权流程,利用高阶组件的条件渲染特性,鉴权失败展示相关文案,鉴权成功则渲染业务组件。由于属性代理和反向继承都可以实现条件渲染,下面我们将使用比较简单的属性代理方式实现的高阶组件来解决问题:

import React from 'react';
import { whiteListAuth } from '../lib/utils'; // 鉴权方法

/**
 * 白名单权限校验
 * @param WrappedComponent
 * @returns {AuthWrappedComponent}
 * @constructor
 */
function AuthWrapper(WrappedComponent) {
  return class AuthWrappedComponent extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        permissionDenied: -1,
      };
    }
    
    async componentDidMount() {
      try {
        await whiteListAuth(); // 请求鉴权接口
        this.setState({
          permissionDenied: 0,
        });
      } catch (err) {
        this.setState({
          permissionDenied: 1,
        });
      }
    }
    
    render() {
      if (this.state.permissionDenied === -1) {
        return null; // 鉴权接口请求未完成
      }
      if (this.state.permissionDenied) {
        return <div>功能即将上线,敬请期待~</div>;
      }
      return <WrappedComponent {...this.props} />;
    }
  }
}

export default AuthWrapper;

对于需要加权限控制的页面,只需要将页面组件作为参数传给高阶组件 AuthWrapper
通过使用高阶组件,使得鉴权与业务完全解耦,也避免了鉴权失败时多余的业务数据请求,只需要增加/删除少量代码,即可增加/去除用户白名单的控制,原有业务组件的逻辑也不会受到影响

组件渲染性能追踪

使用反向继承方式实现的高阶组件完成组件渲染性能的追踪,反向继承方式实现的高阶组件能否劫持原组件生命周期方法,因此,利用该特性,我们可以方便的对某个组件的渲染时间进行记录:

import React from 'react';
// Home 组件
class Home extends React.Component {
  render () {
    return (<h1>Hello World.</h1>);
  }
}

// HOC
function withTiming (WrappedComponent) {
  let start: number, end: number;

  return class extends WrappedComponent {
    constructor (props) {
      super(props);
      start = 0;
      end = 0;
    }
    componentWillMount () {
      if (super.componentWillMount) {
        super.componentWillMount();
      }
      start = +Date.now();
    }
    componentDidMount () {
      if (super.componentDidMount) {
        super.componentDidMount();
      }
      end = +Date.now();
      console.error(`${WrappedComponent.name} 组件渲染时间为 ${end - start} ms`);
    }
    render () {
      return super.render();
    }
  };
}

export default withTiming(Home);