从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂

从零开始的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')
);
从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂

可以看到我们使用了A包裹了B,在A组件的返回中,我们通过{props.children}成功拿到了包裹的B组件,并将其渲染了出来,通过控制台输出也看的很明显,这里的chindren属性其实就是组件A所包含的组件内容。

我们再过分点,直接修改为如下代码:

ReactDOM.render(
    <A>
        {
            <div>
                <div>1</div>
                <div>2</div>
            </div>
        }
    </A>,
    document.getElementById('root')
);
从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂

再看控制台,你会发现通过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两个组件,输出如下:

从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂

因此在使用时,其实也可以像下面这样直接获取到两个组件:

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 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>
        );
    }
}
ControlPanel.contextType = context;
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,你就发现这就是你预期的样子了

从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂

其实可以发现,新版的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')
);
从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂

可以看到,在子组件需要使用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>
    )
}
从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂

当然实际开发中,我们不会推荐这样传递多个参数,因为上述代码中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基本用法

上一篇:03SpringCloud服务的注册与发现(Service Provider)


下一篇:SpringCloud(一)