Skip to content

第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
// - 基于时间的动画
// - 减少绘制调用
// - 使用离屏 Canvas

11.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.html

11.8 结语

恭喜你完成了 Canvas 2D 的系统学习!

通过这 11 章的学习,你已经掌握了:

  1. ✅ Canvas 基础概念和 API
  2. ✅ 图形绑制和路径操作
  3. ✅ 样式、颜色和文本
  4. ✅ 变换和坐标系统
  5. ✅ 图像处理和像素操作
  6. ✅ 合成和混合模式
  7. ✅ 动画和帧循环
  8. ✅ 性能优化技巧
  9. ✅ 实战项目开发

接下来,你可以:

  • 深入学习 WebGL 实现 3D 渲染
  • 探索 PixiJS 等渲染库
  • 开发自己的游戏或可视化项目
  • 研究更复杂的图形学算法

Canvas 是一个强大的工具,期待你用它创造出精彩的作品!


文档版本:v1.0
全系列字数:约 150,000 字
代码示例:400+ 个

如有转载或 CV 的请标注本站原文地址