- Published on
把 Lighthouse CLS 降到 0:從被推動的元素追到真正原因
- Authors

- Name
- Harry
把 Lighthouse CLS 降到 0:從被推動的元素追到真正原因
這次優化的起點,是 production PageSpeed 報告中首頁的 Cumulative Layout Shift(CLS)問題。修改後,在本機 production build 的 Lighthouse 測試中,lab CLS 降到了 0。
改善 CLS 的核心只有一件事:讓瀏覽器在第一次排版時,就知道每個區塊最終需要多少空間。 以下是這次追查與修正的過程。
Lighthouse 標記的是受害者,不一定是原因
PageSpeed 報告將位移歸在「最新面試、評價」區塊。第一眼很容易以為問題出在這個區塊本身,但它的資料其實早就由 SSR 輸出了。
實際流程是:
上方的薪資摘要區塊在 client 端載入
↓
薪資摘要被插入頁面
↓
下方的「最新面試、評價」被往下推
↓
Lighthouse 將它記錄為發生位移的元素
Layout shift 的 attribution 告訴我們「誰移動了」,而不是「誰造成它移動」。追查 CLS 時,看到被標記的元素後,要往它上方的兄弟元素、父層容器,以及稍早載入的內容尋找原因。
修改前:首屏資料要等 useEffect 才開始取得
首頁的薪資摘要需要兩份資料,原本只在瀏覽器端透過 useEffect 取得:
useEffect(() => {
if (isUnfetched(popularCompanyAverageSalaryBox)) {
dispatch(queryPopularCompanyAverageSalary())
}
}, [dispatch, popularCompanyAverageSalaryBox])
Server 輸出 HTML 時資料還不存在,這個區塊完全沒有內容。Hydration 之後才發出 request,畫面依序經過:
空白 → Loader → 完整的薪資摘要區塊
三個狀態的高度都不同。這些變化發生在頁面上方,下方內容便被一次次往下推。
主要修正:將首屏資料加入 SSR
專案本來就有 route component 的 fetchData 機制,因此將兩支薪資 API 加入首頁的 SSR 資料流程:
LandingPage.fetchData = ({ store: { dispatch } }) => {
return Promise.all([
dispatch(queryPopularExperiences()),
dispatch(queryMenuIfUnfetched()),
dispatch(queryPopularCompanyAverageSalary()),
dispatch(queryPopularJobTitleSalaryDistribution()),
])
}
修改後,server 在輸出 HTML 前就取得資料。瀏覽器第一次排版時,薪資摘要區塊已經存在,不必等 hydration 後才插入。
代價是 SSR 必須等最慢的 API 回應,TTFB 可能上升。部署後除了 CLS,也要確認 TTFB 與 LCP 沒有惡化。
把 API 搬進 SSR 前,要確認 action 會把錯誤轉成 error state,而不是讓 promise reject。原本 client 端失敗只會讓單一區塊卡在 Loader;放進 SSR 的
Promise.all後,一個未處理的 rejection 就會讓整個首頁回傳 500。
SSR 只有圖表外框,靠固定高度避免位移
兩張圖表使用 @loadable/component 動態載入,而 server 沒有整合 ChunkExtractor,所以 SSR HTML 裡只有:
- 標題與「看更多」連結
- 卡片外框
- 空白的圖表容器
圖表內容要等 JavaScript chunk 載入後才畫出來。它沒有造成新的位移,是因為容器預先固定了高度:
.barChart {
width: 100%;
height: 265px;
}
這是另一個重要原則:內容不一定要 SSR,但空間一定要先佔好。 固定高度、min-height、aspect-ratio 或尺寸穩定的 skeleton,都能讓非同步內容載入後不撐開頁面。
圖片的 width / height:良好實踐,但不是這次的主因
圖片在下載完成前,瀏覽器不知道它的比例,載入後才撐開高度。替 <img> 補上素材的原始尺寸,瀏覽器就能提早算出 aspect ratio 並預留空間:
<img src={GjLogo} alt="GoodJob" width="160" height="47" />
要注意的是,專案原本的全域規則是:
img {
display: block;
width: 100%;
}
只補 HTML attribute 時,CSS 會蓋過 width,height 卻會被當成固定高度,圖片隨容器縮放時就會變形。因此必須同時補上 height: auto,讓 attribute 只負責提供比例:
img {
display: block;
width: 100%;
height: auto;
}
不過這次補尺寸的圖片都在 Footer。Lighthouse 載入期間不會捲動,只有 viewport 內元素的位移會被計入,所以這項修改對這次的 lab CLS 沒有貢獻,主要改善來自薪資摘要的 SSR 與圖表容器的固定高度。
如何驗證修改真的生效
使用 production build。 Dev mode 的 CSS 由 JavaScript 注入,頁面會先以未套用樣式的狀態出現再重新排版,本身就會產生額外的 CLS,無法反映正式環境:
yarn build
yarn start-production
確認 SSR 輸出。 用「檢視網頁原始碼」搜尋薪資摘要的標題,或關閉 JavaScript 後重新整理,應該能看到區塊外框與預留高度的空白圖表容器。
找出位移來源。 在 Chrome DevTools 的 Performance 面板錄製重新載入,檢查 Layout shifts 軌道標記的元素,再往上尋找稍早插入或改變尺寸的內容。
比較前後時固定條件。 Mobile 與 Desktop 的版面不同,必須使用相同模式、相同 throttling,並至少跑三次取中位數。
Lab CLS 為 0,不等於使用者體驗已經改善
Lighthouse 只測量一次受控的頁面載入;PageSpeed 上方的 CrUX field data 來自真實使用者最近 28 天的滾動資料,包含不同裝置、網路與捲動行為。因此這次能下的結論是:lab CLS 已降至 0,field data 是否同步改善,要在部署後持續觀察。
結語
- Lighthouse 標記的元素是調查起點,不一定是根因。
- 首屏資料移到 SSR,避免 hydration 後才插入整個區塊。
- 無法 SSR 的內容,先用固定高度把空間佔好。
- 圖片提供原始尺寸時,要搭配
height: auto。