Skip to content

高阶组件HOC - 小试牛刀 #6

Description

@SmallStoneSK

高阶组件HOC - 小试牛刀

1. 前言

老毕曾经有过一句名言,叫作“国庆七天乐,Coding最快乐~”。所以在这漫漫七天长假,手痒了怎么办?于是乎,就有了接下来的内容。。。

2. 一个中心

今天要分享的内容有关高阶组件的使用。

虽然这类文章早已经烂大街了,而且想必各位看官也是稔熟于心。因此,本文不会着重介绍一堆HOC的概念,而是通过两个实实在在的实际例子来说明HOC的用法和强大之处。

3. 两个例子

3.1 例子1:呼吸动画

首先,我们来看第一个例子。喏,就是这个。

是滴,这个就是呼吸动画(录的动画有点渣,请别在意。。。),想必大家在绝大多数的APP中都见过这种动画,只不过我这画的非常简陋。在数据ready之前,这种一闪一闪的呼吸动画可以有效地缓解用户的等待心理。

这时,有人就要跳出来说了:“这还不简单,创建个控制opacity的animation,再添加class不就好了。。。”是的,在web的世界中,css animation有时真的可以为所欲为。但是我想说,在RN的世界里,只有Animated才真的好使。

不过话说回来,要用Animated来做这个呼吸动画,的确也很简单。代码如下:

class BreathLoading extends React.PureComponent {

  componentWillMount() {
    this._initAnimation();
    this._playAnimation();
  }

  componentWillUnmount() {
    this._stopAnimation();
  }

  _initAnimation() {
    this.oritention = true;
    this.isAnimating = true;
    this.opacity = new Animated.Value(1);
  }

  _playAnimation() {
    Animated.timing(this.opacity, {
      isInteraction: false,
      duration: params.duration,
      toValue: this.oritention ? 0.2 : 1,
      easing: this.oritention ? Easing.in : Easing.easeOut
    }).start(() => {
      this.oritention = !this.oritention;
      this.isAnimating && this._playAnimation();
    });
  }

  _stopAnimation = () => this.isAnimating = false;

  render = () => <Animated.View style={{opacity: this.opacity, width: 100, height: 50, backgroundColor: '#EFEFEF'}}/>;

}

是的,仅二十几行代码我们就完成了一个简单地呼吸动画。但是问题来了,假如在你的业务需求中有5个、10个场景都需要用到这种呼吸动画怎么办?总不能复制5次、10次,然后修改它们的render方法吧?这也太蠢了。。。

有人会想到:“那就封装一个组件呗。反正呼吸动画的逻辑都是不变的,唯一在变的是渲染部分。可以通过props接收一个renderContent方法,将渲染的实际控制权交给调用方。”那就来看看代码吧:

class BreathLoading extends React.PureComponent {
  // ...省略
  render() {
    const {renderContent = () => {}} = this.props;
    return renderContent(this.opacity);
  }
}

相比较于一开始的例子,现在这个BreathLoading组件可以被复用,调用方只要关注自己渲染部分的内容就可以了。但是说实话,个人在这个组件使用方式上总感觉有点不舒服,有一个不痛不痒的小问题。习惯上来说,在真正使用BreathLoading的时候,我们通常会写出左下图中的这种代码。由于renderContent接收的是一个匿名函数,因此当组件A render的时候,虽然BreathLoading是一个纯组件,但是前后两次接收的renderContent是两个不同的函数,还是会发起一次不必要的domDiff。那还不简单,只要把renderContent中的内容单独抽成一个函数再传进去不就好了(见右下图)。

对溜,这个就是我刚才说的不爽的地方。好端端的一个Loading组件,封装你也封装了,凭啥我还要分两步才能使用。其实BB了那么久,你也知道埋了那么多的铺垫,是时候HOC出场了。。。说来惭愧,在接触HOC之前鄙人一直用的就是上面这种方法来封装。。。直到用上了HOC之后,才发现真香真香。。。

在这里,我们要用到的是高阶组件的代理模式。大家都知道,高阶组件是一个接收参数、返回组件的函数而已。对于这个呼吸动画的例子而言,我们来分析一下:

  1. 接收什么?当然是接收刚才renderContent返回的那个组件啦。
  2. 返回什么?当然是返回我们的BreathLoading组件啦。

OK,看完上面的两句废话之后,再来看下面的代码。

export const WithLoading = (params = {duration: 600}) => WrappedComponent => class extends React.PureComponent {

  componentWillMount() {
    this._initAnimation();
    this._playAnimation();
  }

  componentWillUnmount() {
    this._stopAnimation();
  }

  _initAnimation() {
    this.oritention = true;
    this.isAnimating = true;
    this.opacity = new Animated.Value(1);
  }

  _playAnimation() {
    Animated.timing(this.opacity, {
      isInteraction: false,
      duration: params.duration,
      toValue: this.oritention ? 0.2 : 1,
      easing: this.oritention ? Easing.in : Easing.easeOut
    }).start(() => {
      this.oritention = !this.oritention;
      this.isAnimating && this._playAnimation();
    });
  }

  _stopAnimation = () => this.isAnimating = false;

  render = () => <WrappedComponent opacity={this.opacity} {...this.props}/>;
};

看完上面的代码之后,再回头瞅瞅前面的那两句话,是不是豁然开朗。仔细观察WrappedComponent,我们发现opacity竟然以props的形式传给了它。只要WrappedComponent拿到了关键的opacity,那岂不是想干什么就干什么来着,而且还没有前面说的什么匿名函数和domDiff消耗问题。再配上decorator装饰器,岂不是美滋滋?代码如下:

@WithLoading()
class Test extends React.PureComponent {
  render() {
    const {opacity} = this.props;
    return (
      <View style={{marginTop: 40, paddingHorizontal: 20}}>
        <View style={{marginTop: 20, flexDirection: 'row', justifyContent: 'space-between'}}>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
        </View>
        <View style={{marginTop: 20, flexDirection: 'row', justifyContent: 'space-between'}}>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
        </View>
      </View>
    )
  }
}

相比之下,显然高阶组件的用法更胜一筹。以后不管要做成什么样的呼吸动画,只要加一个@withLoading就搞定了。因为这个高阶函数,赋予了普通组件一种呼吸闪烁的能力(记住这句话,圈起来重点考)。

3.2 例子2:多版本控制的组件

经过上面的例子,我们初步感受到了高阶组件的黑魔法。因为通过它,我们能让一个组件拥有某种能力,能够化腐朽为神奇。。。哦,吹过头了。。。那我们来看第二个例子,也是业务需求中会遇到的场景。为啥?因为善变的产品经常要改版,要做AB!!!

所谓多版本控制的组件,其实就是一个拥有相同功能的组件,由于产品的需求,经历了A版 -> B版 -> C版 -> D版。。。这无穷无尽的改版,有的换个皮肤,改个样式,有的甚至改了交互。

或许对于一个简单的小组件而言,每次改版只要重新创建一个新的组件就可以了。但是,如果对于一个页面级别的Page组件呢?就像下面的这个组件一样,作为容器组件,这个组件充斥着大量复杂的处理逻辑(这里写的是超级简化版的。。。实际应用场景中会复杂的多)。

class X extends Page {

  state = {
    list: []
  };

  componentDidMount() {
    this._fetchData();
  }

  _fetchData = () => setTimeout(() => this.setState({list: [1,2,3]}), 2000);

  onClickHeader = () => console.log('click header');
  
  onClickBody = () => console.log('click body');
  
  onClickFooter = () => console.log('click footer');

  _renderHeader = () => <Header onClick={this.onClickHeader}/>;

  _renderBody = () => <Body data={this.state.list} onClick={this.onClickBody}/>;

  _renderFooter = () => <Footer onClick={this.onClickFooter}/>;

  render = () => (
    <View>
      {this._renderHeader()}
      {this._renderBody()}
      {this._renderFooter()}
    </View>
  );
}

在这种情况下,假如产品要对这个页面做AB该怎么办呢?为了方便做AB,我们当然希望创建一个新的Page组件,然后在源头上根据AB实验分别跳转到PageA和PageB即可。但是如果真的copy一份PageA作为PageB,再修改其render方法的话,那请你好好保重。。。要不然怎么办嘞?另一种很容易想到的办法是在原来Page的render方法中做AB,如下代码:

class X extends Page {

  // ...省略

  _renderHeaderA = () => <HeaderA onClick={this.onClickHeader}/>;

  _renderBodyA = () => <BodyA data={this.state.list} onClick={this.onClickBody}/>;

  _renderFooterA = () => <FooterA onClick={this.onClickFooter}/>;

  _renderHeaderB = () => <HeaderB onClick={this.onClickHeader}/>;

  _renderBodyB = () => <BodyB data={this.state.list} onClick={this.onClickBody}/>;

  _renderFooterB = () => <FooterB onClick={this.onClickFooter}/>;

  render = () => {
    const {version} = this.props;
    return version === 1 ? (
      <View>
        {this._renderHeaderA()}
        {this._renderBodyA()}
        {this._renderFooterA()}
      </View>
    ) : (
      <View>
        {this._renderHeaderB()}
        {this._renderBodyB()}
        {this._renderFooterB()}
      </View>
    );
  }
}

可是这种处理方式有一个很大的弊端!作为Page组件,往往代码量都会比较大,要是再写一堆的renderXXX方法那这个文件势必更加臃肿了。。。要是再改版C、D怎么办?而且非常容易写出诸如version === 1 ? this._renderA() : this._renderB()之类的代码,甚至还有各版本耦合在一起的代码,到了后期就更加没法维护了。

那你到底想怎样。。。为了解决上面臃肿的问题,或许我们可以尝试把这些render方法给移到另外的文件中(这里需要注意两点:由于this问题,我们需要将Page的实例作为ctx传递下去;为了保证组件能够正常render,需要把state展开传递下去),看下代码:

说实话,这段代码写的足够恶心。。。好好的一个组件被拆得支离破碎,用到this的地方全部被替换成了ctx,还将整个state展开传递下去,看着就很隔应,而且很不习惯,对于新接手的人来说也容易造成误解。所以这种hack的方式还是不行,那么到底应该怎么办呢?

噔噔噔噔,高阶组件又要出场了~ 在改造这个Page之前,我们先来想下,现在这个例子和刚才的呼吸动画那个例子有没有什么相似的地方?答案就是:许多逻辑部分都相同,不同点在于渲染部分。所以,我们的重点在于控制render部分,同时还要解决this的指向问题。来看下代码:

重点在两处:一处是constructor的最后一句,我们将renderEntity中方法都绑定到了Page的实例上;另一处则是render方法,我们通过call的方式巧妙地修改了this的指向问题。这样一来,对于PageA和PageB而言,就完全用不到ctx了。我们再来对比下原来的Page组件,利用高阶组件,我们完全就是将相关的render方法挪了一个位置而已,无形之中还保证了本次修改不会影响到原来的功能。

到了这儿,问题似乎都迎刃而解,但其实还有一个瑕疵。。。啥?到底有完没完。。。不信,这时候你给PageB中的子组件再加一个onPressXXX事件试试。是哦,这时候事件该加在哪儿呢。。。很简单,有了renderEntity这个先例,再来一个eventEntity不就好了吗。。。看下代码:

真的是不加不知道,一加吓一跳。。。有了eventEntity之后,思路瞬间豁然开朗。因为通过eventEntity,我们可以将PageA,PageB的事件各自管理,逻辑也被解耦了。我们可以将各版本Page通用的事件仍然保留在Page中,但是各页面独有的事件写在各自的eventEntity中维护。要是日后再想添加新版本的PageC、PageD,或是废弃PageA,维护管理起来都非常方便。

按照剧情,逼也装够了,其实到这里应该要结束了,可是谁让我又知道了高阶组件的反向继承模式呢。。。前一种的方法唯一的缺点就在于为了hack,我们无形中将PageA和PageB拆的支离破碎,各种方法散落在Object的各个角落。而反向继承的巧妙之处就在于高阶函数返回的可以是一个继承自传进来的组件的组件,因此对于之前的代码,我们只要稍加改动即可。看下代码:

相比前一种方法,现在的PageA、PageB显得更加组件了。所以啊,这绕来绕去的,到头来却感觉就只迈出了一小步。。。还记得刚才说要圈起来重点考的那句话吗?对于这个多版本组件的例子,我们只不过是利用高阶组件的形式赋予了PageA,B,C,D这类组件处理该页面业务逻辑的能力。

4. 三点思考

4.1 高阶组件有啥好处?

想必通过上面的两个实际例子,各位看官多多少少已经够体会到高阶组件的好处,因为它确实能够帮助解决平时业务开发中的痛点。其实,高阶组件就是把一些通用的处理逻辑封装在一个高阶函数中,然后返回一个拥有这些逻辑的组件给你。这样一来,你就赋予了一个普通组件某种能力,同时对该组件的入侵也较小。所以啊,如果你的代码中充斥着大量重复性的工作,还不赶紧用起来?

4.2 啥时候用高阶组件?

虽然是建议用高阶组件来解决问题,但可千万别啥都往高阶组件上套。。。实话实说,我还真见过这样的代码。。。但是其实呢,高阶组件本身也只是封装组件的一种方式而已。就比方说文中Loading组件的那个例子,不用高阶不照样能封装一个组件来简化重复性工作吗?

那究竟什么时候用高阶比较合适呢?还记得先前强调了两遍的那句话么?“高阶组件可以赋予一类组件某种能力” 注意这里的关键词【一类】,在你准备使用高阶组件之前想一想,你接下来要做的事情是不是赋予一类组件某种能力?不妨回想一下上面的两个例子,第一个例子是赋予了一类普通组件能够呼吸动画的能力,第二个例子是赋予一类Page组件能够处理当前页面业务逻辑的能力。除此之外,还有一个例子也是特别合适,那就是Animated.createAnimatedComponent,它也是赋予了一类普通组件能够响应Animated.Value变化的能力。所以啊,某种程度上你可以把高阶组件理解为是一种黑魔法,一旦加上了它,你的组件就能拥有某种能力。这个时候,使用高阶组件来封装你的代码再合适不过了。

另外,高阶组件还有一项非常厉害的优势,那就是可以组合。当然了,本文的例子并没有体现出这种能力。但是试想,假如你手上有许多个黑魔法(即高阶组件),当你把它们自由组合在一起加到某个组件上时,是不是可以创造出无限的可能?而相反,如果你在封装一个组件的时候集成了全部这些功能,这个组件势必会非常臃肿,而当另外的组件需要其中某几个类似的功能时,代码还不能复用。。。

4.3 该怎么使用高阶组件?

高阶组件其实共分为两种模式:属性代理 和 反向继承。分别对应上文中的第一个、第二个例子。那该怎么区分使用呢?嘿嘿,自己用用就知道了。看的再多,不如自己动手写一个来的理解更深。本文不是高阶组件的使用教程,只是两个用高阶组件解决实际问题的例子而已。要真想进一步深入了解高阶组件,可以看介绍高阶组件的文章,然后动手实践慢慢体会~ 等到你回过头来再想一下的时候,必定会有一种豁然开朗的感觉。

5. 写在最后

都说高阶组件大法好,以前都嗤之以鼻,直到抱着试一试的心态才发现。。。

真香真香。。。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions