现在的 React 的项目似乎默认配置就是 redux + react-redux 或者其他基于redux的类库。而许多小项目用这些库的时候常常会有种杀鸡焉用牛刀的感觉。
状态管理的库(如 redux/react-redux)本质上都是通过 context 来做全局的数据存储,而类似 connect 组件检测有数据变更后,传递新的数据,并触发其被包裹的组件变更。在 React 中有 setState 可以触发变更,而在 React16.3 之后,出了新的 React Context API,本文将思考如何基于 React 自身的能力做全局状态管理。
我们以最简单的 Counter 计数器来作为例子。
本文不详细介绍 Context API 的使用,如果深入了解请参阅官方文档
文本所有代码用例不采用 hooks,基于 hooks 的状态管理方案会放在后续讨论。
实现思路
实现的大致思路是:
- 新建一个 React 组件,在组件中定义初始化数据,以及变更数据的方法;
- 将变更的方法和初始化数据注入到 Context 中;
- 将该组件作为顶层组件,使子组件可以获得 Context。
// 新建一个 Context,默认值为 null
const CounterContext = React.createContext(null);
class CounterProvider extends React.Component {
state = {
count: 0
};
increment = () => {
this.setState({ count: this.state.count + 1 });
};
decrement = () => {
this.setState({ count: this.state.count - 1 });
}
render() {
return (
<CounterContext.Provider
value={{ count: this.state.count, increment:this.increment, decrement:this.decrement }}
>
{this.props.children}
</CounterContext.Provider>
);
}
}
那么,子组件怎么去使用呢?
- 子组件需要嵌套在
CounterProvider下; - 子组件设定
contextType,获取this.context。
export default function App() {
return <CounterProvider>
<Counter />
</CounterProvider>
}
class Counter extends React.PureComponent {
static contextType = CounterContext;
render() {
const counter = this.context;
return <React.Fragment>
<ComplexDomTree />
<div>
<h1>Count: {counter.count}</h1>
<Button action={() => counter.increment()} buttonTitle="+" />
<Button action={() => counter.decrement()} buttonTitle="-" />
</div>
</React.Fragment>
}
}
性能问题
这么写,代码是正常的。但是会有性能问题。因为我们获取了整个 Context 对象,而只要 Context 变了,就会触发整个 Counter 组件 rerender。
为了验证,我们可以在 CounterProvider 中加些代码:
componentDidMount() {
setTimeout(() => {
this.setState({
value: Math.random()
});
}, 2000);
}
// 在 render 的 return 中修改为
return (
<CounterContext.Provider
value={{
count: this.state.count,
increment: this.increment,
decrement: this.decrement,
value:this.state.value
}}
>
{this.props.children}
</CounterContext.Provider>
);
我们在 CounterProvider 中重新 setState 触发变更,此时会生成新的 Context。
在没有点击的情况下, Counter 组件触发了更新。
而其实 Counter 中非所有组件都需要 Context 的数据,因为我们依赖了整个 Context,导致 Counter 组件 rerender, 而假如 Counter 组件中的 ComplexDomTree 中有非常重的操作,势必会引发一定的性能问题。我们是否可以分离而仅获得组件需要的数据避免不必要的渲染呢?
也就是类似 connect 的能力。
额外插一句:
You can only subscribe to a single context using this API. If you need to read more than one see Consuming Multiple Contexts.
这种直接获取 context 的方式只能针对单一 context,对于多个 React.createContext() 的情况下,是没法用的。
性能优化
接下来,我们来看看方式二(性能更优):即子组件需要通过 <CounterContext.Consumer> 中通过 render function 的形式获取父组件传递的值。
class Counter extends React.PureComponent {
render() {
return (<>
<ComplexDomTree />
<CounterContext.Consumer>
{counter => (
<div>
<h1>Count: {counter.count}</h1>
<Button action={() => counter.increment()} buttonTitle="+" />
<Button action={() => counter.decrement()} buttonTitle="-" />
</div>
)}
</CounterContext.Consumer>
</>
);
}
}
第二次 Counter 组件本身没有触发 rerender,ComplexDomTree 完全 skip render,是不是很棒!!!
所以,我们的组合是通过新建一个顶层组件来当做 Provider,顶层组件的 state 就是 redux 中的 store,其中 setState 的方法就是 dispatch 操作,而最终的 connect 就是 <Context.Consumer>,通过 render function 代替 mapStateToProps。
优劣对比
这种方式的状态管理是完全基于 React 自身的能力实现的,并没有依赖任何库。而其中的 setState 还自带 patch 能力,也有一定的性能优化。
那么,它有什么缺陷么?
个人认为主要是两点,第一点是如果 Context 过多,render function 写法会引发嵌套导致代码丑陋。
第二点的描述比较复杂,视个人使用情况而定。通常我们不会只有一个 reducer。而是会拆成多个小的 reduder,再通过 combinereduer 生成一个 store。
如果在顶层组件中把所有的状态都写在一个 state 里,那你会发现这个顶层组件中需要定义几十甚至几百个方法属性来改变 state。
所以我们需要考虑到适当拆分 context 中的数据,也就是分成多个 ContextProvider 以及 ContextConsumer。
这么拆分之后,整体结构是更加合理。但实际的业务逻辑中,我们往往可能会需要从不同的 context 中获得部分数据计算得到一个新的数据。这种衍生数据的计算,才是真正的 mapStateToProps 的能力。目前 Context 没有很好的方式可以做到(除了只生成单一的 Context 以外)。
这也是 redux 的魅力。因为 dispatch 非常灵活,不需要像 Context 的实现方案,需要把各种 setState 的方法传递到组件中调用。
下一篇,将介绍其他基于 Context 的解决方案。