Canvas 是浏览器提供的一块”画布”,本质上是位图。拿到 2D 上下文后,所有线条、矩形、文字都变成像素落上去,画完就不保留对象信息。这个特性决定了它适合高频重绘、数据点密集的场景(几千个点的散点图、实时刷新的监控曲线),而 SVG 保留节点、适合交互复杂的图形。

准备画布:高清屏适配必须在第一步做对
新手最常踩的坑是 Canvas 在 Retina 屏上糊成一团。原因是 canvas.width 决定像素数,CSS 尺寸决定显示大小,两者不等就会拉伸模糊。
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 轴习惯向上增长。这一步的换算函数写好了,后面画什么图都复用。
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()。
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) 再画弧,否则会连出一条奇怪的弦。
实战项目:一个销售数据看板
把上面的零件组装成一个完整看板,顺序如下:
-
读数据:接口返回
[{ month: '1月', sales: 320, profit: 88 }, ...],先做一次清洗,剔除 null、补 0。 -
建比例尺:销量和利润量级不同,用双 y 轴,各自算 max 与刻度。
-
分层绘制:网格线和坐标轴属于”静态层”,画到一张离屏 canvas(
document.createElement('canvas'))上缓存起来;柱子和折线是”数据层”,每次数据变化只重绘这一层,用drawImage把静态层贴回来。 -
画坐标轴与刻度:刻度数量控制在 5 条以内,
ctx.textAlign = 'right'让 y 轴文字贴齐轴线,ctx.font里字号用整数像素避免虚边。 -
图例:右上角手绘色块 + 文字,顺手把点击切换系列的开关做进去。
-
标题与单位:
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)直接判命中,饼图和不规则图形用这个最省事。
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:
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'。
