高階組件HOC - 小試牛刀

原文地址:https://github.com/SmallStoneSK/Blog/issues/6

1. 前言

老畢曾經(jīng)有過一句名言,叫作“國慶七天樂,Coding最快樂~”。所以在這漫漫七天長假,手癢了怎么辦?于是乎,就有了接下來的內(nèi)容。。。

2. 一個(gè)中心

今天要分享的內(nèi)容有關(guān)高階組件的使用。

雖然這類文章早已經(jīng)爛大街了,而且想必各位看官也是稔熟于心。因此,本文不會(huì)著重介紹一堆HOC的概念,而是通過兩個(gè)實(shí)實(shí)在在的實(shí)際例子來說明HOC的用法和強(qiáng)大之處。

3. 兩個(gè)例子

3.1 例子1:呼吸動(dòng)畫

首先,我們來看第一個(gè)例子。喏,就是這個(gè)。

呼吸動(dòng)畫

是滴,這個(gè)就是呼吸動(dòng)畫(錄的動(dòng)畫有點(diǎn)渣,請別在意。。。),想必大家在絕大多數(shù)的APP中都見過這種動(dòng)畫,只不過我這畫的非常簡陋。在數(shù)據(jù)ready之前,這種一閃一閃的呼吸動(dòng)畫可以有效地緩解用戶的等待心理。

這時(shí),有人就要跳出來說了:“這還不簡單,創(chuàng)建個(gè)控制opacity的animation,再添加class不就好了。。?!笔堑模趙eb的世界中,css animation有時(shí)真的可以為所欲為。但是我想說,在RN的世界里,只有Animated才真的好使。

不過話說回來,要用Animated來做這個(gè)呼吸動(dòng)畫,的確也很簡單。代碼如下:

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'}}/>;

}

是的,僅二十幾行代碼我們就完成了一個(gè)簡單地呼吸動(dòng)畫。但是問題來了,假如在你的業(yè)務(wù)需求中有5個(gè)、10個(gè)場景都需要用到這種呼吸動(dòng)畫怎么辦?總不能復(fù)制5次、10次,然后修改它們的render方法吧?這也太蠢了。。。

有人會(huì)想到:“那就封裝一個(gè)組件唄。反正呼吸動(dòng)畫的邏輯都是不變的,唯一在變的是渲染部分??梢酝ㄟ^props接收一個(gè)renderContent方法,將渲染的實(shí)際控制權(quán)交給調(diào)用方。”那就來看看代碼吧:

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

相比較于一開始的例子,現(xiàn)在這個(gè)BreathLoading組件可以被復(fù)用,調(diào)用方只要關(guān)注自己渲染部分的內(nèi)容就可以了。但是說實(shí)話,個(gè)人在這個(gè)組件使用方式上總感覺有點(diǎn)不舒服,有一個(gè)不痛不癢的小問題。習(xí)慣上來說,在真正使用BreathLoading的時(shí)候,我們通常會(huì)寫出左下圖中的這種代碼。由于renderContent接收的是一個(gè)匿名函數(shù),因此當(dāng)組件A render的時(shí)候,雖然BreathLoading是一個(gè)純組件,但是前后兩次接收的renderContent是兩個(gè)不同的函數(shù),還是會(huì)發(fā)起一次不必要的domDiff。那還不簡單,只要把renderContent中的內(nèi)容單獨(dú)抽成一個(gè)函數(shù)再傳進(jìn)去不就好了(見右下圖)。

對溜,這個(gè)就是我剛才說的不爽的地方。好端端的一個(gè)Loading組件,封裝你也封裝了,憑啥我還要分兩步才能使用。其實(shí)BB了那么久,你也知道埋了那么多的鋪墊,是時(shí)候HOC出場了。。。說來慚愧,在接觸HOC之前鄙人一直用的就是上面這種方法來封裝。。。直到用上了HOC之后,才發(fā)現(xiàn)真香真香。。。

在這里,我們要用到的是高階組件的代理模式。大家都知道,高階組件是一個(gè)接收參數(shù)、返回組件的函數(shù)而已。對于這個(gè)呼吸動(dòng)畫的例子而言,我們來分析一下:

  1. 接收什么?當(dāng)然是接收剛才renderContent返回的那個(gè)組件啦。
  2. 返回什么?當(dāng)然是返回我們的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}/>;
};

看完上面的代碼之后,再回頭瞅瞅前面的那兩句話,是不是豁然開朗。仔細(xì)觀察WrappedComponent,我們發(fā)現(xiàn)opacity竟然以props的形式傳給了它。只要WrappedComponent拿到了關(guān)鍵的opacity,那豈不是想干什么就干什么來著,而且還沒有前面說的什么匿名函數(shù)和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>
    )
  }
}

相比之下,顯然高階組件的用法更勝一籌。以后不管要做成什么樣的呼吸動(dòng)畫,只要加一個(gè)@withLoading就搞定了。因?yàn)檫@個(gè)高階函數(shù),賦予了普通組件一種呼吸閃爍的能力(記住這句話,圈起來重點(diǎn)考)。

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

經(jīng)過上面的例子,我們初步感受到了高階組件的黑魔法。因?yàn)橥ㄟ^它,我們能讓一個(gè)組件擁有某種能力,能夠化腐朽為神奇。。。哦,吹過頭了。。。那我們來看第二個(gè)例子,也是業(yè)務(wù)需求中會(huì)遇到的場景。為啥?因?yàn)樯谱兊漠a(chǎn)品經(jīng)常要改版,要做AB?。?!

所謂多版本控制的組件,其實(shí)就是一個(gè)擁有相同功能的組件,由于產(chǎn)品的需求,經(jīng)歷了A版 -> B版 -> C版 -> D版。。。這無窮無盡的改版,有的換個(gè)皮膚,改個(gè)樣式,有的甚至改了交互。

或許對于一個(gè)簡單的小組件而言,每次改版只要重新創(chuàng)建一個(gè)新的組件就可以了。但是,如果對于一個(gè)頁面級(jí)別的Page組件呢?就像下面的這個(gè)組件一樣,作為容器組件,這個(gè)組件充斥著大量復(fù)雜的處理邏輯(這里寫的是超級(jí)簡化版的。。。實(shí)際應(yīng)用場景中會(huì)復(fù)雜的多)。

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>
  );
}

在這種情況下,假如產(chǎn)品要對這個(gè)頁面做AB該怎么辦呢?為了方便做AB,我們當(dāng)然希望創(chuàng)建一個(gè)新的Page組件,然后在源頭上根據(jù)AB實(shí)驗(yàn)分別跳轉(zhuǎn)到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>
    );
  }
}

可是這種處理方式有一個(gè)很大的弊端!作為Page組件,往往代碼量都會(huì)比較大,要是再寫一堆的renderXXX方法那這個(gè)文件勢必更加臃腫了。。。要是再改版C、D怎么辦?而且非常容易寫出諸如version === 1 ? this._renderA() : this._renderB()之類的代碼,甚至還有各版本耦合在一起的代碼,到了后期就更加沒法維護(hù)了。

那你到底想怎樣。。。為了解決上面臃腫的問題,或許我們可以嘗試把這些render方法給移到另外的文件中(這里需要注意兩點(diǎn):由于this問題,我們需要將Page的實(shí)例作為ctx傳遞下去;為了保證組件能夠正常render,需要把state展開傳遞下去),看下代碼:

說實(shí)話,這段代碼寫的足夠惡心。。。好好的一個(gè)組件被拆得支離破碎,用到this的地方全部被替換成了ctx,還將整個(gè)state展開傳遞下去,看著就很隔應(yīng),而且很不習(xí)慣,對于新接手的人來說也容易造成誤解。所以這種hack的方式還是不行,那么到底應(yīng)該怎么辦呢?

噔噔噔噔,高階組件又要出場了~ 在改造這個(gè)Page之前,我們先來想下,現(xiàn)在這個(gè)例子和剛才的呼吸動(dòng)畫那個(gè)例子有沒有什么相似的地方?答案就是:許多邏輯部分都相同,不同點(diǎn)在于渲染部分。所以,我們的重點(diǎn)在于控制render部分,同時(shí)還要解決this的指向問題。來看下代碼:

重點(diǎn)在兩處:一處是constructor的最后一句,我們將renderEntity中方法都綁定到了Page的實(shí)例上;另一處則是render方法,我們通過call的方式巧妙地修改了this的指向問題。這樣一來,對于PageA和PageB而言,就完全用不到ctx了。我們再來對比下原來的Page組件,利用高階組件,我們完全就是將相關(guān)的render方法挪了一個(gè)位置而已,無形之中還保證了本次修改不會(huì)影響到原來的功能。

到了這兒,問題似乎都迎刃而解,但其實(shí)還有一個(gè)瑕疵。。。啥?到底有完沒完。。。不信,這時(shí)候你給PageB中的子組件再加一個(gè)onPressXXX事件試試。是哦,這時(shí)候事件該加在哪兒呢。。。很簡單,有了renderEntity這個(gè)先例,再來一個(gè)eventEntity不就好了嗎。。??聪麓a:

真的是不加不知道,一加嚇一跳。。。有了eventEntity之后,思路瞬間豁然開朗。因?yàn)橥ㄟ^eventEntity,我們可以將PageA,PageB的事件各自管理,邏輯也被解耦了。我們可以將各版本Page通用的事件仍然保留在Page中,但是各頁面獨(dú)有的事件寫在各自的eventEntity中維護(hù)。要是日后再想添加新版本的PageC、PageD,或是廢棄PageA,維護(hù)管理起來都非常方便。

按照劇情,逼也裝夠了,其實(shí)到這里應(yīng)該要結(jié)束了,可是誰讓我又知道了高階組件的反向繼承模式呢。。。前一種的方法唯一的缺點(diǎn)就在于為了hack,我們無形中將PageA和PageB拆的支離破碎,各種方法散落在Object的各個(gè)角落。而反向繼承的巧妙之處就在于高階函數(shù)返回的可以是一個(gè)繼承自傳進(jìn)來的組件的組件,因此對于之前的代碼,我們只要稍加改動(dòng)即可。看下代碼:

相比前一種方法,現(xiàn)在的PageA、PageB顯得更加組件了。所以啊,這繞來繞去的,到頭來卻感覺就只邁出了一小步。。。還記得剛才說要圈起來重點(diǎn)考的那句話嗎?對于這個(gè)多版本組件的例子,我們只不過是利用高階組件的形式賦予了PageA,B,C,D這類組件處理該頁面業(yè)務(wù)邏輯的能力。

4. 三點(diǎn)思考

4.1 高階組件有啥好處?

想必通過上面的兩個(gè)實(shí)際例子,各位看官多多少少已經(jīng)夠體會(huì)到高階組件的好處,因?yàn)樗_實(shí)能夠幫助解決平時(shí)業(yè)務(wù)開發(fā)中的痛點(diǎn)。其實(shí),高階組件就是把一些通用的處理邏輯封裝在一個(gè)高階函數(shù)中,然后返回一個(gè)擁有這些邏輯的組件給你。這樣一來,你就賦予了一個(gè)普通組件某種能力,同時(shí)對該組件的入侵也較小。所以啊,如果你的代碼中充斥著大量重復(fù)性的工作,還不趕緊用起來?

4.2 啥時(shí)候用高階組件?

雖然是建議用高階組件來解決問題,但可千萬別啥都往高階組件上套。。。實(shí)話實(shí)說,我還真見過這樣的代碼。。。但是其實(shí)呢,高階組件本身也只是封裝組件的一種方式而已。就比方說文中Loading組件的那個(gè)例子,不用高階不照樣能封裝一個(gè)組件來簡化重復(fù)性工作嗎?

那究竟什么時(shí)候用高階比較合適呢?還記得先前強(qiáng)調(diào)了兩遍的那句話么?“高階組件可以賦予一類組件某種能力” 注意這里的關(guān)鍵詞【一類】,在你準(zhǔn)備使用高階組件之前想一想,你接下來要做的事情是不是賦予一類組件某種能力?不妨回想一下上面的兩個(gè)例子,第一個(gè)例子是賦予了一類普通組件能夠呼吸動(dòng)畫的能力,第二個(gè)例子是賦予一類Page組件能夠處理當(dāng)前頁面業(yè)務(wù)邏輯的能力。除此之外,還有一個(gè)例子也是特別合適,那就是Animated.createAnimatedComponent,它也是賦予了一類普通組件能夠響應(yīng)Animated.Value變化的能力。所以啊,某種程度上你可以把高階組件理解為是一種黑魔法,一旦加上了它,你的組件就能擁有某種能力。這個(gè)時(shí)候,使用高階組件來封裝你的代碼再合適不過了。

另外,高階組件還有一項(xiàng)非常厲害的優(yōu)勢,那就是可以組合。當(dāng)然了,本文的例子并沒有體現(xiàn)出這種能力。但是試想,假如你手上有許多個(gè)黑魔法(即高階組件),當(dāng)你把它們自由組合在一起加到某個(gè)組件上時(shí),是不是可以創(chuàng)造出無限的可能?而相反,如果你在封裝一個(gè)組件的時(shí)候集成了全部這些功能,這個(gè)組件勢必會(huì)非常臃腫,而當(dāng)另外的組件需要其中某幾個(gè)類似的功能時(shí),代碼還不能復(fù)用。。。

4.3 該怎么使用高階組件?

高階組件其實(shí)共分為兩種模式:屬性代理 和 反向繼承。分別對應(yīng)上文中的第一個(gè)、第二個(gè)例子。那該怎么區(qū)分使用呢?嘿嘿,自己用用就知道了??吹脑俣?,不如自己動(dòng)手寫一個(gè)來的理解更深。本文不是高階組件的使用教程,只是兩個(gè)用高階組件解決實(shí)際問題的例子而已。要真想進(jìn)一步深入了解高階組件,可以看介紹高階組件的文章,然后動(dòng)手實(shí)踐慢慢體會(huì)~ 等到你回過頭來再想一下的時(shí)候,必定會(huì)有一種豁然開朗的感覺。

5. 寫在最后

都說高階組件大法好,以前都嗤之以鼻,直到抱著試一試的心態(tài)才發(fā)現(xiàn)。。。

真香真香。。。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容