skip to content
OnionTalk

Deep In React (一) 高性能React组件

React在推向社区之初, 一个备受社区欢迎的点就是React的优秀的性能。得益于React出色的架构, React相比于传统的方式确实快很多。但是在实际开发中也存在着很多React性能的陷阱。虽然我们绝大多数时候其实不需要过于关心React的性能问题,但是当需要提升性能时,我们也会有一些很好的切入点去做这件事情.

React 的渲染机制

在 React 内部,存在着初始化渲染和更新渲染的概念。

初始化渲染会在组件第一次挂载时,渲染所有的节点

当我们希望改变某个子节点时

我们所期望 React 帮我们实现的渲染行为是这样的

我们希望当我们的 props 向下传递时,只有对应需要更新的节点进行更新并重新渲染,其余节点不需要更新和重新渲染。

但是事实上,在默认的情况下,结果却是这样的

所有的组件树都被重新渲染,因为对于 React 而言,只要有 props 或者 state 发生了改变,我的组件就要重新渲染,所以除了绿色的节点,所有的黄色节点也被渲染了。

例子:

const Foo = ({ foo }) => {
  console.log('Foo is rendering!');
  return <div>Foo {foo}</div>;
};

const Bar = ({ bar }) => {
  console.log('Bar is rendering!');
  return <div>Bar {bar}</div>;
};

const FooBarGroup = ({ foo, bar }) => {
  console.log('FooBar is rendering!');
  return (
    <div>
      <Foo foo={foo} />
      <Bar bar={bar} />
    </div>
  );
};

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      foo: 0,
      bar: 0,
    };
    this.handleFooClick = this.handleFooClick.bind(this);
    this.handleBarClick = this.handleBarClick.bind(this);
  }

  handleFooClick(e) {
    e.preventDefault();
    const newFoo = this.state.foo + 1;
    this.setState({ foo: newFoo });
  }

  handleBarClick(e) {
    e.preventDefault();
    const newBar = this.state.bar + 1;
    this.setState({ bar: newBar });
  }

  render() {
    const { foo, bar } = this.state;
    return (
      <div className="App">
        <button onClick={this.handleFooClick}>Foo</button>
        <button onClick={this.handleBarClick}>Bar</button>
        <FooBarGroup foo={foo} bar={bar} />
      </div>
    );
  }
}

demo on stackblitz

当我们点击 Foo 按钮的时候,因为只有传入 Foo 组件和 FooBarGroup 组件的 foo 这个 props 更新了,我们希望上只有 Foo 组件和 FooBarGroup 组件会被再次渲染。但是打开 console 你会发现,console 中会出现 Bar 组件渲染时打印的 log。证明 Bar 组件也被重新渲染了。

shouldComponentUpdate

工作原理

避免冗余渲染是一个常见的 React 性能优化方向。造成冗余渲染的原因是在默认情况下,shouldComponentUpdate()这个生命周期函数总是返回 true(source code)意味着所有的组件在默认的情况下都会在组件树更新时去触发 render 方法。

React 官方对于 shouldComponentUpdate 的工作与 React 组件树更新的机制有一个还不错的解释。React 组件的更新决策可以分为两步,通过 shouldComponetUpdate 来确认是否需要重新渲染,React vDOM diff 来确定是否需要进行 DOM 更新操作。 shouldComponentUpdate In Action

在上图中,C2 节点不会重新触发 render 函数因为 shouldComponentUpdate 在 C2 节点返回了 false,更新请求在此处被截断,相应的 C2 节点下的 C4、C5 节点也就不会触发 render 函数。

对于 C3 节点,由于 shouldComponentUpdate 返回了 true,所以需要进行进一步的 Vitural DOM 的 diff(以下简称 vDOM diff,该 diff 算法由 react 提供,不在这细讲)。

而父组件的 vDOM diff 其实是对于子组件遍历进行以上过程。同上,C3 的子组件 C6 由于 shouldComponentUpdate 返回了 true,所以需要进行下一步 vDOM diff,diff 后发现需要更新,所以会重新触发渲染。而 C7 节点由于 shouldComponentUpdate 返回了 false,所以便不再进行进一步的 vDOM diff。而 C8 节点在 vDOM diff 后发现 vDOM 相等,最后其实也不会更新。

如何使用

上面提到了,默认情况下,shouldComponentUpdate 这个方法总是会返回 True。如果我们需要去显式的去决定我们的组件是否需要更新,那就意味着我们可以去显式调用这个生命周期函数。

class Foo extends React.Component {
  shouldComponentUpdate(nextProps) {
    return this.props.foo !== nextProps.foo;
  }

  render() {
    console.log('Foo is rendering!');
    return <div>{this.props.foo}</div>;
  }
}

const Bar = ({ bar }) => {
  console.log('Bar is rendering!');
  return <div>{bar}</div>;
};

const FooBarGroup = ({ foo, bar }) => {
  console.log('FooBarGroup is rendering!');
  return (
    <div>
      <Foo foo={foo} />
      <Bar bar={bar} />
    </div>
  );
};

demo on stackblitz

这时再去查看 console,我们会发现只有当 foo 更新的时候,Foo 组件才会真正的去调用 render 方法。

PureComponent

使用 PureComponent

但是如果所有的组件我们都要去自己实现 shouldComponentUpdate 方法, 有的时候未免会显得很麻烦。不过好在 React 包里面提供了 PureComponent 这个实现。

React.PureComponent

PureComponent 内部实现了一个基于 props 和 state 浅比较的 shouldComponentUpdate 方法,基于这种浅比较,当 props 和 state 没有发生改变时,组件的 render 方法便不会被调用到。

class Foo extends React.PureComponent {
  /*
 我们不需要手动实现shouldCompoUpdate方法了
   shouldComponentUpdate(nextProps) {
     return this.props.foo !== nextProps.foo;
   }
   */

  render() {
    console.log('Foo is rendering!');
    return <div>{this.props.foo}</div>;
  }
}

demo on stackblitz

PureComponent 中的陷阱

我的 props 改变了,为什么我的组件没有更新?

由于 PureComponent 的 shouldComponentUpdate 是基于浅比较shallowEqual.js的,对于复杂对象,如果深层数据发生了改变,shouldComponentUpdate 方法还是会返回 false。

比如

class Foo extends PureComponent {
  constructor(props) {
    super(props);
    this.state = {
      foo: ['test'],
    };
  }

  handleClick = (e) => {
    e.preventDefault();
    const foo = this.state.foo;
    foo.push('test');
    //push是一个mutable的操作,foo的引用并没有改变
    this.setState({ foo });
  };

  render() {
    console.log('Foo is rendering!');
    return (
      <div>
        <button onClick={this.handleClick}>Foo balabala</button>
        {this.state.foo.length}
      </div>
    );
  }
}

上面这段代码当我的 button 进行点击时,即使我的 this.state.foo 发生了改变,但是我的组件却不会有任何更新。因为我的 this.state.foo([‘test’])与 nextState.foo([‘test’, ‘test’])在 shouldComponentUpdate 进行的浅比较(实际使用Object.is)时其实是两个相同的两个数组。

demo on stackblitz

解决办法

// concat会返回一个新数组,引用发生改变, 浅比较(Object.is)会认为这是两个不同的数组
handleClick = (e) => {
  e.preventDefault();
  const foo = this.state.foo;
  const bar = foo.concat('test');
  this.setState({ foo: bar });
};

我的 props 没有变, 为什么我的组件更新了?

然而有的时候, 即使我是 PureComponent, 在组件的 props 看上去没有发生改变的时候, 组件还是被重新渲染了, interesting。

const Foo = ({ foo }) => {
  console.log('Foo is rendering!');
  return <div>{foo}</div>;
};

const Bar = ({ bar }) => {
  console.log('Bar is rendering!');
  return <div>{bar}</div>;
};

const FooBarGroup = ({ foo, bar }) => {
  console.log('FooBar is rendering!');
  return (
    <div>
      <Foo foo={foo} />
      <Bar bar={bar} />
    </div>
  );
};

class PureRenderer extends React.PureComponent {
  render() {
    console.log('PureRenderer is rendering!!');
    const { text } = this.props;
    return <div>{text}</div>;
  }
}

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      foo: 0,
      bar: 0,
    };
  }

  handleFooClick = (e) => {
    e.preventDefault();
    const newFoo = this.state.foo + 1;
    this.setState({ foo: newFoo });
  };

  handleBarClick = (e) => {
    e.preventDefault();
    const newBar = this.state.bar + 1;
    this.setState({ bar: newBar });
  };

  render() {
    const { foo, bar } = this.state;
    return (
      <div className="App">
        <button onClick={this.handleFooClick}>Foo</button>
        <button onClick={this.handleBarClick}>Bar</button>
        <FooBarGroup foo={foo} bar={bar} />
        <PureRenderer text="blablabla" onClick={() => console.log('blablabla')} />
      </div>
    );
  }
}

demo on stackblitz

我的 PureRenderer 明明已经是一个 PureComponent 了。但是当我点击 foo 或者 bar button 时,我还是能发现我的 render 方法被调到了。我似乎并没有进行任何 props 的修改?

导致这种情况是因为 props。onClick 传入的是()=>{console.log('balabalabla'。这就意味着我每次传入的都是一个新的函数实体。对于两个不同的实例进行浅比较, 我总会得到这两个对象不相等的结果。(引用比较)

解决办法其实也很简单, 就是持久化下来我们的对象

const onClickHandler = () => console.log('blablabla')

class App extends Component {
 constructor(props) {
   super(props)
   this.state = {
     foo: 0,
     bar: 0
   };
 }

 render() {
 const {foo, bar} = this.state;
   return (
     <div className="App">
       <button onClick={this.handleFooClick}>Foo</button>
       <button onClick={this.handleBarClick}>Bar</button>
       <FooBarGroup
         foo={foo}
         bar={bar}
       />
       <DateRerender text="blablabla" onClick={onClickHandler}/>
     </div>
   );
 }

这样就能避免不必要的重复渲染了。

React 性能检测

1. 与 Chrome 集成的 TimeLine 工具

React 在开发者模式下通过调用 User Timing API 可以很好的 Chrome 的火炬图进行结合来对组件的性能进行检测。

Profiling Components with the Chrome Performance Tab

可以看到所有的 React 相关的函数调用和操作都出现在了 Timeline 之中。

2. Why did you update?

还有一个有意思的库也可以帮助我们做到这些。

why-did-you-update

这个库会提供一个高阶组件, 将你的组件使用这个高阶组件包裹一下, 打开 console 你就能发现这个库对于你的组件的 profiling。

3. React 16.5 引入的 profiling tool

React 在 16.5 以后引入了更加强大的 profilling tool,后续有时间再拿一篇文章出来讲一讲。 Introducing the React Profiler – React Blog

最后一点:性能可能不是那么的重要

其实大多数时候, React 已经足够的快了。 所以当性能不是明显的痛点的时候, 我们没有必要花费大量的时间去强行让我们的每个组件都做到性能的最优化。 将更多的时间花在更重要的事情上(比如重构, 更好的代码组织), 写出更可维护更可读更优美的代码, 才是我们每一个开发人员需要更加关注的事情。

参考资料

高性能 React 组件 - By 淘宝前端团队

High Performance React - By Ben Edelstein

Optimiz Performance - By React Docs