避免React Context导致的重复渲染

CarpenterCatherine 发布于9月前
0 条问题

避免React Context导致的重复渲染

React推出新的Context API时,很多人都高呼:"终于可以丢掉Redux了!"

But,事实是,新Context API出来也大半年了,依然不见它完全淘汰Redux。我个人也倾向于能用React解决的事情就不劳烦Redux这样的第三方工具,但是,不得不承认,有些事情不要想得太天真,Redux说到底就是专门为状态管理而诞生的,再差也能管理好状态;React Context说到底只是组件,让它去管理状态,不得不多费一些心。

因为要多费一些心,所以还是有门槛的,如果你不费心,很容易就把事情做错。

今天说的,就是Context没用好,可能毁你应用的性能。

Redux有action和reducer,用这两样东西来更新状态的确很啰嗦,现在,假设我们用Context来代替Redux,一个很大的动因就是不想太啰嗦,我们不想写reducer和action,那该怎么做呢?

第一感觉,是直接在Consumer中拿到value之后去修改其中的值,就像下面这样,可是,这样不行。

<SomeContext.Consumer>
  {
    value => (
       <>
          <h1>value.foo</h1>
          {/* 下面这样写不行!!! */}
          <button onClick={() => {value.foo = 'bar'}}></button>
       </>
    )
  }
</SomeContext.Consumer>

上面这样做,虽然真的能够改变value上一个属性的值,但是没有改变value本身,简单说就是React并不知道Context的value被修改了,所以React也不会通知其他Consumer这个变化,也不会引起任何重新渲染,但是,我们改value就是要引起重新渲染,可见这招不行。

你要是在上面的代码中直接去改value,像下面这样,更没有任何作用,因为你只不过改了一个函数参数值而已,甚至都没有改变Context中的value。

<SomeContext.Consumer>
  {
    value => (
       <>
          <h1>value.foo</h1>
          {/* 下面这样连Context的value都没有修改!!! */}
          <button onClick={() => {value = {foo: 'bar'}}></button>
       </>
    )
  }
</SomeContext.Consumer>

所以,还是需要其他办法。

一个惯常的做法,是让Context的value包含函数类型的属性,让Consumer可以通过调用这个函数来修改组件的state,从而引发重新渲染。

用一个简单的切换主题(Theme)的例子来说明问题,代码如下。

const redTheme = {
  color: 'red',
};

const greenTheme = {
  color: 'green',
}

class App extends React.Component {
  state = {
    theme: redTheme,
  }

  switchTheme = (theme) => {
    this.setState({theme});
  }

  render() {
    console.log('render App');
    return (
      <Context.Provider value={{theme: this.state.theme, switchTheme: this.switchTheme}}>
        <div className="App">
          <Header/>
          <Content />
        </div>
      </Context.Provider>
    );
  }
}

上面的App组件是应用的最顶层组件,渲染出一个Context的Provider,Provider的子组件是应用中其他组件,这非常合理。

App组件的state保存当前主题,给Provider的value里,除了包含当前主题,还给一个函数switchTheme,给Consumer一个机会来切换主题,一个Consumer的例子就是Content组件,代码如下(这里我也赶时髦用Hooks的useContext实现)。

function Content() {
  const {theme, switchTheme} = useContext(Context);

  return (
    <>
      <h1 style={theme}>Hello world</h1>
      <button onClick={() => switchTheme(redTheme)}>Red Theme</button>
      <button onClick={() => switchTheme(greenTheme)}>Green Theme</button>
    </>
  );
}

界面是这样,点击"Red Theme"或者"Green Theme"就可以切换Hello World的颜色。

避免React Context导致的重复渲染

我们来梳理一遍这个过程。

Context.Provider
Context.Provider

步骤有一点多,但是的确就是这么一个过程,一切都工作正常。

真的正常吗?

功能虽然正常,但是有一件事不是我们想要的,就是当 Context.Provider 重新渲染的时候,它所有的子组件都被重新渲染了,比如上面例子中子组件有Header和Content,Content作为Consumer之一重画没问题,但是Header不是Consumer,也不依赖于Context的value,根本没有必要重画啊!

大家可以在这里试验一下 context-rerender-demo - CodeSandbox ,在console里可以看到,每一次切换主题,Header的render都会被调用。

在上面的例子中,如果除了Header还有其他比较重的组件,而且这些组件没有用shouldComponentUpdate守住,那么每一次Context的改变,都会引发整个应用组件树的重画,代价就有一点大了。

这就是我说的,如果不费心,就容易把事情做错。

其实, Context.Provider 说到底还是组件,也按照组件基本法来办事,当value发生变化时,它也可以不引发子组件的渲染,前提是,子组件作为一个属性(this.props.children)也要保持不变才行,如果子组件变了, Context.Provider 也不知道你是不是以前的你,只好让你重画了。

表面上看,下面的JSX中 Context.Provider 的子组件任何一次渲染都是一样的。

<Context.Provider value={{theme: this.state.theme, switchTheme: this.switchTheme}}>
        <div className="App">
          <Header/>
          <Content />
        </div>
      </Context.Provider>

其实并不是这样,JSX会被转译成React.CreateElement,所以上面的JSX运行时是类似这样。

React.createElement(Context.Provider, {value: ...},
  React.createElement('div', {className: ...},
    React.createElement(Header),
    React.createElement(Content),
  )
)

你看,每一次渲染都调用React.createElement,所以每一次渲染产生的子组件都是不一样的啊。

所以,我们需要一个方法“说服” Context.Provider ,告诉他你的子组件没有变化,方法也很简单,就是建一个独立的组件来管理state和Provider,把子组件的JSX写在这个组件之外。

我们改进上面的代码,制造一个ThemeProvider,代码如下。

class ThemeProvider extends React.Component {
  state = {
    theme: redTheme,
  }

  switchTheme = (theme) => {
    this.setState({theme});
  }

  render() {
    console.log('render ThemeProvider');
    return (
      <Context.Provider value={{theme: this.state.theme, switchTheme: this.switchTheme}}>
        {this.props.children}
      </Context.Provider>
    );
  }
}

function App () {
  console.log('render App');
  return (
    <ThemeProvider>
      <div className="App">
        <Header />
        <Content />
      </div>
    </ThemeProvider>
  )
}

现在App成了一个无状态组件,只渲染一次,因为state改为ThemeProvider来管理,每次当ThemeProvider的state被switchTheme改变而重新渲染的时候,它看到的子组件(this.props.children)是App传给他的,不需要重新用React.createElement穿件,所以this.props.children是不变的,于是 Context.Provider 也就不会让this.props.children重新渲染了。

改进的代码在这里 context-avoid-rerender-demo - CodeSandbox ,大家可以试试 ,现在切换Theme不会引发Header的重新渲染了。

总结一下,就是Context虽然是一个好东西,但是不要指望无脑使用就能用它替换掉Redux,你要是乱用的话,可能给自己带来更大麻烦。

(P.S. 预告一下,我近期会出一个关于React设计模式的电子读物,这次尝试一下非传统出版方式,希望大家能够喜欢,敬请期待)

查看原文: 避免React Context导致的重复渲染

  • lazydog
  • smallsnake
  • bluebear
  • bluepanda
  • silverbear
  • silverkoala
  • silverbutterfly
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。