今天要解的問題

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。但這只是我猜的,所以動手之前,先去報告的明細看清楚到底是誰占了這些位元組。

gtag/js 從 head 移到 window load 之後:load 1697 → 602 ms,FCP 1.50 → 1.21 s,LCP 1.80 → 1.80 s,Unused JS 72 → 72 KiB

技術背景

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 的報告,結果兩項各只有一個來源:

PageSpeed 說的 83 KiB:unused 72 KiB 全部是 gtag/js;legacy 10 KiB 是 Cloudflare Web Analytics 的 beacon.min.js

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,接受漏掉一點流量

一開始列了兩條路:

  1. GA 留著,延後到 load 之後、瀏覽器閒下來再載入。
  2. 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 ms602 ms
FCP / LCP1.50 s / 1.80 s1.21 s / 1.80 s
Unused JS / Legacy JS72.2 KiB / 072.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。