从零开始的react入门教程(九),react context上下文详解,可能有点啰嗦,但很想让你懂
壹 ❀ 引
我在从零开始的react入门教程(八),redux起源与基础用法一文中,介绍了redux
的前辈Flux
,以及redux
关于单项数据更新的基本用法。我们在前文提到,相对Flux
支持多个store
,redux
推荐唯一数据源,也就是使用一个全局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,像上面这样引用会告诉你PropTypes
是undefied
并报错,比如我参考的《深入浅出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.js
中getChildContext
方法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
的几个核心概念为createContext
,Provider
与Consumer
,我们一个个说。
叁 ❀ 壹 createContext
createContext
顾名思义,创建一个上下文也就是Context
对象,它的一般用法为:
const context = React.createContext();
而这个创建出来的context
对象中,又包含了Provider
与Consumer
两个组件,输出如下:
因此在使用时,其实也可以像下面这样直接获取到两个组件:
const {Provider, Consumer} = React.createContext();
createContext
可以接受一个参数defaultValue
,表示我在创建这个上下文时,就默认定义了一部分的共有的数据,但这个默认数据生效是有条件的,这里引用官方文档的描述:
createContext
创建一个 Context 对象。当 React 渲染一个订阅了这个 Context 对象的组件,这个组件会从组件树中离自身最近的Provider
中读取到当前的 context 值。而如果当前组件所处的组件树中都没有匹配到Provider
是,这时候defaultValue
就会生效。
怎么理解呢?也就是说我们在父组件创建了一个上下文,但后代组件中只用了Consumer
组件,而没有使用Provider
对应提供数据,那这时候相当于处于保护措施,我们让defaultValue
生效,保证Consumer
能拿到默认的数据,免得组件渲染报错了,实属吃低保的行为了。关于这部分的例子,可以参阅React.createContext point of defaultValue?的问题回复,因为这部分知识又涉及到了hook
的useContext
,简单理解就是父组件中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
,那么接下来我们再单独用一个例子,再次结合把Provider
与Consumer
用一用。接下来我们定义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部分