超SEO対策 —— PageSpeed Insights で限界まで点数を上げた記録
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 = 1079px → 1080w が選ばれるようになる。
DPR 別にどのファイルが選ばれるか:
| DPR | 必要 px | 選択されるファイル |
|---|---|---|
| 1.0 | 672 | hero-1x.webp (43 KB) |
| 1.44 | 968 | hero-968.webp (72 KB) |
| 1.6 | 1075 | hero-sm.webp (85 KB) |
| 1.79 | 1203 | hero-md.webp (99 KB) |
| 2.0 | 1344 | hero.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" にしておくと通常画面描画に使われないため、ブラウザは非同期でダウンロードする。ロード完了後に onload で media='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 4h | GitHub Pages の仕様。CDN 移行でのみ解決可能 |
| Google Fonts 60KB | フォント CSS には使わない unicode-range の @font-face が大量に含まれる。自己ホストで解決可能 |
| GTM の未使用 JS 62KB | GA4 に必須。削れない |
「100点を目指す」と言っておきながら、キャッシュと外部スクリプトの問題は構造的に解決できない。PageSpeed Insights のスコアは「測定のたびに変動する推定値」であることも考慮すると、自社コードでできることをやりきった状態が現実的なゴールだった。
振り返り
最初は「OGP タグを入れるだけでしょ」くらいの温度感だったが、PageSpeed Insights を繰り返し回すと次々と問題が出てきた。
特に学びになったのは:
- srcset は数字だけ書いても意味がない —
sizesとの組み合わせで初めて機能する - ResizeObserver と requestAnimationFrame は別物 — rAF はタイミングの話、RO はレイアウト後フックの話
- GA4 自身がリフローを起こす — ページ描画前に読み込むと PageSpeed Insights のスコアに影響する
最終的に「コードで対処できる問題」はほぼ全部対応した。残りはホスティング選択の問題。
最終スコア
モバイル

デスクトップ

Live with a Smile!
