中日文網站手機 Lighthouse 從 77 分到 100 分:我們踩到的字型與語系坑
/ja 手機 Lighthouse 77 分、CLS 0.179,/zh 的 LCP 3.84 秒。查到最後,最大的元兇不是字型,而是語系 store 在 page data 到之前先回答了 en。這篇整理我們改了什麼、量到什麼,以及一個量完反而變慢、所以沒採用的做法。
我們的官網是用 SvelteKit 寫的,英文、繁中、日文三個語系共用同一套頁面。英文首頁在手機 Lighthouse 上還算過得去,中日文頁卻明顯落後:/zh 86 分、LCP 3.84 秒;/ja 77 分,還多了 0.179 的 CLS。
直覺會先怪字型:中日文字型檔大、子集多,Noto Sans TC 動輒好幾 MB。我們一開始也這樣想。結果讓 /ja 版面跳動、讓 /zh 的 LCP 晚一大截的主因,是一個跟字型沒什麼關係的 bug。這篇照查問題的順序寫,數字都是實際量到的,程式碼也是現在線上的版本(有刪減)。
先看結果
| 頁面 | 手機分數 | 手機 LCP(秒) | 手機 CLS |
|---|---|---|---|
| /(英文) | 92 → 100 | 3.01 → 1.66 | 0.001 → 0.001 |
/zh | 86 → 100 | 3.84 → 1.66 | 0 → 0 |
/ja | 77 → 100 | 3.91 → 1.66 | 0.179 → 0 |
/zh/pricing | 93 → 100 | 3.01 → 1.65 | 0.003 → 0.003 |
| 範本頁¹ | 91 → 100 | 3.16 → 1.65 | 0 → 0 |
//zh/ja/zh/pricing/zh/templates/…//zh/ja/zh/pricing/zh/templates/…關於這些數字:表格是 Lighthouse 12.8 的模擬手機(預設節流),在本機的 production build 上跑,每頁三次取中位數,前後兩個版本用同一台機器、在同一段時間量。上線後我們在正式站抽測 /zh 和 /ja,都是 97 分、LCP 1.3 秒、CLS 0(各跑一次)。改之前我們也在正式站跑過,但單次結果飄得很厲害:同一個 /zh 第一次 63 分,幾分鐘後再跑變 89 分。所以前後比較以本機三次中位數為準。
症狀:LCP 幾乎全是「渲染延遲」
Lighthouse 的 LCP 拆解很直接。/zh 的 LCP 元素是首頁標題的文字,不是圖片,沒有「下載資源」那一段;3.84 秒裡 0.45 秒是 TTFB,剩下 3.39 秒(88%)全是 render delay。HTML 早就到了,標題文字卻遲遲沒畫出來。
那畫出來之前,瀏覽器在忙什麼?翻同一份報告的請求列表:
- Noto Serif TC + Noto Sans TC 的 Google Fonts 樣式表,傳輸量 135 KiB(解壓後約 490 KiB),優先權 VeryHigh;
- 拉丁字型(Newsreader、Inter、JetBrains Mono)的 Google Fonts 樣式表,也是 VeryHigh;
- 大約 50 個 JS
modulepreload; - 在快的連線上 JS 早就到手,瀏覽器會先把整個 hydration 跑完,才畫出伺服器已經渲染好的頁面。
這幾件事我們一件一件拆。不過在講字型之前,先講最意外的那個。
坑一:語系 store 在 page data 到之前先說了 en
起點是一個很小的觀察:/ja 載入時,右上角語言切換器的國旗會「日本 → 美國 → 日本」閃一下。
站上的 locale 是從 SvelteKit 的 $page 衍生出來的 store,語系由 server load 決定、放在 page data 裡。問題出在 hydration 剛開始、第一份 page data 還沒進來的那段時間:store 拿不到值,就回答預設的 'en'。而這個 store 的訂閱者會同步改 <html lang>。
// 改之前
export const locale = derived(page, ($page): Locale => {
const raw = ($page?.data as { locale?: unknown } | undefined)?.locale;
return raw === 'zh' ? 'zh' : raw === 'ja' ? 'ja' : 'en';
});
於是 /zh、/ja 的 <html lang> 會變成 en 幾百毫秒。我們的中日文字型是用 html[lang="ja"] 這類選擇器切換 CSS 變數,標題字級也跟語系有關;這幾百毫秒內,標題就用英文的字型與字級重排,等 page data 到了再換回來、再排一次:
- /ja:兩次重排造成 0.179 的 CLS(桌機是 0.094);
- /zh:最大那塊文字在換回來時重畫一次,LCP 被往後推;
- 依語系渲染的區塊先用英文 hydrate,再切回中文或日文。
<html lang="ja"><html lang="en"><html lang="ja"><html lang="ja"><html lang="ja"><html lang="ja">修法很小:還沒有 page data 的時候,從網址判斷語系。server 本來就是看網址前綴決定語系,用同一條規則,兩邊就不會不一致。
// 改之後(src/lib/i18n/locale.ts)
export const locale = derived(page, ($page): Locale => {
const raw = ($page?.data as { locale?: unknown } | undefined)?.locale;
if (raw === 'zh' || raw === 'ja' || raw === 'en') return raw;
// No page data yet: the URL already says which language the page is.
if (browser) return localeFromPath(location.pathname);
return 'en';
});
/ja 的 CLS 就這樣歸零。教訓是:多語系 SSR 網站上的每個「預設值」,都要問一句 hydration 初期它會不會跟 server 輸出的不一樣。 <html lang> 不只影響無障礙和 SEO,它還會透過 CSS 換掉字型。
坑二:135 KiB 的字型 CSS,用最高優先權搶頻寬
中日文字型我們用 Google Fonts,display=optional,也早就依語系拆開:/zh 只載 TC、/ja 只載 JP,英文頁完全不載。問題在載入方式。為了不擋 render,原本是用 rel="preload" as="style",載完再用 script 換成 stylesheet:
// 改之前(src/lib/seo/fonts.ts)
return `<link id="nx-fonts-cjk" rel="preload" as="style" href="${href}" /><noscript><link rel="stylesheet" href="${href}" /></noscript>`;
preload 的確不會 render-blocking,但它是用最高優先權下載的。Google Fonts 的中日文樣式表要列出上百個 unicode-range 子集,光傳輸量就有 135 KiB;它和接著觸發的約 350 KB 字型檔,都在第一次繪製之前以最高優先權下載,跟頁面真正需要的 CSS 搶同一條手機連線。
改法:字型樣式表一律等第一次繪製之後才加。拉丁字型改成自己 host(就是 Google Fonts 原本的檔案和 @font-face 規則,display=swap),英文頁的字型從此不用連第三方。中日文還是 Google Fonts,維持依語系拆分和 display=optional:字型如果沒有幾乎立刻就緒,這次瀏覽就用系統的中日文字型,不會晚一點再換字,所以也不會冒出晚到的 LCP;字型會進快取,下一頁就用得到。我們的 CSS 變數把系統字型排在 Noto 前面,裝置本來就有中日文字型的話,根本不會下載。
坑三:字型樣式表什麼時候加?量出來的取捨
伺服器把兩份樣式表放進一個惰性的 <template>,瀏覽器不會去抓;<noscript> 裡另外留一份給沒有 JavaScript 的訪客:
// src/lib/seo/fonts.ts
const links = (media: string) => sheets.map((href) => `<link rel="stylesheet"${media} href="${href}" />`).join('');
return `${preconnect}<template id="nx-font-sheets">${links(' media="print"')}</template><noscript>${links('')}</noscript>`;
<head> 裡一小段 inline script 等到 first-contentful-paint,才把 link 從 template 複製出來插進去。media="print" 讓瀏覽器用最低優先權下載,載完再切成 all。我們的 CSP 不允許 inline onload="…",所以 listener 用 JS 綁。
第一版 review 時抓到一個退步:首訪沒問題,但回訪時就算字型已經在快取裡,第一次繪製還是先用 fallback 字型(Georgia/系統字),然後才換成 Newsreader/Inter,等於每一頁都閃一次。
解法是一個 localStorage 旗標。拉丁樣式表成功載入過一次,就記下 nx-font-sheets-seen;下次進站,script 直接加上它,並設 blocking="render",第一次繪製就用快取裡的字型:
// src/app.html(節錄)
function start(early) {
// …clone each <link> out of the <template>…
if (early && own) {
el.media = 'all';
el.setAttribute('blocking', 'render');
} else {
el.addEventListener('load', function () { el.media = 'all'; }, { once: true });
}
// …and set nx-font-sheets-seen once the self-hosted sheet has loaded
}
if (seen) return start(true);
new PerformanceObserver(function (list, obs) {
if (list.getEntriesByName('first-contentful-paint').length) {
obs.disconnect();
setTimeout(start, 0);
}
}).observe({ type: 'paint', buffered: true });
setTimeout(start, 3000); // a background tab never paints
這一段我們不用 Lighthouse,而是在加了節流的真實 Chromium 上,分別量首訪(冷)和回訪(熱):
| 版本 | 冷啟動 FCP / · /zh | 冷:FCP 時 字型已就緒 | 熱啟動 FCP / · /zh | 熱:FCP 時 字型已就緒 |
|---|---|---|---|---|
| 改之前 | 708 / 700 ms | 0/3 | 356 / 280 ms | 3/3 |
| 第一版(只延後) | 660 / 628 ms | 0/3 | 308 / 340 ms | 0/3(會閃) |
| 最終版(加回訪旗標) | 632 / 608 ms | 0/3 | 312 / 328 ms | 3/3 |
直接 inline @font-face(試過,沒採用) | 704 / 612 ms | 0/3 | 356 / 340 ms | 3/3 |
最後一列是最直覺的做法:把 @font-face 規則直接 inline 進每一頁。回訪一樣不會閃,但首訪時瀏覽器會在第一次繪製之前就去抓字型檔,Lighthouse 手機分數 / 從 100 掉到 78(LCP 1.85 → 4.35 秒),/zh 從 99 掉到 87。所以我們留在 localStorage 旗標的做法。
坑四:50 個 modulepreload,和在繪製前就跑完的 hydration
SvelteKit 預設會為頁面用到的 JS 模組送出 modulepreload。我們的首頁大約 50 個,全部在第一次繪製之前以高優先權發出,在手機上跟繪製需要的 CSS 共用連線。頁面本來就是 SSR,沒有 JS 也能顯示,所以我們只保留 CSS 的 preload:
// src/hooks.server.ts
const response = await resolve(event, {
preload: ({ type }) => type !== 'js'
// …
});
<body> 底部的入口 script 照樣立刻載入,各路由的模組在 hydration 開始時一起抓。
另一半是 hydration 的時機。JS 已經到手時(回訪、或連線很快),瀏覽器會先跑完整個 hydration 才畫出伺服器渲染好的頁面。SvelteKit 的 hooks.client.ts 可以 export 一個 init,它 resolve 之前不會開始 hydrate;我們讓它等第一次繪製:
// src/hooks.client.ts
export const init: ClientInit = () =>
new Promise<void>((resolve) => {
const go = () => setTimeout(resolve, 0);
try {
if (!PerformanceObserver.supportedEntryTypes.includes('paint')) throw new Error('no paint timing');
new PerformanceObserver((list, observer) => {
if (list.getEntriesByName('first-contentful-paint').length) {
observer.disconnect();
go();
}
}).observe({ type: 'paint', buffered: true });
} catch {
requestAnimationFrame(go);
}
setTimeout(resolve, 1500);
});
最後那個 setTimeout(resolve, 1500) 是給背景分頁的:背景分頁不會繪製,不能讓它永遠不 hydrate。
延後 hydration 最怕的是互動變慢。我們用同樣的節流設定量「頁面完成 hydration 的時間」:/ 是 2.77 秒(改之前 2.75 秒),/zh 是 3.16 秒(改之前 4.25 秒)。英文首頁幾乎一樣,中文頁反而早了一秒多,因為前面的資源不再互相搶。
副作用是 load 事件現在可能比 hydration 早,E2E 測試不能 goto 完就去點 JS 控制項。根 layout 在 mount 時設 <html data-hydrated>,要點按的測試先等它出現。
兩個小改動,效果不小
Logo 換成顯示尺寸的 WebP。 首頁的整合跑馬燈和任務圖示原本直接用原始 logo(最寬 3301 px,每個 8–57 KB),實際最多顯示 44 px 高。改成最高 96 px、最寬 300 px(顯示尺寸的 2–3 倍)的 WebP 之後,16 個檔案從約 438 KB 變成約 37 KB。width/height 跟著新檔案改,預留的版面形狀不變。
文章內文不進首頁 bundle。 首頁某個區塊從 $lib/content import 了 formatDate,順手把 markdown parser marked(36 KB)拉進首頁;首頁列新聞標題用的 ?raw glob,也把三種語言所有文章的內文都打包進去。我們把 formatDate 拆到獨立檔案,再寫一個小 Vite plugin,讓 ?frontmatter 的 import 只回傳開頭的 --- 區塊:
// vite.config.ts
function frontMatterOnly(): Plugin {
return {
name: 'nx-front-matter-only',
enforce: 'pre',
load(id) {
const [file, query] = id.split('?');
if (query !== 'frontmatter' || !file.endsWith('.md')) return;
this.addWatchFile(file);
const block = readFileSync(file, 'utf8').match(/^---\n[\s\S]*?\n---/);
return `export default ${JSON.stringify(block ? block[0] : '')};`;
}
};
}
首頁路由的 JS(未壓縮)從 470 KB 降到 409 KB。
沒做到的,以及這些數字的限制
blocking="render"只有 Chromium 支援。 Safari、Firefox 回訪時一樣會立刻加上樣式表,但不會 render-blocking,可能還是會很短暫地換一次字。- 中日文字型還在 Google Fonts。 那份樣式表在 Lighthouse 裡依然會被列為「未使用/未壓縮的 CSS」。它現在在首訪的第一次繪製之後才載,但我們沒有自己 host 中日文子集。
- 快取被清掉、旗標卻還在的時候,第一次繪製會多等一個同網域請求(約 2 KB)。
- 沒做 fallback 字型的 metric override(
size-adjust之類)。/ja 的 CLS 是語系閃爍造成的,字型交換本身的位移量大約 0.0001。 - JS 模組數量沒變少。 我們試過 Rollup 的
experimentalMinChunkSize,它把原本延遲載入的套件拉進了首屏依賴,所以改成只拿掉 preload;hydration 時還是會載大約 47 個小模組,只是排在第一次繪製之後。 - 實驗室數字不等於真實使用者。 表格是本機 production build(HTTP/1.1、localhost)上的模擬手機;正式站的 97 分、1.3 秒是單次抽測。真實使用者的 Core Web Vitals 要看一段時間的實際資料才算數。
如果你也在做中日文網站
- 先看 LCP 拆解。 LCP 元素是文字、render delay 又佔大半時,問題通常不是「下載太慢」,而是繪製前有東西擋著或搶頻寬。
- 檢查 hydration 初期每個依賴 page data 的值,特別是會寫進
<html lang>的語系。最簡單的檢查:用慢速節流載入非英文頁,盯著語言切換器或<html lang>會不會閃。 rel="preload"不是低調載入,是最高優先權載入。 不是首屏一定要的東西,就不要 preload。- 中日文 Google Fonts 的樣式表本身就超過 100 KB。 依語系拆分、
display=optional、等第一次繪製後再載。 - 每個「直覺上比較好」的做法都量一次。 inline
@font-face看起來更簡單,量完首訪 LCP 從 1.85 秒變成 4.35 秒。
NEXUPIRA 做的是小公司用的 AI 同事:客人在 LINE 問價時先擬好回覆和報價單,也幫忙整理收支、排行程。這篇調校的就是它的官網,有興趣可以到首頁看看。