重拾React: Context

請叫我王磊同學發表於2019-01-17

前言

  首先歡迎大家關注我的Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵,希望大家多多關注呀!好久已經沒寫React,發現連Context都發生了變化,忽然有一種村裡剛通上的網的感覺,可能文章所提及的知識點已經算是過時了,僅僅算作是自己的學習體驗吧,

Context

  對於React開發者而言,Context應該是一個不陌生的概念,但是在16.3之前,React官方一直不推薦使用,並聲稱該特性屬於實驗性質的API,可能會從之後的版本中移除。但是在實踐中非常多的第三方庫都基於該特性,例如:react-redux、mobx-react。

重拾React: Context

  如上面的元件樹中,A元件與B元件之間隔著非常多的元件,假如A元件希望傳遞給B元件一個屬性,那麼不得不使用props將屬性從A元件歷經一系列中間元件最終跋山涉水傳遞給B元件。這樣程式碼不僅非常的麻煩,更重要的是中間的元件可能壓根就用不上這個屬性,卻要承擔一個傳遞的職責,這是我們不希望看見的。Context出現的目的就是為了解決這種場景,使得我們可以直接將屬性從A元件傳遞給B元件。

Legacy Context

  這裡所說的老版本Context指的是React16.3之前的版本所提供的Context屬性,在我看來,這種Context是以一種協商宣告的方式使用的。作為屬性提供者(Provider)需要顯式宣告哪些屬性可以被跨層級訪問並且需要宣告這些屬性的型別。而作為屬性的使用者(Consumer)也需要顯式宣告要這些屬性的型別。官方文件中給出了下面的例子:

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

class Button extends React.Component {

    static contextTypes = {
        color: PropTypes.string
    };

    render() {
        return (
            <button style={{background: this.context.color}}>
                {this.props.children}
            </button>
        );
    }
}

class Message extends React.Component {
    render() {
        return (
            <div>
                {this.props.text} <Button>Delete</Button>
            </div>
        );
    }
}

class MessageList extends React.Component {
    static childContextTypes = {
        color: PropTypes.string
    };

    getChildContext() {
        return {color: "red"};
    }

    render() {
        const children = this.props.messages.map((message) =>
            <Message text={message.text} />
        );
        return <div>{children}</div>;
    }
}
複製程式碼

  我們可以看到MessageList通過函式getChildContext顯式宣告提供color屬性,並且通過靜態屬性childContextTypes宣告瞭該屬性的型別。而Button通過靜態屬性contextTypes宣告瞭要使用屬性的型別,二者通過協商的方式約定了跨層級傳遞屬性的資訊。Context確實非常方便的解決了跨層級傳遞屬性的情況,但是為什麼官方卻不推薦使用呢?

  首先Context的使用是與React可複用元件的邏輯背道而馳的,在React的思維中,所有元件應該具有複用的特性,但是正是因為Context的引入,元件複用的使用變得嚴格起來。就以上面的程式碼為例,如果想要複用Button元件,必須在上層元件中含有一個可以提供String型別的colorContext,所以複用要求變得嚴格起來。並且更重要的是,當你嘗試修改Context的值時,可能會觸發不確定的狀態。我們舉一個例子,我們將上面的MessageList稍作改造,使得Context內容可以動態改變:

class MessageList extends React.Component {

    state = {
        color: "red"
    };

    static childContextTypes = {
        color: PropTypes.string
    };

    getChildContext() {
        return {color: this.state.color};
    }

    render() {
        const children = this.props.messages.map((message) =>
            <Message text={message.text} />
        );
        return (
            <div>
                <div>{children}</div>
                <button onClick={this._changeColor}>Change Color</button>
            </div>
        );
    }

    _changeColor = () => {
        const colors = ["red", "green", "blue"];
        const index = (colors.indexOf(this.state.color) + 1) % 3;
        this.setState({
            color: colors[index]
        });
    }
}
複製程式碼

  上面的例子中我們MessageList元件Context提供的color屬性改成了state的屬性,當每次使用setState重新整理color的時候,子元件也會被重新整理,因此對應按鈕的顏色也會發生改變,一切看起來是非常的完美。但是一旦元件間的元件存在生命週期函式ShouldComponentUpdate那麼一切就變得詭異起來。我們知道PureComponent實質就是利用ShouldComponentUpdate避免不必要的重新整理的,因此我們可以對之前的例子做一個小小的改造:

class Message extends React.PureComponent {
    render() {
        return (
            <div>
                {this.props.text} <Button>Delete</Button>
            </div>
        );
    }
}
複製程式碼

  你會發現即使你在MessageList中改變了Context的值,也無法導致子元件中按鈕的顏色重新整理。這是因為Message元件繼承自PureComponent,在沒有接受到新的props改變或者state變化時生命週期函式shouldComponentUpdate返回的是false,因此Message及其子元件並沒有重新整理,導致Button元件沒有重新整理到最新的顏色。

  如果你的Context值是不會改變的,或者只是在元件初始化的時候才會使用一次,那麼一切問題都不會存在。但是如果需要改變Context的情況下,如何安全使用呢? Michel Weststrate在How to safely use React context 一文中介紹了依賴注入(DI)的方案。作者認為我們不應該直接在getChildContext中直接返回state屬性,而是應該像依賴注入(DI)一樣使用conext。

class Theme {
    constructor(color) {
        this.color = color
        this.subscriptions = []
    }

    setColor(color) {
        this.color = color
        this.subscriptions.forEach(f => f())
    }

    subscribe(f) {
        this.subscriptions.push(f)
    }
}

class Button extends React.Component {
    static contextTypes = {
        theme: PropTypes.Object
    };

    componentDidMount() {
        this.context.theme.subscribe(() => this.forceUpdate());
    }

    render() {
        return (
            <button style={{background: this.context.theme.color}}>
                {this.props.children}
            </button>
        );
    }
}

class MessageList extends React.Component {

    constructor(props){
        super(props);
        this.theme = new Theme("red");
    }

    static childContextTypes = {
        theme: PropTypes.Object
    };

    getChildContext() {
        return {
            theme: this.theme
        };
    }

    render() {
        const children = this.props.messages.map((message) =>
            <Message text={message.text} />
        );
        return (
            <div>
                <div>{children}</div>
                <button onClick={this._changeColor}>Change Color</button>
            </div>
        );
    }

    _changeColor = () => {
        const colors = ["red", "green", "blue"];
        const index = (colors.indexOf(this.theme.color) + 1) % 3;
        this.theme.setColor(colors[index]);
    }
}
複製程式碼

  在上面的例子中我們創造了一個Theme類用來管理樣式,然後通過ContextTheme的例項向下傳遞,在Button中獲取到該例項並且訂閱樣式變化,在樣式變化時呼叫forceUpdate強制重新整理達到重新整理介面的目的。當然上面的例子只是一個雛形,具體使用時還需要考慮到其他的方面內容,例如在元件銷燬時需要取消監聽等方面。

  回顧一下之前版本的Context,配置起來還是比較麻煩的,尤其還需要在對應的兩個元件中分別使用childContextTypescontextTypes的宣告Context屬性的型別。而且其實這兩個型別宣告並不能很好的約束context。舉一個例子,假設分別有三個元件: GrandFather、Father、Son,渲染順序分別是:

GrandFather -> Father -> Son

  那麼假設說元件GrandFather提供的context是型別為number鍵為value的值1,而Father提供也是型別為number的鍵為value的值2,元件Son宣告獲得的是型別為number的鍵為value的context,我們肯定知道元件Son中this.context.value值為2,因為context在遇到同名Key值時肯定取的是最靠近的父元件。

  同樣地我們假設件GrandFather提供的context是型別為string鍵為value的值"1",而Father提供是型別為number的鍵為value的值2,元件Son宣告獲得的是型別為string的鍵為value的context,那麼元件Son會取到GrandFather的context值嗎?事實上並不會,仍然取到的值是2,只不過在開發過程環境下會輸出:

Invalid context value of type number supplied to Son, expected string

  因此我們能得出靜態屬性childContextTypescontextTypes只能提供開發的輔助性作用,對實際的context取值並不能起到約束性的作用,即使這樣我們也不得不重複體力勞動,一遍遍的宣告childContextTypescontextTypes屬性。

New Context

  新的Context釋出於React 16.3版本,相比於之前元件內部協商宣告的方式,新版本下的Context大不相同,採用了宣告式的寫法,通過render props的方式獲取Context,不會受到生命週期shouldComponentUpdate的影響。上面的例子用新的Context改寫為:

import React, {Component} from 'react';

const ThemeContext = React.createContext({ theme: 'red'});

class Button extends React.Component {
    render(){
        return(
            <ThemeContext.Consumer>
                {({color}) => {
                    return (
                        <button style={{background: color}}>
                            {this.props.children}
                        </button>
                    );
                }}
            </ThemeContext.Consumer>
        );
    }
}

class Message extends React.PureComponent {
    render() {
        return (
            <div>
                {this.props.text} <Button>Delete</Button>
            </div>
        );
    }
}

class MessageList extends React.Component {

    state = {
        theme: { color: "red" }
    };

    render() {
        return (
            <ThemeContext.Provider value={this.state.theme}>
                <div>
                    {this.props.messages.map((message) => <Message text={message.text}/>)}
                    <button onClick={this._changeColor}>Change Color</button>
                </div>
            </ThemeContext.Provider>
        )
    }

    _changeColor = () => {
        const colors = ["red", "green", "blue"];
        const index = (colors.indexOf(this.state.theme.color) + 1) % 3;
        this.setState({
            theme: {
                color: colors[index]
            }
        });
    }
}
複製程式碼

  我們可以看到新的Context使用React.createContext的方式建立了一個Context例項,然後通過Provider的方式提供Context值,而通過Consumer配合render props的方式獲取到Context值,即使中間元件中存在shouldComponentUpdate返回false,也不會導致Context無法重新整理的問題,解決了之前存在的問題。我們看到在呼叫React.createContext建立Context例項的時候,我們傳入了一個預設的Context值,該值僅會在Consumer在元件樹中無法找到匹配的Provider才會使用,因此即使你給Providervalue傳入undefined值時,Consumer也不會使用預設值。

  新版的Context API相比於之前的Context API更符合React的思想,並且能解決componentShouldUpdate的帶來的問題。與此同時你的專案需要增加專門的檔案來建立Context。在 React v17 中,可能就會刪除對老版 Context API 的支援,所以還是需要儘快升級。最後講了這麼多,但是在專案中還是要儘量避免Context的濫用,否則會造成元件間依賴過於複雜。

相關文章