4

浅谈React 高阶组件

 2 years ago
source link: https://segmentfault.com/a/1190000040435527
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

浅谈React 高阶组件

5、6月一直忙着自己的琐事,7月(7月31也还是7月嘛)抽空整理一下旧的内容,有之前的读者提到想了解下高阶组件这块的知识点,就刚好整理了一下。

高阶组件 HOC(HigherOrderComponent) ,听起来像是个一个 React 的高级功能,但是实际上它不属于 React API ,而应该归为一个是使用技巧或者说设计模式。首先直击本质:

高阶组件是一个函数, 并且是一个“参数为组件,返回值为新组件的”的函数。
更直白一点如下:

Fn(组件) => 有更强功能地新组件

这里的Fn 就是高阶组件。

组件是 React 中的一个基本单元,通常它接受一些 props 属性,并最终展示为具体的UI,但是某些场景下传统组件还不足以解决问题。

在此分割线下 ,我们可以暂时抛开枯燥的代码,聊一聊生活中一个常见的场景 - 点奶茶。
在当下生活里,奶茶成为很多人生活中的快乐调剂(毕竟生活已经这么苦了-_-),而品种口味也是多种多样的,比如说基础的分类就有纯茶、奶茶、咖啡、鲜榨果汁等,加料也是五花八门,有芝士,牛乳、干果碎,芋泥等.... (嗯 我准备先点一杯奶茶,喝完回来继续写)

好的,我回来了~

那么现在就可以抽象出几个基础组件:

它们分别都可搭配以下加料:

  • 加碧根果碎

对于不同基础茶加料的逻辑行为,是相似的,所以这两种加料方式就可以设计为高阶组件,这样可以很方便地根据需要生成不同类型地最终奶茶。套用前面地函数表达式也就是:

Fn(基础奶茶) => 不同风味的奶茶

这里的Fn也就是加料函数。它的作用是让一款基本的奶茶通过加料变成一款增强的奶茶。

到此,相信大家对高阶函数的作用有个大概的概念了, 接下来进入正题(醒醒 枯燥的来了)。

从一个常见场景说起

相信前端同学都写过不少后台系统,自然免不了某些常见功能,比如操作日志打印,权限控制等,以操作日志打印为例,要实现以下需求:在进入某些页面组件时,需要打印出日志并传送至服务器。

class Page1 extends React.Component {
  componentDidMount() {
    // 用console.log来模拟日志打印 实际上这里一般会传到服务器保存
    console.log('进入page1');
  }
  
  render(){
    return <div>page1</div>
  }
}

class Page2 extends React.Component {
  componentDidMount() {
    console.log('进入page2');
  }
  
  render(){
    return <div>page2</div>
  }
}

观察这 Page1 Page2 两种组件都存在一部分相似的逻辑:在 componentDidMount 阶段,需要console.log 当前的页面名称。

现在把这部分逻辑移动到一个函数里面:

function withLog (WrappedComponent, pageName) {
   // 这个函数接收一个组件作为参数
   return class extends React.Component {
     componentDidMount() {
       // 用console.log来模拟日志打印 实际上这里一般会传到服务器保存
       console.log(pageName);
     }
     render (){
       // 此处注意要把this.props继续透传下去
       return <WrappedComponent {...this.props} />;
     }
   }
}

此时可以解耦掉打印日志的逻辑和具体组件的关联:

class Page1 extends React.Component {
  // 不必保留打印日志的逻辑了
  render(){
    return <div>page1</div>
  }
}

class Page2 extends React.Component {
  // 不必保留打印日志的逻辑了

  render(){
    return <div>page2</div>
  }
}

// 使用时
const Page1WithLog = withLog(Page1);
const Page2WithLog = withLog(Page2);

这样,就实现了一个简单的高阶组件!

高阶组件做了什么

从上面的例子可以看到,高阶组件是将传入的组件,包装在容器组件(容器组件就是 withLog函数中 return 的匿名组件)中,最后返回了一个有增强功能的新组件。 这里有个很关键的地方是:

  • 不要修改原先的原先的组件!
  • 不要修改原先的原先的组件!
  • 不要修改原先的原先的组件!

熟悉React的同学会发现,它处处都贯彻着函数式编程的思想,同样的,高阶组件必须是个纯函数(相同输入必须返回相同的结果) 这样才能保证组件的可复用性,

高阶组件的组合使用

前面介绍了单独使用一个高阶组件的情况,那么如果要同时使用多个高阶组件呢? 延续前面的例子,我们再设计一个提供权限管理功能的高阶组件:

function withCheckPermission (WrappedComponent){
    return class extends React.Component {
       async componentWillMount() {
         const hasPermission = await getCurrentUserPermission();
         this.setState({hasPermission});
       }
       render (){
         // 此处注意要把this.props继续透传下去
         return (
           {this.state.hasPermission ? 
            <WrappedComponent {...this.props} /> :
           <div>您没有权限查看该页面,请联系管理员!</div>}
         )
       }
   }
}

checkPermission 函数会检查用户权限,以判断是否允许用户访问当前页面,接下来我们希望给前面 Page1 组件同时加上权限控制和日志打印的功能:

// 当然可以这样写

// 1. 首先附加Log功能
const Page1WithLog = withLog(Page1, 'pageName1');
// 2. 在1的基础上附加CheckPermission功能
const Page1WithLogAndPermission = withCheckPermission(Page1WithLog);

实际上可以直接用 compose 来实现, 这样在使用多个高阶组件时可以更简洁:
// tips:  compose(f, g, h) 等同于 (...args) => f(g(h(...args)))
import {compose} from 'redux';
const Page1WithLogAndPermission = compose(
  Page1WithLogAndPermission,
  (Component) => withLog(Component, 'pageName1'),
);

在前面提到过,高阶组件不会破坏被包裹组件本身,因此非常适合灵活使用多个组件,实际上产生的效果很类似于在原有的组件外层再包裹了不同的组件。

使用命名来方便调试

由于高阶组件会在 WrapComponent 外层包裹组件,那么在使用的过程,为了方便调试,就很有必要给每个高阶组件设置 displayName 属性,以前面的withLog 为例:

function withLog (WrappedComponent, pageName) {
   return class extends React.Component {
     static displayName = `withLog(${getDisplayName(WrappedComponent)})`;
     componentDidMount() {
       // 用console.log来模拟日志打印 实际上这里一般会传到服务器保存
       console.log(pageName);
     }
     render (){
       // 此处注意要把this.props继续透传下去
       return <WrappedComponent {...this.props} />;
     }
   }
}

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

这样的话调试过程就可以通过调试器的属性,轻易找到最终代码里的每一层组件。

注意事项其实大部分和高阶组件的实现本质有关, 文中一直在强调,高阶组件的实质是: 用一个新的组件包裹原有的 WrappedComponent 组件,并在新组件增加一些行为,那么包裹势必也会带来一些注意点。

注意传递props

传递props 的意义自然不必多说,除高阶组件本身需要的一些专属props以外,其他的props要继续返回给WrappedComponent ,如下:

function withSomeFeature (WrappedComponent){
    return class extends React.Component {
       // 省略功能
       render (){
         // 此处注意 extraProp表示仅仅是当前这个高阶函数要用的props 
         const { extraProp, ...passThroughProps } = this.props;
         
         // 要把剩下和自己无关的props继续透传下去 
         return<WrappedComponent {...passThroughProps } />
       }
   }
}

不要在render中使用高阶组件

具体来说不要这样使用:

class Test extends React.Component {
  render() {
    const EnhancedComponent = enhance(MyComponent);
    return <EnhancedComponent />;
  }
}

在上面的代码中,每次执行render 时, const EnhancedComponent = enhance(MyComponent); 返回的是不同的新组件(因为组件解析最后实际是一个object,也就是一个引用类型的值,所以每次定义相当于是重新生成一个对象),这样导致的结果是,每次该组件和它的所有子组件状态完全丢失。 所以正确的用法是在组件之外,用高阶组件生成所需要的新组件后直接使用新的组件:

const EnhancedComponent = enhance(MyComponent);

class Test extends React.Component {
  render() {
    return <EnhancedComponent />;
  }
}

拷贝静态方法

同样,这也是包裹带来的问题, 假设 WrappedComponent 上有个非常好用的方法,但是经过高阶组件的增强后,如果不加处理,方法就丢失了:

// WrappedComponent原有一些方法
WrappedComponent.staticMethod = function() {/*...*/}

// 使用 HOC
const EnhancedComponent = enhance(WrappedComponent);
// 增强组件没有 staticMethod
typeof EnhancedComponent.staticMethod === 'undefined' // true

解决方案就是去复制静态方法,常见复制的方法有两种:
明确知道有哪些静态方法要拷贝, 然后用 Enhance.staticMethod = WrappedComponent.staticMethod; 逐个拷贝;
使用 hoist-non-react-statics 自动拷贝:
import hoistNonReactStatic from 'hoist-non-react-statics';
function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  // 核心代码
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}

处理Refs

对于部分使用了 refsWrapComponent ,这是无法直接像 props 的属性一样去透传的, 这个应该使用 React.forwardRef API(React 16.3 中引入)来进行处理。 ref 的特殊之处后面在其他文章里做详细描述。

聊一下反向继承

在文章末尾,也顺带说一下反向继承, 之所以放在最后,是因为这种方式并不是 React 官方推崇的方式,官方文档有这么一句:

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

但是看到挺多现有的文章都介绍过这种用法,那就顺便简单介绍下,仅仅作为了解,并不推荐使用(我目前实践里也尚未遇到非用这种不可的场景,如果碰到后面另行补充)。

回顾一下前面提到高阶组件的时候,提到的一直是 装饰者模式 用新组建包裹旧组件做增强 等关键词,与此不同的是,反向代理的思路是这样的,直接上示例代码:

function withHeader (WrappedComponent){
    // 请注意这里是extends WrappedComponent而不是extends React.Component
    return class extends WrappedComponent {
       render (){
         <div>
            <h1 className='demo-header'></h1>
            // 注意此处要调用父类的render函数
            { super.render() }
         </div>
       }
   }
}

观察这个例子的重点部分: 高阶组件实际上是返回了一个继承WrappedComponent组件的新组件,这也是反向继承命名的由来。在这种模式下,主要可以有两种操作:

  • 渲染劫持,如上图的例子可见,在返回的新组件里其实可以控制WrappedComponentrender结果并且执行各种需要的操作,包括选择性的渲染WrappedComponent的子树
  • 操作 state ,由于在新组件可以通过this访问到WrappedComponent,所以同样可以通过this.state 来修改它。
    这种实现高阶组件的方式如果真的要使用,一定要非常谨慎,渲染劫持需要考虑条件渲染(即不完全返回子树)的情况,而操作state也有可能在某些情况下破坏父组件的原有逻辑。

谨慎使用,谨慎使用,谨慎使用

水着水着又到结尾了,简单回顾下本文的主要内容:

  • 高阶组件的本质是一个函数,入参和返回值均为组件,作用是给组件增强某个特定的功能
  • 高阶组件推荐灵活组合的使用方式
  • 使用过程中要记得一些注意事项
  • 大概了解反向继承的原理,但是要谨慎使用

对于ref这块挖了个坑,因为真要写起来还是蛮多内容的,本着每篇文章应该主题清晰,内容简练,让读者10分钟内学到知识的准则,决定还是后面单独再写

最后的最后,首先是感谢每个关注的读者朋友(尤其是这位催更的读者,有机会请你喝奶茶),欢迎大家关注专栏,也希望大家对于喜爱的文章,能够不吝点赞和收藏,对于行文风格和内容有任何意见的,都欢迎私信交流。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK