高階組件 + New Context API = ?

1. 前言

繼上次小試牛刀嚐到高價組件的甜頭以後,現已深陷其中沒法自拔。。。那麼此次又會帶來什麼呢?今天,咱們就來看看【高階組件】和【New Context API】能擦出什麼火花!javascript

2. New Context API

Context API其實早就存在,大名鼎鼎的redux狀態管理庫就用到了它。合理地利用Context API,咱們能夠從Prop Drilling的痛苦中解脫出來。可是老版的Context API存在一個嚴重的問題:子孫組件可能不更新。java

舉個栗子:假設存在組件引用關係A -> B -> C,其中子孫組件C用到祖先組件A中Context的屬性a。其中,某一時刻屬性a發生變化致使組件A觸發了一次渲染,可是因爲組件B是PureComponent且並未用到屬性a,因此a的變化不會觸發B及其子孫組件的更新,致使組件C未能獲得及時的更新。react

好在React@16.3.0中推出的New Context API已經解決了這一問題,並且在使用上比原來的也更優雅。所以,如今咱們能夠放心大膽地使用起來。說了那麼多,都不如一個實際的例子來得實在。Show me the code:git

// DemoContext.js
import React from 'react';
export const demoContext = React.createContext();

// Demo.js
import React from 'react';
import { ThemeApp } from './ThemeApp';
import { CounterApp } from './CounterApp';
import { demoContext } from './DemoContext';

export class Demo extends React.PureComponent {
  state = { count: 1, theme: 'red' };
  onChangeCount = newCount => this.setState({ count: newCount });
  onChangeTheme = newTheme => this.setState({ theme: newTheme });
  render() {
    console.log('render Demo');
    return (
      <demoContext.Provider value={{
        ...this.state,
        onChangeCount: this.onChangeCount,
        onChangeTheme: this.onChangeTheme
      }}>
        <CounterApp />
        <ThemeApp />
      </demoContext.Provider>
    );
  }
}

// CounterApp.js
import React from 'react';
import { demoContext } from './DemoContext';

export class CounterApp extends React.PureComponent {
  render() {
    console.log('render CounterApp');
    return (
      <div>
        <h3>This is Counter application.</h3>
        <Counter />
      </div>
    );
  }
}

class Counter extends React.PureComponent {
  render() {
    console.log('render Counter');
    return (
      <demoContext.Consumer>
        {data => {
          const { count, onChangeCount } = data;
          console.log('render Counter consumer');
          return (
            <div>
              <button onClick={() => onChangeCount(count - 1)}>-</button>
              <span style={{ margin: '0 10px' }}>{count}</span>
              <button onClick={() => onChangeCount(count + 1)}>+</button>
            </div>
          );
        }}
      </demoContext.Consumer>
    );
  }
}

// ThemeApp.js
import React from 'react';
import { demoContext } from './DemoContext';

export class ThemeApp extends React.PureComponent {
  render() {
    console.log('render ThemeApp');
    return (
      <div>
        <h3>This is Theme application.</h3>
        <Theme />
      </div>
    );
  }
}

class Theme extends React.PureComponent {
  render() {
    console.log('render Theme');
    return (
      <demoContext.Consumer>
        {data => {
          const {theme, onChangeTheme} = data;
          console.log('render Theme consumer');
          return (
            <div>
              <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: theme }} />
              <select style={{ marginTop: '20px' }} onChange={evt => onChangeTheme(evt.target.value)}>
                {['red', 'green', 'yellow', 'blue'].map(item => <option key={item}>{item}</option>)}
              </select>
            </div>
          );
        }}
      </demoContext.Consumer>
    );
  }
}

雖然說一上來就貼個百來行代碼的這種行爲有點low,可是爲了介紹New Context API的基本用法,也只能這樣了。。。不過啊,上面的例子其實很簡單,就算是先對New Context API的使用方法來個簡單的科普吧~github

仔細觀察上面的代碼不難發現組件間的層級關係,即:Demo -> CounterApp -> Counter 和 Demo -> ThemeApp -> Theme,且中間組件CounterApp和CounterApp並無做爲媒介來傳遞count和theme值。接下來,咱們就來分析下上面的代碼,看看如何使用New Context API來實現祖先->子孫傳值的:redux

  1. New Context API在React中提供了一個React.createContext方法,它返回的對象中包含了ProviderConsumer兩個方法。也就是DemoContext.js中的代碼。
  2. 顧名思義,Provider能夠理解爲公用值的一個提供者,而Consumer就是這個公用值的消費者。那麼二者是如何聯繫起來的呢?注意Provider接收的value參數。Provider會將這個value原封不動地傳給Consumer,這點也能夠從Demo.js/CounterApp.js/ThemeApp.js三個文件中體現出來。
  3. 再仔細觀察例子中的value參數,它是一個對象,key分別是count, theme, onChangeCount, onChangeTheme。很顯然,在Consumer中,咱們不但可使用count和theme,還可使用onChangeCount和onChangeTheme來分別修改相應的state,從而致使整個應用狀態的更新和從新渲染。

下面咱們再來看看實際運行效果。從下圖中咱們能夠清楚地看到,CounterApp中的number和ThemeApp中的color都能正常地響應咱們的操做,說明New Context API確實達到了咱們預期的效果。除此以外,不妨再仔細觀察console控制檯的輸出。當咱們更改數字或顏色時咱們會發現,因爲CounterApp和ThemeApp是PureComponent,且都沒有使用count和theme,因此它們並不會觸發render,甚至Counter和Theme也沒有從新render。可是,這卻並不影響咱們Consumer中的正常渲染。因此啊,上文提到Old Context API的子孫組件可能不更新的這個遺留問題算是真的解決了~~~app

3. 說好的高階組件呢?

經過上面「生動形象」的例子,想必你們都已經領會到New Context API的魔力,心裏是否是有點蠢蠢欲動?由於有了New Context API,咱們彷佛不須要再借助redux也能建立一個store來管理狀態了(並且仍是區域級,不必定非得在整個應用的最頂層)。固然了,這裏並不是是說redux無用,只是提供狀態管理的另外一種思路。ide

咦~文章的標題不是高階組件 + New Context API = ?嗎,怎麼跑偏了?說好的高階組件呢?函數

別急,上面的只是開胃小菜,普及New Context API的基本使用方法而已。。。正菜這就來了~ 文章開頭就說最近沉迷高階組件沒法自拔,因此在寫完上面的demo以後就想着能不能用高階組件再封裝一層,這樣使用起來能夠更加順手。你別說,還真搞出了一套。。。咱們先來分析上面demo中存在的問題:優化

  1. 咱們在經過Provider傳給Consumer的value中寫了兩個函數onChangeCount和onChangeTheme。可是這裏是否是有問題?假如這個組件足夠複雜,有20個狀態難道咱們須要寫20個函數分別一一對應更新相應的狀態嗎?
  2. 注意使用到Consumer的地方,咱們把全部的邏輯都寫在一個data => {...}函數中了。假如這裏的組件很複雜怎麼辦?固然了,咱們能夠將{...}這段代碼提取出來做爲Counter或Theme實例的一個方法或者再封裝一個組件,可是這樣的代碼寫多了以後,就會顯得重複。並且還有一個問題是,假如在Counter或Theme的其餘實例方法中想獲取data中的屬性和update方法怎麼辦?

爲了解決以上提出的兩個問題,我要開始裝逼了。。。

3.1 Provider with HOC

首先,咱們先來解決第一個問題。爲此,咱們先新建一個ContextHOC.js文件,代碼以下:

// ContextHOC.js
import React from 'react';

export const Provider = ({Provider}, store = {}) => WrappedComponent => {
  return class extends React.PureComponent {
    state = store;
    updateContext = newState => this.setState(newState);
    render() {
      return (
        <Provider value={{ ...this.state, updateContext: this.updateContext }}>
          <WrappedComponent {...this.props} />
        </Provider>
      );
    }
  };
};

因爲咱們的高階組件須要包掉Provider層的邏輯,因此很顯然咱們返回的組件是以Provider做爲頂層的一個組件,傳進來的WrappedComponent會被包裹在Provider中。除此以外還能夠看到,Provider會接收兩個參數Provider和initialVlaue。其中,Provider就是用React.createContext建立的對象所提供的Provider方法,而store則會做爲state的初始值。重點在於Provider的value屬性,除了state以外,咱們還傳了updateContext方法。還記得問題一麼?這裏的updateContext正是解決這個問題的關鍵,由於Consumer能夠經過它來更新任意的狀態而沒必要再寫一堆的onChangeXXX的方法了~

咱們再來看看通過Provider with HOC改造以後,調用方應該如何使用。看代碼:

// DemoContext.js
import React from 'react';
export const store = { count: 1, theme: 'red' };
export const demoContext = React.createContext();

// Demo.js
import React from 'react';

import { Provider } from './ContextHOC';
import { ThemeApp } from './ThemeApp';
import { CounterApp } from './CounterApp';
import { store, demoContext } from './DemoContext';

@Provider(demoContext, store)
class Demo extends React.PureComponent {
  render() {
    console.log('render Demo');
    return (
      <div>
        <CounterApp />
        <ThemeApp />
      </div>
    );
  }
}

咦~ 原來與Provider相關的代碼在咱們的Demo中全都不見了,只有一個@Provider裝飾器,想要公用的狀態全都寫在一個store中就能夠了。相比原來的Demo,如今的Demo組件只要關注自身的邏輯便可,整個組件顯然看起來更加清爽了~

3.2 Consumer with HOC

接下來,咱們再來解決第二個問題。在ContextHOC.js文件中,咱們再導出一個Consumer函數,代碼以下:

export const Consumer = ({Consumer}) => WrappedComponent => {
  return class extends React.PureComponent {
    render() {
      return (
        <Consumer>
          {data => <WrappedComponent context={data} {...this.props}/>}
        </Consumer>
      );
    }
  };
};

能夠看到,上面的代碼其實很是簡單。。。僅僅是利用高階組件給WrappedComponent多傳了一個context屬性而已,而context的值則正是Provider傳過來的value。那麼這樣寫有什麼好處呢?咱們來看一下調用的代碼就知道了~

// CounterApp.js
import React from 'react';
import { Consumer } from './ContextHOC';
import { demoContext } from './DemoContext';

const MAP = { add: { delta: 1 }, minus: { delta: -1 } };

// ...省略CounterApp組件代碼,與前面相同

@Consumer(demoContext)
class Counter extends React.PureComponent {

  onClickBtn = (type) => {
    const { count, updateContext } = this.props.context;
    updateContext({ count: count + MAP[type].delta });
  };

  render() {
    console.log('render Counter');
    return (
      <div>
        <button onClick={() => this.onClickBtn('minus')}>-</button>
        <span style={{ margin: '0 10px' }}>{this.props.context.count}</span>
        <button onClick={() => this.onClickBtn('add')}>+</button>
      </div>
    );
  }
}

// ThemeApp.js
import React from 'react';
import { Consumer } from './ContextHOC';
import { demoContext } from './DemoContext';

// ...省略ThemeApp組件代碼,與前面相同

@Consumer(demoContext)
class Theme extends React.PureComponent {

  onChangeTheme = evt => {
    const newTheme = evt.target.value;
    const { theme, updateContext } = this.props.context;
    if (newTheme !== theme) {
      updateContext({ theme: newTheme });
    }
  };

  render() {
    console.log('render Theme');
    return (
      <div>
        <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: this.props.context.theme }} />
        <select style={{ marginTop: '20px' }} onChange={this.onChangeTheme}>
          {['red', 'green', 'yellow', 'blue'].map(_ => (
            <option key={_}>{_}</option>
          ))}
        </select>
      </div>
    )
  }
}

能夠看到,改造以後的Counter和Theme代碼必定程度上實現了去Consumer化。由於和Consumer相關的邏輯僅剩一個@Consumer裝飾器了,並且咱們只要提供和祖先組件中Provider配對的Consumer就能夠了。相比最初的Counter和Theme組件,如今的組件也是更加清爽了,只需關注自身的邏輯便可。

不過須要特別注意的是,如今想要獲取Provider提供的公用狀態值時,改爲了從this.props.context中獲取;想要更新狀態的時候,調用this.props.context.updateContext便可。

爲何?由於經過@Consumer裝飾的組件Counter和Theme如今就是ContextHOC文件中的那個WrappedComponent,咱們已經把Provider傳下來的Value做爲context屬性傳給它了。因此,咱們再次經過高階組件簡化了操做~

下面咱們再來看看使用高階組件改造事後的代碼看看運行的效果。

3.3 優化

你覺得文章到這裏就要結束了嗎?固然不是,寫論文的套路不都還要提出個優化方法而後作實驗比較麼~ 更況且上面這張圖有問題。。。

沒錯,經過ContextHOC改造事後,上面的這張運行效果圖彷佛看上去沒有問題,可是仔細看Console控制檯的輸出你就會發現,當更新count或theme任意其中一個的時候,Counter和Theme都從新渲染了一次!!!但是,個人Counter和Theme組件明明都已是PureComponent了啊~ 爲何沒有用!!!

緣由很簡單,由於咱們傳給WrappedComponent的context每次都是一個新對象,因此就算你的WrappedComponent是PureComponent也無濟於事。。。那麼怎麼辦呢?其實,上文中的Consumer with HOC操做很是粗糙,咱們直接把Provider提供的value值直接一古腦兒地傳給了WrappedComponent,而無論WrappedComponent是否真的須要。所以,只要咱們對傳給WrappedComponent的屬性值精細化控制,不傳不相關的屬性就能夠了。來看看改造後的Consumer代碼:

// ContextHOC.js
export const Consumer = ({Consumer}, relatedKeys = []) => WrappedComponent => {
  return class extends React.PureComponent {
    _version = 0;
    _context = {};
    getContext = data => {
      if (relatedKeys.length === 0) return data;
      [...relatedKeys, 'updateContext'].forEach(k => {
        if(this._context[k] !== data[k]) {
          this._version++;
          this._context[k] = data[k];
        }
      });
      return this._context;
    };
    render() {
      return (
        <Consumer>
          {data => {
            const newContext = this.getContext(data);
            const newProps = { context: newContext, _version: this._version, ...this.props };
            return <WrappedComponent {...newProps} />;
          }}
        </Consumer>
      );
    }
  };
};

// 別忘了給Consumer組件指定relatedKeys

// CounterApp.js
@Consumer(demoContext, ['count'])
class Counter extends React.PureComponent {
  // ...省略
}

// ThemeApp.js
@Consumer(demoContext, ['theme'])
class Theme extends React.PureComponent {
  // ...省略
}

相比於初版的Consumer函數,如今這個彷佛複雜了一點點。可是其實仍是很簡單,核心思想剛纔上面已經說了,此次咱們會根據relatedKeys從Provider傳下來的value中匹配出WrappedComponent真正想要的屬性。並且,爲了保證傳給WrappedComponent的context值再也不每次都是一個新對象,咱們將它保存在了組件的實例上。另外,只要Provider中某個落在relatedKeys中的屬性值發生變化,this._version值就會發生變化,從而也保證了WrappedComponent可以正常更新。

最後,咱們再來看下通過優化後的運行效果。

4. 寫在最後

通過今天這波操做,不管是對New Context API仍是HOC都有了更深一步的理解和運用,因此收貨仍是挺大的。最重要的是,在現有項目不想引進redux和mobx的前提下,本文提出的這種方案彷佛也能在必定程度上解決某些複雜組件的狀態管理問題。

固然了,文中的代碼還有不少不嚴謹的地方,還須要繼續進一步地提高。完整代碼在這兒,歡迎指出不對或者須要改進的地方。

相關文章
相關標籤/搜索