第11章:实战案例与最佳实践
11.1 章节概述
经过前面十章的学习,你已经掌握了 Canvas 2D 的各项技术。本章将通过完整的实战案例,将这些知识综合运用,同时总结 Canvas 开发的最佳实践。
本章内容:
- 实战案例 1:绘图应用
- 实战案例 2:数据可视化图表
- 实战案例 3:简单游戏
- 实战案例 4:图片编辑器
- 最佳实践:架构、代码组织、常见问题
11.2 实战案例 1:简易绘图应用
11.2.1 功能需求
- 画笔绘制
- 橡皮擦
- 颜色选择
- 线宽调节
- 撤销/重做
- 保存图片
11.2.2 完整实现
javascript
/**
* 简易绘图应用
*/
class DrawingApp {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
// 工具状态
this.tool = 'brush'; // brush | eraser
this.color = '#333';
this.lineWidth = 5;
// 绘制状态
this.isDrawing = false;
this.lastX = 0;
this.lastY = 0;
// 历史记录
this.history = [];
this.historyIndex = -1;
this.maxHistory = 50;
this.init();
}
init() {
// 设置画布样式
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// 保存初始状态
this.saveState();
// 绑定事件
this.bindEvents();
}
bindEvents() {
// 鼠标事件
this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this.draw.bind(this));
this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
this.canvas.addEventListener('mouseout', this.stopDrawing.bind(this));
// 触摸事件
this.canvas.addEventListener('touchstart', this.handleTouch.bind(this));
this.canvas.addEventListener('touchmove', this.handleTouch.bind(this));
this.canvas.addEventListener('touchend', this.stopDrawing.bind(this));
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
this.redo();
} else {
this.undo();
}
} else if (e.key === 's') {
e.preventDefault();
this.save();
}
}
});
}
handleTouch(e) {
e.preventDefault();
const touch = e.touches[0];
const rect = this.canvas.getBoundingClientRect();
const mouseEvent = new MouseEvent(
e.type === 'touchstart' ? 'mousedown' : 'mousemove',
{
clientX: touch.clientX,
clientY: touch.clientY
}
);
this.canvas.dispatchEvent(mouseEvent);
}
getPosition(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
startDrawing(e) {
this.isDrawing = true;
const pos = this.getPosition(e);
this.lastX = pos.x;
this.lastY = pos.y;
}
draw(e) {
if (!this.isDrawing) return;
const pos = this.getPosition(e);
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
this.ctx.lineTo(pos.x, pos.y);
if (this.tool === 'eraser') {
this.ctx.globalCompositeOperation = 'destination-out';
this.ctx.lineWidth = this.lineWidth * 3;
} else {
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.strokeStyle = this.color;
this.ctx.lineWidth = this.lineWidth;
}
this.ctx.stroke();
this.lastX = pos.x;
this.lastY = pos.y;
}
stopDrawing() {
if (this.isDrawing) {
this.isDrawing = false;
this.saveState();
}
}
// 历史记录
saveState() {
// 删除当前位置之后的历史
this.history = this.history.slice(0, this.historyIndex + 1);
// 保存当前状态
const imageData = this.ctx.getImageData(
0, 0, this.canvas.width, this.canvas.height
);
this.history.push(imageData);
this.historyIndex++;
// 限制历史数量
if (this.history.length > this.maxHistory) {
this.history.shift();
this.historyIndex--;
}
}
undo() {
if (this.historyIndex > 0) {
this.historyIndex--;
this.ctx.putImageData(this.history[this.historyIndex], 0, 0);
}
}
redo() {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.ctx.putImageData(this.history[this.historyIndex], 0, 0);
}
}
// 工具方法
setTool(tool) {
this.tool = tool;
}
setColor(color) {
this.color = color;
}
setLineWidth(width) {
this.lineWidth = width;
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.saveState();
}
save() {
const link = document.createElement('a');
link.download = `drawing-${Date.now()}.png`;
link.href = this.canvas.toDataURL('image/png');
link.click();
}
}
// 使用
const canvas = document.getElementById('drawingCanvas');
canvas.width = 800;
canvas.height = 600;
const app = new DrawingApp(canvas);
// 连接 UI
document.getElementById('brushBtn').onclick = () => app.setTool('brush');
document.getElementById('eraserBtn').onclick = () => app.setTool('eraser');
document.getElementById('colorPicker').onchange = (e) => app.setColor(e.target.value);
document.getElementById('lineWidth').oninput = (e) => app.setLineWidth(e.target.value);
document.getElementById('clearBtn').onclick = () => app.clear();
document.getElementById('undoBtn').onclick = () => app.undo();
document.getElementById('redoBtn').onclick = () => app.redo();
document.getElementById('saveBtn').onclick = () => app.save();11.3 实战案例 2:数据可视化图表
11.3.1 柱状图
javascript
/**
* 柱状图组件
*/
class BarChart {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.padding = options.padding || { top: 40, right: 20, bottom: 60, left: 60 };
this.barGap = options.barGap || 10;
this.colors = options.colors || ['#4D7CFF', '#FF6B6B', '#51CF66', '#FAB005'];
this.animate = options.animate !== false;
this.animationDuration = options.animationDuration || 800;
this.data = [];
this.animationProgress = 0;
}
setData(data) {
// data: [{ label: 'A', value: 100 }, ...]
this.data = data;
if (this.animate) {
this.animationProgress = 0;
this.startAnimation();
} else {
this.animationProgress = 1;
this.render();
}
}
startAnimation() {
const startTime = performance.now();
const animate = (timestamp) => {
const elapsed = timestamp - startTime;
this.animationProgress = Math.min(elapsed / this.animationDuration, 1);
// 使用缓动
const eased = 1 - Math.pow(1 - this.animationProgress, 3);
this.render(eased);
if (this.animationProgress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
render(progress = 1) {
const { ctx, canvas, padding, data, barGap, colors } = this;
const chartWidth = canvas.width - padding.left - padding.right;
const chartHeight = canvas.height - padding.top - padding.bottom;
// 清空
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (data.length === 0) return;
// 计算最大值
const maxValue = Math.max(...data.map(d => d.value));
// 计算柱子宽度
const barWidth = (chartWidth - barGap * (data.length + 1)) / data.length;
// 绘制网格线
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
const gridLines = 5;
for (let i = 0; i <= gridLines; i++) {
const y = padding.top + (chartHeight / gridLines) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(canvas.width - padding.right, y);
ctx.stroke();
// Y轴标签
const value = maxValue - (maxValue / gridLines) * i;
ctx.fillStyle = '#999';
ctx.font = '12px Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(value).toString(), padding.left - 10, y);
}
// 绘制柱子
data.forEach((item, index) => {
const x = padding.left + barGap + (barWidth + barGap) * index;
const barHeight = (item.value / maxValue) * chartHeight * progress;
const y = padding.top + chartHeight - barHeight;
// 柱子
ctx.fillStyle = colors[index % colors.length];
ctx.fillRect(x, y, barWidth, barHeight);
// 数值标签
if (progress === 1) {
ctx.fillStyle = '#333';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(item.value.toString(), x + barWidth / 2, y - 5);
}
// X轴标签
ctx.fillStyle = '#666';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(item.label, x + barWidth / 2, canvas.height - padding.bottom + 10);
});
// 绘制坐标轴
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, canvas.height - padding.bottom);
ctx.lineTo(canvas.width - padding.right, canvas.height - padding.bottom);
ctx.stroke();
}
}
// 使用
const chart = new BarChart(canvas, { animate: true });
chart.setData([
{ label: '一月', value: 120 },
{ label: '二月', value: 200 },
{ label: '三月', value: 150 },
{ label: '四月', value: 280 },
{ label: '五月', value: 190 }
]);11.3.2 饼图
javascript
/**
* 饼图组件
*/
class PieChart {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.centerX = canvas.width / 2;
this.centerY = canvas.height / 2;
this.radius = Math.min(canvas.width, canvas.height) / 2 - 50;
this.colors = options.colors || [
'#4D7CFF', '#FF6B6B', '#51CF66', '#FAB005', '#9775FA', '#20C997'
];
this.data = [];
}
setData(data) {
// data: [{ label: 'A', value: 30 }, ...]
const total = data.reduce((sum, item) => sum + item.value, 0);
this.data = data.map((item, index) => ({
...item,
percentage: item.value / total,
color: this.colors[index % this.colors.length]
}));
this.animateRender();
}
animateRender() {
let progress = 0;
const duration = 1000;
const startTime = performance.now();
const animate = (timestamp) => {
progress = Math.min((timestamp - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
this.render(eased);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
render(progress = 1) {
const { ctx, canvas, centerX, centerY, radius, data } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
let currentAngle = -Math.PI / 2; // 从顶部开始
data.forEach(item => {
const sliceAngle = item.percentage * Math.PI * 2 * progress;
// 绘制扇形
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = item.color;
ctx.fill();
// 绘制边框
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// 绘制标签
if (progress === 1 && item.percentage > 0.05) {
const labelAngle = currentAngle + sliceAngle / 2;
const labelRadius = radius * 0.7;
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${(item.percentage * 100).toFixed(1)}%`, labelX, labelY);
}
currentAngle += sliceAngle;
});
// 绘制图例
if (progress === 1) {
this.drawLegend();
}
}
drawLegend() {
const { ctx, canvas, data } = this;
const startY = canvas.height - 30;
const spacing = 100;
const startX = (canvas.width - data.length * spacing) / 2;
data.forEach((item, index) => {
const x = startX + index * spacing;
// 颜色块
ctx.fillStyle = item.color;
ctx.fillRect(x, startY, 15, 15);
// 标签
ctx.fillStyle = '#333';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(item.label, x + 20, startY + 7);
});
}
}
// 使用
const pieChart = new PieChart(canvas);
pieChart.setData([
{ label: '产品A', value: 30 },
{ label: '产品B', value: 25 },
{ label: '产品C', value: 20 },
{ label: '产品D', value: 15 },
{ label: '其他', value: 10 }
]);11.3.3 折线图
javascript
/**
* 折线图组件
*/
class LineChart {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.padding = options.padding || { top: 40, right: 20, bottom: 60, left: 60 };
this.lineColor = options.lineColor || '#4D7CFF';
this.fillColor = options.fillColor || 'rgba(77, 124, 255, 0.1)';
this.pointRadius = options.pointRadius || 5;
this.data = [];
}
setData(data) {
this.data = data;
this.animateRender();
}
animateRender() {
let progress = 0;
const duration = 1500;
const startTime = performance.now();
const animate = (timestamp) => {
progress = Math.min((timestamp - startTime) / duration, 1);
this.render(progress);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
render(progress = 1) {
const { ctx, canvas, padding, data, lineColor, fillColor, pointRadius } = this;
const chartWidth = canvas.width - padding.left - padding.right;
const chartHeight = canvas.height - padding.top - padding.bottom;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (data.length === 0) return;
const maxValue = Math.max(...data.map(d => d.value)) * 1.1;
const minValue = 0;
// 计算点的位置
const points = data.map((item, index) => ({
x: padding.left + (chartWidth / (data.length - 1)) * index,
y: padding.top + chartHeight - ((item.value - minValue) / (maxValue - minValue)) * chartHeight,
value: item.value,
label: item.label
}));
// 绘制网格
this.drawGrid(maxValue);
// 计算当前显示的点数
const visiblePoints = Math.ceil(points.length * progress);
const currentPoints = points.slice(0, visiblePoints);
if (currentPoints.length < 2) return;
// 绘制填充区域
ctx.beginPath();
ctx.moveTo(currentPoints[0].x, canvas.height - padding.bottom);
currentPoints.forEach(p => ctx.lineTo(p.x, p.y));
ctx.lineTo(currentPoints[currentPoints.length - 1].x, canvas.height - padding.bottom);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// 绘制线条
ctx.beginPath();
ctx.moveTo(currentPoints[0].x, currentPoints[0].y);
for (let i = 1; i < currentPoints.length; i++) {
ctx.lineTo(currentPoints[i].x, currentPoints[i].y);
}
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.stroke();
// 绘制数据点
currentPoints.forEach(point => {
ctx.beginPath();
ctx.arc(point.x, point.y, pointRadius, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.stroke();
});
// X轴标签
if (progress === 1) {
ctx.fillStyle = '#666';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
points.forEach(point => {
ctx.fillText(point.label, point.x, canvas.height - padding.bottom + 20);
});
}
}
drawGrid(maxValue) {
const { ctx, canvas, padding } = this;
const chartHeight = canvas.height - padding.top - padding.bottom;
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
const gridLines = 5;
for (let i = 0; i <= gridLines; i++) {
const y = padding.top + (chartHeight / gridLines) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(canvas.width - padding.right, y);
ctx.stroke();
const value = maxValue - (maxValue / gridLines) * i;
ctx.fillStyle = '#999';
ctx.font = '12px Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(value).toString(), padding.left - 10, y);
}
}
}
// 使用
const lineChart = new LineChart(canvas);
lineChart.setData([
{ label: '周一', value: 120 },
{ label: '周二', value: 180 },
{ label: '周三', value: 150 },
{ label: '周四', value: 220 },
{ label: '周五', value: 280 },
{ label: '周六', value: 250 },
{ label: '周日', value: 200 }
]);11.4 实战案例 3:简单游戏
11.4.1 打砖块游戏
javascript
/**
* 打砖块游戏
*/
class BreakoutGame {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.width = canvas.width;
this.height = canvas.height;
this.init();
this.bindEvents();
}
init() {
// 挡板
this.paddle = {
width: 100,
height: 15,
x: this.width / 2 - 50,
y: this.height - 30,
speed: 400
};
// 球
this.ball = {
x: this.width / 2,
y: this.height - 50,
radius: 8,
dx: 200,
dy: -200
};
// 砖块
this.bricks = [];
this.brickRows = 5;
this.brickCols = 10;
this.brickWidth = 70;
this.brickHeight = 20;
this.brickPadding = 5;
this.brickOffsetTop = 50;
this.brickOffsetLeft = (this.width - (this.brickWidth + this.brickPadding) * this.brickCols + this.brickPadding) / 2;
this.createBricks();
// 游戏状态
this.score = 0;
this.lives = 3;
this.isRunning = false;
this.isGameOver = false;
// 输入状态
this.keys = { left: false, right: false };
}
createBricks() {
const colors = ['#FF6B6B', '#FF8E53', '#FAB005', '#51CF66', '#4D7CFF'];
for (let row = 0; row < this.brickRows; row++) {
for (let col = 0; col < this.brickCols; col++) {
this.bricks.push({
x: this.brickOffsetLeft + col * (this.brickWidth + this.brickPadding),
y: this.brickOffsetTop + row * (this.brickHeight + this.brickPadding),
width: this.brickWidth,
height: this.brickHeight,
color: colors[row],
alive: true
});
}
}
}
bindEvents() {
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') this.keys.left = true;
if (e.key === 'ArrowRight') this.keys.right = true;
if (e.key === ' ' && !this.isRunning && !this.isGameOver) {
this.start();
}
});
document.addEventListener('keyup', (e) => {
if (e.key === 'ArrowLeft') this.keys.left = false;
if (e.key === 'ArrowRight') this.keys.right = false;
});
// 鼠标控制
this.canvas.addEventListener('mousemove', (e) => {
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
this.paddle.x = Math.max(0, Math.min(this.width - this.paddle.width, mouseX - this.paddle.width / 2));
});
}
start() {
this.isRunning = true;
this.lastTime = performance.now();
this.gameLoop();
}
gameLoop() {
if (!this.isRunning) return;
const now = performance.now();
const deltaTime = Math.min((now - this.lastTime) / 1000, 0.1);
this.lastTime = now;
this.update(deltaTime);
this.render();
requestAnimationFrame(() => this.gameLoop());
}
update(deltaTime) {
// 移动挡板
if (this.keys.left) {
this.paddle.x -= this.paddle.speed * deltaTime;
}
if (this.keys.right) {
this.paddle.x += this.paddle.speed * deltaTime;
}
this.paddle.x = Math.max(0, Math.min(this.width - this.paddle.width, this.paddle.x));
// 移动球
this.ball.x += this.ball.dx * deltaTime;
this.ball.y += this.ball.dy * deltaTime;
// 墙壁碰撞
if (this.ball.x - this.ball.radius < 0 || this.ball.x + this.ball.radius > this.width) {
this.ball.dx *= -1;
}
if (this.ball.y - this.ball.radius < 0) {
this.ball.dy *= -1;
}
// 挡板碰撞
if (this.ball.y + this.ball.radius > this.paddle.y &&
this.ball.x > this.paddle.x &&
this.ball.x < this.paddle.x + this.paddle.width) {
this.ball.dy = -Math.abs(this.ball.dy);
// 根据击球位置调整角度
const hitPos = (this.ball.x - this.paddle.x) / this.paddle.width;
this.ball.dx = (hitPos - 0.5) * 400;
}
// 砖块碰撞
this.bricks.forEach(brick => {
if (!brick.alive) return;
if (this.ball.x + this.ball.radius > brick.x &&
this.ball.x - this.ball.radius < brick.x + brick.width &&
this.ball.y + this.ball.radius > brick.y &&
this.ball.y - this.ball.radius < brick.y + brick.height) {
brick.alive = false;
this.ball.dy *= -1;
this.score += 10;
}
});
// 球掉落
if (this.ball.y + this.ball.radius > this.height) {
this.lives--;
if (this.lives <= 0) {
this.gameOver();
} else {
this.resetBall();
}
}
// 胜利检测
if (this.bricks.every(b => !b.alive)) {
this.win();
}
}
render() {
const { ctx, width, height } = this;
// 清空
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, width, height);
// 绘制砖块
this.bricks.forEach(brick => {
if (!brick.alive) return;
ctx.fillStyle = brick.color;
ctx.fillRect(brick.x, brick.y, brick.width, brick.height);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.strokeRect(brick.x, brick.y, brick.width, brick.height);
});
// 绘制挡板
ctx.fillStyle = '#4D7CFF';
ctx.fillRect(this.paddle.x, this.paddle.y, this.paddle.width, this.paddle.height);
// 绘制球
ctx.beginPath();
ctx.arc(this.ball.x, this.ball.y, this.ball.radius, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
// 绘制UI
ctx.fillStyle = '#fff';
ctx.font = '18px Arial';
ctx.textAlign = 'left';
ctx.fillText(`分数: ${this.score}`, 20, 25);
ctx.textAlign = 'right';
ctx.fillText(`生命: ${'❤️'.repeat(this.lives)}`, width - 20, 25);
// 开始提示
if (!this.isRunning && !this.isGameOver) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#fff';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.fillText('按空格键开始', width / 2, height / 2);
}
}
resetBall() {
this.ball.x = this.width / 2;
this.ball.y = this.height - 50;
this.ball.dx = (Math.random() > 0.5 ? 1 : -1) * 200;
this.ball.dy = -200;
this.isRunning = false;
this.render();
}
gameOver() {
this.isRunning = false;
this.isGameOver = true;
const { ctx, width, height } = this;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#FF6B6B';
ctx.font = 'bold 36px Arial';
ctx.textAlign = 'center';
ctx.fillText('游戏结束', width / 2, height / 2 - 20);
ctx.fillStyle = '#fff';
ctx.font = '20px Arial';
ctx.fillText(`最终得分: ${this.score}`, width / 2, height / 2 + 20);
}
win() {
this.isRunning = false;
this.isGameOver = true;
const { ctx, width, height } = this;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#51CF66';
ctx.font = 'bold 36px Arial';
ctx.textAlign = 'center';
ctx.fillText('恭喜通关!', width / 2, height / 2 - 20);
ctx.fillStyle = '#fff';
ctx.font = '20px Arial';
ctx.fillText(`最终得分: ${this.score}`, width / 2, height / 2 + 20);
}
}
// 使用
const game = new BreakoutGame(canvas);
game.render();11.5 实战案例 4:图片编辑器核心
11.5.1 基础框架
javascript
/**
* 图片编辑器核心类
*/
class ImageEditor {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.image = null;
this.originalImageData = null;
this.currentImageData = null;
this.history = [];
this.historyIndex = -1;
this.zoom = 1;
this.offsetX = 0;
this.offsetY = 0;
}
async loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
this.image = img;
this.canvas.width = img.width;
this.canvas.height = img.height;
this.ctx.drawImage(img, 0, 0);
this.originalImageData = this.ctx.getImageData(0, 0, img.width, img.height);
this.currentImageData = this.cloneImageData(this.originalImageData);
this.saveHistory();
resolve();
};
img.onerror = reject;
img.src = src;
});
}
cloneImageData(imageData) {
return new ImageData(
new Uint8ClampedArray(imageData.data),
imageData.width,
imageData.height
);
}
// 滤镜方法
applyFilter(filterFunc) {
const data = this.cloneImageData(this.currentImageData);
filterFunc(data);
this.currentImageData = data;
this.ctx.putImageData(data, 0, 0);
this.saveHistory();
}
brightness(value) {
this.applyFilter(data => {
for (let i = 0; i < data.data.length; i += 4) {
data.data[i] = Math.min(255, Math.max(0, data.data[i] + value));
data.data[i + 1] = Math.min(255, Math.max(0, data.data[i + 1] + value));
data.data[i + 2] = Math.min(255, Math.max(0, data.data[i + 2] + value));
}
});
}
contrast(value) {
const factor = (259 * (value + 255)) / (255 * (259 - value));
this.applyFilter(data => {
for (let i = 0; i < data.data.length; i += 4) {
data.data[i] = factor * (data.data[i] - 128) + 128;
data.data[i + 1] = factor * (data.data[i + 1] - 128) + 128;
data.data[i + 2] = factor * (data.data[i + 2] - 128) + 128;
}
});
}
grayscale() {
this.applyFilter(data => {
for (let i = 0; i < data.data.length; i += 4) {
const gray = data.data[i] * 0.299 + data.data[i + 1] * 0.587 + data.data[i + 2] * 0.114;
data.data[i] = data.data[i + 1] = data.data[i + 2] = gray;
}
});
}
sepia() {
this.applyFilter(data => {
for (let i = 0; i < data.data.length; i += 4) {
const r = data.data[i];
const g = data.data[i + 1];
const b = data.data[i + 2];
data.data[i] = r * 0.393 + g * 0.769 + b * 0.189;
data.data[i + 1] = r * 0.349 + g * 0.686 + b * 0.168;
data.data[i + 2] = r * 0.272 + g * 0.534 + b * 0.131;
}
});
}
invert() {
this.applyFilter(data => {
for (let i = 0; i < data.data.length; i += 4) {
data.data[i] = 255 - data.data[i];
data.data[i + 1] = 255 - data.data[i + 1];
data.data[i + 2] = 255 - data.data[i + 2];
}
});
}
blur() {
const kernel = [1, 2, 1, 2, 4, 2, 1, 2, 1];
this.applyConvolution(kernel, 16);
}
sharpen() {
const kernel = [0, -1, 0, -1, 5, -1, 0, -1, 0];
this.applyConvolution(kernel, 1);
}
applyConvolution(kernel, divisor = 1) {
const src = this.currentImageData;
const dst = this.cloneImageData(src);
const w = src.width;
const h = src.height;
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let r = 0, g = 0, b = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const idx = ((y + ky) * w + (x + kx)) * 4;
const k = kernel[(ky + 1) * 3 + (kx + 1)];
r += src.data[idx] * k;
g += src.data[idx + 1] * k;
b += src.data[idx + 2] * k;
}
}
const dstIdx = (y * w + x) * 4;
dst.data[dstIdx] = r / divisor;
dst.data[dstIdx + 1] = g / divisor;
dst.data[dstIdx + 2] = b / divisor;
}
}
this.currentImageData = dst;
this.ctx.putImageData(dst, 0, 0);
this.saveHistory();
}
// 历史记录
saveHistory() {
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push(this.cloneImageData(this.currentImageData));
this.historyIndex++;
if (this.history.length > 30) {
this.history.shift();
this.historyIndex--;
}
}
undo() {
if (this.historyIndex > 0) {
this.historyIndex--;
this.currentImageData = this.cloneImageData(this.history[this.historyIndex]);
this.ctx.putImageData(this.currentImageData, 0, 0);
}
}
redo() {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.currentImageData = this.cloneImageData(this.history[this.historyIndex]);
this.ctx.putImageData(this.currentImageData, 0, 0);
}
}
reset() {
this.currentImageData = this.cloneImageData(this.originalImageData);
this.ctx.putImageData(this.currentImageData, 0, 0);
this.saveHistory();
}
// 导出
export(format = 'image/png', quality = 0.92) {
return this.canvas.toDataURL(format, quality);
}
download(filename = 'edited-image.png') {
const link = document.createElement('a');
link.download = filename;
link.href = this.export();
link.click();
}
}
// 使用
const editor = new ImageEditor(canvas);
await editor.loadImage('photo.jpg');
// 调用滤镜
editor.brightness(30);
editor.contrast(20);
editor.grayscale();
editor.undo();
editor.download();11.6 Canvas 最佳实践
11.6.1 代码组织
javascript
// ✅ 推荐:面向对象组织
class CanvasApp {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.objects = [];
this.isRunning = false;
}
init() {
this.setupCanvas();
this.bindEvents();
}
setupCanvas() {
// 处理高清屏
const dpr = window.devicePixelRatio || 1;
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr);
}
bindEvents() { /* ... */ }
update(deltaTime) { /* ... */ }
render() { /* ... */ }
start() { /* ... */ }
stop() { /* ... */ }
}11.6.2 性能优化清单
javascript
// 1. 使用离屏 Canvas 缓存静态内容
const cache = document.createElement('canvas');
// 2. 批量绘制相同样式的图形
ctx.fillStyle = 'red';
shapes.forEach(s => ctx.fillRect(s.x, s.y, s.w, s.h));
// 3. 避免浮点坐标
ctx.fillRect(Math.round(x), Math.round(y), w, h);
// 4. 减少状态切换
// 按样式分组绘制
// 5. 使用 requestAnimationFrame
requestAnimationFrame(animate);
// 6. 基于时间的动画
const deltaTime = (now - lastTime) / 1000;
object.x += speed * deltaTime;
// 7. 使用对象池
const pool = [];
function getObject() {
return pool.length > 0 ? pool.pop() : createObject();
}11.6.3 常见问题解决
javascript
// 问题1:图像模糊
// 解决:处理设备像素比
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.scale(dpr, dpr);
// 问题2:1像素线条模糊
// 解决:使用 0.5 偏移
ctx.strokeRect(10.5, 10.5, 100, 50);
// 问题3:图像跨域
// 解决:设置 crossOrigin
img.crossOrigin = 'anonymous';
// 问题4:内存泄漏
// 解决:释放资源
bitmap.close();
canvas.width = 1;
canvas.height = 1;
// 问题5:动画卡顿
// 解决:
// - 使用 requestAnimationFrame
// - 基于时间的动画
// - 减少绘制调用
// - 使用离屏 Canvas11.6.4 调试技巧
javascript
// 1. FPS 监控
let lastTime = performance.now();
let frames = 0;
function checkFPS() {
frames++;
const now = performance.now();
if (now - lastTime >= 1000) {
console.log('FPS:', frames);
frames = 0;
lastTime = now;
}
}
// 2. 绘制边界框
function debugBounds(ctx, obj) {
ctx.strokeStyle = 'red';
ctx.strokeRect(obj.x, obj.y, obj.width, obj.height);
}
// 3. 绘制碰撞点
function debugPoint(ctx, x, y) {
ctx.fillStyle = 'red';
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
}
// 4. 性能标记
performance.mark('render-start');
// ... 渲染代码
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');11.7 本章小结
Canvas 开发核心要点
| 方面 | 要点 |
|---|---|
| 基础 | 坐标系、绘制方法、状态管理 |
| 图形 | 路径、贝塞尔曲线、变换 |
| 样式 | 颜色、渐变、阴影 |
| 图像 | 加载、绘制、像素操作 |
| 动画 | requestAnimationFrame、缓动、帧率控制 |
| 性能 | 离屏 Canvas、批量绘制、对象池 |
项目架构建议
project/
├── src/
│ ├── core/ # 核心类
│ │ ├── Canvas.js
│ │ └── Renderer.js
│ ├── objects/ # 可绘制对象
│ │ ├── Shape.js
│ │ └── Sprite.js
│ ├── effects/ # 滤镜和效果
│ │ └── Filters.js
│ ├── utils/ # 工具函数
│ │ ├── Math.js
│ │ └── Color.js
│ └── main.js
├── assets/
└── index.html11.8 结语
恭喜你完成了 Canvas 2D 的系统学习!
通过这 11 章的学习,你已经掌握了:
- ✅ Canvas 基础概念和 API
- ✅ 图形绑制和路径操作
- ✅ 样式、颜色和文本
- ✅ 变换和坐标系统
- ✅ 图像处理和像素操作
- ✅ 合成和混合模式
- ✅ 动画和帧循环
- ✅ 性能优化技巧
- ✅ 实战项目开发
接下来,你可以:
- 深入学习 WebGL 实现 3D 渲染
- 探索 PixiJS 等渲染库
- 开发自己的游戏或可视化项目
- 研究更复杂的图形学算法
Canvas 是一个强大的工具,期待你用它创造出精彩的作品!
文档版本:v1.0
全系列字数:约 150,000 字
代码示例:400+ 个
