简介:Canvas动态心电图绘制是HTML5图形编程中极具实用价值的案例,特别适合前端开发者、医疗信息化从业者以及物联网数据可视化爱好者。内容围绕心电图实时渲染场景,完整演示了从原始ECG数据解析、时间与电压坐标映射到定时器驱动画面刷新的全过程,可帮助读者快速搭建浏览器端实时监护仪效果。压缩包共7个文件,其中html文件为页面入口,两个js脚本分别实现绘图逻辑与jQuery依赖,css负责界面样式,两个txt文件则包含使用说明与插件来源记录,整体大小仅41KB,非常轻量。目前已有3003人学习下载,属于小而精的实战类教程。通过学习这套示例,读者能掌握clearRect清屏、moveTo与lineTo连线、requestAnimationFrame定时更新等核心绘图技巧,并理解数据异常处理、缺失值填充与性能优化等工程化思路,是一份可直接运行、便于拆解学习的Canvas进阶参考。
1. canvas 绘制动态心电图:为什么实时波形我首选 canvas
接到可视化大屏需求时,对方往往甩来一句“要一个跳动的、像医院监护仪那样的心电图”。用 canvas 绘制动态心电图,本质上是在解决一类更普遍的问题:数据持续到达、画面每秒更新几十次、波形不能闪烁也不能断层。ECharts 的 line 图虽然能应付缓慢刷新,但一旦数据点每秒增长几十个、还要带网格和拖影效果,DOM 和 SVG 的更新成本就会拖垮主线程。canvas 直接把绘图变成“画像素”的操作,性能可控、效果自由,而且不依赖任何图表库。
适合用 canvas 做动态心电图的场景主要有三类:医疗或健康类产品的数据演示、物联网设备状态监控面板、以及需要自绘实时曲线的技术验证项目。如果你只是做静态报表图,不要用这套方案;但凡是“数据一直在来、曲线一直在动”的页面,canvas 是性价比最高的选择。这篇笔记会从波形数据的生成讲起,一路写到滚动实现、参数调优和踩坑记录,目标是让你照着代码半小时内跑出一个像模像样的动态心电图。后续提到的所有实现,我都基于纯前端 canvas 2D 接口,不引入任何框架依赖。
2. 心电波形从生理信号到 canvas 坐标:PQRST 怎么变成可绘制数据
2.1 心电图波形不像正弦波:它是一组事件的组合
动态心电图的可视化难点不在滚动,而在“画出来的波形像不像真的”。真实的 ECG 波形由一个周期的 P 波、QRS 波群和 T 波组成,P 波是圆润的小突起,QRS 是尖锐的快速摆动,T 波是较宽缓的大起伏。如果直接用 Math.sin 生成数据,画出来的是平滑波浪线,一眼就能看出是假的。
常见做法是用数学函数去近似每个波段的形态。P 波用窄的高斯曲线模拟,QRS 波群用一组快速衰减的振荡叠加,T 波用较宽的高斯曲线。把这三段按时间轴拼接,就能得到一个周期约 0.8 秒的仿真波形。下面这段代码生成了 200 个采样点的一个完整周期,采样率对应 250Hz,正好覆盖一次完整的心搏。
// 生成一个心动周期的波形采样点 function generateBeat(samplesPerBeat) { const points = []; for (let i = 0; i < samplesPerBeat; i++) { // 将采样点映射到 0~1 的时间轴,一个周期约 0.8s const t = i / samplesPerBeat; let v = 0; // P 波:中心在 0.18 附近,宽度较窄 v += 0.1 * gaussian(t, 0.18, 0.022); // QRS 波群:中心在 0.38 附近,主峰高、宽度窄 v += 1.0 * gaussian(t, 0.38, 0.009); v -= 0.12 * gaussian(t, 0.34, 0.006); v -= 0.08 * gaussian(t, 0.42, 0.007); // T 波:中心在 0.62 附近,宽度较宽 v += 0.28 * gaussian(t, 0.62, 0.035); points.push(v); } return points; } function gaussian(x, center, width) { const d = (x - center) / width; return Math.exp(-0.5 * d * d); }gaussian函数返回的是标准高斯曲线的值,范围在 0 到 1 之间,通过调整 center 和 width 控制每个波出现的位置和宽窄。QRS 主波给了 1.0 的幅值,P 波只有 0.1,T 波 0.28,这样画出来的波形幅度关系符合真实心电图的视觉比例。实际项目里你不需要精确还原医学信号,视觉上“像那么回事”就够用。
2.2 从数据点到画布像素:坐标换算是第一个门槛
波形数据是相对值,范围大概在 -0.3 到 1.0 之间,而 canvas 的坐标系原点在左上角、Y 轴向下。如果直接把数据当坐标画,波形会倒过来并且挤在画布顶部。换算的核心是两件事:把数据范围映射到画布高度,把时间轴映射到画布宽度。
我一般会把换算封装成一个独立的函数,外部只传数据值和画布尺寸,内部统一处理翻转和缩放。基础换算逻辑如下:
function mapPoint(value, index, width, height, scaleY) { const x = index * (width / totalSamples); // 注意:心电图习惯上电压向上为正,canvas Y 轴向下,所以要翻转 const y = height / 2 - value * height * 0.4 * scaleY; return { x, y }; }height / 2是基线位置,value * height * 0.4把幅值 1.0 的 QRS 波限制在画布高度的 40% 以内,scaleY是外部传入的纵向缩放系数,用于适配不同尺寸的容器。这段逻辑在静态绘制和动态滚动中都会用到,建议写在公共模块里而不是散落在各处。
真实心电图还有一个视觉细节:波形不是从画布最底部开始的,而是有一条水平基线,PQRST 波都叠在基线上。所以 mapPoint 里的基线偏移量height / 2可以按需调整,比如改成height * 0.6,让波形偏上一些,底部留出空间给时间刻度。
2.3 生成连续数据流:动态心电图的数据源设计
单周期波形画一次只能得到静态图,动态效果需要源源不断的数据。常见做法是把连续心电信号看作无限长的采样流,用一个缓冲区保存最近 N 个采样点,新数据从尾部追加、旧数据从头部移除。缓冲区长度决定了画布上可见的波形时间跨度,比如画布宽度 800 像素、每个采样点间距 4 像素,那么缓冲区至少保留 200 个点。
为了模拟真实数据源,我用定时器按固定频率生成新采样点,每次把缓冲区尾部的点前移,推入新点。数据生成和绘制的节奏需要解耦——数据源可以以 60Hz 生成,绘制用 requestAnimationFrame 按屏幕刷新率执行,两者之间通过缓冲区衔接。
class EcgBuffer { constructor(maxLength) { this.buffer = new Float32Array(maxLength); this.length = 0; } push(value) { // 数据满时整体前移,丢弃最旧的点,新点追加到尾部 if (this.length === this.buffer.length) { this.buffer.copyWithin(0, 1); this.buffer[this.length - 1] = value; } else { this.buffer[this.length] = value; this.length++; } } }用Float32Array而不是普通数组,是因为心电数据量大且持续追加,TypedArray 的内存布局连续、访问速度快,而且copyWithin可以在底层直接搬移内存块,比Array.prototype.shift逐元素移动高效得多。缓冲区长度和画布宽度的关系要提前算好:如果画布显示 10 秒数据、采样率 250Hz,缓冲区就是 2500 个元素,前端内存占用只有 10KB,完全不用优化。但如果你用普通数组存对象,每个点的开销放大几十倍,跑几分钟就可能感受到 GC 压力。
3. 动态滚动怎么做:离屏 canvas + requestAnimationFrame 的最小实现
3.1 三种滚动方案,我为什么选离屏 canvas
拿到波形数据后,让画面“动起来”有三种常见路径。第一种是每次重绘所有数据点,简单直接,但数据量一上来 CPU 占用肉眼可见;第二种是离屏 canvas 预渲染 + 每帧位移,只画一次波形,之后不断平移;第三种是增量绘制,每帧只画新增的一列像素。中小规模数据下,离屏 canvas 是性能和代码复杂度最平衡的方案。
离屏 canvas 的思路是:把一段固定长度的波形画在一个不显示在页面上的 canvas 上,然后每一帧通过 drawImage 把它贴到主 canvas 的偏移位置,新数据到来时补充绘制到离屏 canvas 的末尾。这样做的核心优化是避免了每帧遍历几百个数据点重新计算坐标,把“画一条完整的线”变成了“贴图 + 补几个点”。
代价是离屏 canvas 需要自己管理尺寸、刷新和清理逻辑,稍微多几行代码。但对比每一帧重绘全部波形,CPU 消耗通常能降一半以上。如果你的数据量只有几十个点,直接全量重绘也行,不过动态心电图动辄几百上千个点,还是建议按下述方案做。
3.2 最小可运行版本:离屏 canvas + 滚动拼接
下面这段代码,把上一章的数据源和离屏 canvas 拼接起来,构成一个能跑的最小动态心电图。代码分为三部分:初始化离屏画布、数据推进、帧循环渲染。
const canvas = document.getElementById('ecg'); const ctx = canvas.getContext('2d'); const width = 800; const height = 240; canvas.width = width; canvas.height = height; // 离屏 canvas:预绘制一整屏波形 const offscreen = document.createElement('canvas'); offscreen.width = width; offscreen.height = height; const offCtx = offscreen.getContext('2d'); // 每帧横向移动的像素数,决定波形的“流速” const speed = 2; // 上次帧的时间,用于帧率无关的位移计算 let lastTime = 0; let offset = 0; // 模拟数据源:每帧生成一个新采样点 const buffer = new EcgBuffer(width / 4); // 把缓冲区中的所有点画到离屏 canvas 上 function renderWave(ctxTarget, targetWidth) { ctxTarget.clearRect(0, 0, targetWidth, height); ctxTarget.beginPath(); for (let i = 1; i < buffer.length; i++) { const x1 = (i - 1) * 4; const y1 = height / 2 - buffer.buffer[i - 1] * height * 0.4; const x2 = i * 4; const y2 = height / 2 - buffer.buffer[i] * height * 0.4; ctxTarget.moveTo(x1, y1); ctxTarget.lineTo(x2, y2); } ctxTarget.stroke(); } function frame(time) { const dt = Math.min(time - lastTime, 50) / 16.67; // 归一化到 60fps lastTime = time; // 模拟数据:每次推进 2 个点 for (let i = 0; i < 2; i++) { buffer.push(nextSample()); } // 重新绘制离屏 canvas,并让波形向左移动 speed * dt 像素 offset = (offset + speed * dt) % width; renderWave(offCtx, width); // 主 canvas 先清屏,再把离屏内容贴过去 ctx.clearRect(0, 0, width, height); ctx.drawImage(offscreen, -offset, 0); // 头部补一块,避免平移后左侧出现空白 ctx.drawImage(offscreen, width - offset, 0); requestAnimationFrame(frame); } requestAnimationFrame(frame);代码里有几个关键点需要说明。renderWave每次把缓冲区全部重画到离屏 canvas,这一步看似重复劳动,但离屏 canvas 的绘图开销不直接阻塞主渲染流水线,实际跑下来每帧耗时远低于直接在主 canvas 上重绘。offset累加到width后取模,配合两张 drawImage 实现无缝平铺,这是滚动心电图最核心的视觉技巧——只要补图的位置正确,肉眼看不到拼接缝隙。
dt的计算让波形速度与屏幕刷新率解耦。如果设备是 120Hz 屏幕,time - lastTime约为 8.3ms,除以 16.67 后速度自动降为 0.5 倍,波形在高速屏和普通屏上的物理速度一致。这是很多人忽略的细节:不做帧率归一化,同一套代码在不同手机上滚动速度可能差一倍。
3.3 数据生成频率与滚动速度的匹配
动态心电图最影响观感的是“数据推进速度”和“滚动速度”的关系。如果每次帧循环生成 2 个数据点,而波形横向间距是 4 像素,那么新数据占 8 像素宽度;与此同时 offset 每帧移动 2 像素,8 像素的新数据足够填满滚动腾出的空间,画面就不会出现断层或跳动。
如果数据生成速度跟不上滚动速度,波形会被横向拉伸,看起来像被拽长;反过来数据太快,波形会堆积、变密。这两个值的配比需要按画布宽度和期望的时间跨度来反推。比如要让屏幕上显示 10 秒波形、画布宽 800 像素、屏幕 60Hz,每个采样点间距 4 像素,那么每帧需要生成 200 / 60 ≈ 3.3 个点,推进 4 像素的滚动速度才匹配。
// 参数配置:显示时长 10s、画布宽 800px、60fps // 每个采样点间距 4px -> 每帧前进 4px -> 需要生成 3.3 点/帧 // 实际取整:每帧生成 3 或 4 个点,长期均值约 3.3 let sampleAccumulator = 0; function pushSamples() { sampleAccumulator += 200 / 60; // 每秒 200 点,对应 50Hz 采样率 while (sampleAccumulator >= 1) { buffer.push(nextSample()); sampleAccumulator -= 1; } }用累加器来生成数据,比固定“每帧生成 2 个点”的写法更贴近真实采样场景。真实心电信号源可能是 250Hz 或 500Hz,前端不需要渲染那么高的采样率,通常降采样到 50~100Hz 就足够流畅。累加器方案天然支持这个处理:源数据用定时器推入一个队列,帧循环里按需从队列取点。你只需要调sampleAccumulator的增量值,就能平滑控制波形的密度,不需要改动渲染逻辑。
4. 网格、配色与刷新率:让波形像监护仪的关键参数
4.1 网格绘制的三层结构:细格、粗格与当前时刻线
监护仪的心电图背景不是纯色的,而是由很小的正方形网格组成,视觉上既能辅助定位波形幅度,又不像坐标轴那么生硬。绘制网格分三层:最底层的细网格、中间的粗网格、最上层的当前时刻扫描线。
细网格通常是 5px 间距的浅色线,粗网格是 25px 间距的深色线,对应医学心电图纸上 0.2s 和 1s 的时间刻度。绘制时注意先画细线再画粗线,粗线最后覆盖在细线上。一个常见的性能坑是每帧重新绘制所有网格线,其实网格是静态的,只需要在初始化或窗口尺寸变化时画到离屏 canvas 上,绘制波形时通过 drawImage 贴底图即可。
function drawGrid(targetCtx, width, height) { targetCtx.clearRect(0, 0, width, height); // 细网格:5px 间距,半透明灰色 targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; targetCtx.lineWidth = 1; for (let x = 5; x < width; x += 5) { targetCtx.beginPath(); targetCtx.moveTo(x, 0); targetCtx.lineTo(x, height); targetCtx.stroke(); } // 粗网格:25px 间距,透明度略高 targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.14)'; for (let x = 25; x < width; x += 25) { targetCtx.beginPath(); targetCtx.moveTo(x, 0); targetCtx.lineTo(x, height); targetCtx.stroke(); } // 水平线同理,画法一致,省略 }细网格的透明度要控制得比粗网格低,否则画面会显得脏。水平网格线和垂直网格线分别用两个循环画是为了避免在循环里反复切换 strokeStyle,减少绘图状态切换的开销。实际项目中网格线数量不多,性能影响很小,但保持这个习惯能让你在网格数量变大时不至于临时优化。
4.2 配色方案:深色背景 + 荧光绿的经典组合
监护仪上最经典的配色是近黑色背景配亮绿色波形,这个组合在视觉上对比度高、长时间观看不刺眼。深色背景下网格线的透明度和波形颜色都需要单独调优:波形主色用#00ff88这类高亮绿,线宽 1.5~2px;如果希望波形有拖影感,可以叠加一层shadowBlur让线条边缘发光。
shadowBlur 的效果很直观,但它也是影响帧率的最大隐患之一。每帧绘制大段路径时开启 shadowBlur,会让浏览器对每个路径片段做一次高斯模糊计算,数据点越多开销越大。推荐的做法是波形主体关闭阴影,用一个低透明度的离屏 canvas 单独画“发光层”,两帧叠加形成拖影效果。或者干脆只在静态展示时开启阴影,动态滚动时关闭。
ctx.shadowColor = 'rgba(0, 255, 136, 0.5)'; ctx.shadowBlur = 8; ctx.strokeStyle = '#00ff88'; ctx.lineWidth = 1.8; // 绘制完波形后立即关闭,避免影响后续绘图 ctx.shadowBlur = 0;要注意 strokeStyle、阴影状态属于 canvas 的绘图状态,不清除的话会污染后续的网格或文字绘制。代码里在绘制结束后马上把 shadowBlur 重置为 0,这是避免“画着画着所有东西都开始发光”这类玄学问题的最直接手段。
4.3 参数速查表:从网格到刷新率的推荐值
动态心电图的参数不像业务图表那样有统一标准,但按视觉习惯有一组比较稳妥的起点值。我在不同项目里用的参数基本如下,按需微调即可。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 细网格间距 | 5px | 对应约 0.04s 时间刻度 |
| 粗网格间距 | 25px | 对应约 0.2s 时间刻度,5 格细网格 |
| 波形主色 | #00ff88 | 高亮绿,深色背景对比度最好 |
| 波形线宽 | 1.5~2px | 小于 1px 在 Retina 屏会发虚 |
| 背景色 | #0a0e14 | 偏蓝黑的深色,比纯黑有层次 |
| 采样点间距 | 4px | 波形密度适中,不疏不密 |
| 波形滚动速度 | 2px/帧(60fps) | 相当于每 0.4 秒滚过一屏宽度 |
| 刷新率 | 60fps | requestAnimationFrame 默认即可 |
采样点间距 4px 配合 2px/帧 的滚动速度,意味着波形以半速平移,视觉上既有明显的滚动感,又不会快到看不清 PQRST 形态。速度参数最后要以“每秒滚动多少像素”为基准调,帧率归一化逻辑让不同设备看到的速度一致。网格间距与采样点间距之间没有强耦合关系,但保持 5px 与 4px 的接近度,网格与波形会形成一种“波形在格子上流动”的和谐观感,间距差距太大就会显得错位。
5. 绘制动态心电图的 4 个避坑记录:从模糊到内存泄漏
5.1 波形模糊:Retina 屏上所有线条像被雾挡着
现象:同样一套代码在普通屏上波形清晰,在 Retina 屏或高 DPR 的手机上线条发虚、边缘有毛刺。
原因:canvas 的绘图缓冲区尺寸默认等于 CSS 尺寸,而高 DPR 屏幕一个 CSS 像素对应多个物理像素,canvas 没有足够的分辨率,浏览器只能把低分辨率位图拉伸放大,自然就模糊了。
解决:初始化时将 canvas 的实际宽高乘以 devicePixelRatio,再用 CSS 样式把显示尺寸固定为原值。具体做法是canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px';并把 ctx.scale(dpr, dpr) 设置好。这样所有后续绘制逻辑都不需要改,因为变换矩阵已经把逻辑坐标放大了。离屏 canvas 同样要做 dpr 处理,否则滚动贴图时依然会模糊。
const dpr = window.devicePixelRatio || 1; const width = 800; const height = 240; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; ctx.scale(dpr, dpr);把 dpr 处理封装在初始化函数里,不要散落在绘制逻辑各处。滚动的离屏 canvas 也要用同样的方式创建,特别是 drawImage 的坐标参数在 dpr 变换下要仔细核对。一个容易翻车的地方是:如果离屏 canvas 没有乘以 dpr,而主 canvas 乘了,drawImage 的缩放会比预期小,波形会在滚动中慢慢变形。
5.2 内存持续上涨:滚动一小时后页面明显卡顿
现象:页面运行几分钟到几十分钟后,DevTools 里的内存曲线稳步上升,操作开始卡顿。
原因:最常见的元凶是 requestAnimationFrame 回调里创建了新的对象或闭包,导致每帧产生垃圾;其次是定时器或事件监听没有清理,组件卸载后仍在跑。心电图的渲染循环是常驻的,任何微小的泄漏都会在长时间运行后被放大。
解决:在帧循环外创建所有可复用的对象,循环体只做赋值和赋值计算。比如坐标点数组用Float32Array预先分配,不要在循环里 push 新对象;生成波形数据的高斯函数只在初始化时调用一次,把结果缓存为静态数组,而不是每帧重新计算。
// 坏习惯:每帧都创建新数组和对象 function frame() { const points = []; // 每次创建新数组,产生垃圾 for (let i = 0; i < n; i++) points.push({ x: i, y: i }); } // 好习惯:预分配,帧循环内只更新值 const xs = new Float32Array(n); const ys = new Float32Array(n); function frame() { for (let i = 0; i < n; i++) { ys[i] = Math.sin(i); // 直接写入预分配的内存 } }排查内存问题时,先用 Performance 面板录制一段 1 分钟的操作,看内存曲线的形状。如果曲线是阶梯状上升后偶尔回落,说明存在大量临时对象,GC 在追赶;如果是持续爬升不回落,大概率是有对象被外部引用无法回收。经常被忽略的是 EventSource 或 setInterval 创建的数据源,页面切走时要主动 clearInterval,否则定时器里的闭包会一直持有 canvas 引用,组件卸载后内存也不会释放。
5.3 波形断层或跳变:滚动一快一慢,像在打嗝
现象:波形滚动不是匀速的,而是走一段停一下,或者偶尔从中间断开一截再续上。
原因:数据生成和帧渲染不同步。定时器生成数据的频率与 requestAnimationFrame 的刷新频率存在微小偏差,如果每次帧循环都从定时器队列里取固定数量的数据,某些帧取到 0 个、某些帧取到 5 个,波形就会出现疏密不均。
解决:放弃“定时器推数据 + 每帧取 N 个”的模式,改成帧循环里用累加器按时间轴推进采样。也就是说,渲染完全由 requestAnimationFrame 驱动,每帧根据真实经过的时间计算应该新增多少个采样点,而不是依赖定时器的回调次数。
let lastTime = 0; const samplesPerSecond = 200; function frame(time) { const dt = (time - lastTime) / 1000; // 秒 lastTime = time; // 按真实时间累计应产生的采样点数 sampleAccumulator += dt * samplesPerSecond; const n = Math.floor(sampleAccumulator); for (let i = 0; i < n; i++) { buffer.push(nextSample()); } sampleAccumulator -= n; requestAnimationFrame(frame); }这套逻辑下,不管屏幕是 60Hz 还是 144Hz,也不管主线程有没有被其他任务短暂阻塞,波形数据总量始终与真实时间一致。画面卡了一下之后,恢复时数据会一次性补齐,视觉上表现为“跳了一段”而不是“漏了一截”,更接近真实监护仪丢失一段信号再续上的表现。
5.4 帧率不稳:阴影和全屏重绘是两大元凶
现象:代码在低分辨率窗口运行流畅,一旦全屏或换高分辨率屏幕,帧率从 60 掉到 30 以下,波形明显掉帧。
原因:全屏重绘所有元素 + 开启 shadowBlur,等于每帧把画布内所有像素重新计算一遍,还要叠加模糊滤镜。分辨率翻倍时,绘图工作量变成原来的 4 倍,帧率自然腰斩。
解决:分三步优化。第一,确认网格线和背景绘制在离屏 canvas,每帧只 drawImage 贴底图,不重新绘制网格路径;第二,关闭波形阴影,用单一 strokeStyle 绘制;第三,只绘制可见区域的波形,不遍历整个缓冲区——滚动时只有右侧新进入画面的波形和新数据补绘需要处理,其余区域通过 drawImage 平移即可。最后这一步就是增量绘制的思路,也是下一章的重点,优化空间最大。
6. 进阶技巧:增量绘制把帧率提高一倍
前面所有实现的画法都是“每帧重绘整条波形路径”。即使用了离屏 canvas,每帧绘制数百个点的路径对 CPU 仍是负担。进一步提升的办法是不重绘整条线,而是把已有画面整体左移,只在右侧补画新增的波形片段。这就是增量绘制,本质上把“每帧画几百个点”降级为“每帧画 2~3 个点加一次位图平移”。
实现思路:准备一个和主 canvas 等尺寸的离屏 canvas,每帧先把当前内容通过 drawImage 向左平移 delta 像素,最左侧多出的部分被裁掉,右侧空出的区域只补画新采样点对应的线段。关键点是平移要用整数像素,否则位图缩放会带来额外的模糊开销。
// 每帧:左移 2 像素,右侧补画新波形 const shift = 2; function frame() { // 1. 把现有画面左移 shift 像素 ctx.drawImage(offscreen, shift, 0, width - shift, height, 0, 0, width - shift, height); // 2. 在右侧空白区域补画新线段 const newX = width - shift; const prevX = newX - 4; const prevY = height / 2 - buffer.buffer[buffer.length - 2] * height * 0.4; const newY = height / 2 - buffer.buffer[buffer.length - 1] * height * 0.4; ctx.beginPath(); ctx.moveTo(prevX, prevY); ctx.lineTo(newX, newY); ctx.stroke(); // 3. 把结果同步回离屏 canvas,供下一帧平移 offscreen.getContext('2d').drawImage(canvas, 0, 0); }这套方案有一个比较隐蔽的坑:每次 drawImage 平移再 drawImage 回离屏 canvas,经过两次位图拷贝后,波形线条会因为缩放出现细小的锯齿累积。解决办法是给波形多画 1 像素的余量(lineWidth 设为 2px),并确保平移量是整数。如果做了 dpr 缩放,平移量也要乘以 dpr 后取整。
增量绘制并不是万能的,它适合波形为单色线条、背景纯色且网格已经固定为底图的情况。如果你需要叠加复杂的动态效果(比如波形渐变色、多通道叠加、区域填充),全量重绘反而更简单也更快——因为增量绘制需要同时维护多个源的偏移状态,复杂度上升得很快。我在实际项目里的习惯是:单通道心电图用增量绘制,多通道或带交互高亮的用离屏 canvas 全量重绘,不做过度优化。
跑完这几章的内容,你已经能把一个画着 PQRST 波形、滚动平滑、带网格的 canvas 心电图放进任何前端项目里。我踩过最痛的一个坑是一开始没做 dpr 处理,辛辛苦苦调好的波形在 Mac 上模糊得像近视眼摘了眼镜,那一刻的感受至今记得。从那以后我把初始化、数据源、渲染循环三层严格分离,换到任何项目只要拷贝这三段,改一改参数就能复用。心电图绘制这套思路,同样适用于股票分时图、实时传感器曲线、音波可视化——数据驱动的实时曲线绘制,核心都是这三板斧。希望帮到你。
本文还有配套的精品资源,点击获取