### [HTML5 Canvas 图表绘制入门到实战:从零搭建数据可视化项目的完整步骤与高清适配与性能优化技巧](https://www.huociguo.com/article/1905)
**Published:** 2026-10-05T02:37:08
**Author:** 米了
**Excerpt:** Canvas 是浏览器提供的一块"画布",本质上是位图。拿到 2D 上下文后,所有线条、矩形、文字都变成像素落…
Canvas 是浏览器提供的一块”画布”,本质上是位图。拿到 2D 上下文后,所有线条、矩形、文字都变成像素落上去,画完就不保留对象信息。这个特性决定了它适合高频重绘、数据点密集的场景(几千个点的散点图、实时刷新的监控曲线),而 SVG 保留节点、适合交互复杂的图形。

## 准备画布:高清屏适配必须在第一步做对
新手最常踩的坑是 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
---