從零開始的react入門教程(九),react context上下文詳解,可能有點啰嗦,但很想讓你懂


壹 ❀ 引

我在從零開始的react入門教程(八),redux起源與基礎用法一文中,介紹了redux的前輩Flux,以及redux關於單項數據更新的基本用法。我們在前文提到,相對Flux支持多個storeredux推薦唯一數據源,也就是使用一個全局Store去掌管所有數據。數據源雖然統一了,但我們要使用Store還是得把Store引入到需要的組件中,比如上文中的Counter組件與Summary組件,畢竟使用dispatch或者監聽Store變化都離不開這個數據源,但這就造就了兩個問題。

問題一,假設我們有多個組件都依賴了Store數據,組件分布在不同文件夾,或者說我們使用的三方庫也依賴了此數據,用一處就得引一次,文件路徑的相對關系都是一個不小的麻煩。

問題二,可能有同學就想到,哎,react不是有個概念叫狀態提升嗎,大不了我在頂層組件引用一次,通過props進行數據傳遞,但這樣就會造成多個組件其實並不需要這份數據,但為了子孫組件能順利訪問數據,都成了數據傳遞的搬運工。

針對上面兩個問題,我們其實可以通過Context得以解決,Context顧名思義就是上下文。就像在一個作用域內我們提前聲明了一個變量,后續代碼不需要再做引用操作,你都能直接訪問它,Context的作用也是如此。

我在整理Context資料的時候發現了一個問題,由於react版本原因,react對於Context的解釋也是存在歷史變遷的。作為一個初學者,如果你在百度想搜Context用法然后發現了不同的介紹,估計你也會納悶我到底應該用哪種(或者對於直接上手react-redux的同學可能根本沒了解過原生Context的用法),這里我先做個簡單的總結,在react版本16.X之前,Context的使用依賴childContextTypes對象,然后手動定義Provider組件,比如在《深入淺出react和Redux》一書中,代碼例子的react版本還是15.4.1,所以書中介紹的自然是前面提到的做法。而對於現在的版本比如官方文檔中,Context的使用已經不需要手動定義Provider組件了,而是createContext方法手動創建,用法上會人性化很多。

本文還是會站在不同的兩個版本,去介紹它們的用法,以達到解決文章開頭關於Store引用與傳遞的問題,當然,如果你已經確定了當前項目的react版本,你可以自由選擇對應的版本文檔了解其用法。

如果可以,我還是希望有緣看到這篇文章的人能跟着手敲代碼,感受其具體的用法,那么本文開始。

貳 ❀ Context 舊版(版本16.X之前)

說在前面,下面的代碼仍然基於上一篇文章的例子修改,當然如果沒有代碼,我盡可能將使用上的細節描述清楚(當然我還是推薦跟着例子來)。如果大家有簡單了解過Context,腦海里一定對Provider的單詞有所印象,不過對於老版本而言,我們並不能直接引用並使用它,而是需要自己創建,確實非常尷尬。

我們現在src目錄下新建一個Provider.js的文件,里面的代碼為:

import {Component} from 'react';
import PropTypes from 'prop-types';

class Provider extends Component {
  getChildContext() {
    // 我們會通過store字段將全局store傳遞進來
    return {
      store: this.props.store
    };
  }
	// 渲染Provoder所包裹的子組件內容
  render() {
    return this.props.children;
  }
}

Provider.propTypes = {
  store: PropTypes.object.isRequired
}

Provider.childContextTypes = {
  store: PropTypes.object
};

export default Provider;

這段代碼有幾點需要擰出來說,第一個是關於PropTypes,寫過react的同學都知道這是做組件屬性的類型檢查,比如我一個組件哪些屬性是必須提供,哪些是字符串等等。這個東西呢其實也存在一個歷史問題....早期版本的react,是可以直接通過引用拿到此對象然后使用,比如:

import { PropTypes } from 'react';

但是在react 15.5之后,此屬性被react官方廢棄掉了,如果你是版本比較高的react,像上面這樣引用會告訴你PropTypesundefied並報錯,比如我參考的《深入淺出react和Redux》一書中都是這么用的,因為作者例子的react版本也比較低(15.4.1),而我在寫demo的react版本已經是16了,自然用不了,不過也沒有關系,咱們可以通過如下方式引用PropTypes

import PropTypes from 'prop-types';

prop-types是一個獨立的三方庫,因此我們需要提前安裝這個包,比如執行命令yarn add prop-types,若你是npm請執行npm i prop-types,這里就不多介紹了,關於prop-types后續也可能會專門寫一篇用法的文章。

回到上面的代碼中,Provider組件定義的內容其實非常簡單,一個getChildContext方法,用於創建子組件的上下文,而上下文中包含的東西其實也就是我們需要使用的store數據,this.props.store怎么來下面的代碼會交代。除此之外還有一個render方法,用於渲染Provider包裹的子組件。關於this.props.children這里做個簡單補充,比如我們有一個父組件A與一個子組件B,A包裹B,如下:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
function A(props) {
    console.log(props);
    return <div>我是父組件{props.children}</div>
}
function B() {
    return <div>我是子組件</div>
}

ReactDOM.render(
    <A><B/></A>,
    document.getElementById('root')
);

可以看到我們使用了A包裹了B,在A組件的返回中,我們通過{props.children}成功拿到了包裹的B組件,並將其渲染了出來,通過控制台輸出也看的很明顯,這里的chindren屬性其實就是組件A所包含的組件內容。

我們再過分點,直接修改為如下代碼:

ReactDOM.render(
    <A>
        {
            <div>
                <div>1</div>
                <div>2</div>
            </div>
        }
    </A>,
    document.getElementById('root')
);

再看控制台,你會發現通過children屬性,我們先訪問到了包裹的最外層的div,然后此div的children又是一個數組,因為它又包含了兩個div,繼續再通過children屬性,我們就可以找到數組第一個元素的孩子是一個數字1,這就是react中children的作用,在實際開發中,我們也常會利用此屬性達到組件父子組件嵌套的目的。

OK,題外話說完了,再回到上述代碼,注意如下這段代碼:

Provider.childContextTypes = {
  store: PropTypes.object
};

這段代碼是必須提供的,不然直接報錯,它的類型定義與getChildContext方法中提供的類型相對應,它用於告訴react我現在為子組件提供了一個上下文,上下文中包含的數據有哪些,每個屬性是什么類型,關於Provider.js先說到這里。

在上一篇文章的例子中,我們通過index.js文件最終渲染了所有組件,這里我們需要做些修改,具體如下:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import store from './Store.js';
import Provider from './Provider.js';
import Counter from './Counter.js';
import Summary from './Summary.js';
class ControlPanel extends Component {
    render() {
        return (
            <Provider store={store}>
                <div>
                    <Counter caption="First" />
                    <Counter caption="Second" />
                    <hr />
                    <Summary />
                </div>
            </Provider>
        );
    }
}

ReactDOM.render(
    <ControlPanel />,
    document.getElementById('root')
);

我們在此文件中引用了前面定義的Provider組件,同時也引用了全局的Store,然后通過Provider組件將上篇文章中需要渲染的組件進行了包裹,同時通過store字段將引用過來的store作為props傳遞了下去,這里就對應了Provider.jsgetChildContext方法this.props.store的來源。

上述的修改其實很好理解,我們將Provider作為頂層組件,為需要渲染的所有組件提供了一個共有的上下文,而這個上下文中存在一個store屬性,也就是全局的Store,現在子組件們不需要再分別引用Store.js文件了,但這些子組件還需要做一些改變才能支持訪問上下文。

Counter組件為例,這里我們說下需要修改的幾個點:

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import * as Actions from './Actions.js';

class Counter extends Component {
  constructor(props,context) {
    super(props,context);
    // 初始化組件的state
    this.state = this.getOwnState();
  }

  getOwnState = () => {
    // 這里的this.props.caption其實就是前面說的First Second
    return {
      // 這里可以拿到當前的Store數據,並根據key取到對應的初始值
      value: this.context.store.getState()[this.props.caption]
    };
  }

  onIncrement = () => {
    // Actions.increment返回的其實是一個action對象,注意這個函數其實只傳遞了一個參數,也就是上面提到的First Second類型
    this.context.store.dispatch(Actions.increment(this.props.caption));
  }

  onDecrement = () => {
    this.context.store.dispatch(Actions.decrement(this.props.caption));
  }
  // 用於更新state
  onChange = () => {
    this.setState(this.getOwnState());
  }

  shouldComponentUpdate(nextProps, nextState) {
    // 如果state的value變了,通知組件更新
    return nextState.value !== this.state.value;
  }

  componentDidMount() {
    // 監聽Store變化,Store變了我們就讓組件的state也跟着變
    this.context.store.subscribe(this.onChange);
  }

  componentWillUnmount() {
    this.context.store.unsubscribe(this.onChange);
  }

  render() {
    const { value } = this.state;
    const { caption } = this.props;

    return (
      <div>
        <button onClick={this.onIncrement}>+</button>
        <button onClick={this.onDecrement}>-</button>
        <span>{caption} count: {value}</span>
      </div>
    );
  }
}
// 這里必須定義,不然訪問不到Context
Counter.contextTypes = {
  store: PropTypes.object
}
export default Counter;

第一點就是我們同樣引入了PropTypes,因為在代碼最下面,我們必須定義contextTypes的類型,這里與Provider.js中的childContextTypes定義其實是對應的,上下文在創建的時候定義了,子組件在引用上下文時同樣得做一個定義聲明。

第二點,在constructor中我們知道super方法用於子組件在初始化時繼承父組件傳遞的屬性,而這里我們得額外添加一個context,表示將上下文傳遞進來。

第三點,之前在Counter中我們直接引入了Store.js,因此可以直接訪問store的數據以及API方法,但此時我們是通過上下文訪問,因此需要對之前所有使用到store的前面添加上this.context,具體可參照上述代碼。同理,我們將Summary組件中也做上述三點修改,然后執行yarn start運行項目,你會發現非常完美,項目成功跑起來了。

那么到這里,我們通過舊版的Context做法取代了傳統Store引用的做法,達到了只在index.js一處引用統一管理,並可在所有子組件中訪問此上下文的目的。

叄 ❀ Context 新版(版本16.X之后)

其實對前面舊版的修改寫下來,你會發現這玩意還真不是那么好用,雖說不用每個組件引入Store了吧,咱還得自己手寫Provider組件不說,每個用到store的組件還得專門定義contextTypes的類型,實屬有點麻煩。沒事,我們繼續來看新版的Context的用法。當然這次,至少咱們不用手寫Provider組件了。

在對於新版本Context資料查閱中,我看到了一句對於Context作用描述比較精准的話,那就是Context能實現組件跨層級的數據傳遞。比如Props傳遞一定是逐層的,這可能就會對一些不需要這部分數據的組件造成感染,那么我想越級傳遞,中間的組件不需要感知這部分數據的存在,Context就是一個不錯的渲染。當然回到上文,我們還是可以理解為Context為相關聯的組件提供了一個共有上下文,子可見后代也可見,那么就不需要子幫忙傳遞后代都可以拿着用。因此,除了應對全局Store的數據傳遞之外,某些部分組件的數據越級傳遞(比如數據與Store無關,單純幾個層級關系組件之間需要做傳遞),以及部分子組件,后代組件都需要訪問到父組件的部分數據,其實都可以使用此做法達到目的

OK,新版Context的幾個核心概念為createContextProviderConsumer,我們一個個說。

叄 ❀ 壹 createContext

createContext顧名思義,創建一個上下文也就是Context對象,它的一般用法為:

const context = React.createContext();

而這個創建出來的context對象中,又包含了ProviderConsumer兩個組件,輸出如下:

因此在使用時,其實也可以像下面這樣直接獲取到兩個組件:

const {Provider, Consumer} = React.createContext();

createContext可以接受一個參數defaultValue,表示我在創建這個上下文時,就默認定義了一部分的共有的數據,但這個默認數據生效是有條件的,這里引用官方文檔的描述:

createContext創建一個 Context 對象。當 React 渲染一個訂閱了這個 Context 對象的組件,這個組件會從組件樹中離自身最近的 Provider 中讀取到當前的 context 值。而如果當前組件所處的組件樹中都沒有匹配到Provider是,這時候defaultValue就會生效。

怎么理解呢?也就是說我們在父組件創建了一個上下文,但后代組件中只用了Consumer組件,而沒有使用Provider對應提供數據,那這時候相當於處於保護措施,我們讓defaultValue生效,保證Consumer能拿到默認的數據,免得組件渲染報錯了,實屬吃低保的行為了。關於這部分的例子,可以參閱React.createContext point of defaultValue?的問題回復,因為這部分知識又涉及到了hookuseContext,簡單理解就是父組件中createContext創建上下文,而在子組件中可以使用useContext解析context中的數據,這里我們先不細談。

叄 ❀ 貳 Provider

故名思域,與舊版我們定義的Provider作用大致相同,它用於包裹需要享有相同上下文的所有組件,以及為其提供上下文中共有的數據,但需要注意的是,這里的數據傳遞必須通過value字段,比如:

<Provider value={/*需要傳遞的共享數據*/}>
    /*被包裹的組件們*/
</Provider>

多個Provider可以嵌套使用,但是里層的Provider的value會覆蓋掉外層的Provider的value,因此Consumer訪問context注定是訪問距離自己最近的Provider。除此之外還有一點,當Provider傳遞的value發生了變化時,Provider內部的所有Consumer組件都會被強制重新渲染,shouldComponentUpdate這玩意都不會限制住它,目的是保證所有消費者組件永遠同步感知最新的context變化。

叄 ❀ 叄 Consumer

如名稱理解的那樣,消費者,也就是消費(使用)Provider傳遞下來數據的組件。正常情況下,Consumer組件得嵌套在Provider組件之下,但如果如上面所說我們沒用Provider組件只用了Consumer組件,那么Consumer組件能訪問的上下文就是在createContext中定義的defaultValue

基本API都介紹了,我們來通過這種方式再來改寫我們前面的例子。

首先,我們在src目錄下新建一個Context.js文件,代碼如下:

import React from 'react';
 
const context = React.createContext();
 
export default context;

之后,在index.js文件引入context,這里直接再貼上代碼:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import store from './Store.js';
import Counter from './Counter.js';
import Summary from './Summary.js';
import context from './Context.js';
class ControlPanel extends Component {
    render() {
        return (
          	//我們使用了Provider包裹子組件,通過value傳遞store
            <context.Provider value={store}>
                <div>
                    <Counter caption="First" />
                    <Counter caption="Second" />
                    <hr />
                    <Summary />
                </div>
            </context.Provider>
        );
    }
}
ReactDOM.render(
    <ControlPanel />,
    document.getElementById('root')
);

同理,我們再次修改Counter組件,還是直接上代碼:

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import * as Actions from './Actions.js';
import context from './Context.js';
// const context = React.createContext();
class Counter extends Component {
  // static contextType = context;
  constructor(props,context) {
    super(props,context);
    // 初始化組件的state
    this.state = this.getOwnState();
  }

  getOwnState = () => {
    // 這里的this.props.caption其實就是前面說的First Second
    return {
      // 這里可以拿到當前的Store數據,並根據key取到對應的初始值
      value: this.context.getState()[this.props.caption]
    };
  }

  onIncrement = () => {
    // Actions.increment返回的其實是一個action對象,注意這個函數其實只傳遞了一個參數,也就是上面提到的First Second類型
    this.context.dispatch(Actions.increment(this.props.caption));
  }

  onDecrement = () => {
    this.context.dispatch(Actions.decrement(this.props.caption));
  }
  // 用於更新state
  onChange = () => {
    this.setState(this.getOwnState());
  }

  shouldComponentUpdate(nextProps, nextState) {
    // 如果state的value變了,通知組件更新
    return nextState.value !== this.state.value;
  }

  componentDidMount() {
    // 監聽Store變化,Store變了我們就讓組件的state也跟着變
    this.context.subscribe(this.onChange);
  }

  componentWillUnmount() {
    this.context.unsubscribe(this.onChange);
  }

  render() {
    const { value } = this.state;
    const { caption } = this.props;

    return (
      <div>
        <button onClick={this.onIncrement}>+</button>
        <button onClick={this.onDecrement}>-</button>
        <span>{caption} count: {value}</span>
      </div>
    );
  }
}
Counter.contextType = context;
export default Counter;

因為我們需要在Counter組件使用context,因此也需要引入context。之后,我們通過Counter.contextType = context;為當前組件綁定context對象,同理,在constructor中還是得初始化context,之后在組件任意地方,我們都可以通過this.context訪問到傳遞進來的store,注意啊,這里的this.context已經等同於store本身了,所以代碼中是this.context.subscribe直接調用store上的API。你可能有點不習慣,還是希望this.context.store去訪問,那就像如下方式這樣傳遞,比如假設我們需要給Provider傳遞多個值:

class ControlPanel extends Component {
    render() {
        const value = {
            store,
            name:1
        };
        return (
            <context.Provider value={value}>
                <div>
                    <Counter caption="First" />
                    <Counter caption="Second" />
                    <hr />
                    <Summary />
                </div>
            </context.Provider>
        );
    }
}

我們再去Counter斷點this,你就發現這就是你預期的樣子了

其實可以發現,新版的context在使用上與舊版還是有些類似的,在使用context的地方同樣得為組件做contextType的定義以及context的初始化,我們同理去修改掉Summary中的代碼,執行運行項目的命令,你會發現也能完美跑起來,那么到這里,我們又通過新版Context的做法修改了例子。

當然到這里我們還沒用到Consumer,那么接下來我們再單獨用一個例子,再次結合把ProviderConsumer用一用。接下來我們定義ABC三個組件,A嵌套B,B又嵌套C,直接修改index.js中的代碼:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import context from './Context.js';
class A extends Component {
    render() {
        const name = '聽風是風';
        return (
            <context.Provider value={name}>
                <div>{`我是A組件,我傳遞了${name}`}</div>
                {/* 注意,這里我們並沒有將name作為props傳遞下去 */}
                <B />
            </context.Provider>
        )
    }
}
function B() {
    return (
        <context.Consumer>
            {
                (name) => {
                    console.log(name);
                    return (
                        <div>
                            {`我是B組件,我接受了${name}`}
                            <C />
                        </div>
                    )
                }
            }

        </context.Consumer>
    )
}
function C() {
    return (
        <context.Consumer>
        {
            (name)=>{
                return (
                    <div>
                        {`我是C組件,我接受了${name}`}
                    </div>
                )
            }
        }
        </context.Consumer>
    )
}
ReactDOM.render(
    <A />,
    document.getElementById('root')
);

可以看到,在子組件需要使用context的地方,我們通過context.Consumer將其包裹,而context.Consumer之間接受一個函數,此函數接受一個參數(參數隨便你叫什么),此參數就是Provider的映射,比如我們上面傳遞的是一個字符串,注意,只有一層花括號進行了包裹,所以函數形參name直接就是所傳遞值的映射。

那假設我們傳遞了多個參數呢?還是一樣,我們稍作修改,這里只貼上修改的部分,並在子組件函數中嘗試打印:

class A extends Component {
    render() {
        const name = '聽風是風';
        const age = '28';
        return (
            <context.Provider value={{name,age}}>
                <div>{`我是A組件,我傳遞了${name}`}</div>
                {/* 注意,這里我們並沒有將name作為props傳遞下去 */}
                <B />
            </context.Provider>
        )
    }
}

function B() {
    return (
        <context.Consumer>
            {
                // 參數其實可以隨便你取名
                (aaa) => {
                    console.log(aaa);
                    return (
                        <div>
                            {`我是B組件,我接受了${aaa.name}`}
                            <C />
                        </div>
                    )
                }
            }

        </context.Consumer>
    )
}

當然實際開發中,我們不會推薦這樣傳遞多個參數,因為上述代碼中value={{name,age}}部分,代碼每次執行{name,age}可以理解為每次都是一個全新的對象,由於對象引用不同這會導致react認為value每次都在發生變化,從而引發子組件全部更新,推薦的做法是使用一個變量去聲明一個對象包含這兩個變量,比如:

// 這里只貼主要修改部分
const user = {
    name:'聽風是風',
    age:28
}
return (
    <context.Provider value={user}>
        <div>{`我是A組件,我傳遞了${user.name}`}</div>
        {/* 注意,這里我們並沒有將name作為props傳遞下去 */}
        <B />
    </context.Provider>
)

<context.Consumer>
    {
        (user) => {
            return (
                <div>
                    {`我是B組件,我接受了${user.name}`}
                    <C />
                </div>
            )
        }
    }

</context.Consumer>

那么到這里,我們其實展示了兩種在子組件中訪問context的方式,第一種是為組件綁定contextType,第二種就是使用Consumer,那么我們直接將C組件修改成如下的方式:

class C extends Component {
    constructor(props, context) {
        super(props, context)
    }
    render() {
        return (
            <div>
                {`我是C組件,我接受了${this.context}`}
            </div>
        )
    }
}
C.contextType = context;

可以看到我們沒有借用Consumer,而是借用組件contextType綁定后,同樣成功訪問到了父組件傳遞的數據。

那么到這里,我們介紹了react中新舊context的基本用法,舊版context需要自定義Provider,並結合getChildContext定義為子組件傳遞的數據。而新版context在使用上相對友好了不少,我們可以通過createContext創建一個context實例,並可以直接使用Provider提供數據,使用Consumer消費數據。通過文中新舊例子對比,其實兩者在使用上存在不少相同點。

在下一篇文章中,我們來了解react-redux基本用法,其實本篇文章與上一篇文章屬於react-redux的鋪墊篇,在了解了react原生的概念后,我想在理解三方封裝時應該會容易很多,那么到這里本文結束。

參考

深入淺出react和Redux 第三章組件context部分

React Context(上下文) 作用和使用 看完不懂 你打我

React系列——React Context

react官網文檔Context

React中Context的使用

React context基本用法


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM