### [HTML5 Canvas 图表绘制入门到实战:从零搭建数据可视化项目的完整步骤与高清适配与性能优化技巧](https://www.huociguo.com/article/1905) **Published:** 2026-10-05T02:37:08 **Author:** 米了 **Excerpt:** Canvas 是浏览器提供的一块"画布",本质上是位图。拿到 2D 上下文后,所有线条、矩形、文字都变成像素落… Canvas 是浏览器提供的一块”画布”,本质上是位图。拿到 2D 上下文后,所有线条、矩形、文字都变成像素落上去,画完就不保留对象信息。这个特性决定了它适合高频重绘、数据点密集的场景(几千个点的散点图、实时刷新的监控曲线),而 SVG 保留节点、适合交互复杂的图形。 ![](https://api.huociguo.com/wp-content/uploads/2026/10/20261005103643774-0502fbdd52-1.png "20261005103643774-0502fbdd52-1") ## 准备画布:高清屏适配必须在第一步做对 新手最常踩的坑是 Canvas 在 Retina 屏上糊成一团。原因是 `canvas.width` 决定像素数,CSS 尺寸决定显示大小,两者不等就会拉伸模糊。 ```js const canvas = document.getElementById('chart'); const ctx = canvas.getContext('2d'); const cssW = 800, cssH = 400; const dpr = window.devicePixelRatio || 1; canvas.width = cssW * dpr; canvas.height = cssH * dpr; canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; ctx.scale(dpr, dpr); ``` `ctx.scale(dpr, dpr)` 之后,后续所有绘制都可以按 CSS 像素思考,不用到处乘 dpr。⚠️ 注意:每次改 `canvas.width` 都会清空画布并重置变换矩阵,所以缩放和重绘要配套执行。 ## 坐标系换算:把业务数据映射成像素坐标 Canvas 的原点在左上角,y 轴向下增长,而图表的 y 轴习惯向上增长。这一步的换算函数写好了,后面画什么图都复用。 ```js const pad = { t: 30, r: 24, b: 40, l: 50 }; // 内边距,留给坐标轴和标签 const innerW = cssW - pad.l - pad.r; const innerH = cssH - pad.t - pad.b; const xOf = i => pad.l + (i / (data.length - 1)) * innerW; const yOf = v => pad.t + innerH - (v / maxValue) * innerH; ``` 真实项目里 `maxValue` 别直接取数据最大值,做个”取整到好看的刻度”处理(比如向上取到 100 的整数倍),网格线才不会落在 137.5 这种奇怪位置。 ## 三种基础图形的绘制套路 **折线图**​ 📈 走的是路径:先 `beginPath()`,`moveTo` 起点,一串 `lineTo` 连点,最后一次性 `stroke()`。 ```js ctx.beginPath(); ctx.lineWidth = 2; ctx.strokeStyle = '#3b82f6'; data.forEach((d, i) => i ? ctx.lineTo(xOf(i), yOf(d.v)) : ctx.moveTo(xOf(i), yOf(d.v))); ctx.stroke(); ``` 想要面积图,就在折线路径后面补两个点回到基线,再 `fill()` 一个渐变色。 **柱状图**​ 📊 更直接,`fillRect` 循环搞定,柱宽取 `innerW / n * 0.6`,剩下 0.4 当间隙。 **饼图**​ 🥧 用 `arc(cx, cy, r, startAngle, endAngle)`,起始角写 `-Math.PI / 2` 才能让第一块从正上方开始,符合视觉习惯。扇形闭合要记得先 `moveTo(cx, cy)` 再画弧,否则会连出一条奇怪的弦。 ## 实战项目:一个销售数据看板 把上面的零件组装成一个完整看板,顺序如下: 1. **读数据**:接口返回 `[{ month: '1月', sales: 320, profit: 88 }, ...]`,先做一次清洗,剔除 null、补 0。 2. **建比例尺**:销量和利润量级不同,用双 y 轴,各自算 max 与刻度。 3. **分层绘制**:网格线和坐标轴属于”静态层”,画到一张离屏 canvas(`document.createElement('canvas')`)上缓存起来;柱子和折线是”数据层”,每次数据变化只重绘这一层,用 `drawImage` 把静态层贴回来。 4. **画坐标轴与刻度**:刻度数量控制在 5 条以内,`ctx.textAlign = 'right'` 让 y 轴文字贴齐轴线,`ctx.font` 里字号用整数像素避免虚边。 5. **图例**:右上角手绘色块 + 文字,顺手把点击切换系列的开关做进去。 6. **标题与单位**:`fillText` 绘制,别指望 Canvas 里的文字能参与 SEO——重要的标题信息请用真实 DOM 元素放在 canvas 旁边。 ## 交互: hover 提示框的命中检测 Canvas 没有 DOM 节点,`mouseover` 无从谈起,得自己算命中。两种做法: - **数学法**:`mousemove` 拿到坐标,反算 `i = Math.round((x - pad.l) / innerW * (n - 1))`,再比较 y 值距离,适合柱状图和折线图。 - **Path2D 法**:把每个图形存成 `Path2D` 对象,`ctx.isPointInPath(path, x, y)` 直接判命中,饼图和不规则图形用这个最省事。 ```js canvas.addEventListener('mousemove', e => { const r = canvas.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; const hit = paths.find(p => ctx.isPointInPath(p.path, mx * dpr, my * dpr)); tooltip.style.cssText = `left:${mx + 12}px;top:${my + 12}px;display:${hit ? 'block' : 'none'}`; }); ``` ⚠️ `isPointInPath` 用的是画布像素坐标,所以要乘 dpr;提示框本身用 HTML div 浮在 canvas 上层,文字渲染比 `fillText` 清晰得多。 ## 动画与重绘节奏 入场动画用 `requestAnimationFrame` 配合缓动函数,别用 `setInterval`: ```js const easeOutCubic = t => 1 - Math.pow(1 - t, 3); const t0 = performance.now(); (function frame(now) { const p = Math.min((now - t0) / 800, 1); redraw(easeOutCubic(p)); // 把进度传给绘制函数,控制柱子高度/折线长度 if (p < 1) requestAnimationFrame(frame); })(t0); ``` 窗口尺寸变化用 `ResizeObserver` 监听容器,回调里重设 dpr、重设宽高、重绘。`resize` 事件记得加防抖,否则拖拽窗口时掉帧明显。 ## 性能优化与常见坑位 - 频繁重绘时,`clearRect` 只清脏区域,别整屏擦。 - 静态背景走离屏缓存,实测在 60fps 刷新场景下能省掉一半以上的绘制耗时。 - 避免在循环里反复赋值 `ctx.font` 和 `ctx.fillStyle`,状态切换本身也有开销。 - 数据点超过 5000 个,先做抽稀(等距采样或 LTTB 降采样),屏幕宽度就那么多像素,画 5000 个点和画 800 个点肉眼无差。 - 导出图片用 `canvas.toDataURL('image/png')`,注意跨域图片会污染画布导致导出失败,需要 `img.crossOrigin = 'anonymous'`。 **Categories:** HTML5 ---