← 所有消息

工程 10 分鐘閱讀

中日文網站手機 Lighthouse 從 77 分到 100 分:我們踩到的字型與語系坑

/ja 手機 Lighthouse 77 分、CLS 0.179,/zh 的 LCP 3.84 秒。查到最後,最大的元兇不是字型,而是語系 store 在 page data 到之前先回答了 en。這篇整理我們改了什麼、量到什麼,以及一個量完反而變慢、所以沒採用的做法。

中日文網站手機 Lighthouse:/ja 從 77 分到 100 分,LCP 3.84 秒降到 1.66 秒,CLS 0.179 降到 0

我們的官網是用 SvelteKit 寫的,英文、繁中、日文三個語系共用同一套頁面。英文首頁在手機 Lighthouse 上還算過得去,中日文頁卻明顯落後:/zh 86 分、LCP 3.84 秒;/ja 77 分,還多了 0.179 的 CLS。

直覺會先怪字型:中日文字型檔大、子集多,Noto Sans TC 動輒好幾 MB。我們一開始也這樣想。結果讓 /ja 版面跳動、讓 /zh 的 LCP 晚一大截的主因,是一個跟字型沒什麼關係的 bug。這篇照查問題的順序寫,數字都是實際量到的,程式碼也是現在線上的版本(有刪減)。

先看結果

頁面手機分數手機 LCP(秒)手機 CLS
/(英文)92 → 1003.01 → 1.660.001 → 0.001
/zh86 → 1003.84 → 1.660 → 0
/ja77 → 1003.91 → 1.660.179 → 0
/zh/pricing93 → 1003.01 → 1.650.003 → 0.003
範本頁¹91 → 1003.16 → 1.650 → 0
改之前 → 改之後。Lighthouse 12.8 模擬手機、本機 production build,三次中位數。¹ /zh/templates/quote/air-conditioning
圖 1 · Lighthouse 手機
Lighthouse 12.8 模擬手機、本機 production build,每頁跑三次取中位數。改之前是改動前一刻的 main,改之後是合併後的版本。上線後正式站抽測:/zh、/ja 都是 97 分,LCP 1.3 秒,CLS 0。

關於這些數字:表格是 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 早就到了,標題文字卻遲遲沒畫出來。

圖 2 · /zh 標題在等什麼
/zh 在 Lighthouse 模擬手機上的 LCP 拆解(取自三次量測裡 LCP 等於中位數的那一次)。LCP 元素是標題文字,沒有圖片下載階段,第一個 byte 之後全部算渲染延遲。標籤上的優先權是同一份報告記錄的請求優先權。

那畫出來之前,瀏覽器在忙什麼?翻同一份報告的請求列表:

  • 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,再切回中文或日文。
圖 3 · /ja 的語系閃爍
示意圖,不按比例;中間那一格大約是幾百毫秒,也就是 JS 開始跑到第一份 page data 到之間。CLS 數字:Lighthouse 模擬手機、/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 ms0/3356 / 280 ms3/3
第一版(只延後)660 / 628 ms0/3308 / 340 ms0/3(會閃)
最終版(加回訪旗標)632 / 608 ms0/3312 / 328 ms3/3
直接 inline @font-face(試過,沒採用)704 / 612 ms0/3356 / 340 ms3/3
真的 Chromium、不是模擬:150 ms RTT、1.6 Mbps、CPU 降速 4 倍、412 px 寬,各三次。熱啟動是快取還在時同一頁的第二次載入;「字型已就緒」指回報 FCP 那一刻 Newsreader/Inter 已經載好。

最後一列是最直覺的做法:把 @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 要看一段時間的實際資料才算數。

如果你也在做中日文網站

  1. 先看 LCP 拆解。 LCP 元素是文字、render delay 又佔大半時,問題通常不是「下載太慢」,而是繪製前有東西擋著或搶頻寬。
  2. 檢查 hydration 初期每個依賴 page data 的值,特別是會寫進 <html lang> 的語系。最簡單的檢查:用慢速節流載入非英文頁,盯著語言切換器或 <html lang> 會不會閃。
  3. rel="preload" 不是低調載入,是最高優先權載入。 不是首屏一定要的東西,就不要 preload。
  4. 中日文 Google Fonts 的樣式表本身就超過 100 KB。 依語系拆分、display=optional、等第一次繪製後再載。
  5. 每個「直覺上比較好」的做法都量一次。 inline @font-face 看起來更簡單,量完首訪 LCP 從 1.85 秒變成 4.35 秒。

NEXUPIRA 做的是小公司用的 AI 同事:客人在 LINE 問價時先擬好回覆和報價單,也幫忙整理收支、排行程。這篇調校的就是它的官網,有興趣可以到首頁看看。