Reactの気持ちになって理解するWebパフォーマンスチューニング


WebアプリケーションのUIの表現はどんどんリッチになってきています。しかしその一方でブラウザ上での描画の負荷は増大し、時としてスムーズに動かず体験を損ねることもあります。ユーザーにとって使い心地の良いUI体験はフロントエンドのパフォーマンスチューニングに大きく委ねられていると言えるでしょう。

スタートアップ開発を支援する株式会社Gaji-Laboでは、サービスの価値に直結するエンドユーザーの体験向上のため、快適なUIを提供する事をとても大切にしています。

この記事では、快適に動くUIを作るため、フロントエンドフレームワークのマジョリティであるReactとどう付き合っていくべきかを考えていきましょう。

Reactの仕事を理解する

まず、Reactがどんな仕事をしているのかを理解するところからはじめましょう。Reactの主な仕事はUIを描画すること、そして必要に応じてそれを再描画することです。

Reactははじめにコンポーネントを組み合わせたページを作り上げ、それをブラウザ上で描画します。その裏でReactはステート(状態)を管理しているのですが、ステートの値が更新されると子孫コンポーネントすべてを再描画するのが基本的なふるまいです。その時、子孫コンポーネントは更新されたステートと関係を持っているかどうかは関係なく再描画されます。Reactが負担に感じるのは主にこの再描画です。

React視点で想像してみると、ページを完成させてリリースしたあとから部分的な変更依頼が五月雨式に降ってくるようなもので、しかもその変更点と関係ない箇所も再度作り直さなければならないのです。つらいですね。

この負担を軽減してあげることで、Reactは「良い仕事」が出来るようになり、快適なUI体験を提供できるようになります。すなわち、再描画の範囲と頻度を必要最低限にすることが、Reactにおけるパフォーマンスチューニングの肝となるのです。

再描画の範囲をコントロールする

早速、再描画の範囲の最適化について考えていきましょう。先に述べたように、Reactは素のまま使うと必要のない部分まで全て再描画するため無駄な労力を使わせることになってしまいます。再描画はできる限り必要な部分だけに限定して行うようにします。

再描画を診断する

まず、不必要な再描画が行われていないか確認するため、再描画の様子を可視化してみましょう。方法は色々ありますが、最も手軽なのがMetaから公式に提供されているChrome の拡張機能「React Developper Tools」を利用することです。

cf) React Developper Tools

インストールするとDevTools に「Components」「Profiler」という2つのタブが追加されます。いずれかのタブを開いて中の歯車アイコンボタンをクリックして設定のポップオーバーを開きます。

設定の「General」タブの中に「Highlight updates when components render.」というチェックボックスがあるのでチェックを入れます。この状態でReactで組まれたページを表示・操作すると、再描画されたコンポーネントがリアルタイムにアウトラインでハイライトされ、視覚的に確認することができます。

もっと詳しく再描画の状況を知るために「Profiler」を活用します。

  1. 設定ポップオーバーの「Profiler」タブを開きます
  2. 「Record why each component rendered while profiling.」にチェックを入れます
  3. 「Start Profiling」をクリックして、アプリケーションを操作します
  4. 「Stop Profiling」をクリックします

Profilingしている間の情報が記録されるので、「Flamegraph」タブで確認してみましょう。暖色になっているバーがコストの高い処理で、マウスオーバーすると「Why did this render?」の項目に「何故描画されたのか」の情報が記載されています。デバッグの手がかりになるでしょう。

メモ化で再描画を減らす

Reactには再描画を抑制する施策として「メモ化」という機能が備わっています。メモ化というのは平たくいうと結果をキャッシュさせる機能で、計算や描画の結果をキャッシュして再利用することで不必要な再計算・再描画処理を省くことができます。つまり、必要な部分だけを再描画させることが出来るようになるのです。

React.memo

コンポーネントをメモ化するためのAPIが React.memo です。 React.memo で関数コンポーネントをラップしてあげることで、コンポーネント自体をメモ化することができます。メモ化されたコンポーネントは props の値が変更されない限り以前のレンダー結果が再利用され、再描画処理がスキップされます。

export const MyComponent = React.memo(
  function MyComponent ({ ... }: Props): JSX.Element {
    return (
      <div>...</div>
    );
  }
);

React.memo は正しく使わなければ効果が得られません。注意すべき点は、Reactから見て props の値が以前のものと同一でないと再描画されてしまうことです。その判別には Object.is() が使用されているため完全に同一である必要があります。

cf) Object.is() – JavaScript | MDN

メモ化したコンポーネントの props にはプリミティブな値(string、boolean、number など)を渡す設計にすることで正しく機能させることができるでしょう。

しかし、たとえばオブジェクトや関数などが props に渡された場合、中身が同じでもReactからは同一ではないと判断され、「値が変わった!」→「再描画しよう」と再描画をスキップしなくなります。オブジェクトや関数を渡す時は、後述する useMemouseCallback でメモ化する事で正しく再描画をスキップさせることができるので、忘れないようにしましょう。

useMemo

useMemo は値をメモ化するためのフックです。値を計算して返す関数と依存配列を引数に渡して使用します。

const visibleItems = useMemo(() => {
  return items.filter(it => it.category === category);
}, [items, category]);

依存する値に変化がなければ同一の値が返されるので、その結果をキャッシュして不要な再計算をスキップしてくれます。これは前述した React.memo とのコンビネーションで活用出来るほか、コストの高い計算の実行を必要最低限にしたい場合にも役立つでしょう。

useCallback

useCallback は関数をメモ化するためのフックです。useMemo と大きく異なる点は、キャッシュするのは結果ではなく関数そのものだというところです。計算内容も計算結果もキャッシュはしません。つまり、メモ化されたコンポーネントに渡す関数の同一性を保証するのが useCallback の主な役割であり、それ以外の用途ではあまり効果がありません。

const handleClick = useCallback((): void => {
  setCount(pre => pre + 1);
}, [setCount]);

すべてのコンポーネントをメモ化するアイデア

メモ化は適した場面で正しく使うことではじめて効果を発揮する機能ですが、「どのコンポーネントをメモ化すべきか」の判断に多くのコストを支払いたくないという気持ちもわかります。その時、「すべてのコンポーネントをメモ化すれば良いのではないか」というアイデアが浮かぶかもしれません。このアイデアは、いくつかのデメリットを伴います。

  1. 極めてシンプルな計算をメモ化する場合、オーバーヘッドが勝ってしまってベストなパフォーマンスではなくなる
  2. コードが冗長となりリーダビリティを損ねる

これらのデメリットよりも開発体験の向上を重視したい場合、「すべてメモ化」戦略は悪くない選択肢です。1. についてはパフォーマンスの低下がおよそニンゲンには検知不可能なぐらいに軽微である事が多いです。(塵も積もればなんとやらですが)

プロダクトの性質やコードの保守性などを判断材料にして、方針を定めると良いでしょう。

再描画のタイミングをコントロールする

先述のとおり、Reactで再描画が行われるのはステート(状態)が変化するタイミングなので、ステートの更新を減らせば再描画を減らすことができます。そして、ステートを更新させるのは主にユーザーアクションが起点となります。ユーザーアクションをハンドリングする箇所では不必要にステートを更新しないこと、不必要に再描画を誘発しないように注意しましょう。

高頻度のイベントの扱いには注意する

高頻度にコールされるイベントハンドラ(例えば scrollresize イベント)の中でステートを更新すると、細かいインターバルで連続して再描画が繰り返されるためページパフォーマンスが著しく低下することがあります。

例えば次のコードは、頻度の高い scroll イベントの中で現在のスクロール位置でステートを更新しているアンチパターンです。

const [currentPosition, setCurrentPosition] = useState<number>(0);
const handleScroll = (): void => {
  setCurrentPosition(window.scrollY);
};

useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

ステートを更新しない場合でも、高速で計算処理が繰り返されることでページの動作に悪影響を及ぼすので注意を怠ってはいけません。このように細かく発生するイベントは、イベントハンドラの実行を間引くことでパフォーマンスへの影響を抑えることができます。

debounce と throttle の違いと使いどころ

イベントハンドラを間引くのに有用な関数として知られているのが debouncethrottle です。いずれも Lodash で提供されているユーティリティ関数で、関数の実行頻度をコントロールすることができます。区別がつきにくく覚えづらいですが、それぞれ得意なシーンがあるので適切な場面で使用しましょう。

cf) Lodash

debounce

debounce はイベントの発火を一時的に遅延させ、連続したイベントの中で最後のものだけを実行させます。(一定時間コールされなかった場合に実行されます)

cf) debounce | Lodash Documentation

ユーザーの入力の完了を待ってから処理を行いたい場合に有効で、テキストフィールドの change イベントなどで使われます。

const handleChange = debounce(
  (event: ChangeEvent<HTMLInputElement>): void => {
    setValue(event.target.value);
  },
  300
);

return <input type="text" onChange={handleChange} />;

throttle

throttle は、連続して呼ばれるコールバックの実行を一定間隔で間引きます。(一定時間内に一回だけ実行させます)

cf) throttle | Lodash Documentation

連続するイベントの頻度を均等に制限したい場合に有効で、scroll イベントや resize イベントなどでよく使用されます。

const handleScroll = throttle(
  (): void => {
    setCurrentPosition(window.scrollY);
  },
  300
);

Observer API を活用する

イベントハンドラを間引くほかに、比較的新しいWeb APIである Observer API を活用するというアプローチも考えられます。Observer API はその名の通りページ上の何かの状態を監視してくれるAPIで、ここではいくつかあるAPIのうち IntersectionObserverResizeObserver に触れます。

IntersectionObserver

IntersectionObserverscroll イベントを使用した実装を代替できる場合があります。

scroll イベントを使用して実装したいことは、主に現在のスクロール位置に応じて何らかの処理を行うことです。そうであるならば、そのスクロール位置の目印となる要素を IntersectionObserver のターゲットとすれば、大抵の実装は実現できるでしょう。

ResizeObserver

ResizeObserverresize イベントによる実装を代替できる場合があります。

このAPIの優れているところは、ビューポートのサイズ変化しか検知できない resize イベントと違って、要素単位でサイズの監視が出来る点です。これにより、ブラウザウィンドウのサイズ変化に依らずにページ内で動的にレイアウトが変化するケースなどにも対応できます。

Observer API 活用の利点

Observer API への差し替えは、基本的に良い事しかありません。

パフォーマンス面においては、不必要かもしれないメソッドのコールを連続的に呼び出すことになるイベントハンドラに対して大きなアドバンテージがあります。

機能面では、従来イベントハンドラの中で手書きしていた処理の一部を Observer API がやってくれるのが大きなメリットです。例えば resize イベントや scroll イベントでは現在のポジションや要素の配置を取得する処理を書く事が多くあり、それがリフローを引き起こしてパフォーマンス低下に直結してしまうのですが、その辺りは Observer API の方で一部面倒を見てくれたりします。

もちろん実装内容にもよりますが、Observer API を活用することでコストの低い実装が期待できるので、用途にしっかり刺さるのであれば活用しない手はないでしょう。

最後に

Webパフォーマンスチューニングの世界はとても広く、出来ること・やるべきことはいくらでもあります。この記事であげた施策は星の数ほどある手段のほんの一部であって、「これだけやっておけばOK」なものではなく「せめてこれくらいはチェックしておきたい」ものにすぎません。

そして、出来ることはWebの進化にあわせて恐ろしいスピードで増えていくので、常にキャッチアップしていかなければいけません。わたしたちのパフォーマンスチューニングは、まだはじまったばかりなのです。

最後に、ここで触れたパフォーマンス向上もまた「快適なUIづくり」という目標のための手段のひとつにすぎません。株式会社Gaji-Laboでは、その目標を一緒に目指せるメンバーを常に募集しています。この記事の内容に関心を持たれた方、快適なUI体験を思い描く方は是非ご一報ください。一緒に働けることを楽しみにお待ちしております。

Gaji-Laboでは、 Next.js 経験が豊富なフロントエンドエンジニアを募集しています

弊社では Next.js の知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違う Gaji-Labo を味わいに来ませんか?

Next.js の設計・実装を得意とするフロントエンドエンジニア募集要項

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!


投稿者 Oikawa Hisashi

フロントエンドエンジニア。モダンなJavaScript開発に関心があります。 デザインからバックエンドまで網羅的にこなすマルチデザイナーとして長く活動してきた経験を活かして、これから関わる様々なものをデザインしていきたいです。チームもコミュニケーションもデザインするもの。ライフワークはピアノと水泳。