组件渲染优化

React 组件渲染优化

组件渲染优化的核心即是尽可能地 避免冗余渲染

why-did-you-update 在 React 做不必要更新的时候进行提示

A function that monkey patches React and notifies you in the console when potentially unnecessary re-renders occur. Super helpful for easy perf gainzzzzz.

import React from "react";
import { whyDidYouUpdate } from "why-did-you-update";

if (process.env.NODE_ENV !== "production") {
  whyDidYouUpdate(React);
}

You can include or exclude components by their displayName with the include and exclude options

whyDidYouUpdate(React, { include: /^pure/, exclude: /^Connect/ })

shouldComponentUpdate 避免无谓渲染

参考这篇文章

所谓的展示型(Presentational)组件或者无状态(Stateless)组件会在每一次

import React, { Component } from "react";

class User extends Component {
  shouldComponentUpdate(nextProps) {
    // Because we KNOW that only these props would change the output
    // of this component.
    return (
      nextProps.name !== this.props.name ||
      nextProps.highlighted !== this.props.highlighted
    );
  }

  render() {
    const { name, highlighted, userSelected } = this.props;
    console.log("Hey User is being rendered for", [name, highlighted]);
    return (
      <div>
        <h3
          style={{ fontStyle: highlighted ? "italic" : "normal" }}
          onClick={(event) => {
            userSelected();
          }}
        >
          {name}
        </h3>
      </div>
    );
  }
}
import React, { PureComponent } from 'react';

class User extends PureComponent {
  render() {
    ...
  }
}
{
  this.state.users.map((user) => {
    return (
      <User
        name={user.name}
        highlighted={user.highlighted}
        userSelected={() => {
          this.setState((prevState) => {
            users: prevState.users.map((u) => {
              if (u.name === user.name) {
                u.highlighted = !u.highlighted;
              }
              return u;
            });
          });
        }}
      />
    );
  });
}

sCU

This method takes the arguments nextProps and nextState, which you can use to compare against this.props and this.state to determine if there was a change, and if that change warrants an update to the component. sCU must always return a boolean:

shouldComponentUpdate = (nextProps, nextState) => {
  return (true|false)
}

Behind the scenes, React implements sCU for every component which always return true. It’s up to the developer to override this method and define the conditions for updating (re-rendering). A useful technique to enforce strict rendering rules is to initially write the component with sCU return false. By doing this, we’re telling the component not to re-render under any circumstances. In the example below, we have a simple component which renders a Button.

class MyComponent extends React.Component {
  shouldComponentUpdate = (nextProps, nextState) => false
  render() {
  <Button label='Click me' />
  }
}
class MyComponent extends React.Component {
  state = {
  loading: false
  }
  loadData = () => {
  this.setState({ loading: true })
  api.loadData.then(() => {
  this.setState({ loading: false })
  })
  .catch(() {
  this.setState({ loading: false })
  })
  }
  shouldComponentUpdate = (nextProps, nextState) => {
  return nextState.loading !== this.state.loading
  }
  render() {
  <div>
  <Button label='Click me' />
  <Loader display={this.state.loading} />
  </div>
  }
}

点击与触摸优化

作为 DOM 的抽象,React 自然也遵循了著名的抽象漏洞定理(详见2016-我的前端之路:工具化与工程化),引入 React 导致了在应用本身的性能消耗之外势必会增加额外的性能损耗。Dan Abramov 在 Twitter 上提到,React 并不能保证性能优于原生的 DOM 实现,但是它能够帮助大量的普通开发者构建大型应用的同时不必在初期就耗费大量的精力在性能优化上,在大部分用户交互界面上 React 已经能够帮我们进行合理的优化了。但是在应用开发的过程,特别是最后的细节优化阶段中,我们需要着眼于部分性能瓶颈页面,正确地认识这种限制的缘由以及相对应的处理方案。本文即是作者在构建自己的大型应用中经验的总结。

避免过早优化

无论你在做的是啥应用,注意要避免如惊弓之鸟般过早优化。换言之,在你真实的发现某些性能问题之前不要为了优化而优化,在 React 中,如果我们进行过多的冗余优化拆分操作反而会造成奇怪的 Bug。正常的性能优化过程应该包含以下几个步骤:

  • 确定发现存在性能的缺陷
  • 使用 DevTools 来解析发现瓶颈所在
  • 尝试使用优化技巧解决这些问题
  • 测试是否确实有性能提升
  • 重复第二步

React 15.4  中引入了新的性能评测工具,可以方便地与 Chrome DevTools 集成使用,从而大大简化我们性能定位地困难。

需要使用 shouldComponentUpdate 吗?

相信几乎每个 React 开发者都会熟悉组件生命周期中的 shouldComponentUpdate,React  会根据该函数返回的布尔值来判断是否需要重渲染该组件。在我最初用 React 的那段时间,我天真的以为 React 会智能地帮我们在 Props 与 State 没有改变的时候取消组件重渲染,不过事实证明只要你调用了setState或者传入了不同的 Props 的时候,React 就会重渲染组件。而重载这个shouldComponentUpdate方法可能是最简单的组件优化方式了,不过这种方式仍然存在某些不足或者副作用。譬如当你在某个高阶组件中重载了该方法之后,尽管你只是希望不重渲染该组件,而实际上 React 可能会依赖于该组件的shouldComponentUpdate返回值而取消对组件树中的整个子组件的重渲染。基于该函数最著名的实现当属shouldPureComponentUpdate,该重载方式会浅层比较当前的与未来的 State 以及 Props 的差异,这种方式的缺陷如下:

  • 它并没有深层比较两个对象,不过如果它真的进行了深层比较,该操作会变得异常缓慢,这也就是使用不可变数据结构的原因。
  • 如果传入的某个 Props 是某个回调函数,那么该函数会一直返回True
  • 比较检测本身也是有性能损耗的,应用中过多的冗余比较反而会降低性能。

总结而言,在实际应用开发中,建议的重载shouldComponentUpdate应该适用于以下类型的组件:

  • 使用简单 Props 的纯组件
  • 叶子组件或者在组件树中较深位置的组件

不过还是需要强调的是,无论你是选择重载shouldComponentUpdate函数还是使用pure HoC这样的模式,还是首先应该找出那个拖慢整个应用性能的组件。

将高性能消耗的代码放置到较高阶组件中

如果在你的渲染函数中存在着部分性能消耗较高的计算代码,那么建议是将这部分代码尽可能地放置到较高阶的组件中,或者使用memorizing(reselect)的方式来减少重复调用或者计算。在我构建https://status.postmarkapp.com/网站的过程中,我主要通过以下的方式优化整体性能:

  • 将可视化数据与覆盖层信息抽取出来放置到独立的组件中。
  • 将执行大量数据转换的代码移出到容器组件中。
  • 仅仅对于可视化组件与覆盖层组件覆写shouldComponentUpdate函数。
  • 使用不可变数据结构(Immutable)来降低比较带来的性能消耗

我发现最常见的降低应用性能的原因就是用户输入引发的 DOM 操作,譬如用户滚动或者鼠标移动的响应,都会大幅度的降低应用的帧数。这些事件往往都会以较高地频次触发,如果你打算监听并且响应任何用户细小的动作,那么估计你的应用离崩溃不远了。我们通常会使用debounce模式来避免频繁地触发响应,不过这种模式也会让用户觉得应用不是那么灵活响应,这里我们再讨论下应该以怎样的方式来解决这个性能问题。

同步滚动组件

为了更好地解释这个问题,我构建了某个同步滚动的组件来演示这个问题,其效果如下所示:

该组件的主要职能在于保持左右两个滚动面板的一致性(就好像常见的 MarkDown 预览),而因为两个面板的内容高度不一致,因此两个面板需要以不同的速度进行滚动。

不要滥用 this.setState

React 应用开发中最常见的某个错误就是对于this.setState函数的使用,我们不应该将render()函数中用不到的状态放置到this.state对象中。下面我们来看下第一个版本的滚动面板的实现:

class ScrollPane extends React.Component {
 componentDidUpdate() {
  // Each time we get new props we set the
  // new scrollTop position on the DOM element
  this.el.scrollTop = this.props.scrollTop
 }
 render() {
  <div ref={(el) => {this.el = el}}>
 }
}


class ScrollContainer extends React.Component {


 constructor() {
  super()
  this.leftPane = null
  this.rightPane = null
  this.state = {
 leftPaneScrollTop: 0,
 rightPaneScrollTop: 0
  }
 }

 handleLeftScroll = (evt) => {
  // Calculate new scrollTop positions
  // for left and right panes based on
  // DOM nodes and evt.target.scrollTop
  const leftPaneScrollTop = …
  const rightPaneScrollTop = …

  // Don't do this since this will re-render everything
  // on each `scroll` event!
  this.setState({
 leftPaneScrollTop,
 rightPaneScrollTop
  })
 }

 render() {
  return (
 <div>
  <ScrollPane
 ref={(el) => {this.leftPane = el}}
 onScroll={this.handleScroll}
 scrollTop={this.state.leftPaneScrollTop}
  >
 <ExpensiveComponent />
  </ScrollPane>
  <ScrollPane
 ref={(el) => {this.rightPane = el}}
 onScroll={this.handleScroll}
 scrollTop={this.state.rightPaneScrollTop}
  >
 <ExpensiveComponent />
  </ScrollPane>
 </div>
  )
 }
}

在这个版本的实现中,我们将所有的状态放置到了this.state中,此时问题就在于每次你调用this.setState来设置组件状态时,React 会重渲染整个组件树。另外,我们是否真的有必要将scrollTop的值以 Props 的方式传递到子组件中?我们可以先将滚动高度从组件状态对象中提取出来:

handleScroll = (evt) => {
  // Calculate new scrollTop positions
  // for left and right panes based on
  // DOM nodes and evt.target.scrollTop
  this.leftPaneScrollTop = …
  this.rightPaneScrollTop = …
}

将滚动高度作为类成员属性就不会触发重渲染,不过此时我们应该如何更新兄弟组件的滚动位置呢?这里的建议是直接进行 DOM 操作。虽然这种方式看起来有点破坏 React 声明式组件的特性,不过笔者在前文中也提到过,声明式的特性与 DOM 操作并不相冲突。我们可以使用 Context(虽然貌似这个也不建议使用)来操作子组件而避免直接操作子组件的命令式代码,从而保证其他组件仍然保持纯粹的声明式。代码如下:

class ScrollPanel extends Component {
  static contextTypes = {
    registerPane: PropTypes.func.isRequired,
    unregisterPane: PropTypes.func.isRequired
  };

  componentDidMount() {
    this.context.registerPane(this.el);
  }

  componentWillUnmount() {
    this.context.unregisterPane(this.el);
  }

  render() {
    return (
      <div
        ref={el => {
          this.el = el;
        }}
      >
        {this.props.children}

      </div>
    );
  }
}

class ScrollContainer extends Component {
  static childContextTypes = {
    registerPane: PropTypes.func,
    unregisterPane: PropTypes.func
  };

  getChildContext() {
    return {
      registerPane: this.registerPane,
      unregisterPane: this.unregisterPane
    };
  }

  panes = [];

  registerPane = node => {
    if (!this.findPane(node)) {
      this.addEvents(node);
      this.panes.push(node);
    }
  };

  unregisterPane = node => {
    if (this.findPane(node)) {
      this.removeEvents(node);
      this.panes.splice(this.panes.indexOf(node), 1);
    }
  };

  addEvents = node => {
    node.onscroll = this.handlePaneScroll.bind(this, node);
  };

  removeEvents = node => {
    node.onscroll = null;
  };

  findPane = node => this.panes.find(pane => pane === node);

  handlePaneScroll = node => {
    window.requestAnimationFrame(() => {
      // Calculate new scrollTop positions
      // for left and right panes based on
      // DOM nodes and evt.target.scrollTop
      // and set it directly on DOM nodes
      this.panes.forEach(pane => {
        pane.scrollTop = ...;
      });
    });
  };

  render() {
    return (
      <div>

        <ScrollPane>
         <ExpensiveComponent />

        </ScrollPane>

        <ScrollPane>
         <ExpensiveComponent />

        </ScrollPane>

      </div>
    );
  }
}

在上述实践中,ScrollContainer组件实现了register/unregister方法用来添加或者删除面板以及注册 DOM 监听事件。而ScrollPane组件仅用来在挂载时注册,在卸载时注销。每次面板触发onScroll事件的时候,回调函数会获得新的滚动高度然后自动为其他面板设置scrollTop位置值。可以在这里查看源代码,并且这种方式也用于了 React Native 的 Animated

PureComponent

在 React 开发中我们习惯使用函数式的方式来进行数组转换,譬如将某个用户信息的数组转化为组件数组:

import User from './User'
const Users = ({users}) =>
  <div>
    {
      users.map(user => <User {...user} />
    }
  </div>

这种写法的问题在于,无论 user Props 的属性值是否发生变化,都会导致组件重渲染;这种正是 React.PureComponent 的适用场景,其会内部对于组件的 Props 对象进行浅层对比,再决定是否进行更新。

图片缺失,需要下载

shouldComponentUpdate

渲染限流

class App extends React.Component {
  state = {
    max: 100,
    percent: 0,
  };

  changePercent = () => {
    let intVal = setInterval(() => {
      if (this.state.percent < this.state.max) {
        let percent = this.state.percent + 1;
        this.setState({ percent });
      }
      if (this.state.percent === this.state.max) {
        clearInterval(intVal);
      }
    }, 10);
  };

  render() {
    return (
      <div>
        <Progress
          percent={this.state.percent}
          strokeWidth={5}
          showInfo={false}
        />
        <progress max="100" value={this.props.percent} />
        <CurrentProgress percent={this.state.percent} max={this.state.max} />
        <button
          onClick={() => {
            this.changePercent();
          }}
        >
          点我开始变化
        </button>
      </div>
    );
  }
}

export default App;
export default class CurrentProgress extends Component {
  innerPercent = 0;

  shouldComponentUpdate(nextProps, nextState) {
    console.log(nextProps.percent + ":" + this.props.percent);

    if (nextProps.percent === nextProps.max) {
      return true;
    }

    if (nextProps.percent - this.innerPercent < 10) {
      return false;
    } else {
      return true;
    }
  }

  render() {
    const prefixCls = "ant-progress";

    this.innerPercent = this.props.percent;

    const percentStyle = {
      width: `${this.props.percent / this.props.max}%`,
      height: 10,
    };

    const progressDiv = (
      <div>
        <div className={`${prefixCls}-outer`}>
          <div className={`${prefixCls}-inner`}>
            <div className={`${prefixCls}-bg`} style={percentStyle} />
          </div>
        </div>
      </div>
    );
    return <div>{progressDiv}</div>;
  }
}

Debounce

A debounce is a tool that every web developer should have in their kit. It improves performance by limiting the number of expensive calculations, API calls, and DOM updates. Although the debounce technique has been around for years, it’s still a great option to employ with modern libraries and frameworks

import React, { Component } from 'react'
import debounce from 'lodash/debounce';

// Create fake projects instead of using a database
let fakeProjects = [];
for (let i = 0; i < 1000000000; i++) {
  fakeProjects.push({
    id: id,
    name: `project ${i}`,
    featured: i % 2 === 0,
    rank: Math.ceil(Math.random() * i),
  });
}

mockApi = () => {
  // 2. Cost here of iterating and also a database delay in real apps
  return fakeProjects
    .filter(project => project.featured)
    .filter(project => project.name.includes(value))
    .sort((a, b) => a.rank - b.rank);
}

const Projects extends Component {
  state = {
    projects: fakeProjects,
  }

  handleFilter = debounce((e) => {
    const value = e.target.value;

    // 1. Network delay in a real-world context
    const displayedProjects = mockApi();

    this.setState({ projects: displayedProjects });
  }, 500)

  render() {
    // 3. Cost of a React reconciliation as well as updating the DOM
    return (
      <div>
        <input onKeyUp={this.handleFilter.bind(this)}/>
        <ul>
          {
            this.props.projects.map((project) => {
              return (
                <li key={project.id}>
                  {project.name}
                </li>
              );
            })
          }
        </ul>
      </div>
    );
  }
}

export default Projects;

We have a simple

    that lists projects by name, and an that allows us to filter these projects. When there is an onKeyUp triggered on the it executes the handleFilter() which is wrapped in our debounce function. This limits our mockApi() function to only be called every 500ms. By debouncing, we remove the need to perform these operations every key press, and we only do it after the user has stopped typing for a short amount of time. This ensures that we don’t perform unnecessary operations to find projects the user didn’t even care to see. Let’s take a look at the individual steps to understand how much we actually saved.

    After the debounce time expires after the final key press, the first step is to make our API call. Since we have debounced the function that fetches from our API, we only make the request once at the end of typing into the input. There are two things happening here — retrieving projects from the database and then performing heavy calculations to filter and sort the data. In a practical context, the server would be more efficient about querying and performing calculations on this data, but the learning outcome is still completely valid. In this instance, let’s assume we type 5 characters within our debounce timer. Without the debounce, we would perform 5 queries of 1 billion items and then perform the filtering, string matching, and sorting. Thus we’ve gained a 5x performance improvement on a what is already an incredibly heavy computationally intensive action. React is fast, but the biggest thing that can slow it down is too many renders and reconciliations. In addition, DOM interaction are incredibly slow. By debouncing, we prevent the setState() which serves to significantly reduce the number of times we force React to reconcile and append the list to the DOM. Without a debounce, this component would be almost unusable with such a large amount of data. This concept is easily extended to Redux for any state updates or API calls. If these interactions dispatched other actions, we are once again achieving a multiplicative gain.

    Common scenarios for a debounce are resize, scroll, and keyup/keydown events. In addition, you should consider wrapping any interaction that triggers excessive calculations or API calls with a debounce.

上一页