HOC高阶组件

1,687 阅读12分钟

背景

为什么会突然出现高阶组件的概念呢?我们使用普通组件出现的瓶颈是什么呢?

  • 我们在开发的过程中,很多时候会遇到有一部分逻辑的组件我们反复的使用,我们如果在函数中遇到这样的问题,我们的做法是自己封装一个公共方法。在使用的时候调用这个方法,我们只要调用公共方法即可,这样减少很多冗余的代码,在后期维护简单
  • 我们根据上面的思想,对于组件,我们也可以将逻辑相同的组件提取出来,提取出公共组件进行复用,但是有的时候我们也不完全是为了复用,更是为了逻辑的加强,这个时候我们利用高阶组件(HOC)这种模式来进行项目的设计。

高阶组件是什么?

从本质上说,高阶组件并不是一个组件,而是一个函数,这个函数接受一个组件作为参数,在最后返回一个组件,我们得到的新的组件是经过这个函数加工过的

我在刚开始接触高阶组件的时候,以为这是一个很高级的概念,看上去是一个高级的先进编程语言的专业术语,然而事实并不是如此,这个词的缘由是因为js中的高阶函数的概念

高阶函数就是接受函数作为输入或者输出的函数

我们写一个高阶组件的例子:

const withDoSomthing =(component)=>{
    const NewComponent =(props) =>{
        return <component {...props} />
    }
    return NewComponent;
}

ps: 对于高阶组件来说,业界用 with前缀来进行区分,命名的后面的部分来表示高阶组件真正的作用,高阶组件的基本特点我们进行一下总结

  • 高阶组件不能修改作为参数的组件。换言之,高阶组件应该是一个纯函数,不应该有任何的副作用
  • 高阶组件会返回一个全新的组件,这个组件会包含传入进来的组件

我们如何实现上面的高阶组件的内容呢?

使用高阶组件的规则

  • 不修改原始的组件

  • props保持一致

  • 保持可组合性

  • displayName

    为了方便调试,最常见的高阶组件命名方式是将子组件名字包裹起来。

  • 不要在render方法内部使用高阶组件

    render中的高阶组件会在每次render时重新mount,之前组件内部的state也会丢失。

学习高阶组件的好处

  • 我们可以通过高级组件来实现组件的逻辑的复用,让我们的代码看上去更加的简练优雅
  • 学习高组件,当我们学习react的第三方库的时候,可以更加理解这样设计的原因
  • 高阶组件主要解决的问题
    • 抽取重复的代码,实现逻辑的复用,常用场景:页面的复用
    • 渲染劫持:让页面根据不同的状态呈现不同的样式,常见场景:权限控制
    • 捕获/劫持被处理组件的生命周期,常见场景:组件渲染性能追踪、日志打点。

高阶组件的实现方式

  • 属性代理(Props Proxy)
  • 反向继承(Inheritance Inversion)

属性代理

实质:包裹原来的组件来实现操作props,举一个简单的例子

  • 属性代理其实本质,就是组合,将组件包含在容器中并实现功能
  • 对于通过组合方式来实现功能的组件来说,高阶组件返回的组件和原组件在生命周期上面的关系和React父子组件的生命周期是一样的,所以用这样的方法来实现高阶组件可能会影响到生命周期或是一些方法。
import React,{Component} from 'React'

// 高阶组件
const withHOC = (WrappedComponent) =>{
    class WrapperComponent extends Component{
        render(){
            return (<WrappedComponent {...this.props}/>)
        }
    }
}

// 普通组件
class WrappedComponent extends Component {
    render(){
      // ... 组件的内容
    }
}


// 使用高阶组件
export default withHOC(WrappedComponent)

上面的例子的作用就是传入了一个作为参数的组件(WrappedComponent),现在的这个高阶组件将传入的组件,不做修改,直接传出。而且将高阶组件的props传给了WrappedComponent组件。这就是一个最简单的高阶组件用到的属性代理功能

操作props

对于高阶组件的返回,可以返回有状态,也可以返回无状态组件,在下面的例子中我们向WrappedComponent组件中添加了固定的属性name,因此WrappedComponent组件多了一个name的属性

// 无状态组件
const HOC = (WrappedComponent)=>{
    const newProps = {name :"HOC"}
    return <WrappedComponent {...props} {...newProps}>
}

// 有状态组件
const HOC =(WrappedComponent)=>
    class wrapperComponent extends wrappedComponent {
        render(){
            const newProps ={
                name : 'HOC'
            }
            return <wrappedComponent {...this.props} {...newProps}>
        }
    }

获得ref的引用

在代理属性中,我们可以直接拿到被包裹的组件的实例(ref)


import React,{Component} from 'React'

const HOC =(WrappedComponent)=>
    class WrapperComponent extends Component {
        storeRef(ref){
            this.ref = ref;
        }
        render(){
            return <WrappedComponent {...this.props} ref ={:: this.storeRef} />
        }
    }

WrapperComponent渲染接受后,我们就可以拿到WrappedComponent组件的实例,进而实现调用实例方法的操作(这样的写法并不推荐)

抽象State

我们将一个不受控的组件向受控组件的转换,我们的做法是将被包裹(WrappedComponent)的组件状态提到包裹组件中(WrapperComponent)中。

class WrappedComponent  extends Component{
    render(){
        return <Input name ="name" {...this.props}>
    }
}

const HOC =(WrappedComponent)=>{
    class WrapperComponent extends Component {
        constructor(props){
            super(props);
            this.state ={
                name:''
            }
            
            this.onChangeName = this.onChangeName.bind(this)
        }
        onChangeName(event){
            this.setState({
                name : event.target.value
            })
        }
        render(){
            const newProps = {
                name :{
                    value:this.state.name,
                    onChange:this.onChangeName
                    },
            }
            return <WrappedComponent {...this.props} {...newProps}>
        }
    }
}

用其他元素包裹组件

我们可以通过类似:

render(){
    <div>
        <WrappedComponent {...this.props}/>
    </div>
}

当我们想要实现一个统一样式的时候,我么可以使用一个div来进行包裹

我们从生命周期的角度进行分析,我们在组件渲染阶段,先渲染WrappedComponent在渲染WrapperComponent组件(componentDidMount),而在卸载的时候,我们先卸载WrapperComponent 再卸载 WrappedComponent 的时候(ComponentWillUnmount)

反向继承

反向继承就是说返回的组件去继承之前的组件

const HOC = (WrappedComponent)=>
        class extends WrappedComponent{
            render(){
                return super.render()
            }
        }

我们返回的组件确实是继承WrappedComponent,所有的调用将是反向调用的,所以这样的方式叫做反向继承。

渲染劫持

我们可以根据自己的想法来控制WrappedComponent的渲染过程,从而控制渲染的结果,我们可以通过传参数决定是否来渲染组件

const HOC =(WrappedComponent)=>

    class extends WrappedComponent{
        render(){
            if(this.props.isRender){
                return super.render()
            }else{
                return null
            }
        }
    }

我们可以通过修改参数来改变render的结果,比如说我们对组件内的值进行修改,原来应该显示hello后来显示word

const HOC =(WrappedComponent)=>
    class extends WrappedComponent{
        render(){
            const elements = super.render();
            let newProps ={}
            if(elements && elements.Type === 'input'){
                newProps ={value:"word"}
            }
            const props =Object.assign({},elements.props,newProps)
            const newElements = React.cloneElement(elements,props, elements.props.children)
            
            return newElements
        }
    }
    
const WrappedComponent extends Component {
    render(){
        return(
            <input value ="hello">
        )
    }
}

export default HOC(WrappedComponent)

在上面的例子中我们得到的结果是input框中显示的是word

操作props和state

当我们又需要的时候我们可以读取propsstate的值,甚至是修改,和删除这些值

import React,{Component} from 'react'

const HOCFactory = (...params) =>{
    return (WrappedComponent) =>{
        return class HOC extends Component {
            render(){
                return <WrappedComponent {...this.props} />
            }
        }
    }
}

HOCFactory(param)(WrappedComponent)

高阶组件的使用方法

假设有一个简单的组件Student,又nameage两个通过props传入后初始化state,一个年龄输入框,一个点击后就会聚焦在input框的button和一个静态方法

import React,{Component} from 'react'

class Student extends Component{
    static staticFunction(){
        console.log("哇卡卡卡")
    }
    constructor(props){
        super(props);
        console.log("构建器")
        this.focus = this.focus.bind(this)
    }
    componentWillMount(){
        console.log("组件将要构造")
        this.state({
            name:this.props.name,
            age:this.props.age
        });
    }
    
    componentDidMound(){
        console.log("构建完成了")
    }
    
    componentWillReceiveProps(nextProps){
        console.log("组件接受的属性发生改变了")
        console.log(nextProps)
    }
    
    focus(){
        this.inputElemenr.focus();
    }
    
    render(){
        return(
            <p>姓名:{this.state.name}</p>
            <p>
                年龄:
                <input  value ={this.state.age} ref ={(input)=>{this.inputElement = input}}/>
            </p>
            <p>
                <button value ="点一点" onClick ={this.focus}/>
            </p>
        )
    }
}
直接返回一个没有状态的组件
const HOC =(WrappedComponent) =>{
    const newProps ={
        name :"kim"
    }
    return props => < WrappedComponent {...props} {...newProps}/>
}

无状态组件是没有自己的生命周期和state,这样的方式常常用于对组件的props进行简单的统一处理

可以

  • 原组件所在位置
  • 可以获取并操作props
  • 可以获取到原组件的生命周期方法

不可以

  • 无法影响原组件的生命周期方法
  • 无法劫持原组件的生命周期

有争议

  • 能否操作并获取到state

    可以通过props和回调函数对state进行操作

  • 能否通过ref访问到原组件中的dom元素

    因为无状态组件是没有实例,所以refthis都是无法访问的,但是可以控制子组件的ref回掉函数来访问子组件的ref

  • 能否渲染劫持

  • 可以通过props来控制是否渲染以及传入数据,但对WrappedComponent内部的render的控制并不强

ref的相关访问,我们用上面的例子进行扩展,高阶组件:

const HOC =(WrappedComponent)=>{
    let inputElement = null;
    const handleClick =()=>{
        inputElement.focus();
    }
    const wrappedComponentStaic =()=>{
        WrappedComponent.staticFunction()
    }
    
    return props =>(
        <div>
            <WrappedComponent
            inputRef ={(el)=>inputElement = el}
            {...props}
            />
            <button onClick={handleClick}>focus子组件input</button>
            <button onClick={wrappedComponentStaic}>调用子组件static</button>
        </div>
    )
}

const WrapperComponent = EnhanceWrapper(Student);

当子组件需要传入父组件传入的ref回调函数

<input 
    ref ={(input)=>{
        this.inputElement = input
    }}
/>

修改成

<input 
    ref ={(input)=>{
        this.inputElement = input
        this.props.inputRef(input); 
    }}
/>
直接返回一个新的class组件
const HOC =(WrappedComponent)=> {
    return class WrappedComponent extends React.Component {
        render() {
           return <WrappedComponent {...this.props} />;
        }
    }
}

可以

  • 原组件所在位置
  • 可以获取并操作props
  • 可以获取到原组件的生命周期方法
  • 影响原组件生命周期等方法

不可以

  • 能否劫持原组件生命周期

有争议

  • 能否操作并获取到state

    可以通过props和回调函数对state进行操作

  • 能否通过ref访问到原组件中的dom元素

    ref无法通过this来直接的访问,但是依然可以根据上面用到的回调函数来访问

  • 能否劫持原组件生命周期

    高阶组件和原组件的生命周期完全是React父子组件的生命周期关系

  • 能否渲染劫持

    可以通过props来控制是否渲染以及传入数据,但对WrappedComponent内部的render的控制并不强

const HOC =(WrappedComponent)=>{
return class WrapperComponent extends Component{
       static wrappedComponentStaic(){
           console.log("调用静态方法")
       }
       constructor(props) {
            super(props);
            console.log("构造器");
            this.handleClick = this.handleClick.bind(this);
        }
        componentWillMount(){
            console.log("组件将要构造")
        };
         componentDidMound(){
            console.log("构建完成了")
        }
        handleClick(){
            this.inputElement.focus();
        }
        render(){
            return(
                 <div>
                    <WrappedComponent
                     inputRef ={(el)=>inputElement = el}
                     {...this.props}
                    />
                    <button onClick={this.handleClick}>focus子组件input</button>
                    <button onClick={this.constructor.wrappedComponentStaic}>调用子组件static</button>
        </div>
            )
        }
    }

    
    return props =>(
        <div>
            <WrappedComponent
            inputRef ={(el)=>inputElement = el}
            {...props}
            />
            <button onClick={handleClick}>focus子组件input</button>
            <button onClick={wrappedComponentStaic}>调用子组件static</button>
        </div>
    )
}

const WrapperComponent = EnhanceWrapper(Student);
继承原来的组件返回一个新的组件
const  HOC =(WrappedComponent)=> {
    return class WrappedComponent extends WrappedComponent {
        render() {
            return super.render();
        }
    }
}

也就是反向继承,这个方法最大的特点就是可以 可以

  • 原组件所在位置(能否被包裹或包裹其他组件)
  • 能否取到或操作原组件的props
  • 能否取到或操作state
  • 能否通过ref访问到原组件中的dom元素
  • 是否影响原组件生命周期等方法
  • 是否取到原组件static方法
  • 能否劫持原组件生命周期
  • 能否渲染劫持
const HOC = (WrappedComponent) =>{
    return class WrapperComponent extends WrappedComponent {
        constructor(props){
            super(props)
            console.log("构造器")
            this.handleCLick = this.handleClick.bind(this)
        }
        handleClick (){
            this.inputElement.focus();
        }
        render(){
            return(
                <div>
                    {super.render()}
                     <button onClick={this.handleClick}>focus子组件input</button>
                    <button onClick={WrapperComponent.wrappedComponentStaic}>调用子组件static</button>
                </div>
            )
        }
    }
}

场景举例

页面的复用

项目中的两个ui完全是一致的,但是犹豫业务的不同,所以数据源和部分的文案不一样,如果我们全部重写,那样代码会有很多重复的部分,所以这个时候我们可以用到高阶组件进行封装

我们的做法就是将获取数据的函数作为参数一样传递,然后返回高阶组件

import React,{Component} from 'react'

class ShopList extends Component{
    componentWillMount(){
        // 内容
    }
    render(){
       // 使用props中的data来进行渲染
    }
}
// 使用上面的普通组件

const shopListFetch (fetchData,defaultProps) =>{
    return class extends Component{
        constructor(props){
            super(props)
            this.state ={
                data:[]
            }
        }
        async componentWillMont(){
           const data = await fetch();
           this.setState({
               data:data
           })
        }
        render(){
           return <ShopList data = {this.state.data} {...defaultProps} {...this.props}> 
        }
   }
}

export default shopListFetch

当组件真正的调用的时候 ,不同的组件只要修改不同的参数就可以

// 获取后端的数据的处理函数 getShopListFirst

const defaultProps ={
    emptyMessage :'暂无数据'
}

const FirstShop = shopListFetch(getShopListFirst,defaultProps)

页面鉴权

在业务中主要体现就是白名单的功能,在白名单的用户可以看到,但是不在白名单的用户是看不到这部分的功能,也不展示业务数据,一周后会去掉白名单,所有的功能对所有的用户开发,而且这个白名单影响多个页面 我们期望在后续维护中对功能的改动最小,影响也是最小的

最简单最直白的方法就是在代码中直接加判断,如果在白名单内就显示真正的业务代码,但是这样的话,就会有一个问题,就是在后续删除的时候,很多页面每一个页面都要删除,没有做到耦合

页面一

import React,{Component} from 'react';

class PageFirst extends Component {
  componentDidMount() {
      // 获取业务数据
  }
  render() {
      // 页面渲染
  }
}
export default PageFirst

页面二

import React,{Component} from 'react';

class PageSecond extends Component {
 componentDidMount() {
     // 获取业务数据
 }
 render() {
     // 页面渲染
 }
}
export default PageSecond

我们的想法就是在顶层进行封装

const AuthWrapper =(WrappedComponent)=>{
    class AuthWrappedComponent extends Component{
        construcot(props){
            super(props);
            this.state={
               permissionDenied = -1 
            }
       }
       async componentDidMount(){
           const permissionDenied = await whiteList();
           this.setState({
               permissionDenied
           })
       }
       render(){
           const {permissionDenied} = this.state
           if(!permissionDenied){
               return (<>功能即将上线,敬请期待</>)
           }
           return <WrappedComponent {...this.props} />;
       }
           
       }
   }
}

业务代码没有变,符合开闭原则。鉴权与业务完全解耦,也避免鉴权失败情况下多余的数据请求,只需要增加/删除一行代码,改动一行代码,即可增加/去除白名单的控制。

日志的打点

所有使用React的前端项目页面需要增加PVUV,性能打点。每个项目的不同页面顶层组件生命周期中分别增加打点代码无疑会产生大量重复代码。

参考文档

React 高阶组件(HOC)入门指南

React高阶组件实践