メインコンテンツまでスキップ

超SEO対策 —— PageSpeed Insights で限界まで点数を上げた記録

· 約8分
Rintaro Nakahodo
NLP Researcher · Engineer · Creator

SEO対策ほぼゼロから PageSpeed Insights ALL 100 にした記録。

画像WebPで95%削減、フォント7.8MB撤去、JSON-LD/OGP……コードで対処できることを全部やりきった。 Cloudflareのメール難読化トラップなど、意外なハマりどころも書いてます。


背景

サイト構成

今回対象にしたのは静的 HTML で作った一枚ページのポートフォリオ。ビルドステップはなく、index.html と CSS・JS・画像を GitHub リポジトリに置いて GitHub Pages で公開している。カスタムドメインは DNS の CNAME レコードで GitHub Pages に向けている。

リポジトリの main ブランチ → GitHub Pages → example.com

ファイルを push すれば数秒で反映される手軽さがある一方、CDN のキャッシュ TTL が 10 分〜数時間に固定されており、配信設定を細かく制御できないという制約もある。後述する「残った課題」はほぼこの制約から来ている。

SEO の初期状態

デザインにはこだわっていたが、SEO は完全に放置していた。

  • <meta name="description"> なし
  • OGP・Twitter Card なし
  • canonical なし
  • 画像は PNG のまま、width/height 属性もなし
  • <h1> タグは CSS で代替していた(実際には <div>

PageSpeed Insights を初めて計測したら、モバイルでパフォーマンスが赤。メタタグやOGPなどをひととおり入れた後でも、Performance スコアは 32。遅さの大半は画像サイズだった。

改善前のスコア


Step 1: SEO の基礎を整える

まず <head> に抜けているものを全部追加した。

メタタグ

<title>Your Name — Your Title</title>
<meta name="description" content="あなたの名前のポートフォリオ。職種・専門領域の説明をここに書く。">
<link rel="canonical" href="https://example.com/">
<link rel="alternate" hreflang="ja" href="https://example.com/">
<link rel="alternate" hreflang="x-default" href="https://example.com/">

OGP / Twitter Card

<meta property="og:type" content="website">
<meta property="og:title" content="Your Name — Your Title">
<meta property="og:image" content="https://example.com/img/ogp.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">

OG 画像は summary_large_image のために 1200px 以上・2:1 比率のものを選ぶ必要がある。ポートフォリオ画像の中で条件を満たすものを使った。

JSON-LD(構造化データ)

Person スキーマと WebSite スキーマを両方追加した。

{
"@context": "https://schema.org",
"@type": "Person",
"name": "Your Name",
"url": "https://example.com/",
"jobTitle": "Your Job Title",
"sameAs": [
"https://github.com/your-username",
"https://twitter.com/your-username",
"https://www.linkedin.com/in/your-profile"
]
}

H1 / H2 の整備

<div> で代替していたセクション見出しを <h2> タグに変更。ヒーローエリアの名前表示は <h1> に変更した。見た目はまったく変わらないが、クローラーへの意味付けが変わる。

サイトマップ

Docusaurus がブログ側の /blog/sitemap.xml を自動生成してくれているが、ルートに sitemap.xml がなかった。sitemapindex 形式で作成した。

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/blog/sitemap.xml</loc>
</sitemap>
</sitemapindex>

Step 2: 画像を徹底的に最適化する

PageSpeed が最も強く警告してくるのが画像だった。

WebP 変換

全 6 枚の PNG/JPG を WebP に変換した。sips は macOS の標準ツールだが WebP 出力に対応していないので cwebp を使う。

cwebp -q 82 img/hero.png -o img/hero.webp

変換前後の合計サイズ:

変換前変換後
約 6.4 MB約 344 KB

95% 削減。これだけでも体感速度が大きく変わる。

レスポンシブ画像(srcset / sizes)

WebP 変換だけでは不十分で、デバイス DPR に応じて最適なサイズの画像を配信する必要がある。PageSpeed Insights はデバイスの DPR(1.44 〜 1.75)を使って「このサイズが必要」と計算してくる。

<picture>
<source
srcset="img/hero-1x.webp 672w,
img/hero-968.webp 968w,
img/hero-sm.webp 1080w,
img/hero-md.webp 1202w,
img/hero.webp 1344w"
sizes="(max-width: 640px) 617px, 672px"
type="image/webp"
>
<img src="img/hero.png" alt="Hero image"
width="1344" height="748">
</picture>

sizes 属性は「このビューポート幅のとき、画像は何 px で表示されるか」を教えるもの。固定値を書いていると、DPR が 1.75 のモバイルで 672 × 1.75 = 1176px のファイルが必要になり、隣の 1202w が選ばれてしまう。617px と宣言することで、617 × 1.75 = 1079px1080w が選ばれるようになる。

DPR 別にどのファイルが選ばれるか:

DPR必要 px選択されるファイル
1.0672hero-1x.webp (43 KB)
1.44968hero-968.webp (72 KB)
1.61075hero-sm.webp (85 KB)
1.791203hero-md.webp (99 KB)
2.01344hero.webp (116 KB)

Step 3: 強制リフローを潰す

PageSpeed の「強制リフロー」警告は、JavaScript がレイアウト計算後にすぐ offsetWidth などを読み取ることで、ブラウザに再計算を強制するもの。

Before: requestAnimationFrame での読み取り

requestAnimationFrame(() => {
const stageW = stage.offsetWidth; // ← ここで強制リフロー
...
});

rAF 内でも、直前に DOM 書き込みがあれば読み取り時に再計算が走る。

After: ResizeObserver でキャッシュ

let stageW = 0;
const ro = new ResizeObserver(entries => {
stageW = entries[0].contentRect.width; // ← レイアウト後に受け取る(リフロー不要)
...
});
ro.observe(stage);

ResizeObserver のコールバックはブラウザのレイアウトフェーズに呼ばれるため、offsetWidth を読まずにサイズを取得できる。アニメーションループ内での DOM 読み取りがゼロになった。


Step 4: レンダリングブロックを排除する

Google Fonts の非同期化

<!-- Before: レンダリングブロック -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?...">

<!-- After: 非同期読み込み -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?...&display=swap"
media="print" onload="this.media='all'">

media="print" にしておくと通常画面描画に使われないため、ブラウザは非同期でダウンロードする。ロード完了後に onloadmedia='all' に変更して適用する。

lite-yt-embed.css のインライン化

外部 CSS ファイルは HTML の解析をブロックする。2.3KB の lite-yt-embed.css を <style> タグにインライン化することでクリティカルパスから除外した。

クリティカルパスの最大待ち時間: 537 ms → 0 ms(この CSS に限る)

GA4 の遅延読み込み

GA4 のスクリプトがモバイルで 53ms の強制リフローを起こしていることが PageSpeed Insights で判明した。window.load 後に動的挿入することで初期描画への干渉をなくした。

window.addEventListener('load', function() {
var s = document.createElement('script');
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
s.async = true;
document.head.appendChild(s);
});

Step 5: アクセシビリティ

PageSpeed Insights のアクセシビリティ監査で、スクロールティッカーのテキストが低コントラストと判定された。

/* Before: コントラスト比 3.6:1(WCAG AA 4.5:1 未満)*/
.ticker-item {
color: var(--accent); /* #c8a96e */
opacity: 0.6;
}

/* After: コントラスト比 約 7:1(WCAG AAA 相当)*/
.ticker-item {
color: var(--accent);
opacity: 0.85;
}

opacity: 0.6 で暗い背景 #0f0f1a に対するコントラストが 3.6:1 まで落ちていた。0.85 に上げることで 7:1 を確保した。


残った課題

いくら最適化しても、GitHub Pages がホストである限り変えられないものがある。

課題理由
キャッシュ TTL 4hGitHub Pages の仕様。CDN 移行でのみ解決可能
Google Fonts 60KBフォント CSS には使わない unicode-range の @font-face が大量に含まれる。自己ホストで解決可能
GTM の未使用 JS 62KBGA4 に必須。削れない

「100点を目指す」と言っておきながら、キャッシュと外部スクリプトの問題は構造的に解決できない。PageSpeed Insights のスコアは「測定のたびに変動する推定値」であることも考慮すると、自社コードでできることをやりきった状態が現実的なゴールだった。


振り返り

最初は「OGP タグを入れるだけでしょ」くらいの温度感だったが、PageSpeed Insights を繰り返し回すと次々と問題が出てきた。

特に学びになったのは:

  • srcset は数字だけ書いても意味がないsizes との組み合わせで初めて機能する
  • ResizeObserver と requestAnimationFrame は別物 — rAF はタイミングの話、RO はレイアウト後フックの話
  • GA4 自身がリフローを起こす — ページ描画前に読み込むと PageSpeed Insights のスコアに影響する

最終的に「コードで対処できる問題」はほぼ全部対応した。残りはホスティング選択の問題。


最終スコア

モバイル

最終スコア(モバイル)

デスクトップ

最終スコア(デスクトップ)

Live with a Smile!