pin_drop当前位置:知识文库 ❯ 图文
数据可视化:Canvas图表基础 - 从入门到实践详解
教程简介
Canvas是HTML5提供的2D绘图API,通过JavaScript在画布上逐像素绘制图形。与SVG的保留模式不同,Canvas采用立即模式——绘制后不保留图形对象,这使得它在处理大量数据点时具有显著的性能优势。本教程将系统讲解Canvas绘图API、坐标映射、刻度计算、图例绘制、交互提示(tooltip)实现,以及Canvas与SVG的选型策略,帮助你掌握使用Canvas构建高性能图表的核心能力。
核心概念
Canvas绘图API
Canvas 2D上下文(CanvasRenderingContext2D)提供了丰富的绘图方法:
基础图形绘制:
路径绘制:
样式属性:
坐标映射
数据可视化中的核心问题之一是将数据值映射到画布像素坐标。
数据空间到像素空间的映射:
代码示例
// 线性映射函数
function linearScale(value, domainMin, domainMax, rangeMin, rangeMax) {
return rangeMin + (value - domainMin) / (domainMax - domainMin) * (rangeMax - rangeMin);
}
// 使用示例:将数据值42映射到画布Y坐标
// 数据范围: 0-100, 画布Y范围: 300(底) - 50(顶)
const y = linearScale(42, 0, 100, 300, 50); // 结果: 195反向映射(像素到数据,用于交互):
代码示例
function invertScale(pixel, domainMin, domainMax, rangeMin, rangeMax) {
return domainMin + (pixel - rangeMin) / (rangeMax - rangeMin) * (domainMax - domainMin);
}常用映射类型:
线性映射:等间距映射,适用于大多数场景
对数映射:用于数据跨度很大的场景(如1到1000000)
时间映射:将日期映射到像素位置
刻度计算
合理的刻度计算是图表可读性的关键:
代码示例
function calculateTicks(min, max, tickCount) {
const range = max - min;
const roughStep = range / tickCount;
// 计算美观的步长(1, 2, 5的倍数)
const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
const residual = roughStep / magnitude;
let niceStep;
if (residual <= 1.5) niceStep = 1 * magnitude;
else if (residual <= 3) niceStep = 2 * magnitude;
else if (residual <= 7) niceStep = 5 * magnitude;
else niceStep = 10 * magnitude;
const niceMin = Math.floor(min / niceStep) * niceStep;
const niceMax = Math.ceil(max / niceStep) * niceStep;
const ticks = [];
for (let v = niceMin; v <= niceMax + niceStep * 0.5; v += niceStep) {
ticks.push(Math.round(v * 1e10) / 1e10); // 消除浮点误差
}
return { min: niceMin, max: niceMax, step: niceStep, ticks };
}图例绘制
Canvas中图例需要手动绘制:
代码示例
function drawLegend(ctx, series, x, y) {
const itemHeight = 25;
const colorBoxSize = 14;
series.forEach((s, i) => {
const iy = y + i * itemHeight;
// 色块
ctx.fillStyle = s.color;
ctx.fillRect(x, iy, colorBoxSize, colorBoxSize);
// 标签
ctx.fillStyle = '#666';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(s.name, x + colorBoxSize + 8, iy + colorBoxSize / 2);
});
}交互提示(tooltip)
Canvas没有DOM事件,需要手动实现命中检测和tooltip:
代码示例
// 命中检测:判断鼠标是否在某个柱子区域内
function hitTest(mx, my, bars) {
for (const bar of bars) {
if (mx >= bar.x && mx <= bar.x + bar.width &&
my >= bar.y && my <= bar.y + bar.height) {
return bar;
}
}
return null;
}
// 绘制tooltip
function drawTooltip(ctx, x, y, content) {
ctx.save();
ctx.font = '12px sans-serif';
const metrics = ctx.measureText(content);
const padding = 8;
const tw = metrics.width + padding * 2;
const th = 24;
// 背景
ctx.fillStyle = 'rgba(0,0,0,0.8)';
ctx.beginPath();
ctx.roundRect(x - tw / 2, y - th - 5, tw, th, 4);
ctx.fill();
// 文字
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(content, x, y - th / 2 - 5);
ctx.restore();
}Canvas图表的优缺点
优势:
高性能渲染,适合大数据量(>1000个元素)
像素级控制,适合复杂绘图
内存占用低,不需要维护DOM树
适合实时渲染和动画
输出为位图,方便截图和保存
劣势:
无DOM结构,交互需要手动实现命中检测
缩放会模糊(非矢量)
不支持CSS样式
无障碍访问困难
文本渲染质量不如SVG
Canvas vs SVG选型
语法与用法
Canvas基本设置
代码示例
// 获取Canvas和上下文
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
// 高DPI适配
function setupCanvas(canvas, width, height) {
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}绘制流程
代码示例
function drawChart(canvas, data) {
const ctx = canvas.getContext('2d');
const margin = { top: 30, right: 20, bottom: 40, left: 50 };
const width = canvas.width - margin.left - margin.right;
const height = canvas.height - margin.top - margin.bottom;
// 1. 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. 保存状态,移动原点
ctx.save();
ctx.translate(margin.left, margin.top);
// 3. 绘制背景元素(网格、坐标轴)
drawGrid(ctx, width, height);
drawAxes(ctx, width, height);
// 4. 绘制数据
drawData(ctx, data, width, height);
// 5. 绘制前景元素(标签、图例)
drawLabels(ctx, data, width, height);
drawLegend(ctx, data);
// 6. 恢复状态
ctx.restore();
}代码示例
示例1:Canvas柱状图(含坐标映射与刻度计算)
以下示例实现一个完整的Canvas柱状图,包含自动刻度计算、坐标映射、网格线、动画和交互提示。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas柱状图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.chart-wrapper {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 900px;
}
.chart-title {
text-align: center;
color: #2c3e50;
font-size: 20px;
margin-bottom: 20px;
}
canvas {
width: 100%;
cursor: crosshair;
}
</style>
</head>
<body>
<div class="chart-wrapper">
<h2 class="chart-title">Canvas柱状图 - 城市人口数据</h2>
<canvas id="barChart"></canvas>
</div>
<script>
const cityData = [
{ name: '北京', value: 2189, color: '#4e79a7' },
{ name: '上海', value: 2487, color: '#f28e2b' },
{ name: '广州', value: 1868, color: '#e15759' },
{ name: '深圳', value: 1756, color: '#76b7b2' },
{ name: '成都', value: 2094, color: '#59a14f' },
{ name: '杭州', value: 1237, color: '#edc948' },
{ name: '武汉', value: 1365, color: '#b07aa1' },
{ name: '南京', value: 942, color: '#ff9da7' }
];
// 刻度计算
function niceNum(range, round) {
const exponent = Math.floor(Math.log10(range));
const fraction = range / Math.pow(10, exponent);
let niceFraction;
if (round) {
if (fraction < 1.5) niceFraction = 1;
else if (fraction < 3) niceFraction = 2;
else if (fraction < 7) niceFraction = 5;
else niceFraction = 10;
} else {
if (fraction <= 1) niceFraction = 1;
else if (fraction <= 2) niceFraction = 2;
else if (fraction <= 5) niceFraction = 5;
else niceFraction = 10;
}
return niceFraction * Math.pow(10, exponent);
}
function calculateNiceTicks(minVal, maxVal, maxTicks) {
const range = niceNum(maxVal - minVal, false);
const step = niceNum(range / (maxTicks - 1), true);
const niceMin = Math.floor(minVal / step) * step;
const niceMax = Math.ceil(maxVal / step) * step;
const ticks = [];
for (let v = niceMin; v <= niceMax + step * 0.5; v += step) {
ticks.push(Math.round(v * 100) / 100);
}
return { min: niceMin, max: niceMax, step, ticks };
}
// Canvas高DPI适配
function setupHiDPI(canvas, w, h) {
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return { ctx, dpr };
}
class BarChart {
constructor(canvasId, data) {
this.canvas = document.getElementById(canvasId);
this.data = data;
this.margin = { top: 30, right: 30, bottom: 60, left: 70 };
this.displayWidth = this.canvas.parentElement.clientWidth - 60;
this.displayHeight = Math.min(450, this.displayWidth * 0.55);
this.bars = [];
this.hoveredBar = null;
this.animProgress = 0;
const { ctx } = setupHiDPI(this.canvas, this.displayWidth, this.displayHeight);
this.ctx = ctx;
this.setupEvents();
this.animate();
}
get plotWidth() { return this.displayWidth - this.margin.left - this.margin.right; }
get plotHeight() { return this.displayHeight - this.margin.top - this.margin.bottom; }
setupEvents() {
this.canvas.addEventListener('mousemove', (e) => {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.displayWidth / rect.width;
const scaleY = this.displayHeight / rect.height;
const mx = (e.clientX - rect.left) * scaleX - this.margin.left;
const my = (e.clientY - rect.top) * scaleY - this.margin.top;
let found = null;
for (const bar of this.bars) {
if (mx >= bar.x && mx <= bar.x + bar.w && my >= bar.y && my <= bar.y + bar.h) {
found = bar; break;
}
}
if (found !== this.hoveredBar) {
this.hoveredBar = found;
this.draw();
}
});
this.canvas.addEventListener('mouseleave', () => {
this.hoveredBar = null;
this.draw();
});
}
animate() {
const start = performance.now();
const duration = 800;
const step = (now) => {
this.animProgress = Math.min(1, (now - start) / duration);
// 缓动函数
this.animProgress = 1 - Math.pow(1 - this.animProgress, 3);
this.draw();
if (this.animProgress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
draw() {
const ctx = this.ctx;
const m = this.margin;
const pw = this.plotWidth;
const ph = this.plotHeight;
ctx.clearRect(0, 0, this.displayWidth, this.displayHeight);
// 计算刻度
const maxVal = Math.max(...this.data.map(d => d.value));
const tickInfo = calculateNiceTicks(0, maxVal, 6);
ctx.save();
ctx.translate(m.left, m.top);
// 网格线
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
tickInfo.ticks.forEach(v => {
const y = ph - (v / tickInfo.max) * ph;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(pw, y);
ctx.stroke();
// Y轴刻度标签
ctx.fillStyle = '#999';
ctx.font = '12px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(v.toLocaleString(), -10, y);
});
// 柱子
this.bars = [];
const barWidth = pw / this.data.length * 0.6;
const gap = pw / this.data.length;
this.data.forEach((d, i) => {
const barH = (d.value / tickInfo.max) * ph * this.animProgress;
const x = i * gap + (gap - barWidth) / 2;
const y = ph - barH;
// 柱子渐变
const grad = ctx.createLinearGradient(x, y, x, ph);
grad.addColorStop(0, d.color);
grad.addColorStop(1, d.color + '99');
ctx.fillStyle = grad;
// 圆角矩形
if (barH > 4) {
ctx.beginPath();
ctx.moveTo(x + 4, y);
ctx.lineTo(x + barWidth - 4, y);
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + 4);
ctx.lineTo(x + barWidth, ph);
ctx.lineTo(x, ph);
ctx.lineTo(x, y + 4);
ctx.quadraticCurveTo(x, y, x + 4, y);
ctx.fill();
}
this.bars.push({ x, y, w: barWidth, h: barH, data: d });
// X轴标签
ctx.fillStyle = '#888';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(d.name, x + barWidth / 2, ph + 10);
});
// 悬停效果
if (this.hoveredBar) {
const bar = this.hoveredBar;
// 高亮柱子
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillRect(bar.x, bar.y, bar.w, bar.h);
// Tooltip
const text = `${bar.data.name}: ${bar.data.value.toLocaleString()}万人`;
ctx.font = 'bold 13px sans-serif';
const tw = ctx.measureText(text).width;
const tp = 10;
const tx = bar.x + bar.w / 2;
const ty = bar.y - 15;
ctx.fillStyle = 'rgba(44,62,80,0.9)';
ctx.beginPath();
ctx.roundRect(tx - tw / 2 - tp, ty - 16, tw + tp * 2, 28, 6);
ctx.fill();
// 小三角
ctx.beginPath();
ctx.moveTo(tx - 6, ty + 12);
ctx.lineTo(tx + 6, ty + 12);
ctx.lineTo(tx, ty + 18);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, tx, ty - 2);
}
// 坐标轴
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph);
ctx.stroke();
// Y轴标题
ctx.save();
ctx.translate(-55, ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = '#666';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('人口(万人)', 0, 0);
ctx.restore();
ctx.restore();
}
}
const chart = new BarChart('barChart', cityData);
</script>
</body>
</html>示例2:Canvas折线图(含平滑曲线与交互)
以下示例实现Canvas折线图,包含贝塞尔曲线平滑、数据点标记、十字准线和tooltip。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas折线图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-wrapper {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 900px;
}
.chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 20px; }
canvas { width: 100%; }
</style>
</head>
<body>
<div class="chart-wrapper">
<h2 class="chart-title">Canvas折线图 - 温度变化趋势</h2>
<canvas id="lineChart"></canvas>
</div>
<script>
const tempData = {
labels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
series: [
{ name: '北京', color: '#e15759', data: [-3, 0, 8, 17, 23, 27, 29, 28, 22, 14, 5, -1] },
{ name: '上海', color: '#4e79a7', data: [4, 6, 10, 16, 21, 25, 29, 29, 25, 19, 13, 7] },
{ name: '广州', color: '#59a14f', data: [14, 15, 18, 23, 26, 28, 29, 29, 27, 24, 20, 15] }
]
};
function setupHiDPI(canvas, w, h) {
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}
class LineChart {
constructor(canvasId, data) {
this.canvas = document.getElementById(canvasId);
this.data = data;
this.margin = { top: 40, right: 30, bottom: 50, left: 50 };
this.displayWidth = this.canvas.parentElement.clientWidth - 60;
this.displayHeight = Math.min(420, this.displayWidth * 0.5);
this.mouseX = -1;
this.mouseY = -1;
this.animProgress = 0;
this.ctx = setupHiDPI(this.canvas, this.displayWidth, this.displayHeight);
this.setupEvents();
this.animate();
}
get pw() { return this.displayWidth - this.margin.left - this.margin.right; }
get ph() { return this.displayHeight - this.margin.top - this.margin.bottom; }
setupEvents() {
this.canvas.addEventListener('mousemove', (e) => {
const rect = this.canvas.getBoundingClientRect();
this.mouseX = (e.clientX - rect.left) * (this.displayWidth / rect.width);
this.mouseY = (e.clientY - rect.top) * (this.displayHeight / rect.height);
this.draw();
});
this.canvas.addEventListener('mouseleave', () => {
this.mouseX = -1;
this.mouseY = -1;
this.draw();
});
}
animate() {
const start = performance.now();
const duration = 1000;
const step = (now) => {
this.animProgress = Math.min(1, (now - start) / duration);
this.animProgress = 1 - Math.pow(1 - this.animProgress, 3);
this.draw();
if (this.animProgress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
// 计算平滑曲线路径
getSmoothedPath(points, tension = 0.3) {
if (points.length < 2) return '';
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) * tension;
const cp1y = p1.y + (p2.y - p0.y) * tension;
const cp2x = p2.x - (p3.x - p1.x) * tension;
const cp2y = p2.y - (p3.y - p1.y) * tension;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
}
return path;
}
draw() {
const ctx = this.ctx;
const m = this.margin;
const pw = this.pw;
const ph = this.ph;
ctx.clearRect(0, 0, this.displayWidth, this.displayHeight);
ctx.save();
ctx.translate(m.left, m.top);
// 数据范围
const allValues = this.data.series.flatMap(s => s.data);
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const padding = (maxVal - minVal) * 0.1;
const yMin = Math.floor((minVal - padding) / 5) * 5;
const yMax = Math.ceil((maxVal + padding) / 5) * 5;
// 网格线
const yTicks = 8;
for (let i = 0; i <= yTicks; i++) {
const val = yMin + (yMax - yMin) * i / yTicks;
const y = ph - ((val - yMin) / (yMax - yMin)) * ph;
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#999';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(val) + 'C', -8, y);
}
// X轴标签
const xStep = pw / (this.data.labels.length - 1);
this.data.labels.forEach((label, i) => {
ctx.fillStyle = '#888';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(label, i * xStep, ph + 10);
});
// 绘制数据系列
this.data.series.forEach((series) => {
const points = series.data.map((v, i) => ({
x: i * xStep,
y: ph - ((v - yMin) / (yMax - yMin)) * ph
}));
// 动画裁剪
const animWidth = pw * this.animProgress;
ctx.save();
ctx.beginPath();
ctx.rect(0, -10, animWidth, ph + 20);
ctx.clip();
// 面积填充
ctx.beginPath();
const smoothPath = new Path2D(this.getSmoothedPath(points));
ctx.addPath(smoothPath);
ctx.lineTo(points[points.length - 1].x, ph);
ctx.lineTo(points[0].x, ph);
ctx.closePath();
const areaGrad = ctx.createLinearGradient(0, 0, 0, ph);
areaGrad.addColorStop(0, series.color + '30');
areaGrad.addColorStop(1, series.color + '05');
ctx.fillStyle = areaGrad;
ctx.fill();
// 折线
ctx.strokeStyle = series.color;
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke(smoothPath);
// 数据点
points.forEach((p, i) => {
if (p.x <= animWidth) {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = series.color;
ctx.lineWidth = 2;
ctx.stroke();
}
});
ctx.restore();
});
// 十字准线和tooltip
const plotLeft = m.left;
const plotRight = m.left + pw;
if (this.mouseX >= plotLeft && this.mouseX <= plotRight) {
const relX = this.mouseX - m.left;
const dataIndex = Math.round(relX / xStep);
if (dataIndex >= 0 && dataIndex < this.data.labels.length) {
const snapX = dataIndex * xStep;
// 垂直准线
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(snapX, 0); ctx.lineTo(snapX, ph); ctx.stroke();
ctx.setLineDash([]);
// 数据点高亮和tooltip
let tooltipLines = [this.data.labels[dataIndex]];
this.data.series.forEach((series) => {
const v = series.data[dataIndex];
const y = ph - ((v - yMin) / (yMax - yMin)) * ph;
// 高亮点
ctx.beginPath();
ctx.arc(snapX, y, 6, 0, Math.PI * 2);
ctx.fillStyle = series.color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
tooltipLines.push(`${series.name}: ${v}C`);
});
// Tooltip
ctx.font = '12px sans-serif';
const lineHeight = 20;
const maxTextWidth = Math.max(...tooltipLines.map(l => ctx.measureText(l).width));
const tw = maxTextWidth + 20;
const th = tooltipLines.length * lineHeight + 12;
let tx = snapX + 15;
if (tx + tw > pw) tx = snapX - tw - 15;
const ty = 10;
ctx.fillStyle = 'rgba(44,62,80,0.9)';
ctx.beginPath();
ctx.roundRect(tx, ty, tw, th, 6);
ctx.fill();
tooltipLines.forEach((line, i) => {
ctx.fillStyle = i === 0 ? '#fff' : this.data.series[i - 1].color;
ctx.font = i === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(line, tx + 10, ty + 8 + i * lineHeight);
});
}
}
// 图例
const legendX = pw - 200;
this.data.series.forEach((s, i) => {
const lx = legendX + i * 70;
ctx.strokeStyle = s.color;
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(lx, -25); ctx.lineTo(lx + 20, -25); ctx.stroke();
ctx.beginPath(); ctx.arc(lx + 10, -25, 3, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#666'; ctx.font = '12px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(s.name, lx + 25, -25);
});
// 坐标轴
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
ctx.restore();
}
}
const lineChart = new LineChart('lineChart', tempData);
</script>
</body>
</html>示例3:Canvas与SVG性能对比
以下示例直观展示Canvas和SVG在不同数据量下的渲染性能差异。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas与SVG性能对比</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
padding: 20px;
}
h1 { text-align: center; color: #2c3e50; margin-bottom: 20px; }
.controls {
display: flex;
gap: 10px;
justify-content: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.controls button {
padding: 10px 24px;
border: none;
border-radius: 8px;
background: #4e79a7;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.controls button:hover { background: #3d6a96; }
.controls button.active { background: #e15759; }
.comparison {
display: flex;
gap: 20px;
max-width: 1200px;
margin: 0 auto;
flex-wrap: wrap;
justify-content: center;
}
.panel {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
flex: 1;
min-width: 400px;
}
.panel h2 { text-align: center; color: #34495e; margin-bottom: 10px; font-size: 16px; }
.render-time {
text-align: center;
font-size: 14px;
margin-bottom: 10px;
font-weight: 600;
}
.time-svg { color: #4e79a7; }
.time-canvas { color: #e15759; }
canvas, svg { width: 100%; border: 1px solid #eee; border-radius: 8px; background: #fafafa; }
.result-table {
max-width: 1200px;
margin: 20px auto 0;
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.result-table h2 { text-align: center; color: #34495e; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 12px; text-align: center; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; color: #2c3e50; font-weight: 600; }
.faster { color: #27ae60; font-weight: 600; }
.slower { color: #e74c3c; }
</style>
</head>
<body>
<h1>Canvas 与 SVG 渲染性能对比</h1>
<div class="controls">
<button onclick="runTest(100)">100 个元素</button>
<button onclick="runTest(500)">500 个元素</button>
<button onclick="runTest(1000)">1,000 个元素</button>
<button onclick="runTest(5000)">5,000 个元素</button>
<button onclick="runTest(10000)">10,000 个元素</button>
</div>
<div class="comparison">
<div class="panel">
<h2>SVG 渲染</h2>
<div class="render-time time-svg" id="svgTime">等待测试...</div>
<svg id="svgTest" viewBox="0 0 800 400" height="400"></svg>
</div>
<div class="panel">
<h2>Canvas 渲染</h2>
<div class="render-time time-canvas" id="canvasTime">等待测试...</div>
<canvas id="canvasTest" width="800" height="400"></canvas>
</div>
</div>
<div class="result-table">
<h2>性能测试结果</h2>
<table>
<thead>
<tr><th>元素数量</th><th>SVG 耗时</th><th>Canvas 耗时</th><th>差异</th></tr>
</thead>
<tbody id="resultBody"></tbody>
</table>
</div>
<script>
const results = [];
function generateData(count) {
const data = [];
for (let i = 0; i < count; i++) {
data.push({
x: Math.random() * 760 + 20,
y: Math.random() * 360 + 20,
r: Math.random() * 6 + 2,
color: `hsl(${Math.random() * 360}, 60%, 55%)`
});
}
return data;
}
function testSVG(data) {
const svg = document.getElementById('svgTest');
const start = performance.now();
// 清除
while (svg.firstChild) svg.removeChild(svg.firstChild);
// 绘制
data.forEach(d => {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', d.x);
circle.setAttribute('cy', d.y);
circle.setAttribute('r', d.r);
circle.setAttribute('fill', d.color);
circle.setAttribute('opacity', '0.7');
svg.appendChild(circle);
});
const end = performance.now();
return end - start;
}
function testCanvas(data) {
const canvas = document.getElementById('canvasTest');
const ctx = canvas.getContext('2d');
const start = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
data.forEach(d => {
ctx.beginPath();
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
ctx.fillStyle = d.color;
ctx.globalAlpha = 0.7;
ctx.fill();
});
ctx.globalAlpha = 1;
const end = performance.now();
return end - start;
}
function runTest(count) {
const data = generateData(count);
// SVG测试
const svgTime = testSVG(data);
document.getElementById('svgTime').textContent = `渲染耗时: ${svgTime.toFixed(2)} ms`;
// Canvas测试
const canvasTime = testCanvas(data);
document.getElementById('canvasTime').textContent = `渲染耗时: ${canvasTime.toFixed(2)} ms`;
// 记录结果
results.push({ count, svgTime, canvasTime });
updateResultTable();
}
function updateResultTable() {
const tbody = document.getElementById('resultBody');
tbody.innerHTML = '';
results.forEach(r => {
const faster = r.svgTime < r.canvasTime ? 'SVG' : 'Canvas';
const ratio = Math.abs(r.svgTime - r.canvasTime) / Math.min(r.svgTime, r.canvasTime);
const row = document.createElement('tr');
row.innerHTML = `
<td>${r.count.toLocaleString()}</td>
<td class="${faster === 'SVG' ? 'faster' : 'slower'}">${r.svgTime.toFixed(2)} ms</td>
<td class="${faster === 'Canvas' ? 'faster' : 'slower'}">${r.canvasTime.toFixed(2)} ms</td>
<td>${faster} 快 ${ratio.toFixed(1)}x</td>
`;
tbody.appendChild(row);
});
}
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
高DPI适配:始终处理devicePixelRatio,确保Canvas在Retina屏幕上清晰显示。
状态管理:使用save/restore管理Canvas状态,避免状态污染。
避免频繁重绘:对于静态内容,只在数据变化时重绘;对于动画,使用requestAnimationFrame。
内存管理:长时间运行的Canvas应用注意释放不再使用的ImageData和Pattern对象。
文本测量:使用measureText()精确计算文本宽度,避免硬编码。
事件坐标转换:鼠标事件坐标需要转换为Canvas内部坐标,考虑缩放和偏移。
性能优化:大量相同颜色的图形可以合并为一次fill操作,减少状态切换。
roundRect兼容性:roundRect是较新的API,旧浏览器需要用quadraticCurveTo手动实现圆角矩形。
代码规范示例
代码示例
// 好的做法:封装Canvas工具类
class CanvasHelper {
constructor(canvas, width, height) {
this.canvas = canvas;
this.dpr = window.devicePixelRatio || 1;
this.logicalWidth = width;
this.logicalHeight = height;
this._setupHiDPI();
}
_setupHiDPI() {
this.canvas.width = this.logicalWidth * this.dpr;
this.canvas.height = this.logicalHeight * this.dpr;
this.canvas.style.width = this.logicalWidth + 'px';
this.canvas.style.height = this.logicalHeight + 'px';
this.ctx = this.canvas.getContext('2d');
this.ctx.scale(this.dpr, this.dpr);
}
clear() {
this.ctx.clearRect(0, 0, this.logicalWidth, this.logicalHeight);
}
roundRect(x, y, w, h, r) {
const ctx = this.ctx;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
getMousePos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (this.logicalWidth / rect.width),
y: (e.clientY - rect.top) * (this.logicalHeight / rect.height)
};
}
}
// 不好的做法:每次绘制都重新获取context
// function draw() {
// const ctx = document.getElementById('canvas').getContext('2d');
// ctx.fillRect(0, 0, 100, 100);
// }
// 好的做法:缓存context引用
// const ctx = canvas.getContext('2d'); // 只获取一次常见问题与解决方案
问题1:Canvas在高DPI屏幕模糊
解决方案:使用devicePixelRatio进行适配
代码示例
function setupHiDPICanvas(canvas, w, h) {
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}问题2:Canvas尺寸为0
原因:Canvas在CSS中设置了display:none或父元素不可见时,getBoundingClientRect返回0。
解决方案:
确保Canvas在可见状态下初始化
使用ResizeObserver监听尺寸变化
设置明确的width/height属性
问题3:Canvas内存泄漏
原因:频繁创建ImageData、Pattern等对象未释放。
解决方案:
复用ImageData对象
及时清除不再使用的引用
对于动画Canvas,使用requestAnimationFrame而非setInterval
问题4:Canvas文本渲染模糊
原因:与高DPI问题类似,或字体大小过小。
解决方案:
确保高DPI适配
使用12px以上的字体大小
设置ctx.textRendering = 'optimizeLegibility'
总结
本教程系统讲解了Canvas图表的基础知识:
Canvas绘图API:fillRect、beginPath、arc、bezierCurveTo等核心方法和样式属性
坐标映射:数据空间到像素空间的线性映射和反向映射
刻度计算:基于1-2-5规则的美观刻度算法
图例绘制:手动绘制色块和标签
交互提示:命中检测和tooltip实现
Canvas vs SVG:大数据量选Canvas,复杂交互选SVG
性能对比:Canvas在大数据量场景下性能优势明显
掌握Canvas基础后,你将在后续教程中学习如何使用Canvas实现各类具体图表类型。
常见问题
Canvas在高DPI屏幕模糊
- 确保Canvas在可见状态下初始化 - 使用ResizeObserver监听尺寸变化 - 设置明确的width/height属性
Canvas尺寸为0
- 复用ImageData对象 - 及时清除不再使用的引用 - 对于动画Canvas,使用requestAnimationFrame而非setInterval
Canvas内存泄漏
- 确保高DPI适配 - 使用12px以上的字体大小 - 设置ctx.textRendering = 'optimizeLegibility'
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别