简介这份资源面向前端开发者与医疗健康类应用开发者聚焦HTML5 Canvas在实时生理数据可视化中的落地实践解决浏览器端动态绘制心电图、模拟监护仪效果的问题。包内共7个文件以js脚本、html页面、css样式为主辅以txt说明与url快捷方式压缩包约41KB体量轻便便于快速运行与二次改造。核心逻辑围绕Canvas绘图API展开涵盖clearRect清屏重绘、moveTo与lineTo连线、lineWidth与配色控制以及setInterval或requestAnimationFrame驱动的定时刷新机制同时涉及ECG波形数据解析、时间轴与电压值到像素坐标的映射、网格线与刻度标注等细节。目前已有3002人学习下载读者可借此理解数据平滑、异常与缺失值处理、性能优化等排错思路并对照示例代码梳理从数据接入到图形渲染的完整链路适合作为动态图表与实时监测类项目的参考起点。1. 用 canvas 绘制动态心电图从一条会“跑”的波形说起监护仪上那条从左往右匀速滚动、遇到 QRS 波突然拔高的绿色曲线很多人第一次见都觉得是某种硬件魔法。其实把它的核心逻辑拆开就是 canvas 上一条不断被重绘的折线加上一个会移动的擦除边界。动态心电图ECG在 Web 端的实现本质是「数据流 定时刷新 局部擦除」三件事的组合跟医院里那台机器用的是同一套视觉原理只是数据源从电极换成了数组或 WebSocket。这个方向适合三类人做健康监测类前端、需要把实时生理信号可视化的工程师以及想找一个「有真实业务压力」的 canvas 练手项目的人。它比画静态图表难的地方在于——波形要动、要连续、不能闪烁、不能越界还要在低端设备上稳住帧率。下面按「先跑通最小闭环再谈性能与坑」的顺序讲代码可以直接抄。2. 先把最小闭环跑通canvas 尺寸、坐标系与滚动擦除2.1 为什么不用 SVG也不直接堆 div动态心电图每秒要重绘几十次每次涉及上千个点。SVG 的每个元素都是 DOM 节点点一多重排重绘的开销会直接把主线程拖死div 加 CSS 动画更不现实波形不是规则图形。canvas 的优势在于它是一块位图绘制指令直接落到像素缓冲区没有 DOM 树参与适合这种「高频、大量、局部更新」的场景。选 2D 上下文而不是 WebGL是因为心电波形是二维折线WebGL 的着色器管线在这里属于杀鸡用牛刀反而增加调试成本。真正需要 WebGL 的是同时渲染几十路导联、每路几万点的场景单导联动态心电图用 2D 足够。2.2 建立画布与设备像素比适配第一件事是把 canvas 的逻辑尺寸和物理像素分开否则在高分屏上波形会糊。// 初始化画布处理 devicePixelRatio 避免高分屏模糊 function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; // 逻辑尺寸用于绘制坐标计算 canvas.style.width cssWidth px; canvas.style.height cssHeight px; // 物理尺寸放大 dpr 倍 canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; const ctx canvas.getContext(2d); // 缩放上下文之后所有绘制都按逻辑坐标写 ctx.scale(dpr, dpr); return ctx; }逻辑说明canvas.width是位图实际像素数style.width是它在页面上占的 CSS 尺寸。两者不一致时浏览器会拉伸位图导致模糊。ctx.scale(dpr, dpr)之后你写ctx.lineTo(100, 50)就代表逻辑坐标 100、50不用自己乘 dpr。参数说明cssWidth、cssHeight按容器实际宽度取常见监护风格是宽高比 4:1 到 6:1。dpr 在普通屏是 1Retina 是 2 或 3不要写死。2.3 滚动擦除的两种思路与选型波形要「往左跑」有两种做法。第一种是整体平移每帧把所有已绘制的点左移一个像素。缺点是每帧要重画全部历史点点越多越慢而且平移会产生累积误差。第二种是游标 局部擦除维护一个 x 游标新点画在游标处游标右移当游标到达右边界时回到左边界同时用clearRect擦掉游标前方一小段。这样每帧只画新增的一小段历史波形原地不动视觉上就是「新波形从右边冒出来旧波形被左边擦掉」。我一般用第二种因为它把每帧的绘制量从「全部点」降到「一个采样间隔的点」帧率稳定得多。// 游标式滚动绘制核心 const state { cursorX: 0, // 当前绘制位置 lastY: null, // 上一个点的 y用于连线 speed: 2, // 每帧前进像素 eraseWidth: 40 // 擦除带宽度 }; function drawFrame(ctx, width, height, newValue) { // 归一化把生理值映射到画布高度 const y height - (newValue / 4096) * height; // 擦除游标前方一段形成“空白跑道” ctx.clearRect(state.cursorX, 0, state.eraseWidth, height); // 从上一个点连到当前点 if (state.lastY ! null) { ctx.beginPath(); ctx.moveTo(state.cursorX - state.speed, state.lastY); ctx.lineTo(state.cursorX, y); ctx.stroke(); } state.lastY y; state.cursorX state.speed; // 到达右边界回到起点重新开始 if (state.cursorX width) { state.cursorX 0; state.lastY null; // 断开避免跨屏连线 } }逻辑说明clearRect擦的是游标「前方」而不是「后方」这样新波形画上去时前方是干净的视觉上像一条跑道在等波形。lastY在回绕时置空否则会在屏幕两端拉出一条横贯的假线这是新手最常见的翻车点。参数说明speed决定走纸速度对应真实心电图机的 25mm/s 或 50mm/s映射到像素要结合画布宽度和采样率。eraseWidth一般取 2 到 3 个采样间隔的像素宽度太小会看到残影太大波形会「断头」。2.4 用 requestAnimationFrame 驱动而不是 setIntervalsetInterval的节奏和浏览器刷新不同步容易丢帧或抖动。requestAnimationFrame跟着屏幕刷新走60Hz 屏就是约 16.7ms 一帧波形更顺。// 主循环每帧取一个采样点绘制 let sampleIndex 0; const samples []; // 假设已填充心电采样数据 function loop() { if (sampleIndex samples.length) { drawFrame(ctx, cssWidth, cssHeight, samples[sampleIndex]); sampleIndex; } requestAnimationFrame(loop); } requestAnimationFrame(loop);逻辑说明这里每帧只取一个采样点是为了让波形速度和帧率解耦。如果采样率是 250Hz而屏幕是 60Hz一帧要喂 4 个点那就把drawFrame循环调用 4 次或者一次画 4 段。参数说明samples的数值范围要和归一化里的除数一致常见 ADC 是 12 位满量程 4096。3. 让波形像真的采样率、走纸速度与网格背景3.1 采样率、走纸速度、像素三者的换算关系这是动态心电图最容易算错的地方。真实心电图的标准走纸速度是 25mm/s1mm 对应 0.04s。在屏幕上你要先定义「1mm 等于多少像素」再反推每帧该前进多少像素。假设画布宽 800px想显示 8 秒波形那么每秒占 100px。采样率 250Hz则每个采样点占 0.4px。如果每帧 16.7ms一帧要前进 100 × 0.0167 ≈ 1.67px也就是约 4 个采样点。// 根据采样率和走纸速度计算每帧步进 function calcStep(sampleRate, pxPerSecond, frameMs) { const samplesPerFrame sampleRate * (frameMs / 1000); const pxPerSample pxPerSecond / sampleRate; return { samplesPerFrame: Math.round(samplesPerFrame), pxPerSample, stepPx: pxPerSample * samplesPerFrame }; } // 250Hz、100px/s、16.7ms 一帧 // 结果samplesPerFrame≈4pxPerSample0.4stepPx≈1.67逻辑说明samplesPerFrame取整会有误差长时间跑会累积偏移。解决办法是维护一个浮点累加器每帧加上samplesPerFrame的精确值取整数部分消费小数部分留到下一帧。参数说明pxPerSecond由画布宽度和想显示的时间窗决定frameMs用 16.7 近似即可。3.2 画网格小格 1mm、大格 5mm 的标准背景心电图的网格不是装饰是读图时的标尺。标准是 1mm 小格、5mm 大格大格线加粗。// 绘制心电标准网格 function drawGrid(ctx, width, height, pxPerMm) { const small pxPerMm; // 1mm const large pxPerMm * 5; // 5mm ctx.lineWidth 0.5; ctx.strokeStyle #e8f0e8; for (let x 0; x width; x small) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } for (let y 0; y height; y small) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } // 大格加粗 ctx.lineWidth 1; ctx.strokeStyle #c8dcc8; for (let x 0; x width; x large) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } for (let y 0; y height; y large) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } }逻辑说明网格只在初始化时画一次之后波形绘制用clearRect只擦波形区域网格保留。如果每帧重画网格性能会白白浪费。参数说明pxPerMm由走纸速度换算25mm/s 且每秒 100px 时pxPerMm 100/25 4。注意如果波形区域和网格区域重叠clearRect会把网格一起擦掉。常见做法是网格画在底层 canvas波形画在上层透明 canvas两层叠加。这样擦波形不影响网格。3.3 用双 canvas 分层避免擦除误伤!-- 底层网格上层波形 -- canvas idgridLayer/canvas canvas idwaveLayer styleposition:absolute;left:0;top:0;/canvas// 两层共用同一套逻辑坐标 const gridCtx setupCanvas(document.getElementById(gridLayer), 800, 200); const waveCtx setupCanvas(document.getElementById(waveLayer), 800, 200); drawGrid(gridCtx, 800, 200, 4); // 之后所有波形绘制和 clearRect 只操作 waveCtx逻辑说明两层 canvas 绝对定位重叠上层透明。clearRect只作用于上层网格永远在。参数说明两层尺寸和 dpr 处理必须完全一致否则波形和网格对不齐看起来像「波形浮在网格外面」。4. 数据从哪来模拟信号生成与真实数据接入4.1 用合成函数造一段可复现的心电波形没有真实设备时用数学函数合成 PQRST 波是最省事的办法。心电一个周期大致是P 波小凸起、QRS 尖锐复合波、T 波圆钝凸起。// 合成单个 PQRST 周期phase 为 0~1 的周期相位 function ecgWave(phase) { // P 波相位 0.1~0.2 const p 0.15 * Math.exp(-Math.pow((phase - 0.15) / 0.025, 2)); // Q 波相位 0.28 附近的小负峰 const q -0.1 * Math.exp(-Math.pow((phase - 0.28) / 0.008, 2)); // R 波相位 0.3 的尖峰 const r 1.0 * Math.exp(-Math.pow((phase - 0.30) / 0.010, 2)); // S 波相位 0.32 的负峰 const s -0.25 * Math.exp(-Math.pow((phase - 0.32) / 0.012, 2)); // T 波相位 0.5 的圆凸 const t 0.3 * Math.exp(-Math.pow((phase - 0.50) / 0.06, 2)); return p q r s t; } // 生成连续采样 function genSamples(count, sampleRate, heartRate) { const period 60 / heartRate; // 每搏秒数 const samples []; for (let i 0; i count; i) { const t i / sampleRate; const phase (t % period) / period; samples.push(ecgWave(phase) * 2048 2048); // 映射到 0~4096 } return samples; }逻辑说明每个波用高斯函数模拟中心位置和宽度控制波形形状。phase取模实现周期循环。参数说明heartRate常用 60 到 100sampleRate用 250 或 500。乘 2048 加 2048 是为了对齐前面归一化里的 4096 满量程。4.2 接入真实数据时的缓冲队列真实场景数据来自 WebSocket 或串口到达节奏不均匀。直接画会导致波形一顿一顿。常见做法是加一个环形缓冲区绘制线程按固定节奏从缓冲区取点取不到就重复上一个点或补零。// 环形缓冲容量按最大延迟估算 class RingBuffer { constructor(size) { this.buf new Float32Array(size); this.size size; this.head 0; this.tail 0; } push(v) { this.buf[this.head] v; this.head (this.head 1) % this.size; if (this.head this.tail) { this.tail (this.tail 1) % this.size; // 覆盖最旧 } } shift() { if (this.head this.tail) return null; // 空 const v this.buf[this.tail]; this.tail (this.tail 1) % this.size; return v; } }逻辑说明head是写入位置tail是读取位置两者相等表示空。写入追上读取时覆盖最旧数据保证不会内存溢出。参数说明容量按「最大网络抖动 × 采样率」估算比如抖动 2 秒、250Hz容量至少 500。提示缓冲区不要设太大否则波形延迟会明显。监护场景下 1 到 2 秒延迟是可接受的超过 3 秒医生会觉得「这机器卡了」。5. 避坑与排查动态心电图 canvas 最常见的 5 个翻车点5.1 波形闪烁或撕裂现象波形边缘抖动快速滚动时能看到断裂。原因每帧先clearRect再绘制如果擦除和绘制之间被浏览器合成会看到中间态。或者用了setInterval与刷新不同步。解决所有绘制放在同一个requestAnimationFrame回调里先擦后画一气呵成。擦除宽度不要大于一帧的步进否则会擦掉还没画的部分。5.2 长时间运行后波形越来越慢现象跑几分钟后波形速度肉眼可见变慢或者出现周期性卡顿。原因采样点数组无限增长或者每帧重画了全部历史点。也可能是浮点累加误差导致步进逐渐偏小。解决用游标式局部绘制历史点不重画。采样数据用环形缓冲固定容量。步进用浮点累加器每帧取整消费。5.3 高分屏上波形发虚现象在 Retina 屏上线条毛糙像蒙了一层灰。原因只设了canvas.width没设style.width或者没做 dpr 缩放。解决按 2.2 的方式物理尺寸乘 dpr上下文 scaleCSS 尺寸保持逻辑值。线条宽度也要按逻辑值写不要手动乘 dpr。5.4 回绕时出现横贯屏幕的直线现象波形跑到右边界回到左边时中间拉出一条水平线。原因回绕时没有重置lastYmoveTo用了上一帧的旧坐标。解决游标归零时把lastY置为null下一帧从moveTo重新开始不连线。5.5 低端设备帧率掉到 20 以下现象在旧手机或低配平板上波形明显卡顿。原因每帧绘制点数太多或者网格每帧重画或者用了阴影、渐变等昂贵效果。解决网格分层只画一次波形线宽用 1 到 1.5不要用shadowBlur如果还卡把每帧绘制点数从 4 个降到 2 个用插值补视觉连续性。极端情况下可以降采样率显示但要在界面上标注。6. 进阶让波形可交互、可回放、可测量跑通滚动之后真正拉开差距的是「能读」。监护场景里医生要能量 R 波间距、能冻结画面、能回看前几秒。这几个能力都建立在同一件事上把「绘制」和「数据」解耦绘制只负责显示数据层保留完整时间戳。一个实用技巧是用离屏 canvas 做波形快照。当用户点击「冻结」把当前波形层drawImage到一张离屏 canvas主循环暂停用户可以在冻结画面上拖拽测量。// 冻结当前波形到离屏画布 function freeze(waveCanvas) { const off document.createElement(canvas); off.width waveCanvas.width; off.height waveCanvas.height; const offCtx off.getContext(2d); offCtx.drawImage(waveCanvas, 0, 0); return off; } // 在冻结画布上测量两点像素距离换算成时间 function measureTime(pxDistance, pxPerSecond) { return pxDistance / pxPerSecond; // 秒 }逻辑说明drawImage把当前位图复制一份之后主循环即使继续跑也不影响这份快照。测量时用像素距离除以pxPerSecond得到秒数再乘 1000 得毫秒。参数说明pxPerSecond必须和绘制时用的值一致否则测量结果会偏。回放则更简单把环形缓冲里的数据按时间戳切片用一个独立的游标重新喂给绘制函数速度可以调成 0.5 倍或 2 倍。关键是回放时不要再往缓冲区写新数据否则会串。我自己的习惯是任何动态波形项目第一版一定先把「冻结 测量」做出来哪怕界面很丑。因为这两个功能会逼你把坐标系、时间换算、数据缓冲全部理清楚后面加导联、加报警、加导出都只是在这套骨架上挂东西。反过来如果只做滚动不做测量很容易在「看起来能动」的阶段停住等真正要读数值时发现底层全是坑。希望帮到你。本文还有配套的精品资源点击获取