Lighthouseの点数を上げようとして、転送量を564KBから350KBまで削ったのに、スコアが94点から92点に下がったことがあります。
軽くしたのに、遅くなる。そんなことがあるのか、と思いました。
別のサイトでは、レポートが「LCP要素はこれです」と指した場所を直したのに、まったく速くなりませんでした。犯人は別にいました。
測り方を間違えると、直す場所を間違えます。 実際に踏んだ落とし穴を4つ書きます。
落とし穴1:計測に使うサーバーがスコアを歪める
ローカルで計測するとき、手軽なのでこれを使いがちです。
python -m http.server 8000
これがよくありませんでした。理由は3つあります。
- gzip圧縮がかからない(転送量が本番より大きく出る)
- キャッシュのヘッダーが付かない(キャッシュ系のauditで減点される)
.webpのMIMEタイプを判別できない
3つめが特に厄介でした。.webp が application/octet-stream として返されるので、Lighthouseがそれを画像として認識しません。結果、画像まわりのauditが軒並みおかしくなります。
本番のサーバーでは減点されない項目で、ローカルでだけ減点される。 これに気づかないと、直す必要のないところをずっと直すことになります。
対策は単純で、gzipとCache-Controlを返して拡張子からMIMEを引く簡易サーバーを用意することです。50行ほどのPythonで書けます。これに変えたら、本番の .htaccess を効かせたときとほぼ同じスコアになりました。
なお、PageSpeed Insights のAPIはキーなしだと429(リクエスト過多)で止まります。npx lighthouse をローカルで叩くのがいちばん確実です。
落とし穴2:LCP要素はレポートを鵜呑みにしない

Lighthouseのレポートには「Largest Contentful Paint element」として、対象の要素が表示されます。ここを直せば速くなる、と思いますよね。
あるサイトでは、レポートがスプラッシュのテキストを指していました。ところが実際のブラウザで測ってみると、LCPはヒーロー画像の232msでした。まったく違う要素です。
実測はこれで取れます。
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
console.log(e.startTime, e.element);
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
なぜ食い違うのか。理由が分かると納得できました。
Lighthouseはネットワークを絞った状態で計測します。 すると画像の到着が遅れます。その間に描画されたテキストが「そのときいちばん大きかった要素」として残る。だからテキストがLCPとして報告されます。
つまりレポートが指しているのは、絞った環境でたまたまLCPになった要素であって、実際のユーザーが見ている犯人ではないことがあります。
本当の犯人は、たいてい帯域を食っている別のリソースです。フォントだったり、先読みしている画像だったりします。
落とし穴3:転送量とスコアは別物

冒頭の話です。564KBから350KBまで削ったのに、94点から92点に下がりました。
Lighthouseの既定の計測は「シミュレーテッド・スロットリング」といって、実際に通信を遅くするのではなく、計測結果から遅い回線での挙動を推定しています。
この推定は、帯域よりもメインスレッドの処理時間や描画コストに強く反応する場面があります。だから転送量を減らしても、その分だけJSの実行やレイアウトの計算が重くなっていれば、スコアは下がります。
ここで判断を間違えると危険です。
スコアを上げるために、実ユーザーの利益(転送量)を捨てるという選択をしてしまう。350KBは564KBより確実に速く届きます。使う人にとっては良くなっている。それを2点のために戻すのは、本末転倒です。
なので、うちは転送量とスコアを別々に記録するようにしました。両方を見て、どちらも悪化していなければ良し、とします。
そもそもスコアは、同じ構成でもブレます。フォントの配信タイミングが揺れるだけで、同じサイトで56点と90点を観測したこともあります。1回の数字で判断しないほうがいいです。
落とし穴4:fullPageScreenshotでレイアウトを判定しない
Lighthouseのレポートには、ページ全体のスクリーンショットが付きます。ここでレイアウト崩れを確認したくなりますが、これは信用できません。
Lighthouseは全体を撮るために、viewportの高さをページ全体の高さまで広げます。あるサイトでは13,708pxまで広がっていました。
そうすると何が起きるか。min-height: 100svh を指定したヒーローが、その13,708pxいっぱいまで伸びた絵になります。実際のブラウザではそんな表示になりません。
レポート内の nodes が持っている座標も同じ値なので、そこから位置を判断するのも危険です。
レイアウトの確認は、実際の幅で表示して測るしかありません。うちはiframeに実幅(390 / 768 / 1280)で読み込むか、puppeteerでビューポートを指定して撮ります。
ひとつ注意があって、getBoundingClientRect は transform適用後の値を返します。ヒーローに scale(1.08) のアニメーションが掛かっている最中に測ると、1.08倍の数字が返ってきます。崩れているように見えて、崩れていない。computedStyleと併せて読むのが安全です。
結局どう切り分けるか
4つ書きましたが、実際に詰まったときの手順はこれです。
1. observedFCP(実測)とsimulated値の差を見る。
metricsのauditには、実測値と推定値の両方が入っています。実測は速いのに推定が遅いなら、そのリソースが「重要なもの」として推定に組み込まれているということです。フォントとサードパーティのJSが、だいたいここに来ます。
2. 疑わしい要素を外したテスト用HTMLを作って、二分探索する。
_test_1.html、_test_2.html と作って、要素を半分ずつ外しながら計測します。原始的ですが、これがいちばん速いです。推測で直すより結果的に短時間で終わります。
まとめ
- 計測サーバーはgzip・Cache-Control・MIMEを返すものを使う
- LCP要素はレポートではなく PerformanceObserver で確認する
- 転送量とスコアは別々に評価する。スコアのために転送量を増やさない
- レイアウトの確認に fullPageScreenshot を使わない
- 詰まったら、実測と推定の差を見て、二分探索する
数字が出るものは信用してしまいがちですが、その数字がどう作られているかを知らないと、逆方向に走ります。測ってから決める、の前に「どう測るか」を決める必要がありました。