原生JavaScript Canvas API绘制饼状图教程
本教程详细讲解如何使用原生JavaScript Canvas API从底层绘制饼状图,涵盖DPR高清屏适配、弧度制映射、扇形闭合路径控制、极坐标引出线算法及完整封装代码。
在前端数据可视化场景中,轻量级仪表盘或嵌入式报告往往只需要展示基础的比例结构。直接引入庞大的第三方图表库不仅增加网络体积,还容易带来版本耦合与样式覆盖成本。使用原生 JavaScript 结合 HTML5 Canvas API 从底层绘制饼状图,是掌握 2D 图形学栅格化原理、高分屏抗锯齿适配以及极坐标几何转换的经典实践。本文将直接从画布分辨率适配、弧度转换与闭合路径、极坐标标签引出线到完整模块封装,详细拆解每一步的具体实现。
一、画布准备与高分屏(Retina)清晰度对齐
在 HTML 文档中使用 <canvas> 元素时,初学者最常遇到的问题是绘制出的文字和扇区边缘模糊发虚。这是因为 Canvas 存在两种截然不同的尺寸概念:画布内部绘图缓冲区的物理像素尺寸(由 canvas.width 与 canvas.height 控制)和 CSS 布局表现尺寸(由 style.width 与 style.height 控制)。
在现代高分屏(如 Retina 显示屏)下,操作系统的物理像素点数通常是 CSS 逻辑像素的 2 倍甚至 3 倍(即 window.devicePixelRatio 属性,简称 DPR)。若直接在 CSS 逻辑尺寸下绘图,浏览器会将低分辨率位图放大后贴在屏幕上,导致明显的锯齿与边缘发虚。
正确的初始化流程是:先通过 getBoundingClientRect() 获取元素在 CSS 中的逻辑宽高,将 Canvas 物理像素尺寸乘以 dpr,随后通过 ctx.scale(dpr, dpr) 将绘图上下文按比例放大。这样所有后续的坐标计算均可继续基于直观的 CSS 逻辑像素编写,同时输出高清无锯齿图像。
function initCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
// 设置物理渲染缓冲区大小
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
// 缩放绘图上下文,后续 API 继续使用逻辑坐标
ctx.scale(dpr, dpr);
return { ctx, width: rect.width, height: rect.height };
}
二、Canvas 坐标系与弧度制(Radian)几何原理
Canvas 2D 渲染上下文的核心绘制方法是 ctx.arc(),其签名如下:
ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise);
其中 x 与 y 代表圆心坐标,radius 是圆半径,而 startAngle 与 endAngle 必须以弧度(Radian)为单位传入。counterclockwise 为布尔值,表示是否逆时针绘制,默认值为 false(即顺时针绘制)。
理解 Canvas 角度系统需要把握两个与常规平面直角坐标系不同的特征:
- Y 轴垂直向下递增:Canvas 的原点
(0, 0)位于画布左上角,垂直向下的方向为正 Y 轴。 - 0 弧度基准轴位于正右方(3 点钟方向):顺时针旋转角度递增。90°(0.5π)位于 6 点钟方向,180°(π)位于 9 点钟方向,270°(1.5π)位于 12 点钟方向。
数据占比到弧度的数学换算非常直接:整圆的总弧度为 2π(即 2 * Math.PI)。若各数据项的总和为 total,则某个数据项 value 所对应的扇区跨度角为:
sliceAngle = (value / total) * (2 * Math.PI)
大部分数据可视化规范倾向于让第一个扇区从正上方(12 点钟方向)开始顺时针展开,因此我们通常将全局起始角 startAngle 的初始值设定为 -0.5 * Math.PI(或等价的 1.5 * Math.PI)。
三、绘制单个扇形切片的 5 个核心步骤与闭合陷阱
在 Canvas 中绘制一个完整的扇形,不仅仅是简单调用 ctx.arc()。一个标准的封闭扇形由两条半径线段和一条圆弧共同围成。如果路径定义不规范,填充和描边时会出现严重的形变。
绘制规范的 5 步状态机:
ctx.beginPath():清空之前的子路径缓存,开启一条新的独立路径,隔离前后图形的属性。ctx.moveTo(cx, cy):将画笔落笔在圆心坐标上。这一步确立了扇形的顶点尖端。ctx.arc(cx, cy, radius, startAngle, endAngle, false):从当前画笔位置(圆心)向圆弧起始点自动拉出第一条半径线段,并沿圆周绘制外轮廓圆弧。ctx.closePath():从圆弧终止点引出第二条线段,强制连回最初的落笔点(即圆心),形成完全封闭的三角扇区轮廓。ctx.fill()与ctx.stroke():使用指定的颜色填充扇区内部;若需要扇形之间有白色间隙,可设置ctx.strokeStyle = '#FFFFFF'并调用ctx.stroke()描边。
如果在调用 arc() 之前没有执行 moveTo(cx, cy),画笔的当前起点将停留在上一个扇形的圆弧终点。Canvas 会自动从上一个终点直接横跨拉一条直线连接到新圆弧起点,导致相邻扇区之间出现异常的贯穿连线,调用 fill() 时内部会被错误涂黑。
四、扇区中心角推导与极坐标转换(绘制引出线与标签)
饼状图如果缺少数据标注与占比说明,其实用价值将大打折扣。最清晰的标注方式是通过折线引出线将文本标注在扇区外部。
引出线的起点应当位于扇形外边缘弧线的正中点。为此,我们首先计算该扇区的平分中点角(midAngle):
const midAngle = startAngle + sliceAngle / 2;
由于 Canvas 的原生绘图方法只接受直角坐标 (x, y),我们需要使用初等三角函数将极坐标 (radius, midAngle) 转化为屏幕像素坐标:
- 水平偏移分量:
Δx = distance * Math.cos(midAngle) - 垂直偏移分量:
Δy = distance * Math.sin(midAngle)
折线通常由两段构成:第一段是从圆弧边缘 (x1, y1) 沿径向外伸到拐点 (x2, y2);第二段是从拐点水平向左或向右延伸到 (x3, y3)。
这里有一个核心排版逻辑:必须根据扇区处于左半圆还是右半圆来调整水平延伸方向与文字对齐方式。判定依据是 Math.cos(midAngle) 的正负号:
| 扇区所在区域 | 余弦值特征 | 折线水平延长方向 | Canvas 文本对齐属性 (ctx.textAlign) |
|---|---|---|---|
| 右半圆(-90° 至 90°) | Math.cos(midAngle) >= 0 |
向右延伸:x3 = x2 + 25 |
ctx.textAlign = 'left'(文本靠左,从延伸点向右书写) |
| 左半圆(90° 至 270°) | Math.cos(midAngle) < 0 |
向左延伸:x3 = x2 - 25 |
ctx.textAlign = 'right'(文本靠右,从延伸点向左书写) |
同时统一设置 ctx.textBaseline = 'middle',保证文字的垂直中心线与水平延伸线精准对齐。
五、完整代码实现与模块化封装
以下是结合上述全部原理封装的完整实战代码。包含高分屏自适应、扇形切割、边缘白缝、极坐标引出线及底部图例渲染,代码开箱即用:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>原生 Canvas 饼状图运行示例</title>
<style>
.chart-wrapper {
width: 600px;
margin: 40px auto;
text-align: center;
}
canvas {
width: 100%;
height: 420px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
border-radius: 8px;
}
</style>
</head>
<body>
<div class="chart-wrapper">
<canvas id="pieChart"></canvas>
</div>
<script>
function drawPieChart(canvas, options) {
const {
data,
radius = 120,
lineWidth = 2,
strokeColor = '#FFFFFF',
showLegend = true
} = options;
// 1. 高清屏 DPR 适配
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// 计算中心点(预留底部图例空间)
const cx = rect.width / 2;
const cy = (rect.height - (showLegend ? 50 : 0)) / 2;
// 2. 数据有效性校验与总和统计
const validData = data.filter(item => typeof item.value === 'number' && item.value > 0);
const total = validData.reduce((sum, item) => sum + item.value, 0);
if (total <= 0) {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#718096';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('暂无有效数据', cx, cy);
return;
}
// 3. 循环绘制扇区
let currentAngle = -0.5 * Math.PI; // 从 12 点钟位置开始
const labelTasks = [];
validData.forEach(item => {
const sliceAngle = (item.value / total) * 2 * Math.PI;
const endAngle = currentAngle + sliceAngle;
// 绘制扇区
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, currentAngle, endAngle, false);
ctx.closePath();
ctx.fillStyle = item.color;
ctx.fill();
// 绘制切片分割线
if (lineWidth > 0) {
ctx.lineWidth = lineWidth;
ctx.strokeStyle = strokeColor;
ctx.stroke();
}
// 收集引出线待绘制数据
labelTasks.push({
midAngle: currentAngle + sliceAngle / 2,
percentage: ((item.value / total) * 100).toFixed(1) + '%',
label: item.label,
color: item.color
});
currentAngle = endAngle;
});
// 4. 统一后置绘制折线引出线与文字(防止被扇形遮挡)
labelTasks.forEach(task => {
const cos = Math.cos(task.midAngle);
const sin = Math.sin(task.midAngle);
const x1 = cx + radius * cos;
const y1 = cy + radius * sin;
const x2 = cx + (radius + 20) * cos;
const y2 = cy + (radius + 20) * sin;
const isRight = cos >= 0;
const x3 = x2 + (isRight ? 24 : -24);
const y3 = y2;
// 绘制两段式折线
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x3, y3);
ctx.strokeStyle = '#4F6272';
ctx.lineWidth = 1;
ctx.stroke();
// 绘制文字
ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif';
ctx.fillStyle = '#2D3748';
ctx.textAlign = isRight ? 'left' : 'right';
ctx.textBaseline = 'middle';
const textOffset = isRight ? 6 : -6;
ctx.fillText(`${task.label} (${task.percentage})`, x3 + textOffset, y3);
});
// 5. 绘制底部图例(Legend)
if (showLegend) {
const legendY = rect.height - 30;
const itemSpacing = 110;
const totalLegendWidth = validData.length * itemSpacing;
let startX = (rect.width - totalLegendWidth) / 2;
validData.forEach(item => {
// 色块
ctx.fillStyle = item.color;
ctx.fillRect(startX, legendY - 6, 12, 12);
// 文本
ctx.fillStyle = '#4F6272';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(item.label, startX + 18, legendY);
startX += itemSpacing;
});
}
}
// 测试调用
const canvas = document.getElementById('pieChart');
drawPieChart(canvas, {
data: [
{ label: '电子数码', value: 380, color: '#477DAD' },
{ label: '生活百货', value: 240, color: '#4F6272' },
{ label: '母婴用品', value: 160, color: '#2E8B57' },
{ label: '服装鞋帽', value: 120, color: '#E28743' }
]
});
</script>
</body>
</html>
六、全流程渲染架构与工程边界防御
在实际生产环境中,前端面对的数据源往往具有不确定性。要保证 Canvas 图表稳定运行而不抛错崩溃,必须在渲染管道中加入防御性逻辑。
生产环境必备的 3 个边界防御处理:
-
浮点数累加与封口缝隙:JavaScript 中的浮点数运算存在固有的精度损耗。在对
sliceAngle进行连续累加时,最后一个扇区的endAngle可能会略小于或略大于1.5 * Math.PI,导致图表在 12 点钟方向出现一条发丝般的白色微小缝隙。在循环到最后一项数据时,推荐直接将endAngle强制赋值为初始基准角加上2 * Math.PI,保证绝对无缝闭合。 -
单项 100% 占比的特殊情况:当有效数组仅包含 1 个有效数值时,
sliceAngle为2π,此时startAngle与endAngle在数值上跨越整圆。如果使用arc()加closePath(),某些旧版浏览器内核会判定起点与终点重合而跳过渲染。建议遇到单项数据时,直接调用ctx.arc(cx, cy, radius, 0, 2 * Math.PI)绘制独立全圆路径。 - 微小碎片扇区的标签碰撞遮挡:当数据中存在占比小于 2%~3% 的极小扇区时,相邻引出线和文字会重叠在一处。在工程上,通常可以在预处理阶段将占比过小的数据聚类归入“其他”分类中,或者为引出线加入基于 Y 轴坐标防碰撞算法(如力导向或自适应阶梯避让)。
JS在setTimeout或Ajax回调中打开新标签页失败怎么处理
2026-11-05 15:50:12
JS直播协议选型:HLS、FLV与WebRTC的延迟与兼容性对比
2026-11-05 12:16:14
JS实现回到顶部:scrollTop赋值与浏览器兼容性处理
2026-11-04 23:43:35
JS使用classList动态添加、移除和切换CSS类名
2026-11-04 23:40:58
在 D3.js 中实现平滑过渡动画的技巧
2026-11-03 23:46:09







