美文网首页
聊一聊React高阶组件(HOC)

聊一聊React高阶组件(HOC)

作者: 喜剧之王爱创作 | 来源:发表于2021-04-21 20:33 被阅读0次

    写在前面(说点废话)

    作为一个react开发者,提起高阶组价,我想并不陌生,即使是没听过,我相信,你毫无察觉的用过,比如redux中的connect函数,比如antd 3.x中的createForm方法,甚至ES6中的filtermapreduce也是高阶函数。等等....?主题不是说“高阶组件”吗?怎么说了一堆函数,组件不应该是渲染UI的吗?当然了,小编之所以这么叫,就是为了告诉大家高阶组件也叫高阶函数,是高阶函数在react中的叫法,那么今天,小编就带你聊一聊react的高阶函数,以及它有什么样的实际意义。

    什么是高阶组件

    正如前面提到的,ES6的filtermap也可以被称为是高阶函数,那么它们的特点我想大家也知道,那就是“对一个数组做遍历后返回一个新的数组”,其实在react HOC中,也是这样的用法,在官网也给出了比较明确的定义

    高阶组件是参数为组件,返回值为新组件的函数。
    组件是将props装换为UI,而高阶组件是将组件转换为另一个组件。

    高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。

    现在我们对HOC的概念和它做的主要事情都搞清楚了,那么接下来,我们就来根据一个假设的需求来写一个高阶组件(同HOC)吧。
    要写一个高阶组件,我们先得知道高阶的使用场景,毕竟有些功能是可以用普通的封装和复用来解决的,使用HOC反而让其变得复杂起来。

    • 代码复用,逻辑抽象。(也就是说,我们不再需要把一些公共逻辑暴露出来来复用,完全可以抽象化,就像不可以不用理会connect是怎么把redux和react联系起来也可以正常使用reudx一样)
    • Props更改,我们可以通过属性代理的方式,对原来的组件Props做出相应的更改。
    • State抽象和更改(也就是说,我们可以通过HOC包裹一个组件后,让其有其他的状态,或者对其状态做一些处理。)

    通过demo来演示HOC的使用场景

    上面我们说了一些概念理论性的东西,如果没有接触过HOC或者HOC新手来说,可能还是一头雾水,那么现在就用一些简单的demo来演示一些HOC的使用场景。
    假设需求:现在我们有一个数字展示组件,接受一个展示的数字,他的父组件是一个“加法器”组件,每次点击按钮,会将状态+1,然后传给展示组件展示。如下图那样



    代码如下:

    父组件:

    import React, { useState } from 'react'
    import CountView from './components/hoc1'
    import { Button } from 'antd'
    
    const Hoc = () => {
      const [count, setCount] = useState(1)
      const btnClick = () => {
        setCount(count + 1)
      }
      return (
        <>
          <h1>HocDemo测试</h1>
          <CountView
            count = {count}
          />
          <Button onClick={btnClick} type="primary" style={{ marginTop: '15px'}}>点我+1</Button>
        </>
      )
    }
    
    export default Hoc
    

    子组件(数字展示组件)

    import React from 'react'
    
    const CountView = (props: any) => {
      const { count } = props
      return (
        <div
          style={{
            width: '50px',
            height: '50px',
            background: '#090',
            color: '#fff',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center'
           }}
        >
          {count}
        </div>
      )
    }
    export default CountView
    

    可以看出,我们的代码非常简单(实际上,我们面对的可能是一个非常复杂的子组件和交互逻辑),现在我们需要将需求变更:要对当前的展示数字乘2。当前了,如果要完成这个需求,我们有更多方法,假设CountView是一个第三方组件,我们不方便改它的源码渲染逻辑,对于父组件的props逻辑更改,可能是一个首选的方案,但是我们这样的需求有好几个地方,并且可能后面还会改(听起来很变态的产品需求),那么去修改父组件的逻辑就会变得工作量很大,也很麻烦,接下来,让我们用高阶组件的方式来实现代码的复用吧。

    定义高阶组件

    通过上面的概念讲解,我们了解到,高阶组件是参数为组件,返回值为新组件的函数。,那么我们这样去定义:

    import React from 'react'
    
    const double = (WrappedComponent: any) => {
      return (props: any) => {
        const { count } = props
        return (
          <WrappedComponent count={count * 2} />
        )
      }
    }
    export default double
    
    const CountView = (props: any) => {
      const { count } = props
      return (
        <div
          style={{
            width: '50px',
            height: '50px',
            background: '#090',
            color: '#fff',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center'
           }}
        >
          {count}
        </div>
      )
    }
    export default double(CountView)
    

    实现了功能:


    实际上这是个动图,我偷懒了,但确实是double了

    通过上面的代码和效果,我们应该知道

    HOC 不会修改传入的组件,也不会使用继承来复制其行为。相反,HOC 通过将组件包装在容器组件中来组成新组件。HOC 是纯函数,没有副作用。

    实际上,上面的功能中,高阶组件接收被包裹组件CountView的所有props,我们只是将props做了更改,然后传给了CountView组件,这样,如果我们只需要在需要做此需求更改的地方,套上这个高阶组件就好了,如果有逻辑修改,我们也只需要修改高阶组件就好了。
    细想一下,是不是和我们上面提到的高阶组件的作用一样呢?

    • 代码复用,逻辑抽象。(也就是说,我们不再需要把一些公共逻辑暴露出来来复用,完全可以抽象化,就像不可以不用理会connect是怎么把redux和react联系起来也可以正常使用reudx一样)
    • Props更改,我们可以通过属性代理的方式,对原来的组件Props做出相应的更改。

    实际上,我们上面的高阶组件的形式叫做属性代理,是高阶组件的一个主流使用方式。这里有一个小弯,为什么高阶组件返回组件的props会和被包裹组件的props一样呢?毕竟是两个组件,如果你光看高阶组件的逻辑,这里很难理解,感觉很绕,如果你把眼光放在父组件,也就是使用“被包裹后的组件”的地方,你会发现:我们在使用被高阶组件包裹后的组件和使用不被高阶组件包裹的组件,他的使用方式完全一样,所以也就导致了,我们传到高阶组件里面的props和被包裹组件应有的props完全一样,这就是高阶组件属性代理的原理,同时,这也体现了他的便利,我们在封装完高阶组件之后,只需要在使用他的地方做一个调用即可,对于原逻辑并不造成任何影响
    上面在说高阶组件优点的时候,还有一个“State抽象和更改”,这里同样做一下demo演示,我们需要点击数字展示区域的时候,改变他的背景颜色,我们对代码做一下修改:
    CountView组件

    import React from 'react'
    import double from './double'
    
    const CountView = (props: any) => {
      const { count, bg, divClick } = props
      return (
        <div
          style={{
            width: '50px',
            height: '50px',
            background: bg,
            color: '#fff',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center'
          }}
          onClick={divClick}
        >
          {count}
        </div>
      )
    }
    
    export default double(CountView)
    

    Hoc

    import React, { useState } from 'react'
    
    const double = (WrappedComponent: any) => {
      return (props: any) => {
        const [ bg, setBg] = useState('#090')
        const { count } = props
        const divClick = () => {
          setBg('#900')
        }
        return (
          <WrappedComponent divClick={divClick} bg={bg} count={count * 2} />
        )
      }
    }
    export default double
    
    实际上,这是动图,小编工具出了点问题,但这个确实是点击后变红的~
    上面的代码中,我们做了这么几件事,首先我们要做得演示是抽象state,这里无论是类组件还是函数组件使用hooks的状态,都是可以改变的,上面的代码中,我们做了这样的事情。
    • 将CountView组件的背景和点击事件通过props抽象出来,如果不抽象的话,我们应该将颜色状态和点击事件都定义在本组件中,但是我们可以使用高阶组件来抽象,那么就是将状态抽离到高阶组件返回的组件中,然后通过高阶组件属性代理的方式,将props传回来,这样,我们就在不修改调用方式的情况下,实现了状态的抽象(让状态不存在于本组件中)。
    • 在高阶组件返回的组件中,我们将状态和事件定义在其中,再通过props传给被包裹组件,这样,我们将被包裹组件的state抽象化了。
    小结
    1. 高阶组件的实现方法,其实包括以下两种
    • 属性代理
    • 反向继承

    我们上面的demo和讲解都是讲解的属性代理,关于反向继承,后面有时间有机会再讲(有点难),感兴趣的同学可以自行学学,实际上,因为现在类组价用的并不多,反向继承也就用的不多了。一般的,我们通过属性代理的方式就可以解决大部分问题。

    1. 不要改变原始组件,使用组合
      我们在做高阶组件的封装的时候,不要试图通过prototype或者其他方式修改组件!比如下面的demo(摘自官网)
    function logProps(InputComponent) {
      InputComponent.prototype.componentDidUpdate = function(prevProps) {
        console.log('Current props: ', this.props);
        console.log('Previous props: ', prevProps);
      };
      // 返回原始的 input 组件,暗示它已经被修改。
      return InputComponent;
    }
    
    // 每次调用 logProps 时,增强组件都会有 log 输出。
    const EnhancedComponent = logProps(InputComponent);
    

    我们在HOC中,修改了被包裹组件的生命周期方法,让他打印一些东西,这样就会带来下面的问题

    • 被包裹组件再也无法像 HOC 增强之前那样使用了,因为他已经被修改。
    • 如果你再用另一个同样会修改 componentDidUpdate 的 HOC 增强它,那么前面的 HOC 就会失效!同时,这个 HOC 也无法应用于没有生命周期的函数组件。
      所以,修改传入组件的 HOC 是一种糟糕的抽象方式。调用者必须知道他们是如何实现的,以避免与其他 HOC 发生冲突。这显然让原本可以提效的东西变得更糟糕了!

    相反的,我们应该使用组合的方式,那么我们使用组合的方式,修改上面的组件

    function logProps(WrappedComponent) {
      return class extends React.Component {
        componentDidUpdate(prevProps) {
          console.log('Current props: ', this.props);
          console.log('Previous props: ', prevProps);
        }
        render() {
          // 将 input 组件包装在容器中,而不对其进行修改。Good!
          return <WrappedComponent {...this.props} />;
        }
      }
    }
    

    这样,我们实现了相同的功能,但是不用修改原始组件,也避免了和其他HOC冲突的情况,可以放心的使用这种抽象的逻辑抽离,并且也不用再关心,包裹的组件是函数组件还是类组件,高阶组件是纯函数、没有副作用
    关于高阶组件,官网还有一些约定,本文就把这些零碎的知识点整合一下把~并做适当的解释。

    约定

    将不相关的 props 传递给被包裹的组件
    render() {
      // 过滤掉非此 HOC 额外的 props,且不要进行透传
      const { extraProp, ...passThroughProps } = this.props;
    
      // 将 props 注入到被包装的组件中。
      // 通常为 state 的值或者实例方法。
      const injectedProp = someStateOrInstanceMethod;
    
      // 将 props 传递给被包装组件
      return (
        <WrappedComponent
          injectedProp={injectedProp}
          {...passThroughProps}
        />
      );
    }
    

    像上面的代码一样,我们把HOC中和自身无关的props透传,也就是说,我们我们在使用这个被高阶组件包裹过的组件后,可能会收到一些无关的属性,我们不应该将这些无关的属性传给被包裹组件,这样可以保证HOC的复用性和灵活性,否则,一些无关的属性可能影响他包裹的组件。

    最大化可组合性
    • 并不是所有的 HOC 都一样。有时候它仅接受一个参数,也就是被包裹的组件:
    const NavbarWithRouter = withRouter(Navbar);
    
    • HOC 通常可以接收多个参数。
    const CommentWithRelay = Relay.createContainer(Comment, config);
    
    包装显示名称以便轻松调试

    HOC 创建的容器组件会与任何其他组件一样,会显示在 React Developer Tools 中。为了方便调试,请选择一个显示名称,以表明它是 HOC 的产物。
    最常见的方式是用 HOC 包住被包装组件的显示名称。比如高阶组件名为 withSubscription,并且被包装组件的显示名称为 CommentList,显示名称应该为 WithSubscription(CommentList)

    注意事项

    高阶组件在使用的时候,有一些需要注意的地方,这里大家一定要关注一下

    • 要在 render 方法中使用 HOC
      如果大家了解过react的更新机制,就应该知道,react在更新的时候,是要做属性或者节点的比较的
    render() {
      // 每次调用 render 函数都会创建一个新的 EnhancedComponent
      // EnhancedComponent1 !== EnhancedComponent2
      const EnhancedComponent = enhance(MyComponent);
      // 这将导致子树每次渲染都会进行卸载,和重新挂载的操作!
      return <EnhancedComponent />;
    }
    

    因为在render中使用HOC会导致每次render都创建一个新的HOC,这样会导致性能的问题,同时组件的重载会导致组件或者起子组件的状态丢失这样肯定会带来一些负面的影响。

    • 务必复制静态方法

    当你将 HOC 应用于组件时,原始组件将使用容器组件进行包装。这意味着新组件没有原始组件的任何静态方法。也就是,我们在使用高阶组件包裹一个组件后偶,因为是新生成的组件,所以里面的静态方法会丢失,所以我们要把原始组件的静态方法复制到高阶函数返回的组件中。

    // 定义静态函数
    WrappedComponent.staticMethod = function() {/*...*/}
    // 现在使用 HOC
    const EnhancedComponent = enhance(WrappedComponent);
    
    // 增强组件没有 staticMethod
    typeof EnhancedComponent.staticMethod === 'undefined' // true
    

    可以这样修改

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

    玩意静态方法很多,有一些没必要复制怎么办?那么你可以在定义原始组件的时候,额外的导出他的静态方法,这样,我们在使用的时候,按需引入就好了

    // 使用这种方式代替...
    MyComponent.someFunction = someFunction;
    export default MyComponent;
    
    // ...单独导出该方法...
    export { someFunction };
    
    // ...并在要使用的组件中,import 它们
    import MyComponent, { someFunction } from './MyComponent.js';
    
    Refs 不会被传递

    因为我们的高阶组件使用的是属性代理(即使是反向继承),ref都不会被当成props传递和代理,这时候,你可能需要使用React.forwardRef来做ref转发了,比如下面这样

    import React, { forwardRef } from 'react'
    import double from './double'
    
    const CountView = (props: any, ref: any) => {
      const { count, bg, divClick} = props
      return (
        <div
          style={{
            width: '50px',
            height: '50px',
            background: bg,
            color: '#fff',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center'
          }}
          onClick={divClick}
          ref={ref}
        >
          {count}
        </div>
      )
    }
    export default double(forwardRef(CountView))
    

    HOC

    import React, { useState, useRef } from 'react'
    
    const double = (WrappedComponent: any) => {
      return (props: any, ref: any) => {
        const [ bg, setBg] = useState('#090')
        const  WrappedComponentRef = useRef()
        const { count } = props
        const divClick = () => {
          setBg('#900')
          console.log(WrappedComponentRef.current)
        }
        return (
          <WrappedComponent ref={WrappedComponentRef} divClick={divClick} bg={bg} count={count * 2} />
        )
      }
    }
    export default double
    

    这样我们就能通过ref转发的形式正常的访问被包裹组件中的Ref了。


    因为Ref不是props,所以我们无法通过属性代理的方式传递。一定要通过ref转发,如果你对Ref转发不属性,那么可以看我之前写的一篇关于ref的文章

    写在后面

    本文介绍了高阶组件(HOC,也叫高阶函数)的用法,如果你弄名了,那你会发现,他可以优雅的处理好多逻辑的抽离,比普通的封装更抽象,还更加灵活,文中我是用一个加法器做得demo,现实开发中,加法器这种简单的demo肯定会很少,一般是比较复杂的逻辑,我们可以借助这种思想来帮我们完成更加复杂的封装和抽离。那么肯定有小伙伴会问,高阶组件到底有什么实际作用呢?有哪些业务场景需要用到呢?(下面内容可以略过)
    我在写文章的时候,也在思考这个问题,毕竟花时间去研究一个没有实际意义的东西也是没啥意思,我想到了两种现实的实用场景

    • 我们可以做公关的鉴权逻辑,比如通过属性代理的方式,控制不同角色权限下一个组件的props渲染和事件执行方式,这样就可以做到业务和权限的解耦。
    • 统一格式化数据。在写文章的时候,中间和群友交流问题,正好他提出了一个问题,我觉得正是高阶组件的使用场景,问题是这样的。

    在使用antd组件库时候,treeData的类型是这样的array<{key, title, children, [disabled, selectable]}>也就是说,当我们的数据源不符合{title,value}的形式,将会出现问题

    对于上述问题,我当时正在写文章,于是就想到了通过高阶函数属性代理去做,具体方式,如果你读懂了上面的文章,应该不能想象,当然了,上面的问题,我们也可以使用一个方法包裹一些,返回这个tree组件,就相当于做了一个简单的组件封装,就像下面这样


    如果你已经很了解高阶组件了,那么就很容易知道高阶组件的优势了,首先同样的问题除了tree组件还有treeSelect组件,甚至还有其他的组件,他们都面临着这样的问题,如果使用上面的封装,那么重复工作将做不少,如果使用高阶组件,就不用管组件的类型是什么,我们需要返回什么样的组件处理什么样的props了,我们只需要使用高阶组件对全部属性做代理,单独处理数据源就好了。这样一看是不是高阶组件更灵活呢?

    好了,说了这么多,就到这里吧。有问题欢迎评论探讨哦~
    demo git 源码https://github.com/sorryljt/demo-hoc-double

    相关文章

      网友评论

          本文标题:聊一聊React高阶组件(HOC)

          本文链接:https://www.haomeiwen.com/subject/wekilltx.html