
DevelopersIO をパフォーマンス面で改修してみた
DevelopersIO の改修を少しずつ進めています。ときには失敗して学んだりもしています。執筆者がやってみたことを気持ちよく発信できて、それを読者が気持ちよく読めるメディアに開発者としてしたいと常日頃思っています。
ブログを仕事に活かしたり、アイディアを見つけたり、ニッチな事象も解消できるような場所を目指して日々精進しています。
さて、読者にとって気持ちよく使えるサイトが何かを一概に考えるのは難しいですが、Core Web Vitals の改善は良い影響を与えます。エンジニアとしても目指すべき指標がはっきりしているので調整のしがいがあります。そこで、どのような変更をして改善したかをまとめてみました。
Core Web Vitals のおさらい
web.dev では以下のように説明されています。ウェブサイトが提供する体験を指標化したもののうち、主要なものがCore Web Vitalsです。ウェブサイトの健康診断のように考えるとわかりやすいでしょう。
Web Vitals は、ウェブで優れたユーザー エクスペリエンスを実現するために重要と考えられる品質シグナルについて、統一的な目安を提供する Google による取り組みです。
中略
サイト所有者は、ユーザーに提供しているエクスペリエンスの質を把握するために、パフォーマンスの専門家である必要はありません。ウェブバイタル イニシアチブは、状況を簡素化し、サイトが最も重要な指標であるCore Web Vitals に集中できるようにすることを目的としています。
https://web.dev/articles/vitals?hl=ja
ウェブサイトにおいて重要なことの 1 つは、ユーザーが快適にサイトを閲覧できることです。読み込みが遅かったり、画面が不安定に動いたりすると、どれだけ良質なコンテンツを提供していても離脱の要因になります。
また、Core Web VitalsはGoogleの検索順位にも影響します。指標が良好であるほど検索結果で上位に表示される可能性が高まるため、Core Web Vitalsの改善は重要な取り組みとなります。
LCP (Largest Contentful Paint)
主要なコンテンツが表示されるまでの時間を測定する指標です。ブラウザを開いたときに大きく表示される画像、動画、テキストブロックが LCP 要素に該当します。これが2.5 秒以内に描写される状態が良好とされています。
通常 img 要素には lazy-loading
などを指定するのが推奨されます。
ただし、LCP 要素は例外です。 lazy-loading
を無効にして、fetchpriority
を high
にするなどで描写までの時間を短くします。
CLS (Cumulative Layout Shift)
画像や動画が読み込まれて表示される際に、読んでいた記事の位置がずれたり、クリックしようとした場所がずれたりした経験はないでしょうか。これが CLS です。影響面積とずれた距離によって算出され、0.1以下が理想的な値とされています。
動的に面積が決まるのは、画像、動画や iframe などの要素が主です。
width/height がわかる場合
これらの属性を指定することで、多くのケースで以下のような挙動となり、レイアウトシフトを防げます。
- CSS で width や height を指定しない場合はその値で表示領域を確保する
- CSS で
width: 100%; height: auto;
のようにしたら元画像の width / height からアスペクト比が計算されて、表示領域を確保する
width/height がわからない場合
aspect-ratio
を指定した上で CSS で width/height を指定しましょう。指定したアスペクト比で計算して表示領域を確保できます。画像の場合、その領域内で表示するようobject-fitなどのスタイリングを施すことで、範囲内に適切にコンテンツを収めることができます。
Interaction to Next Paint (INP)
ユーザー操作に対するレスポンスまでの時間を表す指標です。ただし、DevelopersIO ではフォームなどのユーザーインタラクションが少ないため、本記事では詳細を省略します。
Web Vitals などの改善
ここからは具体的な改善方法について記載します。実際には Chrome DevTools の Lighthouse を実行し、そのスコアをもとに実装を進めています。
そのため、Core Web Vitals だけでなく、アクセシビリティやページ全体のペイロード最適化なども改善スコープに含めています。
Lighthouse の実行結果の推移
かなり数値としては改善しましたが、キャッシュなどで最適化してるインフラのおかげで体感の変化はあまりない可能性があります。インフラの最適化いつもありがとうございます。
まず、Lighthouse 実行結果の概要です。パフォーマンスの改善も嬉しい結果でしたが、個人的にはアクセシビリティが 100 点に達したことが最も嬉しいポイントです。ベストプラクティスについては、third-party cookies など開発側だけでは変更できない要素もあり、100 点には到達できませんでした。これは今後の課題として取り組んでいきます。
項目 | 3 月 | 9 月 |
---|---|---|
Performance | 43 | 74 |
Accessibility | 85 | 100 |
Best Practices | 79 | 96 |
パフォーマンスの詳細は以下の通りです。3 月初旬のコミットと 9 月初旬のコミットで比較しています。一部の指標で若干悪化した箇所もありますが、全体的に改善されました。
まだ改善の余地はあるものの、全体として許容できる範囲に収まったと考えています。
指標 | 3 月 | 9 月 |
---|---|---|
First Contentful Paint | 1.6 s | 2.0 s |
Largest Contentful Paint | 23.9 s | 6.4 s |
Total Blocking Time | 1,040 ms | 120 ms |
Cumulative Layout Shift | 0.193 | 0 |
Speed Index | 1.6 s | 2.3 s |
計測に関する注意点
実際の計測は、ローカル環境でモバイル設定を使用し、トップページに対して3回実行した平均値を算出しています。
Lighthouse のパフォーマンス結果は実行ごとにばらつきが生じるため、複数回実行して平均値を取る方法を採用しました。結果はあくまでも参考値として捉えることをお勧めします。
コードの変更量
コミットハッシュを比較して、AI に変更量を調べさせたら下記のように言っていました。コードをかなり書いてた気がするのですが減っていたらしいです。コードの変更には Web Vitals の改善以外も含まれており、変更量によってパフォーマンスが変わるわけではないのであくまで参考にとどめてくださいね。
追加: 265,912文字
削除: 380,195文字
正味: -114,283文字(削減)223ファイルが変更され、7,418行追加、11,859行削除されています。
LCP 要素の最適化
DevelopersIO では、LCP 要素がすべて画像となっています。そして Next.js を活用しています。Image コンポーネントに priority
を指定すると preload
され、優先度が高いと判断されます。結果として lazy-loading
が無効になり、 fetchpriority
が high
になります。
<Image src="" … priority />
これをページ全体に適用すればよさそうです。DevelopersIOのページは以下のように分類できます。
- トップページ
- 記事本文ページ
- 特集カテゴリの一覧ページ
- タグ・全記事の一覧ページ
- 著者ページ
トップページが最も特殊で、ブラウザ幅によってLCP要素が異なると計測されることがあります。
- ブラウザ幅が縦横とも十分な場合:記事一覧の1つ目の記事画像がLCP要素
- スマートフォンなど横幅が狭い場合:記事一覧の1つ目か2つ目の記事画像、またはヘッダー広告の画像
LCP 要素への対処を 1 つの要素に絞る必要はないため、該当しうる要素を変更の対象としました。
記事本文では、サムネイル画像が LCP 要素です。要素が 1 つなので、あまり悩むところはありませんでした。
特集カテゴリの一覧ページでは、特集カテゴリの画像が LCP 要素です。こちらも悩むところはありません。
タグ・全記事の一覧ページでは、スマートフォンの場合は記事画像が LCP 要素になります。PC では右端の広告になります。ただし、この要素はメインではなく Suspense でスケルトン表示する実装にしているため、こちらには何も付与しないことにしました。キャッシュの都合で streaming されてスケルトンから変わるところが見られない可能性はありますが、内部的にはそのような実装です。
著者ページもやや複雑です。スマートフォンの場合、概要文が長いとそれが LCP 要素になります。著者のプロフィール画像になることもあれば、記事一覧の先頭画像になることもあります。そのため、プロフィール画像と記事の先頭に対して処理を施しました。
CLS の抑制
PC 表示の記事ページとスマートフォンで閲覧した時のトップページで CLS が発生していました。
前者はサイドメニューの width が指定されているのに、Image 要素に適切に属性していないのが原因でした。コメントしている箇所のように width と height を指定することで回避ができます。
<aside className="lg:w-64">
{promoLinks.map((link) => {
return (
<div key={link.title}>
{link.link && (
<Link target={link.openNewTab ? '_blank' : ''} href={link.link}>
<Image
src={link.image}
alt={link.title}
width={0}
// width={256}
height={0}
// height={624}
className="w-full h-auto object-cover"
/>
</Link>
)}
</div>
)
})}
トップページで発生していた CLS はヘッダー広告の高さが取得できず、ずれが生じていました。
<div className="overflow-x-auto">
<div className="flex mx-auto w-fit">
{subHeaderBannerPromoLinks.map((banner) => {
return (
<Link
href={banner.link}
key={banner.title}
className="block flex-none w-full lg:w-auto"
>
<Image
src={banner.image}
alt={banner.title}
width={670}
height={280}
className="h-auto w-full lg:h-36 lg:w-fit"
/>
</Link>
)
})}
</div>
</div>
これを下記のように変更しました。
親要素の width を調整して、子コンポーネントの width / height を明示することで回避ができました。
<div className="overflow-x-auto">
<div className="flex mx-auto w-full lg:w-fit">
{subHeaderBannerPromoLinks.map((banner, index) => {
return (
<Link
href={banner.link}
key={banner.title}
className={cn(
'block flex-none h-auto w-full',
'lg:h-36 lg:w-auto',
)}
>
<Image
src={banner.image}
alt={banner.title}
width={670}
height={280}
className="h-auto w-full lg:h-full lg:w-auto"
/>
</Link>
)
})}
</div>
</div>
CLS は画像サイズが予想できる場合は親と子にサイズ指定をすることで簡単に回避できます。
そしてユーザー体験を簡単に改善できるのでできる限り対処しましょう。
アクセシビリティの改善
アクセシビリティの改善にも力を入れました。ユーザーの投稿内容があるので制御は難しいですが、できる範囲で下記のような対応しました。
- button 要素、a 要素:子が svg のみのアイコンの場合は、操作内容または遷移先が一意に分かる aria-label を付与する
- 各種 SNS ロゴリンク、ハンバーガーメニュー、モード切り替えボタンなど
- キーボードで各要素に適切にアクセスできるようにする
- 通常のリンクは対応不要
- ハンバーガーメニューに下記対応を入れる
- ESC キーで閉じる
- Tab キーで移動する
- フォーカストラップで背景にフォーカスが移らないようにする
- img 要素のalt 属性は必ず設定する
- スクリーンリーダーで読み上げが必要な画像には、alt テキストを指定する
- 読み上げ不要な画像には、空文字を渡してスクリーンリーダーに読まない要素であることを明示する
- 著者アイコンと著者名の並びでは、横に画像のラベルとなる要素があるため、空文字を指定する
- 装飾目的のみの画像には空文字を指定する
他にもデザイン全体でコントラスト比が低い箇所の改善などもしています。誰もが使えるサイトを目指すために対処をしていきましょう。
画像の最適化
画像はパフォーマンスのあらゆる指標に影響します。たとえ LCP 要素を適切に設定していても、元画像が大きければ読み込みは遅くなります。画像点数に比例してネットワーク転送量も増えます。DevelopersIO では CDN により初期表示が速くても、画像が重いと描画が遅れてユーザー体験を損ないます。改善余地が大きく、優先して取り組むべき課題でした。
DevelopersIO では Cloudinary/Contentful/S3 に画像を保存しています。前者 2 つはサーバー側で最適化が可能です。S3 は 2023 年以前に利用していた画像で、今回は対象外としました。
Next.js の Image コンポーネントでは ImageLoader を指定できます。これを使うと Next.js の内蔵最適化ではなく、外部サーバー側の最適化を利用できます。今回の実装は次のとおりです。
- hostname が images.ctfassets.net(Contentful)の場合:クエリパラメータで width と format を指定
- hostname が devio2024-media.developers.io(Cloudinary)の場合:パスに width/format/quality を指定
- それ以外:クエリパラメータに w を付与(Next.js の警告回避のため)
'use client'
import type { ImageLoader } from 'next/image'
export const loader: ImageLoader = ({ src, width }) => {
const url = new URL(src)
if (url.hostname === 'images.ctfassets.net') {
return `${src}?w=${width}&fm=webp`
}
if (
url.hostname === 'devio2024-media.developers.io' &&
url.pathname.includes('image/upload')
) {
const prefix = 'image/upload'
const id = url.pathname.split('image/upload/')[1]
const path = `/${prefix}/f_auto,q_auto,w_${width}/${id}`
url.pathname = path
return url.toString()
}
return `${src}?w=${width}`
}
元画像にもよりますが、これらの対応でネットワークトラフィックを大きく削減できました。パフォーマンス指標の改善は主にこの効果によるものです。特に記事カードの著者画像は 20px 程度のサイズしか必要ないのにフルサイズを取得していた部分は顕著に削減につながりました。また、画像の転送量が減ることで CDN コストの最適化という副次的な効果も得られています。
一方で、まだ最適化の余地はあります。現状はデバイス幅に応じた width の調整をしておらず、記事カードの画像は最大 640px を固定で取得しています。PC やスマートフォンではもっと小さい画像で問題ないため、Image コンポーネントの sizes を用いた調整を検討中です。今回はサイト全体に一括で最適化を適用したこともあり、Cloudinary の利用状況や前段の CloudFront の状況を観察しつつ、まずは最もシンプルな実装に留めています。
バンドルサイズの削減
特に記事ページでほとんどすべてのコンポーネントが Client Components だったので Server Components に差し替えました。できる範囲で状態や副作用を末端に寄せました。これだけでだいぶクライアントサイドに配布されるサイズが小さくなります。また、Client Components で DOM への処理をしている箇所があります。具体的には目次になります。そこで node-html-parser を利用していたので Web 標準の DOMParser に切り替えました。
ヘッダーにモードの切り替えボタンを追加したり、next-intl の導入などがあったために RootLayoutのサイズはやや増加していますが、それ以外の部分は大きく改善されています。
3 月
Route (app) Size First Load JS
┌ ƒ / 3.09 kB 123 kB
├ ƒ /articles/[slug] 503 kB 647 kB
├ ƒ /author/[slug] 1.58 kB 101 kB
├ ƒ /pages/[page] 206 B 120 kB
├ ƒ /referencecat/[slug] 206 B 120 kB
├ ƒ /tags/[slug] 147 B 134 kB
+ First Load JS shared by all 87.3 kB
├ chunks/23-9c37f8ec367d6331.js 31.6 kB
├ chunks/fd9d1056-05a49ed17ec6dd44.js 53.6 kB
└ other shared chunks (total) 2.02 kB
9月
Route (app) Size First Load JS
┌ ƒ / 861 B 130 kB
├ ƒ /articles/[slug] 2.42 kB 139 kB
├ ƒ /author/[slug] 423 B 126 kB
├ ƒ /pages/[page] 1.28 kB 127 kB
├ ƒ /referencecat/[slug] 1.28 kB 127 kB
├ ƒ /tags/[slug] 1.28 kB 127 kB
+ First Load JS shared by all 102 kB
├ chunks/255-5698b1ebe3a4fb99.js 45.5 kB
├ chunks/4bd1b696-409494caf8c83275.js 54.2 kB
└ other shared chunks (total) 1.95 kB
さいごに
DevelopersIO の Core Web Vitals 改善に取り組んだ結果、Lighthouse のスコアは大きく向上しました。特にアクセシビリティで 100 点を達成できたことは、より多くの方に快適にコンテンツをお届けできる環境が整ったことを意味しており、大変嬉しく思っています。
今回の改善では、LCP 要素の最適化、CLS の抑制、画像の最適化、バンドルサイズの削減など、さまざまな施策を実施しました。一つひとつは小さな変更でも、積み重ねることで大きな効果が得られることを実感しています。
ただし、まだ改善の余地は残されています。画像サイズの最適化やベストプラクティススコアの向上など、今後も継続的に取り組んでいく予定です。
本記事が、同じように Web サイトのパフォーマンス改善に取り組まれている方の参考になれば幸いです。
DevelopersIO を今後ともよろしくお願いいたします。