【LINE証券 Frontend】requestIdleCallbackを活用して初期レンダリング時間を約14%削減した話

こんにちは。フィナンシャル開発センターの鈴木です。LINE証券のフロントエンドを担当しています。この記事は【LINE証券 FrontEnd】シリーズの4番目の記事です。

最近のWeb Vitalsの隆盛を受けて、LINE証券のフロントエンドでもパフォーマンスの改善に取り組み始めました。およそ2週間ほど改善に取り組んだ結果として、開発環境での計測ではLighthouseのperformanceスコアが従来より30点ほど上昇しました。

パフォーマンス改善のためにさまざまな施策を行いましたが、この記事ではその中でも興味深かったものとして、requestIdleCallbackを活用してLazy Loadingされるコンポーネントの読み込みを遅延し、その結果初期レンダリングにかかる時間を約14%削減できた事例をご紹介します。

環境

以前の記事でご紹介したとおり、LINE証券のフロントエンドはTypeScript + Reactで作られています。また、JavaScriptバンドルをwebpackで生成しています。今回の話で関連が深いのはReactとwebpackです。

実際の環境はReact 16系とwebpack 4系ですが、現在最新のバージョンであるReact 17とwebpack 5でも今回ご紹介する内容が当てはまることを確認しています。

これまでのパフォーマンスチューニング

今回のパフォーマンスチューニングでは、フロントエンドのパフォーマンスチューニングにおける典型的な指標であるCore Web Vitalsの改善を目標としました。また、開発環境における計測の手段としてGoogle Chromeに付属するLighthouseを使用しました。

まずは基本的なパフォーマンスチューニングとして、CLS (Cumulative Layout Shift) を無くしたり、コンテンツが表示されるまでにAPIサーバーとの間を3往復しているのを修正したりといったロジック的な改善を行いました。それが終わると、残りは初期レンダリングとの戦いになります。

LINE証券ではServer-Side Renderingを採用していませんので、コンテンツが表示されるにはまずJavaScriptバンドルを読み込み、それを実行し終わる必要があります。これにかかる時間を短くすることでパフォーマンス、特にLCP (Largest Contentful Paint) の改善が期待されます。

このための典型的な施策としては、Lazy Loadingによるバンドルの分割が有効です。例えば、トップページ以外の表示に必要なコードを最初に読み込まれるバンドルから除外します。また、LINE証券ではトップページに色々なコンテンツが表示されるためそれでもまだコード量が多かったので、トップページの下のほうのコンテンツの読み込みを遅延させることにしました。すなわち、下の方のコンテンツを表示するのに必要なJavaScriptを別のファイルに分割し、それを後から読み込んで実行するようにしました。これらの施策により、最初に読み込まれるJavaScriptバンドルのサイズが減少するだけでなく、最初のレンダリングに必要なJavaScriptコードの量(JavaScriptの実行時間)も減少します。

具体的には、React.lazyimport()によるwebpackのチャンク分割機能を用います。簡略化した例としては、次のようなコードでこれを行うことができます。

import { lazy, Suspense } from "react";
import MainContents from "./mainContents";
 
const OtherContents = lazy(() => import('./otherContents'));
 
const Home: React.VFC = () => {
  return (
    <div>
      <MainContents />
      <Suspense fallback={<p>Loading...</p>}>
        <OtherContents />
      </Suspense>
    </div>
  );
};

こうすると、Homeコンポーネントを初期レンダリングする際はMainContentsのみがレンダリングされます。OtherContentsの中身は初期レンダリングから除外されます(Suspenseコンポーネントの効果により、Loading…と表示されます)。その後、OtherContentsが読み込まれた時点でHomeが再レンダリングされ、OtherContentsの内容が表示されます。このようなやり方は、CLSの発生に気をつける必要があるもののお手軽です。初期画面から見えない位置のコンテンツだけでなく、重いグラフ描画領域なども同じ方法で遅延させられます。

初期レンダリングを遅延するappendChild

以上の施策を実装したのちにパフォーマンス計測結果を見てみると、レンダリング処理中に何だかやけに時間のかかるappendChildが並んでいるのが見かけられました。Reactは最終的にレンダリング結果をDOMに反映するのでappendChildの存在自体は一見おかしなことではありませんが、この部分はDOMへの反映処理よりも前、コンポーネントツリーを作成する段階です。

▼時間のかかるappendChildの呼び出したち

調べてみると、このappendChildを実行しているのはReactではなく、webpackのランタイムであることが明らかになりました。webpackではチャンクの読み込みにJSONPを用いており、初期チャンクから分離されたチャンクを読み込みたい場合にはそのチャンクを読み込むためのscript要素を生成して文書に追加します。この際に使われるappendChildがここに現れていました。レンダリングに直接影響しないとはいえDOM操作だからなのか、この処理の重さが目立っています。

このappendChildが初期レンダリング中に現れているのを説明する要因は2つあります。まず、import()に相当するコードを実行した際、webpackランタイムがその場で同期的にscript要素の生成・埋め込みを行う点です。もう一つは、Reactのlazy()により生成されたコンポーネントが、レンダリングされたタイミングでやはり同期的にコールバック関数を呼び出す点です。この2つの処理がどちらも同期的に行われた結果、appendChildが初期レンダリング処理に混ざる結果となりました。

requestIdleCallbackによる解決

この記事のタイトルにあるrequestIdleCallbackは、指定したコールバック関数を、ブラウザのメインスレッド(JavaScriptを実行などを担当するスレッド)が空いたら実行するように指示できる関数です。3年以上Proposed Recommendationのままである点がやや不安ですが、Googleでは活用を推奨しています

初期レンダリングの間はメインスレッドは最速でコンテンツを表示することに全力を注いで欲しいですから、Lazy Loadingに関連するタスクは後回しにしたいですね。初期レンダリングを邪魔しない程度に後回しにしつつ、時間に余裕ができたら実行してほしいという場合にrequestIdleCallbackが適しています。今回は、Reactのlazy()と同じ使い方をすることができるlazyIdle()を次のように実装しました。

import { lazy } from 'react'
 
export const lazyIdle: typeof lazy = (factory) => {
  return lazy(
    () =>
      new Promise((resolve) => {
        window.requestIdleCallback(() => resolve(factory()), {
          timeout: 3000
        })
      })
  )
}
 
// 使い方
const OtherContents = lazyIdle(() => import('./otherContents'));

こうすることで、lazyIdle()に渡されるfactory関数( () => import(‘./otherContents’) のような関数)の実行をrequestIdleCallbackで遅延します。これにより、結果的にscript要素の挿入が初期レンダリング完了後に遅延されることになります。

実は先ほど紹介したGoogleによる記事ではrequestIdleCallbackのコールバック内でDOM操作をすることは推奨していませんが、今回はscript要素である(レイアウト等に影響を与えない)のでその点は問題ありません。

また、requestIdleCallbackはまだiOSでのサポートがありませんので、我々はrequestidlecallback-polyfillを用いています。これは厳密にはPolyfillではありませんが(内部でsetTimeoutを使っておりrequestIdleCallbackの本来の挙動を実現していないため)、Progressive Enhancementの考え方に従ってこのアプローチを取っています。

パフォーマンス改善の結果

以上で解説したrequestIdleCallbackによる初期レンダリングの実行時間改善を、実際にLINE証券のトップページで取り入れてみました。5箇所ほどlazy()をlazyIdle()に置き換えています。その結果、記事タイトルにあるように約14パーセントの改善がありました。

改善を行う前は、このように初期レンダリング全体で約700ミリ秒かかっていました(Macbook Pro上のGoogle ChromeでCPUを6x slowdownにして計測)。

requestIdleCallbackによる改善を行なったあとは、次の画像のように約600ミリ秒に実行時間が縮まりました。

劇的な違いではありませんが、14パーセントというのは決して少ない数字ではありません。React + webpackのアプリを極限まで高速化したいときは、パフォーマンス測定結果の中から変なappendChildを探してみてはいかがでしょうか。

まとめ

この記事では、LINE証券のフロントエンドで行なったパフォーマンス改善の事例を紹介しました。requestIdleCallbackは2015年には先述のGoogleの記事がすでに書かれていて意外と息の長い技術ですが、いまいち具体的な活用事例が知られていない技術でもあります。後回しでもよい処理がメインスレッドに居るのを見つけたら、requestIdleCallbackの使用を検討してみましょう。

フロントエンドチームでは、今後もパフォーマンス改善を通じてサービスの使いやすさに貢献していきたいと考えています。

【採用情報】