【React】HOCのレンダリングコスト

何をしたか

React で HOC (Higher Order Component) が肥大化するとレンダリングのコストが増大するはずだと思って調べた。

もっと詳しく

HOC は要するに高階関数であり、高階関数はロジックを抽象化するために優れたデザインパターンだが、パフォーマンス面では関数呼び出しの回数が増えるため、コンポーネントレンダリングするのに要する時間が増す。それはやむを得ないが、問題はどのくらい影響するかである。

そこで、単純な Counter コンポーネントを作り、それを HOC でたくさんラップしたらどのくらいレンダリング速度が低下するかを調べた。

登場人物を挙げる。Counter コンポーネント

import {pure} from 'recompose'

const Counter = pure(({onCountUp, count}) => (
  <div className='counter'>
    <button onClick={onCountUp}>count up</button>
    <div>count: {count}</div>
  </div>
))

何もしない HOC。

import React, {Component} from 'react'

const withNoop = (BaseComponent) => {
  class WithNoop extends Component {
    render () {
      return <BaseComponent {...this.props} />
    }
  }
  return WithNoop
}

何もしない HOC を繰り返し適用できるようにする。

const withNoops = (repeat) => (BaseComponent) => {
  const withNoopArray = new Array(repeat).fill(withNoop)
  return withNoopArray.reduce(
    (Component, withNoop) => withNoop(Component),
    BaseComponent
  )
}

HOC でラップしまくった Counter コンポーネント。とりあえず 10000 回ほどラップする。

const HighCostCounter = pure(
  withNoops(10000)(Counter)
)

そして、メインとなる親コンポーネント

let timerId = 1

class App extends Component {
  constructor (props) {
    super(props)
    this.state = {
      countA: 0,
      countB: 0
    }
    this.onCountUpA = this.onCountUpA.bind(this)
    this.onCountUpB = this.onCountUpB.bind(this)
  }

  render () {
    const {countA, countB} = this.state
    return (
      <div className='App'>
        <Counter
          onCountUp={this.onCountUpA}
          count={countA}
        />
        <HighCostCounter
          onCountUp={this.onCountUpB}
          count={countB}
        />
      </div>
    )
  }

  onCountUpA () {
    this.setState({countA: this.state.countA + 1})
  }

  onCountUpB () {
    this.setState({countB: this.state.countB + 1})
  }

  // state をアップデートするたびにかかった時間を計測する

  componentWillUpdate () {
    console.time(timerId)
  }

  componentDidUpdate () {
    console.timeEnd(timerId)
    timerId++
  }
}

比較した結果

単純な Couner コンポーネントのカウントアップボタンを押すと、親コンポーネントのアップデートが終わるまで平均約 1 msだった。

HOC でラップしまくった HighCostCounter のほうは、平均約 150 ms だった。まあ 10000 回もラップすることは現実のアプリケーションではありそうもないので、100 回のラップで試すと平均約 8 ms だった。

うん、あんまり気にしなくて良さそう。確かに HOC で抽象化することでレンダリングのオーバーヘッドは増すけど、パフォーマンスに深刻な影響はなさそう。

とはいえ props が大きくなってくるとまた違うかもしれないけど。

コード全体

import React, { Component } from 'react'
import {pure} from 'recompose'

// --- HOC ---

const withNoop = (BaseComponent) => {
  class WithNoop extends Component {
    render () {
      return <BaseComponent {...this.props} />
    }
  }
  return WithNoop
}

const withNoops = (repeat) => (BaseComponent) => {
  const withNoopArray = new Array(repeat).fill(withNoop)
  return withNoopArray.reduce(
    (Component, withNoop) => withNoop(Component),
    BaseComponent
  )
}

// --- Components ---

const Counter = pure(({onCountUp, count}) => (
  <div className='counter'>
    <button onClick={onCountUp}>count up</button>
    <div>count: {count}</div>
  </div>
))

const HighCostCounter = pure(
  withNoops(10000)(Counter)
)

let timerId = 1

class App extends Component {
  constructor (props) {
    super(props)
    this.state = {
      countA: 0,
      countB: 0
    }
    this.onCountUpA = this.onCountUpA.bind(this)
    this.onCountUpB = this.onCountUpB.bind(this)
  }

  render () {
    const {countA, countB} = this.state
    return (
      <div className='App'>
        <Counter
          onCountUp={this.onCountUpA}
          count={countA}
        />
        <HighCostCounter
          onCountUp={this.onCountUpB}
          count={countB}
        />
      </div>
    )
  }

  onCountUpA () {
    this.setState({countA: this.state.countA + 1})
  }

  onCountUpB () {
    this.setState({countB: this.state.countB + 1})
  }

  componentWillUpdate () {
    console.time(timerId)
  }

  componentDidUpdate () {
    console.timeEnd(timerId)
    timerId++
  }
}

export default App