2026/08/14
本文案例基於以下套件版本:
{ "react": "19.2.4", "zustand": "5.0.12", "highcharts": "9.3.3", "highcharts-react-official": "3.2.2"}由於 React、Zustand 與 Highcharts 不同版本之間的 rendering、state subscription 與 wrapper 行為可能有所差異,本文提到的效能現象與優化結果,建議仍以實際專案中的 Chrome Performance profiling 為準。
最近在優化一個 React 舊專案的效能時,我遇到一個很有意思的問題:
只是切換側邊選單(Hamburger Menu),整個頁面的圖表卻會明顯卡頓。
這個頁面同時存在多個 Highcharts 圖表,原本第一直覺很容易懷疑是:
但真正開始 profiling 後才發現,問題不是單一原因,而是幾個看似無害的設計疊在一起。
這篇文章記錄我實際處理這次問題的思路,以及幾個在 React + Highcharts 專案中特別容易踩到的效能陷阱。
這次遇到的問題不是單純的「Side Menu Toggle 導致所有圖表重新建立」。
更準確地說,當 Side Menu 開合時,主內容區域寬度會發生變化。為了讓 Highcharts 重新適應新的 container size,原本會在上層 Component 的 useEffect 中觸發圖表尺寸更新。
Highcharts 對這類情境提供了:
chart.reflow();reflow() 的作用主要是重新檢查圖表容器尺寸,並根據新的寬高調整圖表 layout。
因此整體流程比較接近:
Side Menu toggle ↓主內容 container 寬度改變 ↓parent effect / transition handler ↓Highcharts chart.reflow() ↓圖表重新計算尺寸與 layout這跟「重新建立一張 Highcharts instance」是不同層級的事情。
可以簡單區分成:
| 行為 | 意義 |
|---|---|
| React re-render | React Component function / render 流程重新執行 |
Highcharts reflow() | 根據 container 新尺寸重新計算圖表大小 |
Highcharts redraw() | 將目前圖表變更重新繪製 |
| Chart recreate | 舊 Chart instance 被 destroy,再建立新的 instance |
這次主要問題是在 Side Menu 動畫造成 layout 改變後,需要重新計算大量 Highcharts 的尺寸。
如果同一頁有很多圖表,即使只是 reflow(),一次對所有 Chart instance 執行仍然可能產生相當大的 Main Thread 工作量。
因此真正要處理的不是:
「怎麼避免圖表被重建?」
而是:
「怎麼避免在不必要的時間點,讓大量圖表一起進行昂貴的 layout / reflow?」
Side Menu 本身有 CSS transition。
如果在動畫過程中持續進行圖表 resize / reflow,瀏覽器可能需要反覆處理:
Container width change→ Style Calculation→ Layout→ Highcharts reflow→ Layout→ 下一個 animation frame→ 再來一次因此目前的做法是等待 Side Menu 的 transitionend。
const reflowAllCharts = () => {
Highcharts.charts.forEach((chart) => {
if (chart && !chart.renderer?.forExport) {
chart.reflow();
}
});
};
const onSideMenuTransitionEnd = (event) => {
if (event.propertyName !== 'margin-left') return;
const target = event.target;
if (!target?.classList?.contains('side-menu')) return;
reflowAllCharts();
};流程變成:
Side Menu animation ↓container width 持續變化 ↓等待 transitionend ↓Layout 穩定 ↓統一 chart.reflow()這樣可以避免在動畫過程中不斷要求圖表重新計算尺寸。
效能分析時,很容易把下面幾件事混在一起:
React re-renderHighcharts reflowHighcharts redrawHighcharts recreate但它們的成本來源完全不同。
如果真正的問題是:
Layout change→ chart.reflow()那一直研究:
React.memo()useMemo()useCallback()不一定能解決問題。
這次更核心的問題其實是:
Layout 變化發生的時機,以及 Highcharts 何時被要求重新計算尺寸。
因此 Chrome Performance 裡的:
Recalculate StyleLayout反而比單純看 React render 次數更重要。
這次最直接的改善,就是重新檢查:
這個 Component 真的需要知道 sidebar 開關狀態嗎?
很多情況答案其實是:
不需要。
圖表真正需要知道的可能只是自己的 container size,而不是:
isSidebarOpen所以我直接移除圖表 Component 對這個 global state 的訂閱。
function RevenueChart() {
const isSidebarOpen = useLayoutStore(
(state) => state.isSidebarOpen
);
return <Chart />;
}function RevenueChart() {
return <Chart />;
}這個改動看起來很小,但意義很重要。
因為 Zustand selector 雖然能避免其他 state 改變造成更新,但只要你訂閱的 state 本身改變,Component 就一定會收到更新。
所以效能優化不能只是問:
「我的 selector 有沒有寫好?」
還要再問:
「這個 Component 到底為什麼需要訂閱它?」
有時候最有效的 state optimization,就是根本不要 subscribe。
這也是這次優化後,我覺得很值得特別記錄的一點。
很多人在看 React Component 時,容易注意:
useEffect
useMemo
useCallback
props但 global state subscription 本身也是 Component dependency。
例如:
const foo = useStore((state) => state.foo);其實等同於告訴 React:
只要 foo 改變,這個 Component 就有可能需要重新處理。所以當一個頁面存在大量昂貴 Component,例如:
就應該特別注意這些 Component 訂閱了哪些 state。
處理完 Component subscription 後,有另一個頁面依然非常卡。
原本很自然地懷疑:
window resize → Highcharts reflow → chart redraw但進一步 profiling 後,真正花時間的地方反而落在瀏覽器的:
Recalculate Style Layout Render最後發現其中一個問題來自 Highcharts 的文字樣式設定。
例如:
style: { fontSize: '1em' }em 本身沒有錯。
但當頁面存在大量 SVG / chart elements,而且瀏覽器必須頻繁重新計算 layout 時,相對單位可能增加額外的 style resolution 成本。
改成:
style: { fontSize: '14px' }之後,這部分計算成本明顯降低。
這也是為什麼效能問題不能只靠閱讀 React code 猜。
你看到的可能是:
React re-render真正 Chrome Main Thread 忙的卻可能是:
Style Layout Paint以下是ABA test 的實測數據,有明顯的改善:
| 指標 | A 現況 1em | B 14px | 變化幅度 |
|---|---|---|---|
| 樣式重算 | 1261/1213/1258 ms | 247/271/258 ms | −79% |
| 樣式重算次數 | 598/598/598 | 89/96/90 | −85% |
| 最長影格 | 1329/1276/1329 ms | 283/310/286 ms | −78% |
useHTML另一個很有感的地方是 Highcharts 的:
useHTML: trueHighcharts 很多 label、tooltip、data label 都可以選擇使用 HTML render。
例如:
dataLabels: {
useHTML: true
}這很好用,尤其需要複雜樣式時。
但它不是免費的。
如果使用 SVG:
Highcharts ↓SVG text如果使用 useHTML: true:
Highcharts ↓HTML DOM ↓Browser style calculation ↓Layout當一張圖有大量 label,而頁面又存在多張圖表時,DOM 與 style calculation 的成本就可能快速累積。
如果其實不需要 HTML 能力:
dataLabels: {
useHTML: true
}dataLabels: {
useHTML: false
}這種修改有時甚至比調整 React state 更直接。
實際AB test 數據:
| 指標 | 修復前 | 修復後 | 改善 |
|---|---|---|---|
| 樣式重算次數 | 3198 | 1785 | −44% |
| 版面計算次數 | 1333 | 411 | −69% |
| DOM 節點 | 6955 | 6785 | −170(−2.4%) |
這次優化有一個很典型的現象。
一開始看到:
操作 UI→ 頁面卡很容易開始找:
ReactZustandmemouseMemo但 Web Application 的 Main Thread 工作其實包含很多層:
JavaScript ↓React render ↓DOM / SVG 更新 ↓Style Calculation ↓Layout ↓Paint ↓Composite任何一層都可能成為 bottleneck。
例如這次就同時存在:
| 問題 | 所在層級 |
|---|---|
| Zustand 過度訂閱 | State / React |
| 圖表 Component re-render | React |
| Highcharts redraw | Library |
1em style calculation | Browser |
useHTML | DOM / Layout |
所以如果只一直增加:
React.memo()
useMemo()
useCallback()可能只是很努力地優化錯的地方。
經過這次優化後,我目前比較習慣按照下面順序處理。
例如:
S1:首次進入頁面S2:切換 SidebarS3:切換股票S4:操作 Chart FilterS5:Scroll / Resize不要只說:
「這頁很卡。」
必須先定義:
什麼操作會卡?
主要觀察:
Long TaskRecalculate StyleLayoutPaintFunction CallEvent如果主要時間在:
JavaScript再往 React / JS 找。
如果主要時間在:
LayoutStylePaint就不要一直對著 useMemo 拜拜。
例如:
props changed?state changed?context changed?global store changed?parent rendered?尤其是:
useStore(...)要檢查 Component 是否真的需要這個 subscription。
像 Highcharts 這類 library,本身就有自己的:
renderredrawreflowanimationSVGHTML生命週期。
React render 少一次,不代表 Highcharts 一定少一次 redraw。
一定要分開看。
不是每個慢的 Component 都值得重構。
例如某個頁面如果:
使用頻率低+需要同時改 Frontend+需要 Backend API redesign+預估收益有限那合理決策可能就是:
先不要動。
效能優化不是看到紅色 Performance flame chart 就全部消滅。
真正重要的是:
Impact / Cost也就是優化的投資報酬率。
這次圖表效能優化讓我再次確認:
Frontend Performance 最麻煩的地方,就是「看到的症狀」和「真正的原因」經常不在同一層。
一次 Hamburger Toggle 的卡頓,最後可能牽涉:
Global State→ React Subscription→ Component Render→ Highcharts→ SVG / HTML→ CSS Style Calculation→ Browser Layout所以比起一開始就決定:
「我要加 memo。」
我現在更傾向先問:
瀏覽器到底在忙什麼?找到真正 bottleneck 後,再決定要從:
哪一層下手。
而有時候最好的優化並不是什麼複雜技巧。
可能只是:
- useStore(state => state.isSidebarOpen)或:
- fontSize: '1em'
+ fontSize: '14px'甚至:
- useHTML: true
+ useHTML: false真正困難的從來不是把那一行 Code 改掉。
而是找到:
到底是哪一行值得改。