はじめに
ReactNativeにおけるパフォーマンスチューニング(計測編)
の続きです。
続きと言いつつも、今回はただのReactの話になってしまいましたので、
WebでReactを書いている人も参考にしていただければと思います。
Reactのパフォーマンス改善に関しては公式docsにめちゃくちゃいい記事があります。
なのでこれを読めば十分なのですが、英語っていうこともあるので自分なりに噛み砕いてざっくり要点を説明してみます。
ReactのDOM更新アルゴリズム
Reactでは、DOMの状態をあるフォーマットのJSオブジェクトとして保持しています。
例えば、
<div className="hoge">some text</div>
のようなDOMの状態を
{ type: 'div', props: {className: 'hoge'}, children: ['some text'] }
のようなJSオブジェクトとして保持しています。
このDOMを抽象化したJSオブジェクトのことをVirtual DOM と呼びます。
Reactでは、DOMの更新をする際にこのVirtual DOMに差分が生じた場合のみ実際にレンダリングし直すことによって、DOM更新を効率化しています。
更新の際に、実際にDOM同士を比較するよりも遥かにJSオブジェクトの比較の方が高速のため、このような工夫をしているわけです。
また、あるコンポーネントが更新される際、全ての子孫コンポーネントにおいてもVirtual DOMの差分計算が走ります。
ここが素晴らしくもあり厄介なところです。
ある程度大規模なプロダクトだと一画面を描画するのに数百 ~ 数千のコンポーネントがマウントされたりしますよね。
例えば5000個のコンポーネントがマウントされている画面があるとします。
ルートコンポーネントをアップデートすると、その5000個のコンポーネントにおいて、Virtual DOMの差分計算が走るわけです。
Virtual DOMの計算も軽いわけではないので
ここをなんとか効率化できないか、というのが今回のテーマであります。
更新アルゴリズムを最適化する
class Child extends React.Component {
render() {
console.log('virtual dom calculated')
return (<h2>{this.props.text}</h2>)
}
}
のようなコンポーネントを<Child text='hoge' />
のようにマウントしたとしましょう。
textというプロパティは固定です。
親コンポーネントでsetStateなどにより更新が発生した時に、Childコンポーネントのrenderも走ります。
試しに親クラスで100回setState()
を走らせてみると、
のようにconsole上に出力されます。(つまりvirtual DOMの計算が100回行われています。)
今回のケースで言えば、textプロパティを変えていないのだから、virtual DOMを計算するまでもなく、ビューの更新をしなくてよいことは自明ですよね。
なので、Childコンポーネントのrenderメソッドは100回も実行される必要はなく、ただの1回でよかったはずです。
この事例をより一般化して考えてみましょう。
ReactコンポーネントのDOMは、stateとpropsによって一意に決まってくるはずです。
(stateとprops以外に依存している場合は、そもそも設計を見直したほうがいいかもしれません。)
ならば、**stateとpropsが変化していない時は、わざわざvirtual DOMの計算をするまでもなくない?**という発想ができるのではないでしょうか。
こういった考えのもと、ReactのコンポーネントにはshouldComponentUpdate
といメソッドが用意されています。
shouldComponentUpdate
メソッドはrender
メソッドが呼ばれる直前に実行され、このメソッドの返り値をfalse
とするとrender
メソッドが走らない(virtual DOMが計算されない)ようになります。
試しに以下のようにshouldComponentUpdate
を実装すると、親コンポーネントで100回setState()
を走らせても、一度しかvirtual dom calculated
というメッセージが表示されないようになります。
class Child extends React.Component {
shouldComponentUpdate(nextProps) {
if (this.props.text !== nextProps.text) {
return true
}
return false
}
render() {
console.log('virtual dom calculated')
return (<h2>{this.props.text}</h2>)
}
}
これにて計算コストを1/100に抑えることができました
PureComponent
shouldComponentUpdate
をさらに汎用的に実装すると、以下のようになります。
shouldComponentUpdate(nextProps, nextState) {
for (let propKey in this.props) {
if (this.props[propKey] !== nextProps[propKey]) {
return true
}
}
for (let stateKey in this.state) {
if (this.state[stateKey] !== nextState[stateKey]) {
return true
}
}
return false
}
全てのpropsとstateが同じなら、false
を返して、virtual DOMの計算を行わないようになっています。
Reactではこの実装は一つのパターンであるため、これと同じメソッドを実装済みのReact.PureComponent
クラスが存在します。(実際の実装はもう少し複雑です。)
なので、先程のChildクラスをReact.PureComponent
クラスを継承するようにすると、わざわざshouldComponentUpdate
を実装する必要がなくなります。
class Child extends React.PureComponent {
render() {
console.log('virtual dom calculated')
return (<h2>{this.props.text}</h2>)
}
}
注意点
PureComponent
を使う際にはいくつか注意点があるので、いくつか紹介します。
1. PureComponentのはずなのに、毎回virtual DOMの計算が走ってしまう!
PureComponentであるChildコンポーネントがstyle
というオブジェクトをpropsとして持つとしましょう。
親クラスで以下のようにChildをマウントしていると、Parentで更新計算が行われると必ずChildのvirtual dom calculated
が出力されてしまいます。
class Parent extends React.Component {
...
render() {
return (
<Child style={{color: 'red'}} text='hello' />
)
}
}
一体なぜでしょう?
shouldComponentUpdate()
の汎用的な実装を思い出してください。
if (this.props[propKey] !== nextProps[propKey])
のようになっています。
そして、今回のケースでは、styleプロパティに毎回新しいオブジェクトを渡してしまっていることに注目してください。
{color: 'red'} !== {color: 'red'}
がtrue
であることを鑑みると、必ずshouldComponentUpdate
はtrue
となり、一見同じpropsを渡しているように見えて、異なるpropsとみなされてしまうわけですね。
今回のようなケースでは、以下のようにクラス外にstyleを定義しておくと、毎回同一のオブジェクトを渡すことになるため、Parentクラスの更新がなされても、Childクラスにてvirtual dom calculated
が出力されることはなくなります。
class Parent extends React.Component {
...
render() {
return (
<Child style={childStyle} text='hello' />
)
}
}
const childStyle = {color: 'red'}
2. propsを変えたはずなのに、見た目が変わらない!
以下のようなコードを書いた時に、this.state.color
が変化しても、Childコンポーネントの色が全く変わらない。そんな現象が起きます。
class Parent extends React.Component {
...
render() {
childStyle.color = this.state.color
return (
<Child style={childStyle} text='hello' />
)
}
}
const childStyle = {color: 'red'}
勘のいい人はお気づきだと思います。
今回のケースでは先程のケースと逆の罠、即ち**「オブジェクトの中身を変えてもObjectIdが同じ」**であることが原因です。
以下のコードが成り立つことと要は同じですね。
const a = {color: 'red'}
b = a
b.color = 'blue'
a === b // true
最後に
というわけで、React.PureComponent
を使えばReactのパフォーマンスを向上させられることがわかりました。
注意点であげたように、PureComponent
は思わぬバグを引き起こすことがあることに注意した上で慎重にお使いください。
また、このようなバグの引き起こしやすさから、PureComponent
の乱用はおすすめしません。
ReactNativeにおけるパフォーマンスチューニング(計測編)
でも紹介した、Google ChromeのPerformance計測ツールなどを使って、ボトルネックと特定できた段階で使うようにしましょう。
ちなみにPureComponent
はImmutable.js と非常に相性がよく、バグを踏み抜きにくくなるので、興味ある人はお試しください!