今天要解的問題
2026-10-15 拿 PageSpeed Insights 量 SEO 健檢那篇,手機版 Performance 只有 75,FCP 2.8 s、LCP 4.6 s、TBT 130 ms,建議清單裡針對 JavaScript 有兩項建議:
- Reduce unused JavaScript,估計可省 72 KiB
- Legacy JavaScript,估計可省 11 KiB
我想說我的 script 都是小檔案,最可疑的就是 Google Analytics 的 gtag/js。但這只是我猜的,所以動手之前,先去報告的明細看清楚到底是誰占了這些位元組。

技術背景
Lighthouse 怎麼算「沒用到的 JavaScript」
PageSpeed Insights 底下跑的是 Lighthouse。這次用的是本地的 [email protected],以下直接看它的原始碼:
core/audits/byte-efficiency/unused-javascript.js的標題是 Reduce unused JavaScript。說明的建議是減少沒用到的 JavaScript,並且延後載入 script,等真的需要時再載。- 同一個檔案裡有
UNUSED_BYTES_IGNORE_THRESHOLD = 20 * 1024:一個 script 沒用到的部分少於 20 KiB 就不列出來。 legacy-javascript.js的標題是 Avoid serving legacy JavaScript to modern browsers,抓的是 polyfill 和轉譯產生、現代瀏覽器其實不需要的程式碼。
拆開 main 的報告,結果兩項各只有一個來源:

gtag/js 傳輸 179,935 bytes,其中 74,093 bytes(41.2%)在載入期間沒有執行,這就是那 72 KiB。但 Legacy 那 10 KiB 反而是 static.cloudflareinsights.com/beacon.min.js(10,414 bytes),也就是 Cloudflare Web Analytics;gtag/js 在這一項是 0。
Lighthouse 什麼時候停止記錄
同一版的 core/config/constants.js,預設值是:
pauseAfterFcpMs: 1000,
pauseAfterLoadMs: 1000,
networkQuietThresholdMs: 1000,
cpuQuietThresholdMs: 1000,
所以 load 事件之後,Lighthouse 至少再等 1 秒,網路和 CPU 也都要安靜 1 秒才收工,load 之後馬上發出的請求一樣會被記到。
想法與取捨
保留 GA,接受漏掉一點流量
一開始列了兩條路:
- GA 留著,延後到
load之後、瀏覽器閒下來再載入。 - GA 拿掉,改用 Cloudflare Web Analytics。 它已經在正式站跑了,CSP 也早就放行
static.cloudflareinsights.com,前置條件都有。但前提是我沒在看 GA 的報表。
最後還是走第一條:GA 留著,延後載入。
代價是看一眼就離開的訪客,GA 可能來不及記到,我覺得應該不會到很誇張。
量完才出現的第三條路
改完重量才發現,Lighthouse 量到的 unused JS 一點都沒少(原因在「踩到的坑」)。若真的要讓這個數字下降,只能讓 Lighthouse 完全看不到 gtag,例如等讀者第一次點擊或捲動才載入。但這導致進來看完一篇、什麼都沒點就離開的讀者,GA 完全記不到,代價比「看一眼就離開的漏掉一點」大太多了,所以先不做,留著當之後的選項。
實作
網站的改動落在 4 個檔案:hugo.yml、layouts/partials/google_analytics.html、layouts/partials/head.html,以及取代原本 gtag.js 的 assets/js/ga-deferred.js。CSP 維持原樣,googletagmanager.com 和 google-analytics.com 早就放行了。
追蹤碼搬到自己的 params
-googleAnalytics: 'G-LN78RHTEX0'
-
minify:
# ...
params:
social: true
env: production # to enable google analytics, opengraph, twitter-cards and schema.
+ # ...(說明註解略)
+ googleAnalyticsID: 'G-LN78RHTEX0'
頂層的 googleAnalytics 是 Hugo 內建的設定(模板裡讀作 site.Config.Services.GoogleAnalytics.ID)。PaperMod 原版的 head.html 直接呼叫 Hugo 內建的 _internal/google_analytics.html,讀的就是這個 key,輸出的是 <head> 裡的 gtag/js。把追蹤碼搬到自己的 params.googleAnalyticsID,就只有自己的 partial 會讀它。
partial 只載一支自己的 script
{{- if not site.Config.Privacy.GoogleAnalytics.Disable }}
{{- with site.Params.googleAnalyticsID }}
{{- $ga := resources.Get "js/ga-deferred.js" | minify | fingerprint }}
<script src="{{ $ga.RelPermalink }}" integrity="{{ $ga.Data.Integrity }}" crossorigin="anonymous" defer
data-id="{{ . }}" data-dnt="{{ site.Config.Privacy.GoogleAnalytics.RespectDoNotTrack }}"></script>
{{- end }}
{{- end -}}
原本這裡有兩個 <script>:一個 async 的 gtag/js,一個放設定的小檔。現在只剩後者。它照網站的 CSP 規則用 fingerprint 載入、帶 integrity,追蹤碼和 Do Not Track 設定用 data- 屬性傳進去。
先排隊,load 之後再插入 gtag/js
(function () {
var cfg = document.currentScript.dataset;
var dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
if (!cfg.id || (cfg.dnt === 'true' && (dnt == '1' || dnt == 'yes'))) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function () { dataLayer.push(arguments); };
gtag('js', new Date());
gtag('config', cfg.id);
function inject() {
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(cfg.id);
document.head.appendChild(s);
}
function later() {
if ('requestIdleCallback' in window) requestIdleCallback(inject, { timeout: 3000 });
else setTimeout(inject, 1);
}
if (document.readyState === 'complete') later();
else window.addEventListener('load', later);
})();
前半段跟原本一樣:先建好 dataLayer,gtag('config', …) 只是把指令推進陣列排隊,這時候還不需要 gtag/js。後半段是這次新增的:等 load 之後,用 requestIdleCallback 在瀏覽器空閒時才插入 gtag/js,它載入後會把排隊的指令送出去。timeout: 3000 讓瀏覽器一直忙的時候也最多等 3 秒;不支援 requestIdleCallback 的瀏覽器就用 setTimeout。最後的 readyState 判斷,是為了 load 已經發生過才執行到這裡的情況。
只有正式 build 才載入
{{- /* ...(說明註解略) */}}
{{- if hugo.IsProduction }}
{{- partial "google_analytics.html" . }}
{{- end }}
{{- if hugo.IsProduction | or (eq site.Params.env "production") }}
{{- template "partials/templates/opengraph.html" . }}
GA 改成只看 hugo.IsProduction,OpenGraph 這些 SEO 標籤維持原本的條件。為什麼要拆開,見「踩到的坑」。
驗證
$ make clean && hugo --minify
$ grep -c 'googletagmanager.com/gtag' public/index.html
0
$ grep -c 'ga-deferred' public/index.html
1
在分支的 preview 打開 SEO 健檢那篇,用 Performance API 看時間:loadEventEnd 1238 ms,gtag/js 1266 ms 才請求,pageview(google-analytics.com/g/collect)在 1591 ms 送出,console 沒有 CSP 或 integrity 錯誤。hugo server 開出來的頁面則沒有 GA 的 <script>、沒有任何 Google 的請求,window.dataLayer 是 undefined。
GA 後台的 Realtime 有沒有收到,要部署到正式網域之後才看得到。
踩到的坑
hugo server 也在送 GA
要做「本地不載入 GA」時讀了 head.html,才發現本地其實一直都有載。原本的條件是:
{{- if hugo.IsProduction | or (eq site.Params.env "production") }}
{{- partial "google_analytics.html" . }}
但 hugo.yml 裡寫著 env: production # to enable google analytics, opengraph, twitter-cards and schema.,params.env 永遠是 production,所以 or 永遠成立。這導致我本地開 hugo server 寫文章時,每看一頁都在送 pageview 到 GA,等於自己一直在灌自己的流量。拆成兩個條件之後,GA 只看 hugo.IsProduction(hugo server 是 development 環境,不成立),SEO 標籤不受影響。
延後了,unused JS 卻一點都沒少
改完量第一次,gtag 確實排到 load 之後了,但「Reduce unused JavaScript」還是 72.5 KiB,跟改前的 72.4 KiB 一模一樣,明細也還是只有 gtag/js 一項。
對照技術背景裡的預設值就說得通了:trace 裡 gtag 是在 observedLoad 之後 0–16 ms 發出請求,完全落在 Lighthouse 還在記錄的那段時間;而 gtag 這個檔案本身沒變,沒用到的比例當然也沒變。延後載入改變的是 gtag 什麼時候跟頁面搶資源,不是它有多大。
10 KiB 的 legacy JS 根本不是 GA
第一次的前後對照,改前量 kiwi-walk.pages.dev(main 的正式部署),改後量分支的 preview,各跑 3 次取中位數。結果很漂亮:Performance 94 → 99、FCP 2.3 → 1.2 s、LCP 2.3 → 1.7 s,Legacy JavaScript 10.2 KiB → 0,我一開始就全部算成這次改動的功勞了。
結果寫這篇時回頭拆 legacy 那一項的明細,才發現那 10 KiB 是 beacon.min.js。kiwi-walk.pages.dev 有 Cloudflare 注入的 Web Analytics beacon,分支的 preview 沒有,所以 legacy 歸零只是兩個 host 不同,GA 那邊什麼都沒減。連 LCP 的進步也說不準是 GA 帶來的,因為前後量的根本不是同一個環境。
只好重量一次,這次讓兩邊只差這一個改動:把 origin/main 和這個分支各自用 hugo --minify 做 production build,放在同一種本地 server 上(兩邊都沒有 beacon),交錯跑兩輪。SEO 健檢那篇每邊 6 次,首頁和一篇大封面的文章每邊 2 次。PageSpeed 的 API 那天的額度已經用完,所以全程用本地 Lighthouse;本地分數跟 PageSpeed 的不能直接比,只拿本地跟本地比。
| 指標(SEO 健檢那篇,中位數) | main | 改後 |
|---|---|---|
| window load(observed) | 1697 ms | 602 ms |
| FCP / LCP | 1.50 s / 1.80 s | 1.21 s / 1.80 s |
| Unused JS / Legacy JS | 72.2 KiB / 0 | 72.5 KiB / 0 |
首頁的 FCP 是 1.20 → 1.05 s,大封面那篇是 1.35 → 1.28 s;LCP 分別是 2.17 → 2.25 s、1.65 → 1.58 s,都在誤差內。同條件比下來,真的變了的只有 load 提早 1 秒多、FCP 小幅改善,LCP 根本沒動。
小結
GA 的 gtag/js 現在等頁面載完、瀏覽器閒下來才插入,而且只有正式 build 會載,本地寫文章不再送 pageview。同條件比下來,window load 提早約 1.1 秒,FCP 小幅改善,LCP 沒變。
PageSpeed 那兩條建議的數字則都還在:72 KiB 的 unused JS 因為 Lighthouse 在 load 之後還會繼續記錄,照樣量得到 gtag;10 KiB 的 legacy JS 後來發現是 Cloudflare 的 beacon,冤枉了 GA。