今天早上起來把文章列表的計數功能修好了,想說順便用手機打開自己的文章看一下。結果一滑就卡,卡得滿誇張的,滑一滑還會整塊先變空白,過一下才補上來。
我本來以為是 React 一直在重算,就錄了兩份 Performance trace 來看,結果主執行緒根本沒在忙。後來一個一個排除,最後發現問題出在信紙的紙紋上。這篇記錄一下怎麼找到的。
環境
Samsung S24 Ultra(120Hz)、Chrome,頁面是 kitchen-sink 這篇,整頁高約 46,000px。
症狀
滑動文章時會頓、偶爾整塊先變空白再補上,碰互動元件也不順。第一個直覺是 React 在重算,但 trace 完全不是這樣。
trace 說了什麼
兩份 10 秒的錄製,數字長得很像:
- 主執行緒只忙了約 0.7 到 1.2 秒,沒有超過 50ms 的長任務(唯一的長任務是頁面載入和錄製工具自己啟動)
- GPU 程序卻忙了 3.1 到 3.8 秒,單一任務最長 360ms
- 滑動時出現約 40 次
has_missing_content,也就是新內容還沒畫好就先顯示;其中 10 個幀花了中位數 240ms、最長 673ms
120Hz 螢幕每幀預算只有 8.3ms,所以 DevTools 裡一大片「掉幀」有一部分只是預算太小。真正讓人感覺「卡住」的,是那 10 個空白幀。
重點
Bottom-Up 表格看不出問題,因為工作發生在 GPU,不在主執行緒。看 JS 和 Layout 永遠找不到它。
用排除法找兇手
我一直猜錯,所以改成直接在手機 Console 注入 CSS,一類一類關掉,看會不會變順:
先關背景的雲
document.querySelector('.cloud-sea').remove(),沒用。那是固定在畫面下方的動畫雲,看起來最可疑,但不是主因。全部效果一起關
動畫、transition、
backdrop-filter、filter、mask-image、box-shadow、will-change全部none,還是卡。這代表問題不在這幾類。隱藏文章本體
article.prose { visibility: hidden },還是卡。問題在文章「外面」,也就是包住文章的那張信紙。只拿掉紙紋
把
--paper-texture設成none,立刻順很多。
前兩個測試之所以沒抓到,是因為我關的清單漏了 background-image、background-blend-mode 和 mask-border,而紙紋正好都在這些裡面。
為什麼紙紋這麼貴
信紙是這樣組成的:
.letter-paper::before,
.letter-sheet {
background: var(--paper-texture), var(--sheet-color, var(--card));
background-blend-mode: var(--paper-blend); /* multiply */
mask-border: url("data:image/svg+xml,..."); /* 毛邊 */
}--paper-texture 裡是 4 張 SVG,全都用 feTurbulence 即時產生雜訊。幾個因素疊在一起:
- 濾鏡不會被存成圖:Chrome 把 SVG 背景當成繪製指令,每次光柵化磚塊都重新跑一次濾鏡,
numOctaves越高越貴。 - 倍數很大:紙有三層(
::before加兩張底紙),每層疊 4 張雜訊再multiply,而且每層都和文章一樣高。 - 像素很多:S24U 的 DPR 約 3,實際像素是一般桌機螢幕的好幾倍。
- 滑動就是不斷產生新磚塊:每個新進畫面的磚塊都要把這堆雜訊重算一遍,趕不上 8.3ms 的預算,就先顯示空白。
下面是同一塊深色紙面,有紋理和沒紋理的差別。拖曳中間的滑桿比較:


肉眼看有紋理確實比較有質感,但它的代價是每個磚塊都要重算一次。
修法
最小的修法是在手機和平板直接關掉紋理:
@media (pointer: coarse), (hover: none), (max-width: 1024px) {
.letter-paper,
.dark .letter-paper {
--paper-texture: none;
}
}要保留質感,有兩個更根本的做法:
兩張底紙本來就只露出右邊和下面一小條邊,紋理根本看不到。讓 .letter-sheet 不用 --paper-texture,只留 --sheet-color,雜訊成本直接降到三分之一。
把那 4 層雜訊各渲染一次,存成小張 WebP 平鋪圖(SVG 已經有 stitchTiles='stitch',天生可以無縫平鋪),CSS 改成 url(/paper-grain.webp)。每個磚塊只是貼一張現成的圖,成本幾乎是零,所有裝置都能保留質感。
問答
不是。這頁的 Layout 只有約 3,200 個物件,主執行緒的樣式和排版加起來不到一秒,也沒有長任務。我另外試過把整篇文章設成 visibility: hidden(DOM 還在,只是不畫),還是卡。只改掉紙紋那一個 CSS 變數就順了,所以成本在「畫」,不在 DOM 的數量。
單純的 SVG 不貴,貴的是 feTurbulence。它不是貼一張圖,而是對每一個像素現算一次雜訊(numOctaves 越高,每個像素要算的次數越多)。這裡又疊了好幾層:紙有三層,每層 4 張雜訊,再加 multiply 混合,全部和文章一樣高,在 DPR 約 3 的螢幕上像素又特別多。更關鍵的是它不是畫一次就結束,滑動時每個新進畫面的磚塊都要重算一遍。
這部分有一點我沒辦法完全確定:trace 裡 GPU 任務沒有細節,我只能確認「GPU 程序很忙」,沒辦法直接看到是哪一段在算。
換成 Canvas 本身不會變快。如果是每幀或每個磚塊都用 Canvas 重畫雜訊,一樣要算同樣的東西。而且整頁約 46,000px 高,已經超過瀏覽器對單張 Canvas 的尺寸和記憶體限制,要用的話得自己切塊。
真正的解法是「只算一次」。Canvas 可以當產生器:先畫一小塊(例如 256px)存成圖,再平鋪成背景。但最後貼上去的是一張現成的圖,所以直接預先烤成 WebP 效果一樣,也比較單純。
小結
卡的時候不要只盯著 JS。主執行緒很空、GPU 卻很忙,就往 CSS 的繪製成本找,像 feTurbulence、混合模式、遮罩這類東西都要懷疑。
排查的時候直接在手機 Console 注入 CSS,一類一類關掉,比改程式碼再部署快很多。只是我第一輪關的清單漏了 background-image、background-blend-mode 和 mask-border,紙紋就一直沒被抓到,清單要把相關的屬性都列進去。
還沒量化
我只確認了「拿掉紙紋後體感順很多」,沒有在修好後再錄一次 trace 比對 GPU 時間,所以這裡沒有「修好後降了多少」的數字。