LoginSignup
1647
1672

More than 1 year has passed since last update.

Webフロントエンドパフォーマンスチューニング80選

Last updated at Posted at 2022-02-13

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

近年、Webフロントエンドではサイトのパフォーマンスの重要性が高まっています

例えば、GoogleはCore Web Vitalというパフォーマンスに指標を検索結果のランキング要因に組み込みました。
また、近年の某企業が「パフォーマンスの改善に取り組んだ結果、セッション数〇%アップ、CVR〇%アップ...」などの事例は枚挙にいとまがないでしょう。

パフォーマンスチューニングするためには、定量的に計測してボトルネックを探すようなトップダウンなアプローチもあります。

しかしながら、時には千本ノック的にハウツーを片っ端から試していくボトムアップなアプローチも有効になることもあったり、日々のコーディングでパフォーマンスを意識したコードを書くことは大切でしょう。

この記事ではパフォーマンス最適化のハウツーを紹介します。
パフォーマンス改善の施策が思い浮かばない時やフロントエンドのスキルを磨きたい時に辞書的な役割を果たせれば良いかなーと思っています。

※この記事を読んでいる方にはこれからフロントエンジニアになりたい方、駆け出しエンジニアの方もいると思います。正直、何言ってるかわからない部分が結構あると思います。ですが、私の経験則上、「あの時書いてあったことはこういうことか!」と後々になって理解することがよくありました。今はよくわからないかもしれませんが、とりあえずストックなりしておいて、数ヶ月後にこの記事を見返すとまた理解度も変わるのかなーと思います。

更新情報(2022/02/14)

5つ追加しました

注意事項

  • 一口にフロントエンドといっても、SSRやらSSGやらでサーバー側も関わってくることもあるので、バックエンド寄りも話も混じっているので悪しからず。
  • わかりやすくするためにカテゴリに分けしていますが、微妙なカテゴリ分けのものもあるので悪しからず。
  • 中には具体的なハウツーというより考え方みたいなものも混じっているかもしれませんが悪しからず。
  • 環境によって必ずしもパフォーマンスが改善されるとは限らないので悪しからず。
  • あくまでパフォーマンスの観点なので他の観点では最適となるとは限らないので悪しからず。例えば、IndexedDBを紹介していますが、Sarafi 15で脆弱性が見つかっています。
  • 紹介するものには特定のブラウザでしかサポートされていないものもあるので悪しからず。

JavaScript編

複数の非同期処理はPromise.allを使う

もし互いに依存関係のない複数の非同期処理を実行しているのならば、Promise.allを使うのも手です。

async function notUsePromiseAll() {
  console.log('Start!!');
  const response1 = await fetch("https://example.com/api/1");
  const response2 = await fetch("https://example.com/api/2");
  console.log('End!!');
}

async function usePromiseAll() {
  console.log('Start!!');
  const [response1, response2] = await Promise.all([
    fetch("https://example.com/api/1"),
    fetch("https://example.com/api/2"),
  ]);
  console.log('End!!');
}

Promise.allはいずれかの非同期処理が失敗すると、 Promise.all の結果は失敗扱いになります。
失敗扱いにしたくない場合はPromise.allSettledが使えます。

非同期処理を待たなくて良い場合は待たない

コードを眺めてみて、非同期処理を待たなくて良いところは待たないようにしましよう。
具体的には、もしasync/await構文を使っているならawaitを使わないことです。

const sendErrorToServer = async (message) => {
  // サーバーにエラー情報を送る処理
};

console.log('何かエラーが起きた');
// 後続の処理はサーバーにエラー情報を送る処理とは関係ないので await をつけない
sendErrorToServer('エラーです');
console.log('後続の処理');

先に非同期処理を走らせておく

互いに依存関係のある複数の非同期処理を実行する場合でも、時間がかかる処理の方を先に走らせておくのも良いでしょう。

const response1Promise = requestLongTime();
// ...
// 色々処理
// ...
const response1 = await response1Promise;
const response2 = await requestShortTime();
console.log(response1, response2);

キー/バリューを頻繁に追加や削除する場合はMapを使う

MDNにも記載がありますが、キー/バリューのペアを頻繁に追加や削除する場合はObjectよりもMapを使ったほうが最適です。

const nameAgeMap = new Map()
nameAgeMap.set('Tom', 19)
nameAgeMap.set('Nancy', 32)
nameAgeMap.delete('Tom')
nameAgeMap.delete('Nancy')
...

膨大な配列の検索はキー/バリューで

JavaScriptというよりかはロジックの問題かもしれません。
膨大な配列を検索する場合はキー/バリューに変換してから検索した方が速いです。

const thousandsPeople = [
  { name: 'Tom', age: 19 },
  { name: 'Nancy', age: 32 },
  // ...めちゃくちゃ多い
]

// 時間かかる
const myFriend = thousandsPeople.find(({ name }) => name === 'Tom');
console.log(`The age is ${myFriend.age}`);

const thousandsPeopleMap = {
  'Tom': 19,
  'Nancy': 32,
  // ...
}

// こっちのほうが速い
const myFriendAge = thousandsPeopleMap['Tom'];
console.log(`The age is ${myFriendAge}`);

関数の結果をキャッシュする

頻繁に同じ引数で関数を実行したり、重い処理を走らせるなら関数の結果をキャッシュするのも有効です。
次のようなデコレータ関数を作れば、関数の結果をキャッシュできます。

function cachingDecorator(func) {
  const cache = new Map();
  return x => {
    if (!x) {
      return func(x)
    }
    if (cache.has(x)) {
      return cache.get(x);
    }
    const result = func(x);
    cache.set(x, result);
    return result;
  }
}

function heavyFuncNoCache(str) {
  // 重い処理
}

const heavyFunc = cachingDecorator(heavyFuncNoCache);
heavyFunc('hoge');
// キャッシュから結果が返却される
heavyFunc('hoge');

requireではなくimportを使う

JavaScriptのモジュールの読み込み方にはrequireimportの2種類があります。
requireは同期的、importは非同期的にモジュールを読み込むので、importの方が良いでしょう。
Node.jsといったサーバーサイドでJavaScriptを記述する場合はrequireを使うことが多いと思いますが、バージョン14であればpackage.jsonだったりファイルの拡張子をmjsにしたりいじることでimportで読み込めます。

なお、Qiitaのこの記事がわかりやすいです。

フェッチにはKeep-Aliveを指定する

何度も同じドメインへアクセスするのであればkeep-aliveを指定することでフェッチ処理が短縮されます。

import axios from 'axios';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';

const httpAgent = new HttpAgent({ keepAlive: true });
const httpsAgent = new HttpsAgent({ keepAlive: true });

const keepAliveAxios = axios.create({
  httpAgent,
  httpsAgent,
});

keepAliveAxios.get(...);

非同期の関数を使う

Node.jsには同期/非同期で別で用意されている関数があったりします。
例えばファイルに書き込みをする関数にはfs.writeFileSyncfs.writeFileがあります。
もしフロントエンドアプリケーションのビルド時などに静的ファイルを生成する必要がある場合、特段理由がなければfs.writeFileを使いましょう。

不要なimportは削除する

不要な import によってスクリプトサイズが肥大化しないように削除しましょう。
eslint を使っているのであれば eslint-plugin-unused-imports で不要な import を発見できます。
VSCode や WebStorm などのIDEでファイル保存時に eslint を走らせるようにすると便利です。

TreeShaking を意識して書く

webpack などのバンドラーではコードを解析し、利用していないコードは削除してくれる TreeShaking という仕組みがあります。
この Tree Shaking を理解しながらコードを書くとスクリプトサイズを落とすことができます。

例えば、「クラスを使わずにできるだけ関数に分割して export する」というのが挙げられます。
次のような 2 つのファイルがあったとしましょう。

// ファイル1:クラスで書いたファイル
export class Test {
  static hoge() { console.log('hoge') }
  static fuga() { console.log('fuga') }
}

// ファイル2:関数で書いたファイル
export function hoge() { console.log('hoge') }
export function fuga() { console.log('fuga') }

このとき、 hoge の機能を使いたい場合、それぞれ次のようなコードになります。

// ファイル1の場合
import { Test } from 'file1';

Test.hoge();

// ファイル2の場合
import { hoge } from 'file2';

hoge();

ファイル1のケースでは、 Text クラスを丸ごと import しているため、 Tree Shaking が効かず利用していない fuga がバンドルに含まれます。
一方でファイル2では hoge のみ import しているため、 Tree Shaking が効いて fuga はバンドルに含まれず、最終的なスクリプト量を削減することができます。

このように、 Tree Shaking を意識してコードを設計することによりスクリプトサイズを削減することができます。
Tree Shaking はライブラリを選定する上でも重要です。

トランスパイル後のコードを意識して書く

Babel などを使って JavaScript をブラウザが対応するバージョンへ変換(トランスパイル)することが多いと思います。
自分が書いたコードが最終的にどのようなコードに変換されるかはチェックした方が良いでしょう。

例えば、次のようなクラスを使ったコードを ES2015 に変換するとします。

class Test {
    hoge(){
      console.log('hoge');
    }
}

この場合、次のようにスクリプトサイズが大きくなってしまいます。

'use strict';

var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var Test = function () {
    function Test() {
        _classCallCheck(this, Test);
    }

    _createClass(Test, [{
        key: 'hoge',
        value: function hoge() {
            console.log('hoge');
        }
    }]);

    return Test;
}();

一方で関数の場合はどうでしょうか?

function hoge() {
  console.log('hoge');
}

このコードはほぼそのままの形で変換されます。

'use strict';

function hoge() {
  console.log('hoge');
}

このように、 同じ機能を実現しようとしても書き方によってはトランスパイル後のコードが肥大化することもあります。

なお、簡易的にトランスパイル後のコードを確認するツールもあるので利用するのも良いでしょう。

HTML/CSSなどリソース編

imgやiframe、linkタグなどにimportance属性を追加する

imgやiframe、linkタグなどではimportance属性を使うことでブラウザに読み込みの優先度を指定できます。
タグだけでなくfetch関数でもオプションでimportanceを指定できたりします。

(2022/4/26 追記)
importance属性はfetchpriority属性に変更されました。
また、fetch関数を使う場合はpriorityプロパティを指定することなります。

<img src="/images/sample.svg" fetchpriority="low" alt="example">

fetch('https://example.com/', {priority: 'low'})

imgやiframeタグにloading属性を追加する

imgやiframeタグにはloading属性を使うことで読み込みのタイミングを指定できます。
もし、遅延/非同期読み込みしたい場合はloading='lazy'を使うと良いでしょう。
ただし、ファーストビューに使うと返って読み込みが遅くなる可能性もあるので注意しましょう。

imgタグにdecoding属性を追加する

imgタグはdecoding属性を使うことでデコードを同期/非同期的に読み込むかを指定できます。
decoding='async'を指定すれば非同期的にデコード処理をブラウザに指示できます。

imgタグにはサイズを指定しておく

imgタグのwidth/height属性などを使って、画像のサイズを指定しておきましょう。ブラウザのレンダリングの助けになります。
CLSの改善にも繋がります。
わからない場合は大体のサイズを指定しましょう。

優先度の高いリソースはlinkタグにpreloadを指定する

ファーストビューに表示する画像など、優先度の高いリソースはlinkタグのrel属性にpreloadを指定ことで速い読み込みが期待できます。

優先度の高い外部ドメインへのアクセスがある時はlinkタグにdns-prefetchまたはpreconnectを指定する

外部ドメインからリソースを取得したり重要度の高い外部リンクを設置している場合などは、linkタグのdns-prefetchpreconnectが使えます。
dns-prefetchはDNSルックアップ、preconnectは事前接続まで行います。
かなり優先度の高い外部ドメインへのアクセスはpreconnect、少し優先度が落ちる場合はdns-prefetchを使うと良いでしょう。

ユーザーがよく遷移するページはlinkタグにprerenderを指定する

linkタグのrel属性にprerenderを指定することで、ブラウザは指定されたページをバック グラウンドでレンダリングします。
なので、ユーザーが指定されたページへ遷移する時はすぐに画面表示ができます。
ユースケースとしては、ランキングサイトのようなページで1位へのページへ遷移するユーザーは多いので、prerenderを指定しておくと良いかもしれません。
ただし、レンダリングされる都合上、ブラウザへの負荷が高かったり、JavaScriptで仕込んでいる計測処理が発火するなどの注意は必要です。

scriptタグにdeferやasync属性を追加する

ブラウザでスクリプトが読み込まれるとHTMLやCSSの解析がブロックされます。
このような問題を解決するためにdeferasync属性が使えます。

deferはHTMLやCSSの解析をブロックすることなくスクリプトを読み込んでおき、解析が完了したらスクリプトを実行します。

asyncはHTMLやCSSの解析とは独立してスクリプトの読み込み・実行をします。

Qiitaのこの記事がわかりやすいです。

優先度の高いリソースの読み込みはできるだけHTML上部で定義する

ブラウザはHTMLドキュメントの上から解釈してきます。
なので、例えば同じpreloadを指定しているリソースでも、さらに優先度の高いものはよりHTML上部に定義して早めにブラウザが読み込めるようにしましょう。

CSSで余計なセレクタは書かない

ブラウザはCSSセレクタを右から左に解析します。
なので、できる限り単一のクラス名やid名で指定した方が解析のスピードが上がります。

/* ブラウザは全てのdivタグを探し、さらに上の階層のhogeクラスを見つけようと解析する */
.hoge div {}

/* Best Practice */
.hoge {}
#hoge {}

style属性を使って直接スタイルを指定する

クラスなどセレクタを指定してCSSを書くよりも、直接HTMLタグのstyle属性を使ったほうがブラウザの解析は速いです。
ただし、コードの可読性やメンテが厳しくはなります。

<div style='color: red;'>ほげ</div>

不要なCSSを削除する

使っていないCSSは削除しましょう。
Chromeのデベロッパーツールを使えば不要なCSSを洗い出すことができます。

不要なJavaScriptを削除する

使っていないJavaScriptは削除しましょう。
例えば、console.logは基本的にプロダクションのコードでは不要なので、eslintで検出するなりbabelで削除するなりします。

ファーストビューに影響のあるCSSはheadタグの先頭で読み込む

JavaScriptと違い、ブラウザのCSSの解析はHTMLの解析をブロックしません。
ファーストビューで読み込ませたいCSSはできるだけheadタグの先頭に読み込ませて、速くスタイリングされたファーストビューをユーザーに見せるようにしましょう。

ファーストビューに影響のないCSSはbodyタグの末尾で読み込む

逆にファーストビューに影響のないCSSはbodyタグの末尾で読み込ませることで、ブラウザにCSSの読み込みを遅延させます。

JavaScriptはbodyタグの末尾で読み込む

ブラウザはJavaScriptの解析を始めるとHTMLやCSSの解析をストップします。
なので、JavaScriptはbodyタグの末尾で読み込み、HTMLやCSSの解析が終わった後のJavaScriptを解析するようにしましょう。
ただし、Google Analyticsなどの解析用のJavaScript等は除きます。

HTMLやCSS、JSをMinify/バンドルする

webpackswcなどのバンドラーを使いましょう。

JavaScriptのトランスパイルを最新のESに合わせる

もしJavaScriptをES2015でトランスパイルしている場合は、それよりも最新のバージョンでトランスパイルすることによって、JavaScriptのサイズを落とすことができます。
ただし、IEといった古いブラウザを切り捨てる覚悟は必要です。

画像はWebPやAVIFを使う

次世代の画像フォーマットとしてWebPAVIFがあります。
こららの画像フォーマットを使うことで従来のPNG等の形式よりも画像サイズを縮小できたりします。

IKEAではAVIFによって画像の転送量を21.4%削減した例もあります。

画像サイズを縮小する

画質を落とすなり幅/高さを小さくするなりして画像サイズを縮小させます。
例えば、SVGでは作成したツールによってはコメントアウトが残っていたりで最適化されずに出力されている場合もあるので、手動で削除するなりツールを使うなりで縮小させます。

画像をインライン化する

インライン画像としてHTMLに直接埋め込むことで、画像のリクエスト数を抑えることができます。
ただし、画像サイズが大きくなったりブラウザのキャッシュが効かない等のデメリットはあります。
画像サイズが小さく、一度しか読み込まれない場合などに有効といわれています。

過大なDOMを避ける

DOMが多すぎるとブラウザの描画に負担をかけてしまいます。
不要なDOMを削除するのはもちろん、遅延読み込みや仮想無限スクロールなどを駆使してユーザーに表示されている部分だけ描画することで対策できます。

サードパーティスクリプトの読み込みにはPartytownを使う

Google Analytics のような分析、または Google Adsense のような広告などサードパーティスクリプトをサイトに貼っている人も多いかと思います。
このようなサードパーティスクリプトはブラウザのメインスレッドの処理を妨げることが多々あります。

この記事の執筆現在、まだベータ版ではありますが Partytown というライブラリが使えます。
詳しい仕組みは割愛しますが、 Partytown によってサードパーティスクリプトの読み込みを WebWorker に移譲することができ、メインスレッドへの負担を軽減させることができます。

特定の文字のみGoogleFontを使っている場合はtextパラメータを使う

もしあなたのサイトで特定の文字の装飾のためにGoogleFontを読み込んでいる場合は、 text パラメータに装飾したい文字だけ指定することでパフォーマンスを上げることができます。

CSS Containment を活用する

JavaScript で DOM を挿入するように、DOM の構造が変わることで全体のスタイルの再計算が走ります。
CSS Containment を活用することで、ある箇所で DOM の変更があっても、他の箇所のスタイルの再計算は走らせないといった制御ができます。

リダイレクトを避ける

a タグや img タグなどブラウザからリソースを取得させる場合は、できる限りリダイレクトが発生しない URL を設定した方がリソースの取得が早くなります。

<!-- リダイレクトが発生する -->
<img src='//www.test.com/images/1234.png' />

<!-- リダイレクトが発生しない -->
<img src='//test.com/images/1234.png' />

画像を使わず HTML/CSS でアイコンを表示する

複雑なアイコンでなければインラインで SVG を埋め込んだり外部から画像をリクエストせず HTML/CSS を使ってアイコンを表示できます。

hamburger button.png

例としてハンバーガーボタンを挙げましょう。
ハンバーガーボタンは次のような HTML と CSS で作成できます。

<button class='hamburger-button' aria-label='メニュー'>
  <div class='bar'></div>
</button>
.hamburger-button, .hamburger-button::before, .hamburger-button::after, .bar {
  width: 32px;
}

.hamburger-button {
  height: 32px;
  background-color: white;
  /* ユーザーエージェントの Style が当たるのでリセット */
  padding: 0;
  border-width: 0;
}

.hamburger-button::before, .hamburger-button::after, .bar {
  height: 4px;
  background-color: gray;
}

.hamburger-button::before, .hamburger-button::after {
  display: block;
  content: ' ';
}

.hamburger-button::before {
  margin-bottom: 8px;
}

.hamburger-button::after {
  margin-top: 8px;
}

before や after の疑似要素を使って不要な DOM を作らない

疑似要素の beforeafter を使えば必要以上な DOM の作成を抑えることもできます

先述の「画像を使わず HTML/CSS でアイコンを表示する」のハンバーガーメニューを例に挙げます。
疑似要素を使わない場合は次のような HTML になります。

<button class='hamburger-button' aria-label='メニュー'>
  <div class='bar1'></div>
  <div class='bar2'></div>
  <div class='bar3'></div>
</button>

このように装飾のために div を作成する必要があります。
一方で、 画像を使わず「画像を使わず HTML/CSS でアイコンを表示する」でお話したように疑似要素の beforeafter を使って次のように div を減らすことができます。

<button class='hamburger-button' aria-label='メニュー'>
  <div class='bar'></div>
</button>

このように 疑似要素の beforeafter を使うことで必要以上な DOM 生成を抑え、 HTML ファイルサイズの削減にも繋がります
この他、コードの可読性を高めたり、 SEO 対策(直接的にページのコンテンツに関係ないものを省けて適切なコンテンツ評価に繋がる)にもなるといったメリットもあります。

JSONCrush を使って JSON 文字列を圧縮する

JSONCrush というライブラリを使うことで JSON 文字列を圧縮することができます。

アプリケーションのビルド時に json 形式で静的なファイルに出力してアプリケーションで参照するなどの場合は JSONCrush で圧縮するのも 1 つの選択肢でしょう。
また、 URL に JSON 文字列を含める場合も JSONCrush で圧縮した文字列を URL にセットするといったこともできます。

TreeShaking を有効化する

webpack や rollup などのバンドラーはバンドル時に実行されないコードを削除します。
これを TreeShaking と言います。

もし開発しているアプリケーションで TreeShaking が有効でない場合は有効化するようにしましょう。
TreeShaking を有効化させるには、 ESM にする、 package.json に sideEffects: false を指定するといった条件があります。下記は webpack の例です。

ブラウザAPI編

永続化ストレージはLocalStorageよりIndexedDBを使う

ブラウザの永続化ストレージにはLocalStorageIndexedDBが使えます。
LocalStorageは同期的、IndexedDBは非同期処理なので、IndexedDBの方がブラウザの動きを阻害することなくデータアクセスができます。

重たい処理やUIに依存しない処理はWebWorkerを使う

WebWorkerを使うことでブラウザのメインスレッドとは別のスレッド立ち上げることができます。
フロントで検索機能といった重たい処理だったり、エラーをサーバーに送信するといったUIに依存しない処理はWebWorkerを使うことでメインスレッドの処理を阻害させません。

ServiceWorkerでリソースをキャッシュする

ServiceWorker といえばPWA(Progressive Web Application)のイメージが強いですが、ブラウザから外部サーバーへのリクエストをフックしてHTMLやCSS、JSなどのリソースをキャッシュすることができます。
リクエストする際はキャッシュから取得することができるので外部サーバーへのリクエストするよりも処理が速くなります。
また、キャッシュから取得するか、先にサーバーへデータ取得してからキャッシュするかなど柔軟なキャッシュ戦略を選択できます。

ServiceWorkerを使う時はNavigationPreloadsも使う

サイトにアクセス時、必要なリソースをフェッチする時にはServiceWorkerが起動するのを待ってフェッチ処理が走ります。
NavigationPreloadsではServiceWorkerの起動を待たずフェッチ処理を開始することができます。

WebAssembly を使う

JavaScriptだけでなく、CやRustで書いたコードがブラウザで実行でき、JavaScriptよりも高速化される場合があります。
Amazonの事例もあります。

優先度の低く軽い処理は requestIdleCallback を使う

requestIdleCallback を使えばブラウザのアイドル中(何もしていない状態)に処理を走らせることができます。

なお、requestIdleCallback はアイドル状態が解除された後続の処理に影響が出てしまわないように軽い処理をすることがおすすめです。

例えば、 Google Analyticsで重要度の低いイベントの送信をする際に活用できるでしょう。
そうすればブラウザはイベントによるメインの処理を優先的に行うことができます。

requestIdleCallback を便利に扱うライブラリも公開しているので、ぜひ使ってみてください!

アニメーション中の JavaScript の実行は requestAnimationFrame を使う

ブラウザは絶えずフレームを更新し再描画をしていますが、スクロール等のアニメーション中に setInterval などで JavaScript を実行すると描画を中断してしまいます。
その結果、ユーザーから見たらアニメーションがカクついて見えることもあります。

requestAnimationFrame を使えば次のフレーム開始で JavaScript を実行することができ、アニメーションでの JavaScript 実行を最適化することができます。

アナリティクスにはnavigator.sendBeaconを使う

ページ遷移する際、ページ遷移をブロックして分析用のデータを送信しているケースがあるのではないでしょうか。
確実に分析データを送信するためには必要ですが、ページ遷移が遅くなってしまいます。
これを防ぐためには navigator.sendBeacon が使えます。

ちなみにほとんどのWebサイト運営者が使っている Google Analytics にも sendBeacon を使うことができます。
gtag.js であれ anatlytics.js であれ sendBeacon を設定できます。

Event.preventDefault を使わない場合は passive: true を指定する

touchstartなどのタッチイベントで Event.preventDefault を使わない場合は passive: true を指定することでスクロールの性能が改善されることがあります。
(ただし、ブラウザによってはデフォルトで passive: true になっていたりします)

const handler = () => console.log('test');

window.addEventListener('touchstart', handler, {
   passive: true,
});

遅延読み込みや無限スクロール等を実装するときは Intersection Observer API を使う

遅延読み込みや無限スクロール等を実装するときはブラウザ上での座標の計算が必要になります。
Element.getBoundingClientRect を使えば座標計算ができますが、 setInterval 等を用いて逐一計算するのはパフォーマンスに悪影響が出ます。
Intersection Observer API を使えばこのような問題を回避できます。

個人的に Intersection Observer API を使って遅延読み込みできる React コンポーネントを npm で公開しているので参考にしてみてください。

setTimeout を使ってタスクを分割する

次の記事で詳しく説明していますが、 setTimeout を使うことでタスクを分割することができます。

50 ミリ秒での実行が推奨 されているので、 50 ミリ秒を超える処理は setTimeout を使ってタスクを分割することによって、例えばボタンをクリックした時にユーザーは UI の更新が早く感じられます。

function clickHandler() {
  // 50 ミリ秒かかる処理
  hoge();
  // タスクを分割して処理を後回し
  setTimeout(fuga, 0);
}

document.readyState や load イベントを使ってページが完全に読み込まれたら処理を開始する

これは Next.jsPartytown などでも使われているテクニックです。

次のようなコードを利用することで、 CSS などのサブリソースを含めて完全にページが読み込みを完了したら処理を開始させることができます。
こうすることによって、優先度の低い処理は後回しにすることができます。

// すでにページが完全に読み込まれている
if (document.readyState === 'complete') {
  hoge();
} else {
  // またページが完全に読み込まれていないので、読み込みが完了したら処理させる
  window.addEventListener('load', hoge);
}

Scheduler.postTask を使って処理の優先度を決める

執筆時点(2022/12/8)で Chrome など一部の最新版のブラウザScheduler.postTask API が使えます。

Scheduler.postTask では引数に user-blockinguser-visible , background という優先度を指定することができます。
user-blocking > user-visible > background の順で処理の優先度が高くなります。

使い分けとしては、例えば画像を表示するカルーセルで 1 枚目の画像読み込みは user-blocking 、 2 枚目以降の読み込みは user-visible を使う、といったことが考えられるでしょう。

bfcache を無効化させない

モダンブラウザには bfcache という機能が備わっています。
bfcache はブラウザで「戻る」や「進む」を押した時に、キャッシュからページを復元し、高速に表示できる機能です。
bfcache は基本的に有効化されていますが、条件によっては無効になっているケースがあります。

無効になる条件を含め bfcache について詳しく知りたい方は次の記事が参考になります。

V8エンジン編

ChromeやNode.jsでは内部的にV8エンジンが使われています。
ここまで最適化すると変態ですが、チップスとして紹介します。

参考

値の格納はコンストラクタで

V8エンジンでは内部的にhidden classというものを生成します。
詳しい仕組みは割愛しますが、インスタンス化したオブジェクトに対して値を追加すると、新しいhidden classが生成されてしまいます。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

var p1 = new Point(11, 22);  // hidden class の生成
var p2 = new Point(33, 44);  // hidden class の再利用

p1.z = 55; // hidden class が生成されてしまう

オブジェクトは同じ順番のプロパティで生成する

これもhidden classに関わる話ですが、違う順番でプロパティを生成すると新たにhidden classが生成されます。

const obj = { a: 1 };
obj.b = 2

// hidden classを使い回せる
const obj2 = { a: 1 };
obj2.b = 2

// 新しいhidden classが生成されてしまう
const obj3 = { b: 2 };
obj3.a = 1

関数は同じ引数の型を使う

関数の引数はできるだけ同じ型を使うようにします。
V8エンジン(正確には内部で使われている TurboFun と呼ばれるコンパイラ)は引数の型が異なっても4回目までは最適化してくれますが、それ以降は最適化してくれません。

function add(x,y) {
  return x + y
}

add(1, 2);  // 最適化
add("a", "b"); // 再度最適化
add(true, false);
add([], []);
add({}, {});  // 最適化が働かない

クラスは関数外で定義する

関数の引数はできるだけ同じ型を使うの文脈で、関数内でクラスを定義するのも良くはありません。

// NG
function createPoint(x, y) {
  class Point {
    constructor(x,y) {
      this.x = x
      this.y = y
    }
  }

  return new Point(x,y)
}

function length(point) {
  //...
}

createPointPoint インスタンスを生成し、 length の引数に渡すことを考えます。
この時 length の引数の型は毎回違うものとして認識されるため、 関数の引数はできるだけ同じ型を使う と同じく最適化が行われません。

ライブラリ編

軽量なライブラリを採用する

ライブラリを採用する1つの観点としてサイズがあります。
bundlephobia というサイトでライブラリのサイズをチェックすることができます。

ライブラリのサイズを減らす

moment.jslodash などのライブラリはWebpackのプラグインを使って不必要なスクリプトを削減することができます。

ライブラリのドキュメントを読む

ライブラリの公式ドキュメントには最適化のTipsが載っていたりします。
例えば、Reactにはパフォーマンス最適化TailwindCSSにはOptimizing for Productionというページが公式のドキュメントに記載されています。
各ライブラリのドキュメントをしっかり見てみましょう。

ライブラリに頼らず自前で作る

ライブラリは万人向けに最適化されており、あなたのアプリケーション向けには最適化されていません。
あなたのアプリケーション以上に機能過多であることがほとんどです。
時には自前で作るのも1つの手です。

ライブラリを最新バージョンにアップデートさせる

ライブラリを最新バージョンにアップデートさせることでパフォーマンスが良くなることもあります。

例えば、 React は v18 へのメジャーアップデート時にメモリの改善 を行っており、 著者が個人開発したサイトでもメモリ使用量が 20 % ほど改善されました。
その他、 Chart.js も v3 では Tree Shaking が効かせられるようになった 例もあります。

このように、ライブラリを最新バージョンにアップデートさせることもパフォーマンス改善につながったりします。

代替ライブラリに切り替える

同じ機能を実現するものでも、より軽量なライブラリに乗り換えるのも 1 つの手です。
例えば、 moment.js を使っているのであれば day.jsReact を使っているのであれば Preact への切り替えが考えられるでしょう。

TreeShakable なライブラリを採用する

webpack や rollup などのバンドラーはバンドル時に実行されないコードを削除します。
これを TreeShaking と言います。

TreeShaking を有効化するには条件があります。
そのため、ライブラリによっては TreeShaking が有効化されていないものもあります。

TreeShaking が有効かどうかは軽量なライブラリを採用するで紹介した bundlephobia というサイトでチェックできます。

ライブラリの観点で TreeShaking が重要かを紹介しましたが、TreeShaking は普段コードを書く上でも重要です。

SPA編

ReactVueといったコンポーネント志向のライブラリを想定しています。
Reactのコード例が多いですが、Vueでも参考になるかと思います。

コンポーネントがマウントされた後、遅延的にデータを読み込みする

優先順位だったりデータサイズが大きい場合等はマウント後リソースを取得します。

// 先にimportしない
// import articles from './articles.json';

function ArticlesComponent() {
  const [articles, setArticles] = useState([]);

  // マウント後にデータを読み込む
  useEffect(() => {
    import('./articles.json').then(res => setArticles(res.default));
  }, [])

  return articles.map(article => <div key={article.id}>{article.title}</div>)
}

クリック等のイベント後に遅延的にデータを読み込みする

コンポーネントがマウントされた後、遅延的にデータを読み込みすると話は似ていますが、
クリック後など必要なタイミングで遅延的にデータを読み込みするのもアリです。

// 先にimportしない
// import articles from './articles.json';

function ArticlesComponent() {
  const [articles, setArticles] = useState([]);

  return (
    <>
      <div onClick={() => import('./articles.json').then(res => setArticles(res.default))}>
        記事一覧を見る
      </div>
      <div>
         {articles.map(article => <div key={article.id}>{article.title}</div>)}
      </div>
    </>
  )
}

コンポーネントを遅延読み込みする

初めてコンポーネントが表示されるタイミングでコンポーネントを読み込みます。
例えば、ユーザーがボタンをタップして初めて表示されるコンポーネントは遅延読み込みでの実装を考えます。
Reactで言えばSuspenseNext.jsならdyamicのAPIを使ってコンポーネントの遅延読み込みを実装できます。

SSRやSSG、ISRに移行する

ReactVueなど通常のSPAは性質上、初期描画が遅くなります。
ReactであればNext.jsGatsuby.jsVueであればNuxt.jsといったフレームワークを使えば初期描画が遅くなる問題を解決できます。

コンポーネントの設計を最適化する

ReactVueだとコンポーネントのレンダリングの仕組みが違うので一概にこれが最適とは言えませんが、共通した設計の最適化があります。
例えば、「コンポーネントとデータの依存を考えて、再レンダリングの範囲を最小限にする」ことでしょう。
次のコンポーネントの例を見てください。

<!-- とあるコンポーネント -->
<div>
  <div>データAに依存するUI部分</div>
  <div>データAに依存しないUI部分</div>
</div>

1つのコンポーネント内に「データAに依存するUI部分」と「データAに依存しないUI部分」があります。
ReactであれVueであれこのようなケースの場合は「データAに依存しないUI部分」を別コンポーネントに切り出したほうが良いでしょう。
そうすればデータAに変更があった時、「データAに依存するUI部分」のみ再レンダリングさせることができます。
Vueであれば問題ないですが、Reactの場合はステート管理のライブラリを使っていない場合はReact.memoを使う必要はあります)

サーバー編

必要なデータのみフロントへ返却する

例えば、記事の一覧ページに各記事の本文を一部表示するとします。
「本文を一部」だけならサーバーからは一部だけ返却するようにします。
そうすることでファイルサイズ削減などができます。

事前に静的ファイルにしておく

都度APIへアクセスするのであれば予めJsonにしておくのも良いでしょう。

日本にあるサーバーを使う

日本向けのアプリを開発しているのであれば、地理的に近い日本のサーバーを選びましょう。

Brotli圧縮を使う

gzipよりは圧縮後のサイズ削減や圧縮速度の向上が見込めます。

CDNを使う

Amazon CloudFrontなどのCDNはできるなら使いましょう。

HTTP/2を使う

できるなら使いましょう。HTTP/1.1より速いです。

HTTPキャッシュを使う

Cache-ControlなどのHTTPヘッダーを利用して、ブラウザにリソースをキャッシュさせます。

103 Early Hints を使う

最初にリクエストする HTML には CSS に代表される色々なリソースファイルの読み込みの記述があるでしょう。
通常であれば HTML の解析中にリソースファイルを外部から取得します。
が、 CSS のようなファイルは事前に取得した方が HTML の解析中に即座に CSS の解析も始められます。

103 Early Hints を使うことでリソースファイルの読み込みを最適化できます
サーバーが HTML のレスポンスを準備している前に先に CSS をブラウザに返却することで、ブラウザが HTML を取得・解析を始めて即座に CSS も解析することができます

まとめ

この記事では次のようにカテゴリ分けしてWebフロントエンドのパフォーマンスチューニングのハウツーを紹介しました。

その他、パフォーマンスチューニングの実例も紹介しているので、興味あればぜひご覧ください。

皆さんのパフォーマンスチューニング力の力添えになれば幸いです! by ぬこすけ

1647
1672
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1647
1672