1. 前言
继上次小试牛刀尝到高价组件的甜头之后, 现已深陷其中无法自拔... 那么这次又会带来什么呢? 今天, 我们就来看看[高阶组件] 和[New Context API] 能擦出什么火花!
2. New Context API
Context API 其实早就存在, 大名鼎鼎的 redux 状态管理库就用到了它. 合理地利用 Context API, 我们可以从 Prop Drilling 的痛苦中解脱出来. 但是老版的 Context API 存在一个严重的问题: 子孙组件可能不更新.
举个栗子: 假设存在组件引用关系 A -> B -> C, 其中子孙组件 C 用到祖先组件 A 中 Context 的属性 a. 其中, 某一时刻属性 a 发生变化导致组件 A 触发了一次渲染, 但是由于组件 B 是 PureComponent 且并未用到属性 a, 所以 a 的变化不会触发 B 及其子孙组件的更新, 导致组件 C 未能得到及时的更新.
好在 React@16.3.0 中推出的 New Context API 已经解决了这一问题, 而且在使用上比原来的也更优雅. 因此, 现在我们可以放心大胆地使用起来. 说了那么多, 都不如一个实际的例子来得实在. Show me the code:
- // DemoContext.JS
- import React from 'react';
- export const demoContext = React.createContext();
- // Demo.JS
- import React from 'react';
- import { ThemeApp } from './ThemeApp';
- import { CounterApp } from './CounterApp';
- import { demoContext } from './DemoContext';
- export class Demo extends React.PureComponent {
- state = { count: 1, theme: 'red' };
- onChangeCount = newCount => this.setState({ count: newCount });
- onChangeTheme = newTheme => this.setState({ theme: newTheme });
- render() {
- console.log('render Demo');
- return (
- <demoContext.Provider value={{
- ...this.state,
- onChangeCount: this.onChangeCount,
- onChangeTheme: this.onChangeTheme
- }}>
- <CounterApp />
- <ThemeApp />
- </demoContext.Provider>
- );
- }
- }
- // CounterApp.JS
- import React from 'react';
- import { demoContext } from './DemoContext';
- export class CounterApp extends React.PureComponent {
- render() {
- console.log('render CounterApp');
- return (
- <div>
- <h3>This is Counter application.</h3>
- <Counter />
- </div>
- );
- }
- }
- class Counter extends React.PureComponent {
- render() {
- console.log('render Counter');
- return (
- <demoContext.Consumer>
- {data => {
- const { count, onChangeCount } = data;
- console.log('render Counter consumer');
- return (
- <div>
- <button onClick={() => onChangeCount(count - 1)}>-</button>
- <span style={{ margin: '0 10px' }}>{count}</span>
- <button onClick={() => onChangeCount(count + 1)}>+</button>
- </div>
- );
- }}
- </demoContext.Consumer>
- );
- }
- }
- // ThemeApp.JS
- import React from 'react';
- import { demoContext } from './DemoContext';
- export class ThemeApp extends React.PureComponent {
- render() {
- console.log('render ThemeApp');
- return (
- <div>
- <h3>This is Theme application.</h3>
- <Theme />
- </div>
- );
- }
- }
- class Theme extends React.PureComponent {
- render() {
- console.log('render Theme');
- return (
- <demoContext.Consumer>
- {data => {
- const {theme, onChangeTheme} = data;
- console.log('render Theme consumer');
- return (
- <div>
- <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: theme }} />
- <select style={{ marginTop: '20px' }} onChange={evt => onChangeTheme(evt.target.value)}>
- {['red', 'green', 'yellow', 'blue'].map(item => <option key={item}>{item}</option>)}
- </select>
- </div>
- );
- }}
- </demoContext.Consumer>
- );
- }
- }
虽说一上来就贴个百来行代码的这种行为有点 low, 但是为了介绍 New Context API 的基本用法, 也只能这样了... 不过啊, 上面的例子其实很简单, 就算是先对 New Context API 的使用方法来个简单的科普吧~
仔细观察上面的代码不难发现组件间的层级关系, 即: Demo -> CounterApp -> Counter 和 Demo -> ThemeApp -> Theme, 且中间组件 CounterApp 和 CounterApp 并没有作为媒介来传递 count 和 theme 值. 接下来, 我们就来分析下上面的代码, 看看如何使用 New Context API 来实现祖先 ->子孙传值的:
New Context API 在 React 中提供了一个 React.createContext 方法, 它返回的对象中包含了 Provider 和 Consumer 两个方法. 也就是 DemoContext.JS 中的代码.
顾名思义, Provider 可以理解为公用值的一个提供者, 而 Consumer 就是这个公用值的消费者. 那么两者是如何联系起来的呢? 注意 Provider 接收的 value 参数. Provider 会将这个 value 原封不动地传给 Consumer, 这点也可以从 Demo.JS/CounterApp.JS/ThemeApp.JS 三个文件中体现出来.
再仔细观察例子中的 value 参数, 它是一个对象, key 分别是 count, theme, onChangeCount, onChangeTheme. 很显然, 在 Consumer 中, 我们不但可以使用 count 和 theme, 还可以使用 onChangeCount 和 onChangeTheme 来分别修改相应的 state, 从而导致整个应用状态的更新和重新渲染.
下面我们再来看看实际运行效果. 从下图中我们可以清楚地看到, CounterApp 中的 number 和 ThemeApp 中的 color 都能正常地响应我们的操作, 说明 New Context API 确实达到了我们预期的效果. 除此之外, 不妨再仔细观察 console 控制台的输出. 当我们更改数字或颜色时我们会发现, 由于 CounterApp 和 ThemeApp 是 PureComponent, 且都没有使用 count 和 theme, 所以它们并不会触发 render, 甚至 Counter 和 Theme 也没有重新 render. 但是, 这却并不影响我们 Consumer 中的正常渲染. 所以啊, 上文提到 Old Context API 的子孙组件可能不更新的这个遗留问题算是真的解决了~~~
3. 说好的高阶组件呢?
通过上面 "生动形象" 的例子, 想必大家都已经领会到 New Context API 的魔力, 内心是不是有点蠢蠢欲动? 因为有了 New Context API, 我们似乎不需要再借助 redux 也能创建一个 store 来管理状态了(而且还是区域级, 不一定非得在整个应用的最顶层). 当然了, 这里并非是说 redux 无用, 只是提供状态管理的另一种思路.
咦~ 文章的标题不是高阶组件 + New Context API = ? 吗, 怎么跑偏了? 说好的高阶组件呢?
别急, 上面的只是开胃小菜, 普及 New Context API 的基本使用方法而已... 正菜这就来了~ 文章开头就说最近沉迷高阶组件无法自拔, 所以在写完上面的 demo 之后就想着能不能用高阶组件再封装一层, 这样使用起来可以更加顺手. 你别说, 还真搞出了一套... 我们先来分析上面 demo 中存在的问题:
我们在通过 Provider 传给 Consumer 的 value 中写了两个函数 onChangeCount 和 onChangeTheme. 但是这里是不是有问题? 假如这个组件足够复杂, 有 20 个状态难道我们需要写 20 个函数分别一一对应更新相应的状态吗?
注意使用到 Consumer 的地方, 我们把所有的逻辑都写在一个 data => {...}函数中了. 假如这里的组件很复杂怎么办? 当然了, 我们可以将 {...} 这段代码提取出来作为 Counter 或 Theme 实例的一个方法或者再封装一个组件, 但是这样的代码写多了之后, 就会显得重复. 而且还有一个问题是, 假如在 Counter 或 Theme 的其他实例方法中想获取 data 中的属性和 update 方法怎么办?
为了解决以上提出的两个问题, 我要开始装逼了...
3.1 Provider with HOC
首先, 我们先来解决第一个问题. 为此, 我们先新建一个 ContextHOC.JS 文件, 代码如下:
- // ContextHOC.JS
- import React from 'react';
- export const Provider = ({Provider}, store = {}) => WrappedComponent => {
- return class extends React.PureComponent {
- state = store;
- updateContext = newState => this.setState(newState);
- render() {
- return (
- <Provider value={{ ...this.state, updateContext: this.updateContext }}>
- <WrappedComponent {...this.props} />
- </Provider>
- );
- }
- };
- };
由于我们的高阶组件需要包掉 Provider 层的逻辑, 所以很显然我们返回的组件是以 Provider 作为顶层的一个组件, 传进来的 WrappedComponent 会被包裹在 Provider 中. 除此之外还可以看到, Provider 会接收两个参数 Provider 和 initialVlaue. 其中, Provider 就是用 React.createContext 创建的对象所提供的 Provider 方法, 而 store 则会作为 state 的初始值. 重点在于 Provider 的 value 属性, 除了 state 之外, 我们还传了 updateContext 方法. 还记得问题一么? 这里的 updateContext 正是解决这个问题的关键, 因为 Consumer 可以通过它来更新任意的状态而不必再写一堆的 onChangeXXX 的方法了~
我们再来看看经过 Provider with HOC 改造之后, 调用方应该如何使用. 看代码:
- // DemoContext.JS
- import React from 'react';
- export const store = { count: 1, theme: 'red' };
- export const demoContext = React.createContext();
- // Demo.JS
- import React from 'react';
- import { Provider } from './ContextHOC';
- import { ThemeApp } from './ThemeApp';
- import { CounterApp } from './CounterApp';
- import { store, demoContext } from './DemoContext';
- @Provider(demoContext, store)
- class Demo extends React.PureComponent {
- render() {
- console.log('render Demo');
- return (
- <div>
- <CounterApp />
- <ThemeApp />
- </div>
- );
- }
- }
咦~ 原来与 Provider 相关的代码在我们的 Demo 中全都不见了, 只有一个 @Provider 装饰器, 想要公用的状态全都写在一个 store 中就可以了. 相比原来的 Demo, 现在的 Demo 组件只要关注自身的逻辑即可, 整个组件显然看起来更加清爽了~
3.2 Consumer with HOC
接下来, 我们再来解决第二个问题. 在 ContextHOC.JS 文件中, 我们再导出一个 Consumer 函数, 代码如下:
- export const Consumer = ({Consumer}) => WrappedComponent => {
- return class extends React.PureComponent {
- render() {
- return (
- <Consumer>
- {data => <WrappedComponent context={data} {...this.props}/>}
- </Consumer>
- );
- }
- };
- };
可以看到, 上面的代码其实非常简单... 仅仅是利用高阶组件给 WrappedComponent 多传了一个 context 属性而已, 而 context 的值则正是 Provider 传过来的 value. 那么这样写有什么好处呢? 我们来看一下调用的代码就知道了~
- // CounterApp.JS
- import React from 'react';
- import { Consumer } from './ContextHOC';
- import { demoContext } from './DemoContext';
- const MAP = { add: { delta: 1 }, minus: { delta: -1 } };
- // ... 省略 CounterApp 组件代码, 与前面相同
- @Consumer(demoContext)
- class Counter extends React.PureComponent {
- onClickBtn = (type) => {
- const { count, updateContext } = this.props.context;
- updateContext({ count: count + MAP[type].delta });
- };
- render() {
- console.log('render Counter');
- return (
- <div>
- <button onClick={() => this.onClickBtn('minus')}>-</button>
- <span style={{ margin: '0 10px' }}>{this.props.context.count}</span>
- <button onClick={() => this.onClickBtn('add')}>+</button>
- </div>
- );
- }
- }
- // ThemeApp.JS
- import React from 'react';
- import { Consumer } from './ContextHOC';
- import { demoContext } from './DemoContext';
- // ... 省略 ThemeApp 组件代码, 与前面相同
- @Consumer(demoContext)
- class Theme extends React.PureComponent {
- onChangeTheme = evt => {
- const newTheme = evt.target.value;
- const { theme, updateContext } = this.props.context;
- if (newTheme !== theme) {
- updateContext({ theme: newTheme });
- }
- };
- render() {
- console.log('render Theme');
- return (
- <div>
- <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: this.props.context.theme }} />
- <select style={{ marginTop: '20px' }} onChange={this.onChangeTheme}>
- {['red', 'green', 'yellow', 'blue'].map(_ => (
- <option key={_}>{_}</option>
- ))}
- </select>
- </div>
- )
- }
- }
可以看到, 改造之后的 Counter 和 Theme 代码一定程度上实现了去 Consumer 化. 因为和 Consumer 相关的逻辑仅剩一个 @Consumer 装饰器了, 而且我们只要提供和祖先组件中 Provider 配对的 Consumer 就可以了. 相比最初的 Counter 和 Theme 组件, 现在的组件也是更加清爽了, 只需关注自身的逻辑即可.
不过需要特别注意的是, 现在想要获取 Provider 提供的公用状态值时, 改成了从 this.props.context 中获取; 想要更新状态的时候, 调用 this.props.context.updateContext 即可.
为什么? 因为通过 @Consumer 装饰的组件 Counter 和 Theme 现在就是 ContextHOC 文件中的那个 WrappedComponent, 我们已经把 Provider 传下来的 Value 作为 context 属性传给它了. 所以, 我们再次通过高阶组件简化了操作~
下面我们再来看看使用高阶组件改造过后的代码看看运行的效果.
3.3 优化
你以为文章到这里就要结束了吗? 当然不是, 写论文的套路不都还要提出个优化方法然后做实验比较么~ 更何况上面这张图有问题...
没错, 通过 ContextHOC 改造过后, 上面的这张运行效果图似乎看上去没有问题, 但是仔细看 Console 控制台的输出你就会发现, 当更新 count 或 theme 任意其中一个的时候, Counter 和 Theme 都重新渲染了一次!!! 可是, 我的 Counter 和 Theme 组件明明都已经是 PureComponent 了啊~ 为什么没有用!!!
原因很简单, 因为我们传给 WrappedComponent 的 context 每次都是一个新对象, 所以就算你的 WrappedComponent 是 PureComponent 也无济于事... 那么怎么办呢? 其实, 上文中的 Consumer with HOC 操作非常粗糙, 我们直接把 Provider 提供的 value 值直接一股脑儿地传给了 WrappedComponent, 而不管 WrappedComponent 是否真的需要. 因此, 只要我们对传给 WrappedComponent 的属性值精细化控制, 不传不相关的属性就可以了. 来看看改造后的 Consumer 代码:
- // ContextHOC.JS
- export const Consumer = ({Consumer}, relatedKeys = []) => WrappedComponent => {
- return class extends React.PureComponent {
- _version = 0;
- _context = {};
- getContext = data => {
- if (relatedKeys.length === 0) return data;
- [...relatedKeys, 'updateContext'].forEach(k => {
- if(this._context[k] !== data[k]) {
- this._version++;
- this._context[k] = data[k];
- }
- });
- return this._context;
- };
- render() {
- return (
- <Consumer>
- {data => {
- const newContext = this.getContext(data);
- const newProps = { context: newContext, _version: this._version, ...this.props };
- return <WrappedComponent {...newProps} />;
- }}
- </Consumer>
- );
- }
- };
- };
- // 别忘了给 Consumer 组件指定 relatedKeys
- // CounterApp.JS
- @Consumer(demoContext, ['count'])
- class Counter extends React.PureComponent {
- // ... 省略
- }
- // ThemeApp.JS
- @Consumer(demoContext, ['theme'])
- class Theme extends React.PureComponent {
- // ... 省略
- }
相比于第一版的 Consumer 函数, 现在这个似乎复杂了一点点. 但是其实还是很简单, 核心思想刚才上面已经说了, 这次我们会根据 relatedKeys 从 Provider 传下来的 value 中匹配出 WrappedComponent 真正想要的属性. 而且, 为了保证传给 WrappedComponent 的 context 值不再每次都是一个新对象, 我们将它保存在了组件的实例上. 另外, 只要 Provider 中某个落在 relatedKeys 中的属性值发生变化, this._version 值就会发生变化, 从而也保证了 WrappedComponent 能够正常更新.
最后, 我们再来看下经过优化后的运行效果.
4. 写在最后
经过今天这波操作, 无论是对 New Context API 还是 HOC 都有了更深一步的理解和运用, 所以收货还是挺大的. 最重要的是, 在现有项目不想引进 redux 和 mobx 的前提下, 本文提出的这种方案似乎也能在一定程度上解决某些复杂组件的状态管理问题.
当然了, 文中的代码还有很多不严谨的地方, 还需要继续进一步地提升. 完整代码在这儿, 欢迎指出不对或者需要改进的地方.
来源: http://www.jianshu.com/p/423e2054878a