重拾React: Context

4,826 阅读8分钟

前言

  首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励,希望大家多多关注呀!好久已经没写React,发现连Context都发生了变化,忽然有一种村里刚通上的网的感觉,可能文章所提及的知识点已经算是过时了,仅仅算作是自己的学习体验吧,

Context

  对于React开发者而言,Context应该是一个不陌生的概念,但是在16.3之前,React官方一直不推荐使用,并声称该特性属于实验性质的API,可能会从之后的版本中移除。但是在实践中非常多的第三方库都基于该特性,例如:react-redux、mobx-react。

  如上面的组件树中,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的滥用,否则会造成组件间依赖过于复杂。