一個天真的 append,不到一分鐘就讓即時圖表當掉
每個 tick 往陣列後面接一筆,前十秒看起來完全正常。實測每秒 3,000 筆時,大約第 55 秒撞破幀預算,然後超線性惡化。

即時圖表做好了。你 demo 過,很順,大家都滿意。然後它跑過一次開盤,分頁就沒辦法用了。
Bug 只有一行,而且看起來是對的
setData((data) => [...data, tick]);
那個陣列從不縮短。每個 tick 都會複製它前面的所有東西,所以一個 tick 的成本 隨著頁面開多久而成長。它不是傳統意義上的記憶體洩漏——沒有任何東西無法回收—— 但效果一樣,而且更糟,因為工作量也跟著長。
要看幀時間,不是幀率
這是問題藏起來的地方。requestAnimationFrame 封頂在螢幕更新率,所以幀率在你
已經超支之前一直都讀 60。它是一顆只有「過/不過」的燈,沒有刻度。
做下面這個工具時兩個都量了。每秒 3,000 筆時,天真 append 每秒在做六千八百萬次 陣列元素複製,而幀率仍然是健康的 60。幀時間是 2.46 毫秒——16.7 毫秒預算的 15%, 而且在爬。
每幀 0.00 毫秒 · 用掉 16.7 毫秒預算的 0%
記憶體裡保留著 0 個點
每秒複製 0 個值
那條斜坡,量出來的樣子
天真 append,每秒 3,000 筆,視窗 2,000 點,一台普通筆電:
| 經過時間 | 每幀時間 | 佔幀預算 | 保留點數 |
|---|---|---|---|
| 10 秒 | 3.06 毫秒 | 18% | 29,045 |
| 30 秒 | 5.73 毫秒 | 34% | 90,142 |
| 50 秒 | 10.99 毫秒 | 66% | 149,717 |
| 60 秒 | 21 毫秒 | 126% | 180,394 |
| 70 秒 | 72 毫秒 | 431% | 210,149 |
大約在第 55 秒越線。然後它變成超線性:十秒之內從 21 毫秒衝到 72 毫秒。 那是回饋迴路——一旦一幀花的時間超過 tick 間隔,每幀累積的 tick 就更多, 複製量更大,於是更慢。
前三十秒沒有任何徵兆。 這就是為什麼這種東西會上線。
三種策略,同一個視窗
同一組測試,第十秒時:
| 策略 | 每幀時間 | 每秒複製值數 | 保留點數 |
|---|---|---|---|
[...data, tick] |
3.06 毫秒 | 68,523,000 | 無限成長 |
[...data.slice(-n), tick] |
0.29 毫秒 | 6,000,000 | 2,000 |
| 環形緩衝區 | 0.04 毫秒 | 3,000 | 2,000 |
環形緩衝區的每幀成本是天真 append 的 1/61,而且是平的——它的成本不隨
視窗大小或執行時間改變,因為它只覆寫一個預先配置好的 Float64Array 裡的一格,
之後再也不配置記憶體。
不過也注意切視窗的代價:每秒複製六百萬個值,只為了顯示兩千個。它是有界的, 那才是重點,但它不是免費的。
實際該做的三件事
- 一定要把緩衝區限住。 如果只改一件事就改這個。切成固定視窗比天真 append 只多打幾個字,卻完全消滅了無限成長。
- 量幀時間,不是幀率,並且拿它對 16.7 毫秒比。幀率只會在來不及之後才告訴你。
- tick 率上到幾千才需要環形緩衝區——市場資料、遙測、音訊。在那之下切視窗 就夠了,而且更簡單,簡單的贏。
常見追問
看幀率不對嗎?
不對,而這正是陷阱。requestAnimationFrame 封頂在螢幕更新率,所以幀率在你 已經超支之前一直都讀 60。幀時間才看得到那條上升的斜坡,而那時你還有餘裕可用。
為什麼越線之後惡化得那麼快?
因為它變成回饋迴路。一旦一幀花的時間超過 tick 間隔,每一幀累積的 tick 就更多, 複製量又更大,於是更慢。實測:第 60 秒 21 毫秒,第 70 秒 72 毫秒。
切成固定視窗夠不夠?
通常夠。它把記憶體限住了,而且在同一組測試裡比天真 append 便宜 8 倍。 但它每個 tick 仍然配置一個長度 n 的新陣列——每秒 3,000 筆、視窗 2,000 點的話, 那是每秒複製六百萬個值,只為了顯示兩千個。
什麼時候值得多寫環形緩衝區的程式碼?
當 tick 率高到「每個 tick 的配置」會出現在效能剖析上的時候——實務上就是 市場資料、遙測與音訊。一秒更新一次的儀表板用切視窗就好,也更簡單。