高階元件 + New Context API = ?

SmallStoneSK發表於2018-10-15

原文地址:github.com/SmallStoneS…

1. 前言

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

2. New Context API

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

舉個栗子:假設存在元件引用關係A -> B -> C,其中子孫元件C用到祖先元件A中Context的屬性a。其中,某一時刻屬性a發生變化導致元件A觸發了一次渲染,但是由於元件B是PureComponent且並未用到屬性a,所以a的變化不會觸發B及其子孫元件的更新,導致元件C未能得到及時的更新。

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

// 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的使用方法來個簡單的科普吧~

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

  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的子孫元件可能不更新的這個遺留問題算是真的解決了~~~

高階元件 + New Context API = ?

3. 說好的高階元件呢?

通過上面“生動形象”的例子,想必大家都已經領會到New Context API的魔力,內心是不是有點蠢蠢欲動?因為有了New Context API,我們似乎不需要再借助redux也能建立一個store來管理狀態了(而且還是區域級,不一定非得在整個應用的最頂層)。當然了,這裡並非是說redux無用,只是提供狀態管理的另一種思路。

咦~文章的標題不是高階元件 + 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方法怎麼辦?

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

高階元件 + New Context API = ?

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屬性傳給它了。所以,我們再次通過高階元件簡化了操作~

下面我們再來看看使用高階元件改造過後的程式碼看看執行的效果。

高階元件 + New Context API = ?

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能夠正常更新。

最後,我們再來看下經過優化後的執行效果。

高階元件 + New Context API = ?

4. 寫在最後

經過今天這波操作,無論是對New Context API還是HOC都有了更深一步的理解和運用,所以收貨還是挺大的。最重要的是,在現有專案不想引進redux和mobx的前提下,本文提出的這種方案似乎也能在一定程度上解決某些複雜元件的狀態管理問題。

當然了,文中的程式碼還有很多不嚴謹的地方,還需要繼續進一步地提升。完整程式碼在這兒,歡迎指出不對或者需要改進的地方。

相關文章