こんにちは。ぬこすけです。
今年の2月にフロントエンドのパフォーマンスチューニングに関する記事を公開したところ、多くの反響をいただきました。
さて、 一丁前にパフォーマンスチューニングの記事を書いた本人はどうなんですか? というお話です。
ということで 個人開発している「ぬこぷろ」という技術書のランキングサイトで PageSpeed Insights を 💯 点のスコアを出してみました。
※ちょくちょく改修しているので、スコアは変動します。
弱小なインフラ構成や広告・分析用の第三者コードの読み込み、そしてある程度リッチなコンテンツを表示するサイトでここまでのスコアを叩き出せたのは割とがんばったかなーと思っています。
この記事では ぬこぷろで行ったパフォーマンスチューニングの実例を紹介 します。
みなさんのサイトのパフォーマンスチューニングの参考になればと思います。
注意事項
- 割と中上級者向けの記事です。初心者の方も読んで分からない部分もあるかと思いますが、ストックして読み返すとまた理解度が変わるかなと思います。
- わかりやすくするためにカテゴリに分けしていますが、微妙なカテゴリ分けのものもあるので悪しからず。
- 例として挙げているコードは
React
やTailwindCSS
が多めです。 - ライブラリや API については詳しい説明はしません。
- 直接的に PageSpeed Insights のスコアの改善に役立ったかわからないものもありますが、皆さんのパフォーマンスチューニングの参考になればと思い紹介しています。
前提条件
パフォーマンスチューニングの実例を紹介する前に、使っているライブラリやインフラ環境などの前提条件を書いておきます。
- バージョン
- React 18.2.0
- Next.js 12.3.1
- Node.js 16
- TailwindCSS 3.1.8
- インフラ
-
Cloud Run
- リージョンは
asia-northeast1
-
memory=512Mi
,cpu=1
- リージョンは
-
- その他
- Amazon アフィリエイトの規約に基づいて、 Amazon に用意されている画像( jpg 😇)をそのまま使用。
- なので画像を WebP に変換するなどの最適化はなし。
- Google Analytics や Google Adsense の第三者コードの読み込み有。
- Amazon アフィリエイトの規約に基づいて、 Amazon に用意されている画像( jpg 😇)をそのまま使用。
HTML/CSS 編
画像の読み込み戦略を考える
まず、次のように読み込みの優先度高い/低い画像を分けます。
- 優先度高い
- ファーストビューに表示されるメインコンテンツの画像。
- 優先度低い
- ファーストビューに表示されない画像
- ファーストビューに表示されるがメインコンテンツでない画像。
このように画像を分類した上で、次のように html を使い分けています。
<!-- 優先度の高い画像 -->
<!-- link タグは head タグの中で -->
<link
rel='preload'
as='image'
href='...'
fetchpriority='high'
>
<img
src='...'
decoding='async'
loading='eager'
fetchpriority='high'
>
<!-- 優先度の低い画像 -->
<img
src='...'
decoding='async'
loading='async'
fetchpriority='low'
>
2 つポイントがあるので、詳しく説明します。
①link タグで事前に画像を読み込んでおく
link
タグ rel=preload
を指定すれば、ページ読み込みが開始される早期の段階で事前に画像などのリソースを読み込んでおくことができます。
読み込みの優先度高い画像にはこのような link
タグ rel=preload
を指定して先読みしておきます。
また後述する fetchpriority
も指定できます。
② decoding
と loading
、 fetchpriority
属性を使いこなす
それぞれの属性ごとで説明します。
decoding
画像のデコードを同期 or 非同期に処理するかを指定できます。
この属性は 画像読み込みの優先度に関わらず非同期( decoding=async
)を指定 するようにしています。
同期的な処理にしてしまうとブラウザの他の処理がブロッキングされてしまうため です。
loading
画像などの読み込みを即時 or 遅延の指定ができます。
ファーストビューで表示される優先度の高い画像は即時読み込み( loading='eager'
)、それ以外の優先度の低いものは遅延読み込み( loading='async'
) にします。
fetchpriority
一部のモダンなブラウザで使用できますが、 画像などの取得の優先度をブラウザに明示できます 。
読み込みの優先度の高い画像は fetchpriority='high'
、 低い画像は fetchpriority='low'
を指定します。
TailwindCSS で余計なクラスを指定しない
TailwindCSS では使ったクラスだけスタイル定義されます。
逆にいえば、一箇所でしか使われないクラスであっても CSS としてクライアントに配信 されます。
なので、できるだけ既存のコードですでに使われているクラスを使うようにします。
CSS の contain
プロパティを使う
スタイルに変更が起こるとブラウザはページ全体のレイアウトを再計算します。
これを限られた領域にだけ再計算させるようにブラウザに指示できるのが contain
プロパティです。
TailwindCSS を使っている場合、デフォルトでは contain
プロパティは使えないので、次のように tailwind.config.js
に設定を加えることで contain
が使えるようになります。
const plugin = require('tailwindcss/plugin');
module.exports = {
// ....
plugins: [
plugin(function ({ addUtilities }) {
addUtilities({
'.contain-strict': {
contain: 'strict',
},
'.contain-content': {
contain: 'content',
},
'.contain-size': {
contain: 'size',
},
'.contain-layout': {
contain: 'layout',
},
'.contain-style': {
contain: 'style',
},
'.contain-paint': {
contain: 'paint',
},
});
}),
],
};
慎重にスタイル当てないとスタイル崩れ起きるのでお気をつけて😇。
CSS を縮小させる
cssnano
を使って CSS を縮小させます。
TailwindCSS を使っている場合には公式ドキュメントにやり方が記載されています。
アイコンは画像を使わない方法で代替する
複雑なアイコンでなければインラインで SVG を埋め込んだり外部から画像をリクエストせず HTML/CSS を使ってアイコンを表示 できます。
下記の記事にハンバーガーボタンの例を挙げているので、ぜひ参考にしてみてください。
また、凝ったデザインにしないのであれば、 絵文字での代替 も検討します。
<div className='before:content-["👍"]'>左に👍が表示される</div>
不要な DOM やスタイルを削除する
見つけ次第やっつけます。
特にリストの表示は DOM やスタイルが肥大化はパフォーマンスへのボトルネックになりがちなので注意深く実装を眺めてリファクタします。
ブラウザ API 編
IntersectionObserver
を使ってビューポート外のコンポーネントは描画させない
IntersectionObserver
を使えば、対象の DOM が特定の領域内に入ったかどうかをチェックすることができます。
ぬこぷろではビューポート内に入っていない DOM は基本的に非表示にし、ビューポートに入る少し手前くらいで DOM を描画するように調整しています。
具体的なソースコードは次のような感じです。
ぬこぷろではアプリケーションのニーズに合わせて次のソースコードをこのまま使っているわけではないですが、どのような感じで IntersectionObserver
を使っているかイメージしていただければなと思います。
npm パッケージとして公開しているので、興味があれば使ってみてください!
(バグってたら issue ください!笑)
ユーザーがスクロールするまでビューポート外のリストを描画しない
ぬこぷろの技術書一覧のページでは、初回読み込み時は 2 つしかリストのアイテムを描画していません。
ユーザーのファーストビューにおいては 2 つのアイテム表示で十分だからです。
他のアイテムはユーザーがスクロールしたタイミングで描画 します。
(描画するといっても IntersectionObserver
によって非表示の状態での描画です)
このように初回読み込みを早くするために、最初のリストのアイテムの描画は最低限にしています。
<!-- 初期描画 -->
<ul>
<li>アイテム1</li>
<li>アイテム2</li>
</ul>
<!-- ユーザーがスクロールしたら他のアイテムも描画 -->
<!-- display を hidden にしているのは Core Web Vitals の CLS 対策 -->
<!-- IntersectionObserver を使ってビューポート手前までスクロールされたらアイテム3やアイテム4を表示 -->
<ul>
<li>アイテム1</li>
<li>アイテム2</li>
<li style="display: hidden">アイテム3</li>
<li style="display: hidden">アイテム4</li>
<li style="display: hidden">アイテム5</li>
</ul>
優先度の低いタスクは requestIdleCallback
や Scheduler.postTask
を使う
執筆時点で Safari などを除き、 Chrome などでは requestIdleCallback
という API が利用できます。
これは、ブラウザがアイドル状態になった時に実行したい処理を定義することができます。
一方で、こちらも執筆時点で Safari などを除き、 Chrome などでは Scheduler
API の postTask
というメソッドも利用できます。
postTask
はタスク実行の優先度を指定することができます。
分析データの送信や優先度の低いモジュールの読み込み、キャッシュのセットなどはこれらの API を使って実装 しています。
また、後続の重要なタスクに影響が出さないため、 50ms を超えないようにタスクを分割してこれらの API を使っています。
React 編
コンポーネントのレンダリングを最適化する
memo
や useCallback
、 useMemo
を使って余計な余計なレンダリングが走らないように地道にチューニング していきます。
メモ化の効果を最大限得るために、同時にデータが更新されうるのかも考えてコンポーネントも作成 します。
// Before
import FavoriteIcon from 'FavoriteIcon';
function Article({ title, description, isFavorite }) {
return (
<>
<span>{title}</span>
<span>{description}</span>
<FavoriteIcon isFavorite={isFavorite} />
</>
)
}
// After
import { memo } from 'React';
import FavoriteIcon from 'FavoriteIcon';
// 記事タイトルと記事説明文は同時にデータ変更されるので別コンポーネント化
import ArticleSummary from 'ArticleSummary';
// メモ化
const MemorizedArticleSummary = memo(ArticleSummary);
const MemorizedFavoriteIcon = memo(FavoriteIcon);
function Article({ title, description, isFavorite }) {
return (
<>
<MemorizedArticleSummary title={title} description={description} />
<MemorizedFavoriteIcon isFavorite={isFavorite} />
</>
)
}
何がなんでもメモ化するのもバッファが発生してしまうので、レンダリングツリーを考えて 親コンポーネントでメモ化できていれば子コンポーネントはメモ化しない ようにします。
また次の例で記載するような、できるだけ children
を使った実装にします 。
こうすることによって、 状態変更による再レンダリングが走っても、 children で渡されたコンポーネントは再レンダリングが走りません。
function Timer({ children }) {
const [isTimeout, setIsTimeout] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsTimeout(true), 3000);
return () => clearTimeout(timer);
}, [])
return (
{/* 3 秒後に再レンダリングが走っても、 children は再レンダリングされない */}
<div style={{ backgroundColor: isTimeout ? 'red' : 'white' }}>
{children}
</div>
)
}
不要な useState
や useEffect
を削除する
地道に不要な useState
や useEffect
を削除していきます。
useState
の削除は、例えば同時に状態を更新する場合は 1 つの State にまとめられます。
// Before
const [isLoading, setIsLoading] = useState(true);
const [isFetchSuccess, setIsFetchSuccess] = useState();
useEffect(() => {
setIsLoading(true);
fetch('...')
.then(() => setIsFetchSuccess(true))
.catch(() => setIsFetchSuccess(false))
.finally(() => setIsLoading(false));
}, [])
// After
const [fetchStatus, setFetchStatus] = useState();
useEffect(() => {
setFetchStatus('loading');
fetch('...')
.then(() => setFetchStatus('success'))
.catch(() => setFetchStatus('failed'));
}, [])
useEffect
の削除は、例えば親コンポーネントで key
を指定することで useEffect
を削除できるケースがあります。
// Before
function Article({ articleId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [articleId]);
}
// After
function UserBestArticle({ articleId }) {
// key を指定すると、 key が変わるたびにリセットされる
return <Article key={articleId} articleId={articleId} />
}
function Article({ articleId }) {
const [comment, setComment] = useState('');
// useEffect は不要
}
useState
や useEffect
の削除については React の公式ドキュメントが参考になります。
IntersectionObserver
インスタンスを ref
を使ってキャッシュする
「IntersectionObserver を使ってビューポート外のコンポーネントは描画させない」でお話した IntersectionObserver
ですが、 再レンダリングのたびに IntersectionObserever
が生成されるのはムダです。
そこで ref を使うことで IntersectionObserver をレンダリングに関わらず保持 しておくようにします。
const observerRef = useRef();
useEffect(() => {
// ...
if (!observerRef.current) {
observerRef.current = new IntersectionObserver(callback, option);
}
// ...
}, [dependencies])
必要なイベントリスナーの登録は 1 回で
単純に React のカスタムフックを使って addEventListener
をすると、カスタムフックを使っている箇所がある分だけイベントリスナーが登録されてしまいます。
例えば、次のようなビューポートの横の長さを計算してモバイルかどうか判定するカスタムフックを用意します。
export default function useDeviceDetect() {
// サーバーサイドでのレンダリングを考慮して初期値は window.innerWidth ではなく 0
const [width, setWidth] = useState(0);
const handleWindowResize = () => {
setWidth(window.innerWidth);
};
useEffect(() => {
!width && handleWindowResize();
}, [width]);
useEffect(() => {
window.addEventListener('resize', handleWindowResize, { passive: true });
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, []);
const isDetect = !!width;
const isMobile = width < 1024; // ブレークポイントは 1024 px
return [isDetect, isMobile];
}
この useDeviceDetect
を 5 箇所で使っている場合は 5 個も 'resize'
に handleWindowResize
が登録されてしまいます。
(似たようなコードを職場で発見してしまい、意外とやっちゃっている人多いかもしれません)
本来、 'resize'
に handleWindowResize
を登録されるのは 1 回で十分です。
React の Context を使って、アプリケーションで 1 度だけイベントを登録し、各コンポーネントは Context を通じてモバイルかどうかを参照させることで解決できます。
ライブラリの使用を抑える
極力ライブラリの使用を抑えて、スクリプトサイズを抑える ようにします。
例えば、 React だと Recoil
や Redux
など、状態管理のライブラリを検討しますが、ぬこぷろでは何も使っていません。
グローバルでステートを管理したい場合は React の Context で十分だからです。
フロントから fetch もしているので SWR
や React Query
も検討しますが、そもそもフロントから fetch するケースが少なかったのでぬこぷろでは使いませんでした。
レスポンスのキャッシュも自分で頑張ります。
このように極力ライブラリを使わずに開発をしています。
Next.js 編
prefetch は重要なものだけにする
Next.js の Link コンポーネントは便利です。ビューポートにリンクが表示されると自動でリンク先の必要なデータを取得します。
しかし、 初期描画でリンクの表示が多いページはデータ取得のための処理が走り、逆にブラウザに負担がかかります 。
ぬこぷろでは技術書ランキングの1位のアイテムなど本当に重要なリンクのみ prefetch を有効化 しています。
他にもリクエスト減らしてサーバーの費用を抑えたいという理由もあります(個人開発なので...)。
import Link from 'next/link';
function PrefetchLink({ priority = false, ...props }) {
return <Link prefetch={priority} {...props} />
}
next/image は使わない
Vercel や Akamai など、クラウドの画像最適化サービスも使わない、ある程度は自分で画像最適化のコードは書けるので特に next/image は使ってません。
開発しているサービスによりますが、無理に next/image を使ってバンドルサイズを増やさなくて良いかなーと思います。
next/dynamic を使ってコード分割する
基本的に 必要になるまで余計なスクリプトは読み込ません。
例えば、フッターのコンポーネントはスクロールして表示されそうになって始めてサーバーからスクリプトを取得し、実行しています。
「IntersectionObserver を使ってビューポート外のコンポーネントは描画させない」で紹介した IntersectionObserver
と組み合わせて実装しています。
const Footer = dynamic(() => import('Footer'));
function LazyLoadFooter() {
return (
{/* ビューポートに入ったら読み込む */}
<LazyLoad>
<Footer />
</LazyLoad>
)
}
なお、 next/dynamic
でなくとも React.lazy
/ React.Suspense
でも実現できます。
ビルド時に画像のリダイレクト先 URL やサイズを取得する
Amazon アフィリエイトの画像 URL をそのまま使うと、リダイレクトが発生します。
なので、 ビルド( next build
)時に予め画像 URL を叩いてリダイレクト先 URL を取得しておきます。
また、画像のレンダリング最適化のためには事前にサイズを知っておきたいところですが、外部リソースなので画像のサイズはわかりません。
なので、ビルド時にリダイレクト先 URL だけでなくサイズも計算 しておきます。
サイズ計算には image-size
を使っています。
このように画像に関する情報を予め取得しておくで画像表示を最適化しています。
Amazon さん、リクエストしすぎでごめんなさい。
ビルド時に必要なデータを静的ファイルにしておく
Next.js ではページ単位で必要なデータを静的ファイルに出力してくれますが、ページに依存しないもの、例えばグローバルに必要なデータは静的ファイルにしてくれません。
next build
を実行する前に予め必要なデータは静的ファイルに出力 しておきます。
静的ファイルに出力する際は、 アプリケーションにとって必要な情報のみのデータに加工し、できるだけファイルサイズを落とします。
こうすることにより、 バックエンドの DB へのリクエストしてデータを返却することなく、即座にデータを表示 することができます。
// Before
function BestArticles() {
const [bestArticles, setBestArticles] = useState([]);
useEffect(() => {
fetch('バックエンドのAPI URL')
.then(( { articles } ) => setBestArticles(setBestArticles));
}, [])
return (
<ul>
{bestArticles.map(article => (
<li key={article.id}>{article.title}</li>
))}
</ul>
)
}
// After
// 事前に静的ファイルにしておく
import bestArticles from './best_articles.json';
function BestArticles() {
return (
<ul>
{bestArticles.map(article => (
<li key={article.id}>{article.title}</li>
))}
</ul>
)
}
// 読み込み戦略によってはこういうのもあり
function BestArticles() {
const [bestArticles, setBestArticles] = useState([]);
useEffect(() => {
const importBestArticles = () => {
import('./best_articles.json')
.then(({ default: bestArtcles }) => setBestArticles(bestArtcles));
}
// ページが完全に読み込みが完了してから遅延的に静的ファイルを読み込む
if (document.readyState === 'complete') {
importBestArticles();
} else {
window.addEventListener('load', importBestArticles);
}
}, [])
return (
<ul>
{bestArticles.map(article => (
<li key={article.id}>{article.title}</li>
))}
</ul>
)
}
モダンなブラウザ向けにバンドルする
ビルドツールによっては package.json
に browsersList
フィールドを追加することで、配信したいブラウザ向けにバンドルが最適化され、スクリプトサイズを落とすことができます。
Next.js には experimantal な機能ですがこの browsersList
の機能を利用することができます。
Next.js の設定ファイルには次のような設定を指定します。
// next.config.mjs
export default {
// ...
experimental: {
browsersListForSwc: true,
legacyBrowsers: false,
}
// ...
}
こうすることで、 package.json
の browsersList
フィールドが機能します。
"browserslist": [
"> 1% in JP",
"not IE 11"
]
第三者コード編
Google Analytics や Google Adsense など、分析や広告表示のために第三者コードを読み込んでいるサイトがほとんどでしょう。
パフォーマンスチューニングに挑戦した方はわかると思いますが、この第三者コードの読み込みは必ずぶち当たる壁だと思います。
これから「ぬこぷろ」で実践した第三者コードに対する最適化を紹介します。
Partytown を使って Google Analytics の読み込みを別スレッドに移譲する
みなさん Partytown というライブラリをご存知でしょうか?
Partytown を使うことで Google Analytics などの第三者コードの読み込みを WebWorker に読み込ませることができます。
これにより、メインスレッドは第三者コードの読み込みに阻害されることなく他の重要な処理を実行できます。
Next.js を使っている場合は next/script
で strategy='worker'
を指定することで Partytown を使えるようになります。もちろん、他のフレームワークでも使うことはできます。
Partytown 自体 experimental なので、導入するかは要検討です。
ユーザーがスクロールしてから Google Adsense を読み込む
Google Adsense を使っている方はわかるかもしれませんが、 Google Adsense のスクリプト読み込みはパフォーマンスに大きな影響が出ます。
ぬこぷろでは スマホでアクセスした場合は、ユーザーがスクロールしてから Google Adsense を読み込む ようにしています。
こうすることで初回読み込みをスピードアップさせています。
その他
ライブラリを最新バージョンにアップデートさせる
ライブラリを最新バージョンにアップデートさせることもパフォーマンスの観点でも重要です。
例えば、 React は v18 へのメジャーアップデート時にメモリの改善 を行っており、 ぬこぷろでもメモリ使用量が 20 % ほど改善 されました。
その他、 Chart.js
も v3 では Tree Shaking が効かせられるようになった 例もあります。
このように、ライブラリを最新バージョンにアップデートさせることもパフォーマンス改善につながったりします。
バンドルされたファイルを眺める
バンドルされたファイルを眺めると「おや???」と気づくこともあります。
例えば巨大な文字列を JSON.parse
している js ファイルが初回読み込みでブラウザに配信されていることに気づきました。
できるだけ JSON.parse
のような同期的な処理は初回読み込みでは避けたいですし、本来、初回読み込みでは必要のないファイルだったため、 next/dyamic
を使ったコード分割によって初回は読み込みさせないようにしました。
意外と バンドルされたファイルを眺めることも大事 です。
番外編:試したけどあきらめた施策
Partytown を自作する
Partytown は ServiceWorker を使って WebWorker とメインスレッドでコミュニケーションを取ります。
元々ぬこぷろでは自前で ServiceWorker のコードを書いていたため、「Partytown と同じようにすでにある ServiceWorker を使って WebWorker 上から DOM を操作できるんじゃね?」と思い、 Partytown の自前実装に挑戦しました。
一応、 WebWorker 上で DOM の操作(例えば getBoundingClientRect
を実行するなど)はできるようにしました。
が、 Proxy
の再起処理地獄などでコードがエグいことになったり、 目に見えるパフォーマンス上の効果もないなどの理由で断念しました。
もし「 WebWorker 上で window
や document
などを使って DOM の操作してみたい!」というギークな方がいれば下記の記事が参考になるのでぜひやってみてください!笑
Webfont を読み込む
Webfont もパフォーマンスチューニングでは厄介な存在です(特に日本語の Webfont は)。
容量の多いリソースをフロントで読み込む必要があります。
Next.js の Font Optimization や Fontsource という npm ライブラリを使ったセルフホスティング も試しましたが、 PageSpeed Insights ではスコア 90 の壁を越えるのは厳しい印象です。
(ちなみに後者の Fontsource
の方が スコアは上がりました)
デザインとパフォーマンスを天秤にかけて、パフォーマンスを優先し、 Webfont はやめました。
Partytown で Atomics や SharedArrayBuffer を使ったモードを機能させる
Partytown は ServiceWorker と WebWorker を使ってスクリプトを読み込むというお話をしました。
実は サイトを「クロスオリジン分離( crossOriginIsolated
)」させると、 Atomics と SharedArrayBuffer を使ってスクリプトを読み込むこともできます。
実際にぬこぷろでも Next.js の middleware を利用し「クロスオリジン分離」させて Partytown に Atomics と SharedArrayBuffer を使ったスクリプト読み込みに変更したところ、 めちゃくちゃ PageSpeed Insights のスコアが爆上がり しました。
Webfont の読み込みをあきらめた話をしましたが、 Webfont を読み込んでも PageSpeed Insights のスコアが 100 点満点 でした。
その代わり自分のサイト以外のリソース、例えば 画像や広告が全部しにます(読み込めなくなります) 😇。
リソース取得先がクロスオリジン分離に対応していないことがほとんどなので、執筆時点では実用化は難しいところです。
Preact に移行する
Preact
は React の軽量版のライブラリです。
ある程度 React でパフォーマンスチューニングをした経験がある方はわかるかもしれませんが、 パフォーマンスの観点で react-dom
の読み込みには苦しめられます 。
この過大な react-dom
の読み込みを回避する方法として、軽量版のライブラリである Preact
への移行があります。
ということでスクリプトサイズを減らすために Preact へスイッチしましたが、無限スクロールのリストのレンダリングが原因不明の挙動に悩まされて、余儀なく React へ戻りました。
締めのご挨拶
パフォーマンスチューニングの実例、いかがだったでしょうか?
本当はもっとコード例をのっけて詳しく説明したい箇所もあるのですが、それだけでまるまる記事埋まりそうだったので端折った説明もありました。
また、私自身も過去の施策を忘れていて、全てを紹介はしきれていない部分もあります。
またこの記事を更新するか、別記事にまとめてようかなぁと思っています。
(ストックいただけると通知します!)
こちらのパフォーマンスチューニングの記事も随時更新していくのでぜひご覧いただければと思います!
Twitter もやっているのでぜひフォローお願いします!
みなさんのサイトの改善の参考になれば!
ご覧いただきありがとうございました!