注意書き (2019/12/16 追記)
こちらのコメント を受けて追記します。
コメントにあるように、React Developer Tools が示している「レンダリング」とは、関数コンポーネントの本体(あるいはクラスコンポーネントの render())が呼ばれること、そして結果として仮想DOMの差分検出処理(リコンシリエーション)が走ることを指しています。
よって、緑の枠がなんども表示されるからと言って、そこにかかる処理のコストは多くの場合高くないので、そこを減らすためのパフォーマンスチューニングがコストに見合うことはあまりないかと思いますので、この記事を参考にする場合は注意してください。
ただこの記事で紹介した react-redux の useSelector に shallowEqual を渡す手法や、コメントにある React.memo を使用すること自体は最適化の方法として正しいので、最適化するべきか否かの見極めを意識することと共に、覚えておくと良いかと思います。
本題
この記事は SmartHR Advent Calendar 2019 の13日目の記事です。
突然ですがあなたは React でアプリケーションを作る際、どれだけの思いやりの気持ちを持って React コンポーネントを作っていますか?
僕はこれまで何も考えずに React に接してきました。当然です、 React はただの道具でありそこに感情が入り込む余地はありません。
そんな僕に訪れた転期
React Developer Tools の Highlight updates when components render
の機能がそんな僕を変えました。
ここのチェックボックスにチェックを入れましょう
state や props の変更が検知されてコンポーネントがレンダリング・再レンダリングされた箇所をハイライトしてくれる機能です。
初めてこの機能を知ったときは「ふーん?便利じゃん?」などとスカしていた僕ですが、実際にこの機能をオンにして自分が開発しているアプリケーションを動かしてみると…
無駄にレンダリングしまくっている!!!!!!!!
緑っぽい枠が表示される箇所が再レンダリングされている部分です。
本来再レンダリングされるべきはユーザーのアクションによってデータに変更がかかった箇所のみで良いはず。
にも関わらず僕の作ったアプリケーションは不必要な箇所までも再レンダリングしてしまっていました。どこまで一生懸命でまっすぐなんだ React のやつ…。
(キャプチャのアプリケーションはフィクションです。実在する団体・人物などとは関係ありません。)
この光景を目の当たりにして僕の心は変わりました。
React に優しい僕でありたい、と。
この後めちゃくちゃチューニングした
そんなわけでどうやったら再レンダリングを防げるのか調べて試行錯誤を繰り返して、なんとか納得できるところまで持っていくことができたので、まずその結果をぜひ見てください。ぜひ。
一覧から詳細への遷移
before
after
若干わかりにくいですが、メインコンテンツの外側の枠だったりとかよく見ると結構減っています。
一覧のソート
before
after
一覧のソートは右側のユーザー詳細とは一切関係のない事柄なので本来は再レンダリングは必要ありませんでした。
after ではリストのみ再レンダリングされているのがわかると思います。
テキスト入力フォーム
before
after
枠の色が徐々に黄色っぽくなっていることに気づきましたか?これは React の目から流れる血の涙の色です。
再レンダリングが短い感覚で行われすぎて警告を出している状態です。
after でもレンダリングの頻度は変わっていないのですが、問題の箇所を今入力しているフォームだけに押し込むことができています。
(左側のリストも影響受けちゃっているのは解決できなかった・・・)
チェックボックス
before
after
テキスト入力フォームとほぼ同じなのですが、チェックボックスのクリックも他のフォームの項目に影響を及ぼさなくなったことがわかります。
ただ、一つのチェックボックスをクリックした時に別のチェックボックスも再レンダリングされてるのはなんとかしたかったのですが、ここらへんで力尽きました。
やったこと
実際にやったこととして以下の2つくらいです。
- container を細分化する
- react-redux の useSelector の第二引数に shallowEqual を渡す
お気づきだろうか・・・ Redux の話しかしていないことに・・・。
実際のところ僕は最近は SPA を作る時はほぼ Redux を使っていて、 やり方によりますが Redux を使うと React が local state を持つことはなくなるので React の再レンダリングの要素って渡された props が変わったかどうかだけなんですよね。
もちろん Hooks の useEffect や useCallback などありますがそれも props 基準でどうこうするものなので。
そうなると必然的に Redux の store に起因する部分や React と Redux を繋いでいる部分が主なチューニングの対象となりました。
container を細分化する
これは、バケツリレーをなくすっていうことなんですけど、改善前のアプリケーションではページのルートとなるコンポーネントや Atomic Design で言うところの organims にあたるコンポーネントでのみ container でラップして store の値を注入していました。
そうなると、末端のコンポーネントの表示にたどり着くまでで本来そのコンポーネントには必要のない props を親から子に渡すためだけに渡されちゃうみたいなことが起きたりします。
そのような props の変更に影響されて再レンダリングが発生する、みたいな状況を避けるために粒度を細かく container を作って無駄なバケツリレーを防ぎました。
これによっていくらかの再レンダリングを防げたのですが、正直このやり方はコンポーネントの管理コストが上がるわりにそこまで効果が大きいわけではないのであまりオススメはしません。
react-redux の useSelector の第二引数に shallowEqual を渡す
これが効果としてはとても良かったです。
最近は react-redux の useSelector で store の値を取得することが多いと思います。
そこで気をつけなければいけないのは、 useSelector がコンポーネントを再レンダリングするロジックは、 useSelector に渡した selector 関数が返す値が action が dispatch される前と後で比較して違った時です。
この比較のやり方が ===
なので selector 関数でオブジェクトや配列を返していると全ての action の dispatch でコンポーネントが再レンダリングされることになってしまいます。
なのでそれを防ぐために、 selector 関数ではプリミティブな値のみ返してコンポーネント内で複数の useSelector を用意する、だったり、 Reselect などのライブラリを使って selector 関数をメモ化する、だったり色々とあるのですが、簡単なのが react-redux の shallowEqual を使う方法です。
useSelector の第二引数に shallowEqual を渡すことで比較の方法を ===
ではなく lodash の isEqual と同じロジックに変更できます。
import { useSelector, shallowEqual } from 'react-redux'
const result = useSelector(state => state.hoge, shallowEqual)
これによって useSelector でオブジェクトの形で返していた部分の再レンダリングが発生しなくなったのでだいぶエコになりました。
気をつけなければいけないのが、比較のロジックが複雑になるのでその分比較自体のコストは上がる、ということです。再レンダリングと比較のどちらの方がコストが高くなるのかを意識して使い分けていく必要があります。
あとがき
今回の調査のために作ったリポジトリはこちらです。
改善前: https://github.com/nabeliwo/react-eco-rerendering/tree/before-improvement
改善後: https://github.com/nabeliwo/react-eco-rerendering
正直な話をすると、キャプチャ映えを狙って改善後の方では上記でやったこと以外にも泥臭いことをたくさんやって綺麗に再レンダリングが行われるように持っていたというところがあります。
とは言え、 useSelector の shallowEqual に関しては本当に簡単に対応できて目に見えて効果が出るので再レンダリングのコストが気になる方は是非やってみてください。
container を分割する方は、がっつり分割した割に効果が大きくはなかったのでそこに時間を割くくらいなら container の管理をしやすい分割の粒度を保って別のところでチューニングする方がよっぽど良いかなと思いました。
最後になりますが、この記事を読んだ React ユーザーの方は普段社会で発揮する優しさの一部でも React に分け与えてくれたら嬉しいなと僕はそう思いました。おわり。