火次果
火次果

暂无菜单项

首页/文章/技术文章/web前端/HTML5/
打开 MD 链接

HTML5 Canvas 图表绘制入门到实战:从零搭建数据可视化项目的完整步骤与高清适配与性能优化技巧

发布于 3小时前
5

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) 再画弧,否则会连出一条奇怪的弦。

实战项目:一个销售数据看板

把上面的零件组装成一个完整看板,顺序如下:

  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) 直接判命中,饼图和不规则图形用这个最省事。

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'。

支持作者
如果这篇内容对你有帮助,可以请作者喝杯咖啡
0 点赞
0 收藏
分享
0 讨论
反馈
0 / 600
细中粗
0 讨论
热门最新
总结
暂无总结
嗨,下午好!
所有的成功,都源自一个勇敢的开始
创作
社区
购物
会员
近期热门

暂无数据

火次果
火次果
首页
资迅中心
小店
AI导航
社区
所有的成功,都源自一个勇敢的开始
不辜负每一个勇敢的开始
关于FAQ协议
火次果 © 2026鲁ICP备2025164830号-1