背景:为什么实时折线图会「数据不多却卡」
监控系统上线后,实时折线图的刷新率明明只有几百点/秒,曲线却在滚屏时一卡一顿;DevTools Performance 里时不时冒出几十毫秒的黄色长条,定位到最后并不是后端慢、也不是数据量大------是前端每帧都在用最高成本的方式重画整张图。2026 年 7 月 20 日公开的图表基准显示,单条 10 万点折线在 uPlot 里只要 9 ms 就能渲染完成,说明数据量本身远未到瓶颈,真正的瓶颈在「渲染架构」。
实时仪表盘、行情流、IoT 监控的折线图通常遵循同一套模式:一个固定宽度的视口,新点从右侧进入,旧点从左侧退出。大多数实现会在 requestAnimationFrame 回调里执行:
- 把新样本推进数据数组;
- 如果超过窗口容量,把最旧点
shift()掉; - 重新计算所有可见点的缩放坐标;
clearRect()清空画布,然后beginPath / lineTo / stroke重画整条线。
这套写法在小数据量时完全没问题,但当视口保留 5 万、10 万点时,直觉上「只是重画一条线」的代码会把 CPU 和 GC 同时拉爆。问题的吊诡之处在于:同一份 10 万点数据,在 uPlot(1.6.32)里渲染只需要 9 ms,在 ApexCharts 6 的 canvas 渲染器里也只 29 ms,远小于 60 fps 的 16.7 ms 帧预算。因此卡顿不是「点太多」,而是「每帧都从零开始」。
解剖:一次 redraw 到底发生了什么
为了把成本讲清楚,我把实时折线图的绘制拆成三种实现策略:

图1:逐段描边策略每帧派发 N 次 stroke;单 path 策略把 draw call 降到 1,但仍需每帧重建;环形缓冲策略 draw call=1 且无每帧分配。
- A · 逐段描边(naive_segment) :把每个线段都当成独立图形,循环里
beginPath(); moveTo(); lineTo(); stroke();。10 万点 = 9.9 万次stroke(),每一次都会触发一次完整的光栅化 pass。 - B · 单 path 每帧重建(naive_batched) :
beginPath()一次,循环里把所有lineTo()攒进同一条 path,最后stroke()。draw call 只有 1 次,但每帧仍要shift()数组、重算所有缩放坐标、重建 path,因此持续分配新内存。 - C · 环形缓冲 + 批量(optimized) :数据进入时只缩放一次并写进
Float32Array环形缓冲;每帧只维护头指针,按可见窗口顺序 emit 一次 path,0 次分配。

图2:逐段描边策略的 draw call 数随 N 线性爆炸(10 万点→9.9 万次/帧),单 path 与环形缓冲恒为 1。
策略 A 的问题最直观:浏览器里一次 stroke() 的开销不是 O(1) 的「画一笔」,而是「把整段 path 栅格化到像素缓冲」;当一帧里有 9.9 万次栅格化 pass 时,16.7 ms 的帧预算显然不可能满足。策略 B 看似把 draw call 降到最低,却掉进了另一个坑:每帧重建数组和路径。
实证:一次可复现的 Node 微基准
我用一个确定性随机游走生成样本,在 Node 22(managed)下跑 600 帧(等效 10 秒 @60fps),对比三种策略的每帧耗时、stroke 调用数和分配量。测试代码在同一目录的 bench_render.cjs 中,可直接复现:
bash
cd D:\WB_Files\__skills_tmp\csdn_auto\2026-08-04-evening
node bench_render.cjs
结果如下(保留点数 N 从 1k 到 200k):
| N | 策略 | 中位耗时 | p95 耗时 | stroke/帧 | alloc MB/s |
|---|---|---|---|---|---|
| 10k | A 逐段描边 | 0.030 ms | 0.032 ms | 9,999 | 4.8 |
| 10k | B 单 path 重建 | 0.015 ms | 0.016 ms | 1 | 4.8 |
| 10k | C 环形缓冲 | 0.011 ms | 0.016 ms | 1 | 0 |
| 100k | A 逐段描边 | 0.303 ms | 0.321 ms | 99,999 | 48 |
| 100k | B 单 path 重建 | 0.151 ms | 0.178 ms | 1 | 48 |
| 100k | C 环形缓冲 | 0.114 ms | 0.123 ms | 1 | 0 |
| 200k | A 逐段描边 | 0.611 ms | 0.646 ms | 199,999 | 96 |
| 200k | B 单 path 重建 | 0.300 ms | 0.324 ms | 1 | 96 |
| 200k | C 环形缓冲 | 0.228 ms | 0.246 ms | 1 | 0 |
注:这里的耗时只统计 JS 侧的「指令派发 + 坐标运算 + 分配/GC」开销,未包含浏览器实际 GPU 光栅化;真正的帧时间会显著高于表中数字。

图3:每帧重建路径的写法每秒向 GC 倾倒 48--96 MB,环形缓冲写法为 0。
关键发现:
- stroke 调用数决定 GPU 侧上限 :策略 A 在 100k 点时发出 99,999 次
stroke(),浏览器侧不可能跑到 60fps。 - B 看似只发 1 次 stroke,但分配压力会回噬主线程:100k 点时每秒 48 MB 的新对象会触发多次 GC pause,DevTools 里就会表现为周期性卡顿。
- C 把 draw call 和分配同时压到最低,每帧中位耗时比 B 低 25% 左右,更关键的是 alloc MB/s 为 0。
关键证据:浏览器侧为什么这决定了 60fps
Node 里测的是 JS 成本,浏览器里还要叠加 GPU/栅格化。公开基准恰好能填补这块空白。2026 年 7 月 20 日 ApexCharts 团队发布的 benchmark(ApexCharts 6.3.0、Chart.js 4.5.1、ECharts 6.1.0、uPlot 1.6.32 等,Apple M4 Pro + headless Chromium 147)显示:
- uPlot 渲染一条 10 万点折线只需 9 ms,核心原因正是「单条 path + 预缩放 ringbuffer」。
- ApexCharts 6 的 canvas 渲染器在 2 万散点场景比 SVG 快 3.9 倍:canvas 侧只产生 104 个绘制调用,而 SVG 产生了 20,102 个 DOM 节点。
- Chart.ts 的自动切换阈值也验证同一结论:0--5k 用 SVG,5k--50k 用 Canvas,5 万以上切 WebGL。
这些数字与本文 Node 微基准的「draw call 数随 N 线性爆炸」完全同源:决定实时图表是否流畅的不是库名,而是「每帧向渲染管线派发多少条独立指令」以及「是否在主线程制造持续分配」。
局限:这次复盘没覆盖什么
- 未测量真实 GPU 光栅化时间 :本例在 Node 里用 mock 2D 上下文,浏览器里的
stroke()实际耗时与 path 复杂度、fill-rule、阴影、线宽有关;文章用公开基准佐证趋势,而非给出浏览器帧时间。 - 未覆盖交互命中(hit-test):tooltip、hover 高亮需要把鼠标坐标映射回数据点,常用做法(空间索引、分桶、离屏 picking)会额外占用帧预算。
- 未覆盖降采样(downsampling):当点数远超屏幕像素宽度时,正确做法是 LTTB 等降采样算法先减少数据,而不是单纯优化渲染循环。
- 环形缓冲只适用于固定窗口的滚动流:缩放、平移、变长窗口需要维护额外数据结构。
结论与下一步
实时折线图卡顿,优先检查两件事:每帧向 GPU 发出多少条独立绘制指令 ,以及每帧在主线程制造多少临时分配。如果这两项都控制不住,再换更牛的库也只是在同一个坑里换一把更快的铲子。把「逐段描边」改成「单 path 批量描边」,再把「每帧重建数组」改成「预缩放 ringbuffer」,通常就能把 draw call 从 N 降到 1、把分配降到 0,效果比换库更直接。
开源地址: