2026/08/14
本文案例基於以下套件版本:
1{
2 "react": "^19.2.4",
3 "zustand": "^5.0.12",
4 "highcharts": "^9.3.0",
5 "highcharts-react-official": "^3.2.2"
6}由於 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 對這類情境提供了:
1chart.reflow();reflow() 的作用主要是重新檢查圖表容器尺寸,並根據新的寬高調整圖表 layout。
因此整體流程比較接近:
1Side Menu toggle
2 ↓
3主內容 container 寬度改變
4 ↓
5parent effect / transition handler
6 ↓
7Highcharts chart.reflow()
8 ↓
9圖表重新計算尺寸與 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,瀏覽器可能需要反覆處理:
1Container width change
2→ Style Calculation
3→ Layout
4→ Highcharts reflow
5→ Layout
6→ 下一個 animation frame
7→ 再來一次因此目前的做法是等待 Side Menu 的 transitionend。
1const reflowAllCharts = () => {
2 Highcharts.charts.forEach((chart) => {
3 if (chart && !chart.renderer?.forExport) {
4 chart.reflow();
5 }
6 });
7};
8
9const onSideMenuTransitionEnd = (event) => {
10 if (event.propertyName !== 'margin-left') return;
11
12 const target = event.target;
13
14 if (!target?.classList?.contains('side-menu')) return;
15
16 reflowAllCharts();
17};流程變成:
1Side Menu animation
2 ↓
3container width 持續變化
4 ↓
5等待 transitionend
6 ↓
7Layout 穩定
8 ↓
9統一 chart.reflow()這樣可以避免在動畫過程中不斷要求圖表重新計算尺寸。
效能分析時,很容易把下面幾件事混在一起:
1React re-render
2Highcharts reflow
3Highcharts redraw
4Highcharts recreate但它們的成本來源完全不同。
如果真正的問題是:
1Layout change
2→ chart.reflow()那一直研究:
1React.memo()
2useMemo()
3useCallback()不一定能解決問題。
這次更核心的問題其實是:
Layout 變化發生的時機,以及 Highcharts 何時被要求重新計算尺寸。
因此 Chrome Performance 裡的:
1Recalculate Style
2Layout反而比單純看 React render 次數更重要。
這次最直接的改善,就是重新檢查:
這個 Component 真的需要知道 sidebar 開關狀態嗎?
很多情況答案其實是:
不需要。
圖表真正需要知道的可能只是自己的 container size,而不是:
1isSidebarOpen所以我直接移除圖表 Component 對這個 global state 的訂閱。
1function RevenueChart() {
2 const isSidebarOpen = useLayoutStore(
3 (state) => state.isSidebarOpen
4 );
5
6 return <Chart />;
7}1function RevenueChart() {
2 return <Chart />;
3}這個改動看起來很小,但意義很重要。
因為 Zustand selector 雖然能避免其他 state 改變造成更新,但只要你訂閱的 state 本身改變,Component 就一定會收到更新。
所以效能優化不能只是問:
「我的 selector 有沒有寫好?」
還要再問:
「這個 Component 到底為什麼需要訂閱它?」
有時候最有效的 state optimization,就是根本不要 subscribe。
這也是這次優化後,我覺得很值得特別記錄的一點。
很多人在看 React Component 時,容易注意:
1useEffect
2useMemo
3useCallback
4props但 global state subscription 本身也是 Component dependency。
例如:
1const foo = useStore((state) => state.foo);其實等同於告訴 React:
1只要 foo 改變,
2這個 Component 就有可能需要重新處理。所以當一個頁面存在大量昂貴 Component,例如:
就應該特別注意這些 Component 訂閱了哪些 state。
處理完 Component subscription 後,有另一個頁面依然非常卡。
原本很自然地懷疑:
1window resize
2→ Highcharts reflow
3→ chart redraw但進一步 profiling 後,真正花時間的地方反而落在瀏覽器的:
1Recalculate Style
2Layout
3Render最後發現其中一個問題來自 Highcharts 的文字樣式設定。
例如:
1style: {
2 fontSize: '1em'
3}em 本身沒有錯。
但當頁面存在大量 SVG / chart elements,而且瀏覽器必須頻繁重新計算 layout 時,相對單位可能增加額外的 style resolution 成本。
改成:
1style: {
2 fontSize: '14px'
3}之後,這部分計算成本明顯降低。
這也是為什麼效能問題不能只靠閱讀 React code 猜。
你看到的可能是:
1React re-render真正 Chrome Main Thread 忙的卻可能是:
1Style
2Layout
3PaintuseHTML另一個很有感的地方是 Highcharts 的:
1useHTML: trueHighcharts 很多 label、tooltip、data label 都可以選擇使用 HTML render。
例如:
1dataLabels: {
2 useHTML: true
3}這很好用,尤其需要複雜樣式時。
但它不是免費的。
如果使用 SVG:
1Highcharts
2 ↓
3SVG text如果使用 useHTML: true:
1Highcharts
2 ↓
3HTML DOM
4 ↓
5Browser style calculation
6 ↓
7Layout當一張圖有大量 label,而頁面又存在多張圖表時,DOM 與 style calculation 的成本就可能快速累積。
如果其實不需要 HTML 能力:
1dataLabels: {
2 useHTML: true
3}1dataLabels: {
2 useHTML: false
3}這種修改有時甚至比調整 React state 更直接。
這次優化有一個很典型的現象。
一開始看到:
1操作 UI
2→ 頁面卡很容易開始找:
1React
2Zustand
3memo
4useMemo但 Web Application 的 Main Thread 工作其實包含很多層:
1JavaScript
2 ↓
3React render
4 ↓
5DOM / SVG 更新
6 ↓
7Style Calculation
8 ↓
9Layout
10 ↓
11Paint
12 ↓
13Composite任何一層都可能成為 bottleneck。
例如這次就同時存在:
| 問題 | 所在層級 |
|---|---|
| Zustand 過度訂閱 | State / React |
| 圖表 Component re-render | React |
| Highcharts redraw | Library |
1em style calculation | Browser |
useHTML | DOM / Layout |
所以如果只一直增加:
1React.memo()
2useMemo()
3useCallback()可能只是很努力地優化錯的地方。
經過這次優化後,我目前比較習慣按照下面順序處理。
例如:
1S1:首次進入頁面
2S2:切換 Sidebar
3S3:切換股票
4S4:操作 Chart Filter
5S5:Scroll / Resize不要只說:
「這頁很卡。」
必須先定義:
什麼操作會卡?
主要觀察:
1Long Task
2Recalculate Style
3Layout
4Paint
5Function Call
6Event如果主要時間在:
1JavaScript再往 React / JS 找。
如果主要時間在:
1Layout
2Style
3Paint就不要一直對著 useMemo 拜拜。
例如:
1props changed?
2state changed?
3context changed?
4global store changed?
5parent rendered?尤其是:
1useStore(...)要檢查 Component 是否真的需要這個 subscription。
像 Highcharts 這類 library,本身就有自己的:
1render
2redraw
3reflow
4animation
5SVG
6HTML生命週期。
React render 少一次,不代表 Highcharts 一定少一次 redraw。
一定要分開看。
不是每個慢的 Component 都值得重構。
例如某個頁面如果:
1使用頻率低
2+
3需要同時改 Frontend
4+
5需要 Backend API redesign
6+
7預估收益有限那合理決策可能就是:
先不要動。
效能優化不是看到紅色 Performance flame chart 就全部消滅。
真正重要的是:
1Impact / Cost也就是優化的投資報酬率。
這次圖表效能優化讓我再次確認:
Frontend Performance 最麻煩的地方,就是「看到的症狀」和「真正的原因」經常不在同一層。
一次 Hamburger Toggle 的卡頓,最後可能牽涉:
1Global State
2→ React Subscription
3→ Component Render
4→ Highcharts
5→ SVG / HTML
6→ CSS Style Calculation
7→ Browser Layout所以比起一開始就決定:
「我要加 memo。」
我現在更傾向先問:
1瀏覽器到底在忙什麼?找到真正 bottleneck 後,再決定要從:
哪一層下手。
而有時候最好的優化並不是什麼複雜技巧。
可能只是:
1- useStore(state => state.isSidebarOpen)或:
1- fontSize: '1em'
2+ fontSize: '14px'甚至:
1- useHTML: true
2+ useHTML: false真正困難的從來不是把那一行 Code 改掉。
而是找到:
到底是哪一行值得改。
你的意見會幫助我們持續改善內容。