2026/08/21
最近在優化一個包含大量 Highcharts 圖表的 Dashboard。
前一輪修正已經把最嚴重的凍結問題解掉:
1最長影格
22917 ms → 383 ms但實際滾動時還是明顯卡頓。
重新量測後發現:
1p95 frame = 300 ms也就是最大的 freeze 雖然消失了,但整個滾動過程還是在持續掉幀。
頁面上有很多圖表,所以第一個懷疑很自然:
是不是圖表建立時插入太多 SVG,導致畫面卡頓?
但做了一個最小重現後,結果很奇怪。
專案外:
11510 個 SVG 元素
2建圖約 26 ms實際專案:
1165~218 個 SVG 元素
2卻可能要約 250 ms元素更少,反而慢了將近十倍。
這表示真正昂貴的不是圖表本身。
而是:
圖表建立時,順便觸發了其他昂貴工作。
打開 Chrome Performance 後,可以看到大量:
1Recalculate Style接著搜尋專案 CSS,發現全站有大量 :has():
1.ua-dashboard:has(.guide-wrapper) {
2 ...
3}
4
5.ua-dashboard:has(
6 > .dashboard-tab-container.collapsed
7) {
8 ...
9}
10
11.table-component:has(
12 .rt-td:nth-child(3):hover
13) {
14 ...
15}問題不是單純「用了 :has()」。
而是這些 selector 的起點都非常大:
1.ua-dashboard
2.table-component例如:
1.ua-dashboard:has(.guide-wrapper)代表瀏覽器必須根據 .ua-dashboard 裡面的後代狀態,決定祖先自己的樣式。
當 Highcharts 建圖時,會一次插入大量:
1<svg>
2<g>
3<path>
4<text>DOM 一直變動。
這時瀏覽器就必須重新確認:
這些 DOM 變動有沒有讓某個 :has() 條件成立或失效?結果原本只是「建立一張圖」,卻連帶讓很大的 DOM 範圍重新計算樣式。
:has() 拿掉後,差距非常誇張為了確認真的是 :has(),我做了一個上限實驗:
滾動測試前,直接把相關 :has() CSSRule 移除。結果:
| 指標 | 原本 | 移除 :has() |
|---|---|---|
| 樣式重算 | 3685 ms | 151 ms |
| 樣式重算次數 | 929 | 927 |
| 單次重算成本 | 3.97 ms | 0.16 ms |
| p95 frame | 300 ms | 17 ms |
最有趣的是:
1重算次數
2929 → 927幾乎完全沒變。
但總時間:
13685 ms → 151 ms下降了約 96%。
平均每次 style recalculation:
13.97 ms → 0.16 ms大約差了 25 倍。
所以這次真正的問題不是:
Style recalculation 做太多次。
而是:
每一次 recalculation 都太貴。
原本的寫法:
1.ua-dashboard:has(.dashboard-tab-container) {
2 ...
3}本質上是在讓 CSS 問 DOM:
裡面現在有沒有某個元素?
但 React 本來就知道這些狀態。
所以我把它改成由 JavaScript 明確宣告:
1<div
2 class="ua-dashboard"
3 data-has-tabs
4 data-tabs-collapsed
5>CSS:
1.ua-dashboard[data-has-tabs] {
2 ...
3}
4
5.ua-dashboard[data-tabs-collapsed] {
6 ...
7}資料流從:
1DOM 改變
2↓
3CSS 用 :has() 往下搜尋
4↓
5重新判斷祖先樣式改成:
1React state
2↓
3data-* attribute
4↓
5普通 CSS selector瀏覽器不再需要透過整個 descendant tree 推導狀態。
data-*?第一版其實試過直接用:
1element.classList.toggle('has-tabs', true)但這個 class 後來在 SPA 換頁時消失了。
原因是 .ua-dashboard 的 className 本來就由 React 管理:
1<div className={...} />React 下一次更新時會直接重新寫入整個 class attribute,把手動加入的 class 蓋掉。
但 data-* 不在 JSX 裡:
1data-has-tabsReact 不會去動它。
所以最後選擇 attribute,而不是 runtime class。
正式修改後:
| 指標 | 修正前 | 修正後 |
|---|---|---|
| 樣式重算 | 3685 ms | 177 ms(-95%) |
| p95 frame | 300 ms | 17 ms |
| 頓幀(>33 ms) | 25 | 7 |
| 任務總時間 | 4933 ms | 1424 ms(-71%) |
其中最重要的是:
1p95 frame
2300 ms → 17 ms17 ms 已經非常接近 60 FPS 的一幀預算。
換句話說,原本整段滾動都在掉幀,修正後大部分 frame 已經可以正常完成。
:has() 不是不能用這次不是要得出:
:has() 很慢,不要用。實際上修正後專案裡還保留不少 :has()。
真正要注意的是這種 pattern:
1.large-container:has(.deep-descendant)特別是當 .large-container:
這時 :has() 很可能放大 style invalidation 的成本。
最開始我以為:
Highcharts 畫圖太慢。
最後發現其實是:
Highcharts 建圖時觸發的 DOM mutation,讓大型祖先上的 `:has()` 變成昂貴的 style recalculation。
而且這次有一個很值得記住的現象:
1Style recalculation 次數
2929 → 906
3
4Style recalculation 時間
53685 ms → 177 ms次數幾乎沒變,成本卻下降 95%。
所以分析前端效能時,不能只問:
「這件事發生了幾次?」
還要問:
「每次發生時,到底牽動了多少東西?」
這才是這次真正的問題。
你的意見會幫助我們持續改善內容。