自作サイトを自作OSSで5%クリック速度を向上させた話

2022/12/05に公開

こんにちは。ぬこすけです。

最近(2022/11/24)、ブラウザのアイドル中にJavaScriptを実行する良い感じの OSS を公開したお話を記事にしました。

https://zenn.dev/nuko_suke_dev/articles/62c8b7a0abda31

この OSS を使って個人開発したサイトに適用してどのくらいパフォーマンス最適化できるか試してみました!

どんなライブラリを自作したの?

idle-task という requestIdleCallback を良い感じにラップしたライブラリです。

https://github.com/hiroki0525/idle-task

requestIdleCallback については次の記事でくわしく解説しています。

https://qiita.com/nuko-suke/items/c8c31ee34fd539805910

idle-task にはたくさん機能があるので、詳細については Github の README だったり、 Zenn の記事 を見てもらいたいですが、例えば次のようなことができます。

import { getResultFromIdleTask } from 'idle-task';

// ブラウザのアイドル中に実行される yourFunction の結果を取得
const result = await getResultFromIdleTask(yourFunction);

戻り値のある関数を requestIdleCallback に登録し、結果を取得しようとすると結構実装が面倒です。
idle-task はそこらへんを良い感じにラップしてくれています。

自作したサイトってどんなサイト?

この idle-task の実験台の対象となるのは「 Instagram Hashtag Translator 」 というサイトです。

https://instagram-hashtag-translator.com/

どんなサイトかいうと、 インスタグラムの投稿で外国語でハッシュタグをつけたい時に、インスタグラム向けにハッシュタグを翻訳し、コピペして投稿できるようにするツール です。

例えば、「猫 カフェ」で英語も含めてハッシュタグを用意したい場合は、「#猫 #カフェ #cat #cafe」という形でアウトプットを出してくれます。

chrome-capture-2022-10-18 (1).gif

企画の背景や技術スタックなど、詳しい話は記事にまとめているので、ご興味あればご覧ください。

https://qiita.com/nuko-suke/items/8765118a3020692083c2

これから本題のパフォーマンス改善のお話に移りますが、その前に Instagram Hashtag Translator の簡単な前提条件を確認しておきましょう。

  • 使っている主なライブラリとバージョン
    • React v18.2.0
    • Next.js v13.0.5
      • app Directory といった v13 の最新機能は使っていません。
  • インフラ
    • Vercel

そして今回パフォーマンス改善の対象となるのは、次のモバイルで翻訳ボタンを押した時に発火するクリックイベントになります。

先ほどの動画のように、「猫 カフェ」を入力した状態で翻訳ボタンを押します。

計測については、クリック時の処理の最初と最後に performance API を挟んで計測します。

const start = performance.now();
// ...
// 色々処理
// ...
// かかった時間を出力。小数点は良い感じに丸める。
console.log(Math.ceil((performance.now() - start) * 100) / 100);

前提条件を確認したところで、いよいよ本題に入りたいと思います!

idle-task を使う前の実装

idle-task を使う前の翻訳ボタンをクリックした時の処理は次のようになっています。
(かなり簡略化はしてます)

const clickHandler = async () => {
  // 分析データ送信用モジュール読み込み
  const { default: sendGaEvent } = await import(
    '~/lib/analytics/sendGaEvent'
  );
  // ...
  // 分析データ送信
  sendGaEvent(data);
  // ...
  // 翻訳用モジュール読み込み
  const { default: translate } = await import('~/infra/translate');
  // ...
}

ここでのポイントは 2 あります

  1. 動的 import を使ってクリック時にモジュールを遅延読み込みさせる
  2. 分析用のデータを送信している

1. 動的 import を使ってクリック時にモジュールを遅延読み込みさせる

動的 import はモジュールを遅延的に読み込みさせることができます。

先ほどあげたコード例で言うと、ユーザーがクリックして初めて sendGaEventtranslate の JavaScript ファイルを取得し、スクリプトを読み込みます。

言い換えれば、この動的 import を使うことで 初回読み込みが早くなる メリットが受けられます。

しかし、逆に言えばユーザーがクリックして初めてモジュールが読み込まれるので、 クリックした時の処理が遅くなります。これはデメリットです。

2. 分析用のデータを送信している

コード例にあげた処理は、ユーザーが入力した値を元に翻訳するのがメインの処理です。
この処理結果を元に画面が更新されるので重要な処理です。

一方で 分析用のデータの送信はそこまで重要な処理ではありません
分析用データの送信を元に画面を更新するわけでもないですし、できればメインの翻訳処理のお邪魔にならないようにしたいところです。

以上の 2 つのポイントについて、次のように問題を整理できます。

  1. 動的 import を使ってクリック時にモジュールを遅延読み込みさせる
    🥲 クリック後にモジュール読み込みが走って遅くなる
  2. 分析用のデータを送信している
    🥲 メインの翻訳処理とは関係のない処理で余計に遅くなる

これらの問題を idle-task を使って解決します。

idle-task を使った後の実装

idle-task を使って次のようにコードを書き換えました。

import { setIdleTask, forceRunIdleTask } from 'idle-task';

// ポイント①
const importSendGaEventTaskId = setIdleTask(
  () => import('~/common/analytics/sendGaEvent')
);
const importTranslateTaskId = setIdleTask(() => import('~/infra/translate'));

const clickHandler = async () => {
  // ポイント①
  const { default: sendGaEvent } = await forceRunIdleTask(
    importSendGaEventTaskId
  );
  // ...
  // ポイント②
  setIdleTask(() => sendGaEvent(data));
  // ...
  // ポイント①
  const { default: translate } = await forceRunIdleTask(importTranslateTaskId);
  // ...
}

コメントアウトにポイントを書いていますが、それぞれ詳しく見ていきましょう。

ポイント①

ブラウザのアイドル中にモジュール読み込みをキックしておきます

const importSendGaEventTaskId = setIdleTask(
  () => import('~/common/analytics/sendGaEvent')
);
const importTranslateTaskId = setIdleTask(() => import('~/infra/translate'));

まず、 setIdleTask という関数を使ってブラウザのアイドル中に実行したい処理を登録 します。

この例では sendGaEventtranslate モジュールをブラウザのアイドル中に読み込みを開始しておくように指示しておきます。

  const { default: sendGaEvent } = await forceRunIdleTask(
    importSendGaEventTaskId
  );
  const { default: translate } = await forceRunIdleTask(importTranslateTaskId);
  // ...
}

アイドル中に実行した関数の結果は forceRunIdleTask を使って取得しています。

ブラウザが忙しい場合は必ずしもアイドル中になるとは限りません。
forceRunIdleTask を使うことでアイドル中に登録した関数が実行されていればその結果を返し、されていなければ即時に実行します。

余談ですが、 idle-taskwaitForIdleTask という、同じくアイドル中に実行した関数の結果を取得する API を用意しています。
こちらは forceRunIdleTask と違い、アイドル中に登録した関数が実行されるのを待ちます。

ポイント②

分析用のデータを送信はブラウザのアイドル中に実行させます

setIdleTask(() => sendGaEvent(data));

ポイント① でもお話した通り、 setIdleTask を使うことでブラウザのアイドル中に実行したい処理を登録 できます。

メインの翻訳処理を優先したいため、分析用のデータを送信はブラウザのアイドル中に実行させるように遅延させます。

さて、今までの話を整理すると次のようになります。

  1. 動的 import を使ってクリック時にモジュールを遅延読み込みさせる
    🥲 クリック後にモジュール読み込みが走って遅くなる
    😄 ブラウザのアイドル中に事前に読み込みさせておくことで解決
  2. 分析用のデータを送信している
    🥲 メインの翻訳処理とは関係のない処理で余計に遅くなる
    😄 ブラウザのアイドル中に処理を実行するように後回しすることで解決

これで問題は解決したようです!

その他にも idle-task を使って細かいチューニングをしていますが、主な実装のポイントとしては以上になります!
実際、どのくらい翻訳ボタンをクリックした時の処理が早くなったかを見ていきます!

計測結果

実装前 実装後
1回目 1110.8 1048.8
2回目 1191.2 1068.9
3回目 1070.4 1028.4
4回目 1084.8 1023.2
5回目 1099.4 1080.8
平均 1111.3 1050.0

※単位はミリ秒です。
※キャッシュは削除して計測しています。

5.5% ほど改善しました🎉。
実はメインの翻訳の処理自体が 1 秒(= 1000 ミリ秒)くらいかかっているので、メイン処理以外という枠組みで考えると 55% ほど改善したということになります。

絶対値でいうと 50 ~ 60 ミリ秒の改善 で、一見して数値的にはたいしたことないように見えますが、 50 ~ 60 ミリ秒程度の改善は大きい です。

RAILモデル に照らし合わせると、大体 100 ミリ秒以下(ガイドライン的には 50 ミリ秒での実行が推奨)だとユーザーはサクサクに感じます

そう考えると 50 ~ 60 ミリ秒程度の改善は大きいのではないでしょうか。

まとめ

自分で作ったサイト「 Instagram Hashtag Translator 」 を、これもまた自分で作ったライブラリ「 idle-task 」を使い、ブラウザのアイドル中に処理を実行させることでパフォーマンスチューニングをするお話でした。

ぜひ idle-task を使ってみてください!

https://github.com/hiroki0525/idle-task

もしバグや使い方がわからない場合は日本語でも OK なので、 Github の issue なりにコメントいただければと思います!

また、別の自作ライブラリで改善した話も書いているので、よかったら覗いてみてください!

https://qiita.com/nuko-suke/items/58de7fc0ad8eb5efd7bc

ここまで読んでいただきありがとうございました! by ぬこすけ

Discussion