将 React 应用优化到 60fps

将 React 应用优化到 60fps

将 React 应用优化到 60fps翻译自React at 60fps,从属于Web 前端入门与工程实践

作为 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这样的模式,还是首先应该找出那个拖慢整个应用性能的组件。

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

如果在你的渲染函数中存在着部分性能消耗较高的计算代码,那么建议是将这部分代码尽可能地放置到较高阶的组件中,或者使用memorizingreselect)的方式来减少重复调用或者计算。在我构建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(虽然貌似这个也不建议使用)来操作子组件而避免直接操作子组件的命令式代码,从而保证其他组件仍然保持纯粹的声明式。代码如下:

export default class ScrollPane 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>
  )
  }
}

export default 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

编辑于 2017-01-19

文章被以下专栏收录