聊一聊ContextApi

React 中的 Context API 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。但旧版 Context API 一直是被官方强调为试验性,不推荐使用,但新版 Context API 解决了旧版的一些弊端,不再有这些警告。

旧的Context API

以 Redux 的 Provider 为例我们先看下老的 Context API 是如何使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {PropTypes, Component} from 'react';

class Provider extends Component {
// 提供一个函数getChildContext,返回context对象
getChildContext() {
return {
store: this.props.store
};
}

render() {
return this.props.children;
}

}

Provider.propTypes = {
store: PropTypes.object.isRequired
}
// 指定childContextTypes属性,和getChildContext对应
Provider.childContextTypes = {
store: PropTypes.object
};

export default Provider;
1
2
3
4
5
6
7
componentDidMount() {
this.context.store.subscribe(this.onChange);
}

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

这样使用 Context API 的方法非常不 React,目标组件中的 this.context 非常 magic,顶层组件中 getChildContext() 也与 React 本身所推崇的声明式写法背道而驰,除此之外还有以下缺陷。

破坏分形架构

Context 作为一个实验性质的 API,直到 React v16.3.0 版本前都一直不被官方所提倡去使用,其主要原因就是因为在子组件中使用 Context 会破坏 React 应用的分形架构

这里的分形架构指的是从理想的 React 应用的根组件树中抽取的任意一部分都仍是一个可以直接运行的子组件树。在这个子组件树之上再包一层,就可以将它无缝地移植到任意一个其他的根组件树中。

但如果根组件树中有任意一个组件使用了支持 props 透传的 Context API,那么如果把包含了这个组件的子组件树单独拿出来,因为缺少了提供 Context 值的根组件树,这时的这个子组件树是无法直接运行的。

另一方面,虽然 React 官方不推崇使用 Context API,我们在日常工作中却每天都在使用着这个实验性质的 API。或者说虽然开发者可能在自己的应用中并没有直接调用过,但应用本身依赖的 react-reduxmobx-react 等状态管理库之所以能够实现在任意组件中访问全局 store 这一功能,其基础依赖的就是 Context API。

可能无法触发底层组件的 rerender

现有的原生 Context API 存在着一个致命的问题,那就是在 Context 值更新后,顶层组件向目标组件 props 透传的过程中,如果中间某个组件的 shouldComponentUpdate 函数返回了 false,因为无法再继续触发底层组件的 rerender,新的 Context 值将无法到达目标组件。这样的不确定性对于目标组件来说是完全不可控的,也就是说目标组件无法保证自己每一次都可以接收到更新后的 Context 值。

新的 Context API

新的 Context API 采用声明式的写法,并且可以透过 shouldComponentUpdate 返回 false 的组件继续向下传播,以保证目标组件一定可以接收到顶层组件 Context 值的更新,一举解决了现有 Context API 的两大弊端,也终于成为了 React 中的 Top-Level API。

新的 Context API 分为三个组成部分:

  • React.createContext 用于初始化一个 Context。
  • XXXContext.Provider 作为顶层组件接收一个名为 value 的 prop,可以接收任意需要被放入 Context 中的字符串,数字,甚至是函数。
  • XXXContext.Consumer 作为目标组件可以出现在组件树的任意位置(在 Provider 之后),接收 children prop,这里的 children 必须是一个函数(context => ())用来接收从顶层传来的 Context(render props模式,用 prop/child 传递一个 render 函数)。

Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import React from "react";
import { render } from "react-dom";

const ThemeContext = React.createContext();

class ThemeProvider extends React.Component {
state = {
theme: "dark",
color: "blue"
};

changeTheme = theme => {
this.setState({
theme
});
};

changeColor = color => {
this.setState({
color
});
};

render() {
return (
<ThemeContext.Provider
value={{
theme: this.state.theme,
color: this.state.color,
changeColor: this.changeColor
}}
>
<button onClick={() => this.changeTheme("light")}>change theme</button>
{this.props.children}
</ThemeContext.Provider>
);
}
}

const SubComponent = props => (
<div>
<div>{props.theme}</div>
<button onClick={() => props.changeColor("red")}>change color</button>
<div>{props.color}</div>
</div>
);

class App extends React.Component {
render() {
return (
<ThemeProvider>
<ThemeContext.Consumer>
{context => (
<SubComponent
theme={context.theme}
color={context.color}
changeColor={context.changeColor}
/>
)}
</ThemeContext.Consumer>
</ThemeProvider>
);
}
}

render(<App />, document.getElementById("app"));

vanilla模拟Redux

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import React from "react";
import { render } from "react-dom";

const initialState = {
theme: "dark",
color: "blue"
};

const GlobalStoreContext = React.createContext({
...initialState
});

class GlobalStoreContextProvider extends React.Component {
// initialState
state = {
...initialState
};

// reducer
handleContextChange = action => {
switch (action.type) {
case "UPDATE_THEME":
return this.setState({
theme: action.theme
});
case "UPDATE_COLOR":
return this.setState({
color: action.color
});
case "UPDATE_THEME_THEN_COLOR":
return new Promise(resolve => {
resolve(action.theme);
})
.then(theme => {
this.setState({
theme
});
return action.color;
})
.then(color => {
this.setState({
color
});
});
default:
return;
}
};

render() {
return (
<GlobalStoreContext.Provider
value={{
dispatch: this.handleContextChange,
theme: this.state.theme,
color: this.state.color
}}
>
{this.props.children}
</GlobalStoreContext.Provider>
);
}
}

const SubComponent = props => (
<div>
{/* action */}
<button
onClick={() =>
props.dispatch({
type: "UPDATE_THEME",
theme: "light"
})
}
>
change theme
</button>
<div>{props.theme}</div>
{/* action */}
<button
onClick={() =>
props.dispatch({
type: "UPDATE_COLOR",
color: "red"
})
}
>
change color
</button>
<div>{props.color}</div>
{/* action */}
<button
onClick={() =>
props.dispatch({
type: "UPDATE_THEME_THEN_COLOR",
theme: "monokai",
color: "purple"
})
}
>
change theme then color
</button>
</div>
);

class App extends React.Component {
render() {
return (
<GlobalStoreContextProvider>
<GlobalStoreContext.Consumer>
{context => (
<SubComponent
theme={context.theme}
color={context.color}
dispatch={context.dispatch}
/>
)}
</GlobalStoreContext.Consumer>
</GlobalStoreContextProvider>
);
}
}

render(<App />, document.getElementById("root"));

在上面的例子中,我们使用 Context API 实现了一个简单的 redux + react-redux,这证明了在新版 Context API 的支持下,原先 react-redux 帮我们做的一些工作现在我们可以自己来做了。另一方面,对于已经厌倦了整天都在写 action 和 reducer 的朋友们来说,在上面的例子中忽略掉 dispatch,action 等这些 Redux 中的概念,直接调用 React 中常见的 handleXXX 方法来 setState 也是完全没有问题的,可以有效地缓解 Redux 模板代码过多的问题。而对于 React 的初学者来说,更是省去了学习 Redux 及函数式编程相关概念与用法的过程。

如果说在现有 Context API 的基础上我们还需要 react-redux 的帮助去克服无法穿透 shouldComponentUpdate 返回 false 组件这个障碍的话,在新的 Context API 的语境中就真得不需要再将 redux 与 react-redux 作为项目开始时就必须安装的依赖了,如果我们只需要 props 透传这一个特性的话。

甚至在数据管理方面,新的 Context 还可以做得更好。新的 Context API 不受单一 store 的限制,每一个 Context 都相当于 store 中的一个分支,我们可以创建多个 Context 来管理不同类型的数据,相应的在使用时也可以只为目标组件上包上需要的 Context Provider。

最后,受益于新的 Context API 的声明式写法,我们终于可以抛开 Connect 轻松地写符合分形要求的业务组件了,如上面代码中的 SubComponent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const SubComponent = props => (
<div>
<div>{props.theme}</div>
<button onClick={() => props.changeColor("red")}>change color</button>
<div>{props.color}</div>
</div>
);

const SubComponentWithContext = props => (
<ThemeContext.Consumer>
{context => (
<SubComponent
theme={context.theme}
color={context.color}
changeColor={context.changeColor}
/>
)}
</ThemeContext.Consumer>
)

相较于 Redux 的写法,整体代码直观了许多,在后期组件层级调整时,代码的整体可复用性也提升了许多。

对比新旧Context API

新的 Context API 主要好处:

  1. 应用了render props模式(不再需要子组件引入context,不破坏分形架构)
  2. 不再只是试验性,荣升 Top-Level API
  3. 新的 Context 有一个明显实用好处,就是可以很清晰地让多个Context交叉使用,比如组件树上可以有两个 Context Provider,就可以通过 Context1.Consumer、Context2.Consumer 直接区分开。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Context1.Consumer>
{
context1 => (
<Context2.Consumer>
{
context2 => {
// 在这里可以通过代码选择从context1或者context2中获取数据
return ...;
}
}
</Context2.Consumer>
)
}
</Context1.Consumer>

不足的地方:

新的 Context API 通过创建一个 Context 对象来完成,在实际项目中,Provider 和 Consumer 往往都存在于不同的源代码文件中,如何让他们访问同一个Context对象呢?一个最直接的方式,是在一个文件中定义 Context 对象,然后,这个文件被被其他文件来 import 。

可是,使用老的Context API并不需要这样,新的 Context API 貌似用 render props 这种模式优雅地解决了 context 的问题,但是却引入新的问题——如何管理 context 对象。

参考链接

如何解读 react 16.3 引入的新 context api?

从新的 Context API 看 React 应用设计模式

React 16.3新的Context API真的那么好吗?