React 入门系列(三)Context
这篇博客我们继续来看React的高级指引中关于Context的部分。
这其中会穿插组件组合,render prop,函数式组件的部分知识。
为什么要用到Context
Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。
在一个典型的 React 应用中,数据是通过 props 属性自上而下(由父及子)进行传递的,但此种用法对于某些类型的属性而言是极其繁琐的(例如:地区偏好,UI 主题),这些属性是应用程序中许多组件都需要的。Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props。
就我的理解来说,如果某个属性需要多个组件公用并层层传递的时候,我们可以使用这种方式。
为什么要满足这两个条件呢?
如果单纯地是多个同级组件公用,我们可以用状态提升的方法。
如果单纯地层层传递我们也可以用组件组合的方式。
何时使用 Context
Context 设计目的是为了共享那些对于一个组件树而言是“全局”的数据,例如当前认证的用户、主题或首选语言。举个例子,在下面的代码中,我们通过一个 “theme” 属性手动调整一个按钮组件的样式:
1 | class App extends React.Component { |
使用 context, 我们可以避免通过中间元素传递 props:
1 | // Context 可以让我们无须明确地传遍每一个组件,就能将值深入传递进组件树。 |
注意,要想使用
this.context
,你首先要定义static contextType
为你想要订阅的Context才行。比如上面这个例子中的
static contextType = ThemeContext
使用 Context 之前的考虑
Context 主要应用场景在于很多不同层级的组件需要访问同样一些的数据。请谨慎使用,因为这会使得组件的复用性变差。
如果你只是想避免层层传递一些属性,组件组合(component composition)有时候是一个比 context 更好的解决方案。
组件组合
比如,考虑这样一个 Page
组件,它层层向下传递 user
和 avatarSize
属性,从而深度嵌套的 Link
和 Avatar
组件可以读取到这些属性:
1 | <Page user={user} avatarSize={avatarSize} /> |
如果在最后只有 Avatar
组件真的需要 user
和 avatarSize
,那么层层传递这两个 props 就显得非常冗余。而且一旦 Avatar
组件需要更多从来自顶层组件的 props,你还得在中间层级一个一个加上去,这将会变得非常麻烦。
一种无需 context 的解决方案是将 Avatar
组件自身传递下去,因而中间组件无需知道 user
或者 avatarSize
等 props:
1 | function Page(props) { |
这种变化下,只有最顶部的 Page 组件需要知道 Link
和 Avatar
组件是如何使用 user
和 avatarSize
的。
这种对组件的控制反转减少了在你的应用中要传递的 props 数量,这在很多场景下会使得你的代码更加干净,使你对根组件有更多的把控。但是,这并不适用于每一个场景:这种将逻辑提升到组件树的更高层次来处理,会使得这些高层组件变得更复杂,并且会强行将低层组件适应这样的形式,这可能不会是你想要的。
而且你的组件并不限制于接收单个子组件。你可能会传递多个子组件,甚至会为这些子组件(children)封装多个单独的“接口(slots)”,正如这里的文档所列举的
1 | function Page(props) { |
这种模式足够覆盖很多场景了,在这些场景下你需要将子组件和直接关联的父组件解耦。如果子组件需要在渲染前和父组件进行一些交流,你可以进一步使用 render props。
但是,有的时候在组件树中很多不同层级的组件需要访问同样的一批数据。Context 能让你将这些数据向组件树下所有的组件进行“广播”,所有的组件都能访问到这些数据,也能访问到后续的数据更新。使用 context 的通用的场景包括管理当前的 locale,theme,或者一些缓存数据,这比替代方案要简单的多。
不知道大家看到这段文档会不会有点懵,我来简单梳理一下:
- 如果你只是想避免属性值在组件的层层传递,那你可以直接用组件组合,也就是直接把要传递的属性直接给最终使用的组件,然后把这个组件层层传递下去。
- 这种对组件的控制反转减少了在你的应用中要传递的 props 数量,这在很多场景下会使得你的代码更加干净,使你对根组件有更多的把控。但是,这并不适用于每一个场景:这种将逻辑提升到组件树的更高层次来处理,会使得这些高层组件变得更复杂
- 如果又更加复杂的情况,比如子组件需要在渲染前和父组件进行一些交流,可以用到render prop
render prop
具有 render prop 的组件接受一个返回 React 元素的函数,并在组件内部通过调用此函数来实现自己的渲染逻辑。
1 | <DataProvider render={data => ( |
这里只是简单提下概念,具体的render prop的使用我会在以后的博客中分析。
API
React.createContext
1 | const MyContext = React.createContext(defaultValue); |
创建一个 Context 对象。当 React 渲染一个订阅了这个 Context 对象的组件,这个组件会从组件树中离自身最近的那个匹配的 Provider
中读取到当前的 context 值。
只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue
参数才会生效。此默认值有助于在不使用 Provider 包装组件的情况下对组件进行测试。注意:将 undefined
传递给 Provider 的 value 时,消费组件的 defaultValue
不会生效。
Context.Provider
1 | <MyContext.Provider value={/* 某个值 */}> |
每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化。
Provider 接收一个 value
属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。多个 Provider 也可以嵌套使用,里层的会覆盖外层的数据。
当 Provider 的 value
值发生变化时,它内部的所有消费组件都会重新渲染。Provider 及其内部 consumer 组件都不受制于 shouldComponentUpdate
函数,因此当 consumer 组件在其祖先组件退出更新的情况下也能更新。
通过新旧值检测来确定变化,使用了与 Object.is
相同的算法。
注意
当传递对象给
value
时,检测变化的方式会导致一些问题:详见注意事项。
Class.contextType
1 | class MyClass extends React.Component { |
挂载在 class 上的 contextType
属性会被重赋值为一个由 React.createContext()
创建的 Context 对象。此属性能让你使用 this.context
来消费最近 Context 上的那个值。你可以在任何生命周期中访问到它,包括 render 函数中。
注意:
你只通过该 API 订阅单一 context。如果你想订阅多个,阅读使用多个 Context 章节
如果你正在使用实验性的 public class fields 语法,你可以使用
static
这个类属性来初始化你的contextType
。
1 | class MyClass extends React.Component { |
Context.Consumer
1 | <MyContext.Consumer> |
一个 React 组件可以订阅 context 的变更,此组件可以让你在函数式组件中可以订阅 context。
这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。传递给函数的 value
值等价于组件树上方离这个 context 最近的 Provider 提供的 value
值。如果没有对应的 Provider,value
参数等同于传递给 createContext()
的 defaultValue
。
注意
想要了解更多关于 “函数作为子元素(function as a child)” 模式,详见 render props。
Context.displayName
context 对象接受一个名为 displayName
的 property,类型为字符串。React DevTools 使用该字符串来确定 context 要显示的内容。
示例,下述组件在 DevTools 中将显示为 MyDisplayName:
1 | const MyContext = React.createContext(/* some value */); |
这里我们再总结下关于这些API的使用和注意事项:
- 创建Context的方法有一种,就是调用
const MyContext = React.createContext(defaultValue);
- 订阅Context的方法有两种:
- 在你要订阅的组件中
static contextType = MyContext;
但是这种方法只能订阅一个Context Context.Consumer
:这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。
- 在你要订阅的组件中
- context 对象接受一个名为
displayName
的 property,类型为字符串。React DevTools 使用该字符串来确定 context 要显示的内容。
示例
动态 Context
一个更加复杂的方案是对上面的 theme 例子使用动态值(dynamic values):
theme-context.js
1 | export const themes = { |
这里声明了一个Context,默认值是theme.dark
themed-button.js
1 | import {ThemeContext} from './theme-context'; |
这里声明了一个组件,叫做
ThemedButton
通过
ThemedButton.contextType = ThemeContext;
的方式让组件订阅了ThemeContext
app.js
1 | import {ThemeContext, themes} from './theme-context'; |
首先
ThemeContext.Provider
提供了value,所以其内部的Toolbar中使用的是value,而不是ThemeContext的默认值而下面直接使用的
ThemedButton
,不在任何ThemeContext.Provider
内部,直接使用最近的默认值。
在嵌套组件中更新 Context
从一个在组件树中嵌套很深的组件中更新 context 是很有必要的。在这种场景下,你可以通过 context 传递一个函数,使得 consumers 组件更新 context:
theme-context.js
1 | // 确保传递给 createContext 的默认值数据结构是调用的组件(consumers)所能匹配的! |
theme-toggler-button.js
1 | import {ThemeContext} from './theme-context'; |
根据我们前面分析的,
ThemeContext.Consumer
的children是一个函数组件,当Context改变时,就会重新生成组件。也就是说,当我们theme或者toggleTheme发生变化时,就会利用变化后的值创建一个新的组件出来替换旧的组件。
app.js
1 | import {ThemeContext, themes} from './theme-context'; |
ThemeContext.Provider
中渲染Content,Content中渲染ThemeTogglerButton
。
ThemeContext.Provider
提供的value是自身的state。这个state中包含了theme和toggleTheme函数。当我们在
ThemeTogglerButton
中触发toggleTheme后,修改的其实是最外层的state。而这个state又会被
ThemeContext.Provider
当做value传递下去,从而触发ThemeTogglerButton
的更新。
这种方式本质上与状态提升没什么区别,可以让子组件有方法改变父组件的state,只是这个状态不用层层传递下来。
消费多个 Context
为了确保 context 快速进行重渲染,React 需要使每一个 consumers 组件的 context 在组件树中成为一个单独的节点。
1 | // Theme context,默认的 theme 是 “light” 值 |
如果两个或者更多的 context 值经常被一起使用,那你可能要考虑一下另外创建你自己的渲染组件,以提供这些值。
注意事项
因为 context 会使用参考标识(reference identity)来决定何时进行渲染,这里可能会有一些陷阱,当 provider 的父组件进行重渲染时,可能会在 consumers 组件中触发意外的渲染。举个例子,当每一次 Provider 重渲染时,以下的代码会重渲染所有下面的 consumers 组件,因为 value
属性总是被赋值为新的对象:
1 | class App extends React.Component { |
为了防止这种情况,将 value 状态提升到父节点的 state 里:
1 | class App extends React.Component { |
这样Provider每次重新渲染的时候,不会对value重新赋值为一个新的对象,而一直都是state.value。