高阶组件 + New Context API = ?

原文地址:https://github.com/SmallStoneSK/Blog/issues/7

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来实现祖先->子孙传值的:

  1. New Context API在React中提供了一个React.createContext方法,它返回的对象中包含了ProviderConsumer两个方法。也就是DemoContext.js中的代码。
  2. 顾名思义,Provider可以理解为公用值的一个提供者,而Consumer就是这个公用值的消费者。那么两者是如何联系起来的呢?注意Provider接收的value参数。Provider会将这个value原封不动地传给Consumer,这点也可以从Demo.js/CounterApp.js/ThemeApp.js三个文件中体现出来。
  3. 再仔细观察例子中的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中存在的问题:

  1. 我们在通过Provider传给Consumer的value中写了两个函数onChangeCount和onChangeTheme。但是这里是不是有问题?假如这个组件足够复杂,有20个状态难道我们需要写20个函数分别一一对应更新相应的状态吗?
  2. 注意使用到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的前提下,本文提出的这种方案似乎也能在一定程度上解决某些复杂组件的状态管理问题。

当然了,文中的代码还有很多不严谨的地方,还需要继续进一步地提升。完整代码在这儿,欢迎指出不对或者需要改进的地方。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,573评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,336评论 25 707
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,679评论 2 59
  • 01 很喜欢《少有人走的路》里的一句话:勇气是尽管你感觉害怕,但仍能迎难而上;尽管你感觉痛苦,但仍能直接面对。 很...
    衷曲无闻阅读 2,371评论 25 55
  • 最近,卤煮在微博上看到这么个消息,说李小冉之前拍摄的《风筝》要上星播出了,先不论这条消息的真假,听到这样的消息,卤...
    神资讯阅读 228评论 0 0