SmallStoneSK / Blog

总结 -> 成长
136 stars 22 forks source link

高阶组件 + New Context API = ? #7

Open SmallStoneSK opened 6 years ago

SmallStoneSK commented 6 years ago

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的前提下,本文提出的这种方案似乎也能在一定程度上解决某些复杂组件的状态管理问题。

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