こんにちは、ぬこすけです。
近年、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のモジュールの読み込み方にはrequire
とimport
の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.writeFileSync
とfs.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-prefetch
やpreconnect
が使えます。
dns-prefetch
はDNSルックアップ、preconnect
は事前接続まで行います。
かなり優先度の高い外部ドメインへのアクセスはpreconnect
、少し優先度が落ちる場合はdns-prefetch
を使うと良いでしょう。
ユーザーがよく遷移するページはlinkタグにprerenderを指定する
linkタグのrel属性にprerender
を指定することで、ブラウザは指定されたページをバック グラウンドでレンダリングします。
なので、ユーザーが指定されたページへ遷移する時はすぐに画面表示ができます。
ユースケースとしては、ランキングサイトのようなページで1位へのページへ遷移するユーザーは多いので、prerender
を指定しておくと良いかもしれません。
ただし、レンダリングされる都合上、ブラウザへの負荷が高かったり、JavaScriptで仕込んでいる計測処理が発火するなどの注意は必要です。
scriptタグにdeferやasync属性を追加する
ブラウザでスクリプトが読み込まれるとHTMLやCSSの解析がブロックされます。
このような問題を解決するためにdefer
やasync
属性が使えます。
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/バンドルする
webpack
やswc
などのバンドラーを使いましょう。
JavaScriptのトランスパイルを最新のESに合わせる
もしJavaScriptをES2015
でトランスパイルしている場合は、それよりも最新のバージョンでトランスパイルすることによって、JavaScriptのサイズを落とすことができます。
ただし、IEといった古いブラウザを切り捨てる覚悟は必要です。
画像はWebPやAVIFを使う
次世代の画像フォーマットとしてWebP
やAVIF
があります。
こららの画像フォーマットを使うことで従来の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 を使ってアイコンを表示できます。
例としてハンバーガーボタンを挙げましょう。
ハンバーガーボタンは次のような 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 を作らない
疑似要素の before
や after
を使えば必要以上な 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 でアイコンを表示する」でお話したように疑似要素の before
や after
を使って次のように div
を減らすことができます。
<button class='hamburger-button' aria-label='メニュー'>
<div class='bar'></div>
</button>
このように 疑似要素の before
や after
を使うことで必要以上な 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を使う
ブラウザの永続化ストレージにはLocalStorage
とIndexedDB
が使えます。
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.js や Partytown などでも使われているテクニックです。
次のようなコードを利用することで、 CSS などのサブリソースを含めて完全にページが読み込みを完了したら処理を開始させることができます。
こうすることによって、優先度の低い処理は後回しにすることができます。
// すでにページが完全に読み込まれている
if (document.readyState === 'complete') {
hoge();
} else {
// またページが完全に読み込まれていないので、読み込みが完了したら処理させる
window.addEventListener('load', hoge);
}
Scheduler.postTask を使って処理の優先度を決める
執筆時点(2022/12/8)で Chrome など一部の最新版のブラウザで Scheduler.postTask
API が使えます。
Scheduler.postTask
では引数に user-blocking
と user-visible
, background
という優先度を指定することができます。
user-blocking
> user-visible
> background
の順で処理の優先度が高くなります。
使い分けとしては、例えば画像を表示するカルーセルで 1 枚目の画像読み込みは user-blocking
、 2 枚目以降の読み込みは user-visible
を使う、といったことが考えられるでしょう。
bfcache を無効化させない
モダンブラウザには bfcache という機能が備わっています。
bfcache はブラウザで「戻る」や「進む」を押した時に、キャッシュからページを復元し、高速に表示できる機能です。
bfcache は基本的に有効化されていますが、条件によっては無効になっているケースがあります。
無効になる条件を含め bfcache について詳しく知りたい方は次の記事が参考になります。
V8エンジン編
ChromeやNode.jsでは内部的にV8エンジンが使われています。
ここまで最適化すると変態ですが、チップスとして紹介します。
参考
- https://www.youtube.com/watch?v=UJPdhx5zTaw
- https://www.digitalocean.com/community/tutorials/js-v8-engine
- https://blog.logrocket.com/how-javascript-works-optimizing-the-v8-compiler-for-efficiency/
値の格納はコンストラクタで
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) {
//...
}
createPoint
で Point
インスタンスを生成し、 length
の引数に渡すことを考えます。
この時 length
の引数の型は毎回違うものとして認識されるため、 関数の引数はできるだけ同じ型を使う と同じく最適化が行われません。
ライブラリ編
軽量なライブラリを採用する
ライブラリを採用する1つの観点としてサイズがあります。
bundlephobia というサイトでライブラリのサイズをチェックすることができます。
ライブラリのサイズを減らす
moment.js
や lodash
などのライブラリはWebpackのプラグインを使って不必要なスクリプトを削減することができます。
ライブラリのドキュメントを読む
ライブラリの公式ドキュメントには最適化のTipsが載っていたりします。
例えば、React
にはパフォーマンス最適化、TailwindCSS
にはOptimizing for Productionというページが公式のドキュメントに記載されています。
各ライブラリのドキュメントをしっかり見てみましょう。
ライブラリに頼らず自前で作る
ライブラリは万人向けに最適化されており、あなたのアプリケーション向けには最適化されていません。
あなたのアプリケーション以上に機能過多であることがほとんどです。
時には自前で作るのも1つの手です。
ライブラリを最新バージョンにアップデートさせる
ライブラリを最新バージョンにアップデートさせることでパフォーマンスが良くなることもあります。
例えば、 React は v18 へのメジャーアップデート時にメモリの改善 を行っており、 著者が個人開発したサイトでもメモリ使用量が 20 % ほど改善されました。
その他、 Chart.js
も v3 では Tree Shaking が効かせられるようになった 例もあります。
このように、ライブラリを最新バージョンにアップデートさせることもパフォーマンス改善につながったりします。
代替ライブラリに切り替える
同じ機能を実現するものでも、より軽量なライブラリに乗り換えるのも 1 つの手です。
例えば、 moment.js
を使っているのであれば day.js
、 React
を使っているのであれば Preact
への切り替えが考えられるでしょう。
TreeShakable なライブラリを採用する
webpack や rollup などのバンドラーはバンドル時に実行されないコードを削除します。
これを TreeShaking と言います。
TreeShaking を有効化するには条件があります。
そのため、ライブラリによっては TreeShaking が有効化されていないものもあります。
TreeShaking が有効かどうかは軽量なライブラリを採用するで紹介した bundlephobia というサイトでチェックできます。
ライブラリの観点で TreeShaking が重要かを紹介しましたが、TreeShaking は普段コードを書く上でも重要です。
SPA編
React
やVue
といったコンポーネント志向のライブラリを想定しています。
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
で言えばSuspense
、Next.js
ならdyamic
のAPIを使ってコンポーネントの遅延読み込みを実装できます。
SSRやSSG、ISRに移行する
React
やVue
など通常のSPAは性質上、初期描画が遅くなります。
React
であればNext.js
やGatsuby.js
、Vue
であればNuxt.js
といったフレームワークを使えば初期描画が遅くなる問題を解決できます。
コンポーネントの設計を最適化する
React
やVue
だとコンポーネントのレンダリングの仕組みが違うので一概にこれが最適とは言えませんが、共通した設計の最適化があります。
例えば、「コンポーネントとデータの依存を考えて、再レンダリングの範囲を最小限にする」ことでしょう。
次のコンポーネントの例を見てください。
<!-- とあるコンポーネント -->
<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 ぬこすけ