Canvas
HTML5 <canvas> 元素提供了一块可编程的画布,允许通过 JavaScript 绘制图形、动画和可视化内容。Canvas 是 Web 图形编程的基础,广泛应用于数据可视化、游戏开发、图像处理等领域。
1. 概述
Canvas 在 Web 图形体系中的定位
发展简史时间线
Canvas 与 SVG 的选择
| 特性 | Canvas | SVG |
|---|---|---|
| 渲染方式 | 位图(像素) | 矢量图(DOM 节点) |
| 分辨率 | 依赖像素密度,需手动处理 DPR | 无限缩放不失真 |
| 事件处理 | 需手动计算命中区域 | 每个 DOM 元素可绑定事件 |
| 性能 | 大量元素时性能好 | DOM 节点多时性能下降 |
| 适用场景 | 游戏、图像处理、大量粒子 | 图标、图表、交互式地图 |
| 动画 | 通过重绘实现 | 通过 CSS/SMIL 动画实现 |
| 可访问性 | 差(纯像素) | 好(DOM 语义) |
决策原则: 需要高性能渲染大量图形对象 → Canvas;需要交互性和可访问性 → SVG。
基本结构
<canvas id="myCanvas" width="800" height="600">
您的浏览器不支持 Canvas,请升级浏览器。
</canvas><canvas> 的 width 和 height 属性定义画布的绘图分辨率,而非 CSS 显示尺寸。通过 CSS 缩放 Canvas 会导致模糊,应始终在 HTML 属性或 JavaScript 中设置实际像素尺寸。
2. Canvas vs SVG vs WebGL 决策树
技术选型对比表
| 维度 | Canvas 2D | SVG | WebGL | WebGPU |
|---|---|---|---|---|
| 学习曲线 | ⭐⭐ 低 | ⭐⭐ 低 | ⭐⭐⭐⭐ 高 | ⭐⭐⭐⭐⭐ 极高 |
| 性能上限 | CPU 绑定 | DOM 绑定 | GPU 加速 | GPU 并行 |
| 适用对象数 | 10,000+ | < 1,000 | 100,000+ | 无限 |
| 2D 能力 | 完整 | 完整 | 需自己实现 | 需自己实现 |
| 3D 能力 | 有限(伪3D) | 有限(CSS 3D) | 完整 | 完整 |
| 移动端兼容 | 优秀 | 良好 | 一般 | 有限 |
| 开发效率 | 高 | 高 | 中 | 低 |
3. 渲染管线原理
理解 Canvas 的渲染管线有助于写出高性能代码:
关键概念说明
- 命令缓冲区:Canvas API 调用不会立即执行,而是先进入缓冲区批量处理
- 光栅化:将矢量图形转换为像素网格的过程
- 合成:将多个图层按照
globalCompositeOperation规则合并为最终图像
Canvas 采用即时模式(Immediate Mode)渲染——每次调用绘制 API 都会立即产生副作用。这与 SVG 的保留模式(Retained Mode)形成鲜明对比。
4. 坐标系统与变换矩阵
<h4>009-transform-system.html</h4><!-- 来源:12-Canvas.md - 第4章 坐标系统与变换矩阵 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【9】Canvas 变换系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #5a6268; }
.info-row {
display: flex; gap: 16px; justify-content: center; margin-top: 12px;
font-size: 13px; color: #666;
}
.info-item { background: #f8f9fa; padding: 8px 14px; border-radius: 6px; }
.info-item strong { color: #007bff; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 变换系统(translate / rotate / scale / save/restore / setTransform)</div>
<div class="controls">
<button class="btn" onclick="drawAll()">🎨 绘制全部</button>
<button class="btn" onclick="toggleAnimation()">⏯️ 动画开/关</button>
<button class="btn btn-secondary" onclick="clearCanvas()">🗑️ 清空</button>
</div>
<canvas id="transformCanvas" width="850" height="600"></canvas>
<div class="info-row">
<span class="info-item">旋转角度:<strong id="angleVal">0</strong>°</span>
<span class="info-item">缩放比例:<strong id="scaleVal">1.00</strong>x</span>
<span class="info-item">动画状态:<strong id="animStatus">运行中</strong></span>
</div>
</div>
<script>
const canvas = document.getElementById("transformCanvas")
const ctx = canvas.getContext("2d")
let animating = true
let angle = 0
let animId = null
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
function drawAll() {
// 清屏(保留半透明拖尾)
ctx.fillStyle = "rgba(248,249,250,0.3)"
ctx.fillRect(0, 0, canvas.width, canvas.height)
drawTranslateDemo()
drawRotateDemo()
drawScaleDemo()
drawSaveRestoreStack()
drawSetTransformDemo()
}
// ====== 1. translate 平移 ======
function drawTranslateDemo() {
ctx.save()
// 原点标记
ctx.fillStyle = "#e74c3c"
ctx.beginPath()
ctx.arc(80, 70, 4, 0, Math.PI*2)
ctx.fill()
ctx.font = "11px monospace"
ctx.fillText("(0,0)", 88, 74)
// 平移后绘制
ctx.save()
ctx.translate(120, 50)
ctx.fillStyle = "#3498db"
ctx.fillRect(0, 0, 60, 40)
ctx.strokeStyle="#2980b9"
ctx.lineWidth=1
ctx.strokeRect(0,0,60,40)
ctx.restore()
// 标签
ctx.font="bold 13px sans-serif"
ctx.fillStyle="#333"
ctx.fillText("① translate(120, 50) — 坐标系平移", 30, 140)
ctx.restore()
}
// ====== 2. rotate 旋转(动态)======
function drawRotateDemo() {
ctx.save()
const cx = 280, cy = 90
// 参考圆
ctx.strokeStyle = "#eee"
ctx.lineWidth=1
ctx.setLineDash([4,4])
ctx.beginPath()
ctx.arc(cx, cy, 55, 0, Math.PI*2)
ctx.stroke()
ctx.setLineDash([])
// 中心点
ctx.fillStyle="#999"
ctx.beginPath()
ctx.arc(cx,cy,3,0,Math.PI*2)
ctx.fill()
// 旋转的矩形
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(angle)
ctx.fillStyle = "#e74c3c"
ctx.fillRect(-30, -20, 60, 40)
ctx.strokeStyle="#c0392b"
ctx.lineWidth=2
ctx.strokeRect(-30,-20,60,40)
// 方向指示
ctx.fillStyle="#fff"
ctx.beginPath()
ctx.moveTo(25,0)
ctx.lineTo(15,-6)
ctx.lineTo(15,6)
ctx.closePath()
ctx.fill()
ctx.restore()
ctx.font="bold 13px sans-serif"
ctx.fillStyle="#333"
ctx.fillText("② rotate(angle) — 绕原点旋转", 190, 170)
ctx.restore()
}
// ====== 3. scale 缩放(动态)======
function drawScaleDemo() {
ctx.save()
const cx = 480, cy = 90
const s = 1 + Math.sin(angle * 2) * 0.4
ctx.save()
ctx.translate(cx, cy)
ctx.scale(s, s)
ctx.fillStyle = "#2ecc71"
roundRect(ctx,-30,-25,60,50,6)
ctx.fill()
ctx.strokeStyle="#27ae60"
ctx.lineWidth=2/s // 补偿缩放导致的线宽变化
ctx.strokeRect(-30,-25,60,50)
ctx.restore()
// 原始尺寸参考(虚线)
ctx.strokeStyle="rgba(46,204,113,0.3)"
ctx.lineWidth=1
ctx.setLineDash([4,4])
ctx.strokeRect(cx-30,cy-25,60,50)
ctx.setLineDash([])
ctx.font="bold 13px sans-serif"
ctx.fillStyle="#333"
ctx.fillText(`③ scale(${s.toFixed(2)}) — 动态缩放`, 400, 170)
ctx.restore()
}
// ====== 4. save/restore 栈式管理 ======
function drawSaveRestoreStack() {
ctx.save()
const startX = 650
// 层级1:基础坐标系
ctx.fillStyle = "#f8f9fa"
ctx.fillRect(startX - 10, 35, 200, 120)
ctx.strokeStyle="#ddd"
ctx.lineWidth=1
ctx.strokeRect(startX-10,35,200,120)
ctx.save() // push state 1
ctx.translate(startX + 30, 60)
ctx.fillStyle = "#3498db"
ctx.fillRect(0, 0, 40, 30)
ctx.save() // push state 2
ctx.translate(55, 15)
ctx.rotate(Math.PI / 6)
ctx.fillStyle = "#e74c3c"
ctx.fillRect(0, 0, 40, 30)
ctx.save() // push state 3
ctx.translate(55, -5)
ctx.scale(0.7, 0.7)
ctx.fillStyle = "#2ecc71"
ctx.fillRect(0, 0, 40, 30)
ctx.restore() // pop state 3 → 回到 state 2
ctx.restore() // pop state 2 → 回到 state 1
ctx.restore() // pop state 1 → 回到初始
ctx.font="bold 13px sans-serif"
ctx.fillStyle="#333"
ctx.fillText("④ save()/restore() — 栈式状态管理", 620, 175)
ctx.font="11px monospace"
ctx.fillStyle="#888"
ctx.fillText("蓝→红→绿: 嵌套变换", 665, 168)
ctx.restore()
}
// ====== 5. setTransform 矩阵变换 ======
function drawSetTransformDemo() {
ctx.save()
const startY = 210
// 区域标题
ctx.font="bold 14px sans-serif"
ctx.fillStyle="#333"
ctx.fillText("⑤ setTransform(a,b,c,d,e,f) — 矩阵变换", 30, startY)
// 网格背景
ctx.strokeStyle = "#f0f0f0"
ctx.lineWidth = 1
for(let x=0;x<850;x+=30){
ctx.beginPath();ctx.moveTo(x,startY+15);ctx.lineTo(x,580);ctx.stroke()
}
for(let y=startY+15;y<580;y+=30){
ctx.beginPath();ctx.moveTo(30,y);ctx.lineTo(830,y);ctx.stroke()
}
// 原始矩形(参考)
ctx.fillStyle = "rgba(52,152,219,0.3)"
ctx.fillRect(60, startY + 50, 80, 60)
ctx.strokeStyle = "#3498db"
ctx.lineWidth = 1.5
ctx.strokeRect(60, startY + 50, 80, 60)
ctx.font = "11px sans-serif"
ctx.fillStyle = "#3498db"
ctx.fillText("原始", 85, startY + 125)
// setTransform: 水平倾斜 shear
ctx.save()
ctx.setTransform(1, 0, 0.5, 1, 220, startY + 50)
ctx.fillStyle = "#e74c3c"
ctx.fillRect(0, 0, 80, 60)
ctx.strokeStyle = "#c0392b"
ctx.lineWidth = 1.5
ctx.strokeRect(0, 0, 80, 60)
ctx.restore()
ctx.font = "11px sans-serif"
ctx.fillStyle = "#e74c3c"
ctx.fillText("shear X (c=0.5)", 240, startY + 125)
// setTransform: 旋转45°
ctx.save()
const rad = Math.PI / 6
ctx.setTransform(Math.cos(rad), Math.sin(rad), -Math.sin(rad), Math.cos(rad), 420, startY + 100)
ctx.fillStyle = "#2ecc71"
ctx.fillRect(0, 0, 80, 60)
ctx.strokeStyle = "#27ae60"
ctx.lineWidth = 1.5
ctx.strokeRect(0, 0, 80, 60)
ctx.restore()
ctx.font = "11px sans-serif"
ctx.fillStyle = "#2ecc71"
ctx.fillText("rotate 30°", 440, startY + 125)
// setTransform: 缩放+翻转
ctx.save()
ctx.setTransform(-1.2, 0, 0, 0.8, 620, startY + 110)
ctx.fillStyle = "#9b59b6"
ctx.fillRect(0, 0, 80, 60)
ctx.strokeStyle = "#8e44ad"
ctx.lineWidth = 1.5
ctx.strokeRect(0, 0, 80, 60)
ctx.restore()
ctx.font = "11px sans-serif"
ctx.fillStyle = "#9b59b6"
ctx.fillText("scale(-1.2,0.8)", 600, startY + 125)
// 复合变换:倾斜+缩放+旋转(动态)
ctx.save()
const dynamicAngle = angle * 0.8
const cosA = Math.cos(dynamicAngle)
const sinA = Math.sin(dynamicAngle)
ctx.setTransform(
cosA * 1.1, sinA * 1.1,
-sinA * 0.8 + 0.2, cosA * 0.8,
720, startY + 95
)
ctx.fillStyle = "#f39c12"
ctx.fillRect(0, 0, 80, 60)
ctx.strokeStyle = "#d68910"
ctx.lineWidth = 2
ctx.strokeRect(0, 0, 80, 60)
ctx.restore()
ctx.font = "11px sans-serif"
ctx.fillStyle = "#f39c12"
ctx.fillText("复合矩阵(动态)", 728, startY + 125)
// 矩阵公式说明
ctx.font="12px monospace"
ctx.fillStyle="#666"
ctx.fillText("矩阵公式: [a c e] [x] a=水平缩放 b=水平倾斜", 30, startY + 155)
ctx.fillText(" [b d f] [y] c=垂直倾斜 d=垂直缩放 e=平移X f=平移Y", 30, startY + 172)
ctx.restore()
}
function roundRect(ctx,x,y,w,h,r){
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()
}
function toggleAnimation() {
animating = !animating
document.getElementById("animStatus").textContent = animating ? "运行中" : "已暂停"
if (animating && !animId) animate()
}
function animate() {
if (!animating) { animId = null; return }
angle += 0.02
// 更新信息显示
const deg = ((angle * 180 / Math.PI) % 360).toFixed(0)
document.getElementById("angleVal").textContent = deg
const s = (1 + Math.sin(angle * 2) * 0.4).toFixed(2)
document.getElementById("scaleVal").textContent = s
drawAll()
animId = requestAnimationFrame(animate)
}
// 初始绘制
ctx.fillStyle = "#fff"
ctx.fillRect(0, 0, canvas.width, canvas.height)
animate()
</script>
</body>
</html>坐标系基础
Canvas 使用左上角为原点的坐标系:
(0,0) ────────→ x
│
│ (x, y)
│
↓
y变换矩阵数学原理
每个变换都对应一个 3×3 变换矩阵:
$$ \begin{bmatrix} a & c & e \ b & d & f \ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \ y \ 1 \end{bmatrix}
\begin{bmatrix} ax + cy + e \ bx + dy + f \ 1 \end{bmatrix} $$
变换方法详解
ctx.save();
// 平移坐标系原点
ctx.translate(200, 200);
// 旋转(弧度制)
ctx.rotate(Math.PI / 4);
// 缩放(可分别控制 X/Y 轴)
ctx.scale(1.5, 1.5);
// 自定义变换矩阵(a b c d e f)
// a: 水平缩放, b: 水平倾斜
// c: 垂直倾斜, d: 垂直缩放
// e: 水平平移, f: 垂直平移
ctx.transform(1, 0.2, 0.2, 1, 0, 0);
// 重置为单位矩阵后应用新变换
ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置
ctx.translate(100, 100); // 重新设置
ctx.fillRect(-50, -50, 100, 100);
ctx.restore();变换方法汇总表:
| 方法 | 说明 | 典型用途 |
|---|---|---|
translate(x, y) | 平移坐标系 | 设置绘制原点 |
rotate(angle) | 旋转(弧度) | 旋转物体 |
scale(sx, sy) | 缩放 | 放大/缩小物体 |
transform(a,b,c,d,e,f) | 乘以当前矩阵 | 复合变换 |
setTransform(a,b,c,d,e,f) | 重置并设置矩阵 | 完全重置状态 |
resetTransform() | 重置为单位矩阵 | 快速重置 |
save() | 保存当前状态 | 状态隔离 |
restore() | 恢复上次保存的状态 | 状态恢复 |
5. Path2D API
Path2D 是 Canvas 路径的高级封装,支持路径复用和组合操作。
<h4>005-path2d-stars.html</h4><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【5】Path2D 星星绘制</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #0d1117; color: #fff; }
.demo-container { max-width: 800px; margin: 0 auto; background: #161b22; padding: 24px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #58a6ff; border-bottom: 2px solid #58a6ff; padding-bottom: 8px; }
canvas {
display: block;
margin: 16px auto;
border-radius: 12px;
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
}
.controls { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
.btn {
padding: 8px 18px;
border: 1px solid #30363d;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
background: #21262d;
color: #c9d1d9;
transition: all 0.3s;
}
.btn:hover { background: #30363d; color: #fff; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Path2D 可复用路径 - 动态星空</div>
<div class="controls">
<button class="btn" onclick="changeStarCount(-2)">⭐ 减少</button>
<button class="btn" onclick="changeStarCount(2)">⭐ 增加</button>
<button class="btn" onclick="toggleAnimation()">⏯️ 暂停/继续</button>
</div>
<canvas id="starCanvas" width="760" height="500"></canvas>
</div>
<script>
const canvas = document.getElementById("starCanvas")
const ctx = canvas.getContext("2d")
let stars = []
let animating = true
let starCount = 12
// 创建可复用的星星 Path2D
function createStarPath(cx, cy, outerR, innerR, points) {
const path = new Path2D()
const step = Math.PI / points
path.moveTo(cx, cy - outerR)
for (let i = 0; i < 2 * points; i++) {
const r = i % 2 === 0 ? outerR : innerR
const angle = i * step - Math.PI / 2
path.lineTo(cx + r * Math.cos(angle), cy + r * Math.sin(angle))
}
path.closePath()
return path
}
class Star {
constructor(index, total) {
this.index = index
this.total = total
this.reset()
}
reset() {
const cols = Math.ceil(Math.sqrt(this.total * 2))
const row = Math.floor(this.index / cols)
const col = this.index % cols
this.baseX = 70 + col * ((canvas.width - 140) / Math.max(cols, 1))
this.baseY = 70 + row * 110
this.x = this.baseX
this.y = this.baseY
this.rotation = 0
this.rotSpeed = (Math.random() - 0.5) * 0.03
this.scale = 0.6 + Math.random() * 0.5
this.outerRadius = 28 * this.scale
this.innerRadius = 14 * this.scale
this.points = 5
this.hue = (this.index * 360 / this.total) % 360
this.twinkle = Math.random() * Math.PI * 2
this.twinkleSpeed = 0.02 + Math.random() * 0.04
}
update() {
this.rotation += this.rotSpeed
this.twinkle += this.twinkleSpeed
this.x = this.baseX + Math.sin(this.twinkle) * 5
this.y = this.baseY + Math.cos(this.twinkle * 0.7) * 3
}
draw(ctx) {
ctx.save()
ctx.translate(this.x, this.y)
ctx.rotate(this.rotation)
const brightness = 55 + Math.sin(this.twinkle) * 15
const starPath = createStarPath(0, 0, this.outerRadius, this.innerRadius, this.points)
// 发光效果
const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, this.outerRadius * 1.8)
glow.addColorStop(0, `hsla(${this.hue}, 90%, ${brightness}%, 0.4)`)
glow.addColorStop(1, "transparent")
ctx.fillStyle = glow
ctx.fill(starPath)
// 核心
ctx.fillStyle = `hsl(${this.hue}, 85%, ${brightness + 15}%)`
ctx.fill(starPath)
// 描边
ctx.strokeStyle = `hsla(${this.hue}, 100%, 80%, 0.6)`
ctx.lineWidth = 1.5
ctx.stroke(starPath)
ctx.restore()
}
}
function initStars(count) {
stars = []
for (let i = 0; i < count; i++) stars.push(new Star(i, count))
}
function changeStarCount(delta) {
starCount = Math.max(3, Math.min(30, starCount + delta))
initStars(starCount)
}
function toggleAnimation() { animating = !animating }
function animate() {
if (animating) {
ctx.fillStyle = "rgba(13, 17, 23, 0.2)"
} else {
ctx.fillStyle = "#0d1117"
}
ctx.fillRect(0, 0, canvas.width, canvas.height)
stars.forEach(s => {
if (animating) s.update()
s.draw(ctx)
})
requestAnimationFrame(animate)
}
initStars(starCount)
animate()
</script>
</body>
</html>```
### 创建 Path2D 对象
```javascript
// 方式1:空路径
const path1 = new Path2D();
// 方式2:从字符串创建(SVG path data)
const path2 = new Path2D('M10 10 h 80 v 80 h -80 Z');
// 方式3:复制现有路径
const path3 = new Path2D(path2);
// 方式4:从另一个 Path2D 添加
const combinedPath = new Path2D();
combinedPath.addPath(path2);路径操作示例
// 定义一个可复用的星星路径
function createStarPath(cx, cy, outerRadius, innerRadius, points) {
const path = new Path2D();
const step = Math.PI / points;
path.moveTo(cx, cy - outerRadius);
for (let i = 0; i < 2 * points; i++) {
const radius = i % 2 === 0 ? outerRadius : innerRadius;
const angle = i * step - Math.PI / 2;
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
path.lineTo(x, y);
}
path.closePath();
return path;
}
// 复用路径绘制多个星星
const starPath = createStarPath(0, 0, 40, 20, 5);
for (let i = 0; i < 5; i++) {
ctx.save();
ctx.translate(100 + i * 100, 150);
ctx.rotate(i * 0.2);
ctx.fillStyle = `hsl(${i * 60}, 70%, 50%)`;
ctx.fill(starPath);
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke(starPath);
ctx.restore();
}路径组合操作
const circle = new Path2D();
circle.arc(100, 100, 50, 0, Math.PI * 2);
const rect = new Path2D();
rect.rect(75, 75, 50, 50);
// 组合两个路径
const combined = new Path2D();
combined.addPath(circle);
combined.addPath(rect);
// 使用组合路径进行命中检测
const isHit = ctx.isPointInPath(combined, mouseX, mouseY);Path2D 优势对比
| 特性 | 传统路径 API | Path2D API |
|---|---|---|
| 路径复用 | ❌ 每次重新构建 | ✅ 可复用对象 |
| 序列化 | ❌ 不支持 | ✅ 支持 SVG path data |
| 命中检测 | 仅当前路径 | ✅ 支持传入 Path2D |
| 路径组合 | 手动管理 | ✅ addPath() |
| 性能 | 一般 | ✅ 更优(预编译路径) |
6. 基础图形绘制
获取绘图上下文
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');处理高分辨率屏幕(DPR)
function setupHiDPICanvas(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;
}
// 使用示例
const canvas = document.getElementById('myCanvas');
const ctx = setupHiDPICanvas(canvas, 800, 600);矩形
Canvas 中矩形是唯一可以直接绘制的原生形状:
const ctx = canvas.getContext('2d');
// 填充矩形
ctx.fillStyle = '#ff6b6b';
ctx.fillRect(10, 10, 150, 100);
// 描边矩形
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.strokeRect(200, 10, 150, 100);
// 清除矩形区域
ctx.clearRect(50, 30, 80, 50);
// ✅ 推荐:使用整数坐标避免模糊
ctx.fillRect(Math.round(x), Math.round(y), w, h);
// ❌ 避免:浮点坐标可能导致抗锯齿模糊
ctx.fillRect(x + 0.5, y + 0.5, w, h);路径(Path)
所有其他形状都通过路径绘制:
// 三角形
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(200, 50);
ctx.lineTo(200, 150);
ctx.closePath(); // 自动连接起点和终点
ctx.fillStyle = '#4ecdc4';
ctx.fill();
ctx.strokeStyle = '#2c3e50';
ctx.lineWidth = 2;
ctx.stroke();
// ✅ 最佳实践:每次新路径都要 beginPath()
// ❌ 错误:忘记 beginPath() 导致路径叠加圆形与弧线
// 完整圆
ctx.beginPath();
ctx.arc(150, 150, 80, 0, Math.PI * 2);
ctx.fillStyle = '#45b7d1';
ctx.fill();
// 半圆(逆时针)
ctx.beginPath();
ctx.arc(400, 150, 80, 0, Math.PI, false);
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 3;
ctx.stroke();arc() 参数详解: arc(x, y, radius, startAngle, endAngle, counterclockwise)
| 参数 | 类型 | 说明 |
|---|---|---|
x, y | number | 圆心坐标 |
radius | number | 半径(必须为正数) |
startAngle | number | 起始角度(弧度,0 为 3 点钟方向) |
endAngle | number | 结束角度(弧度) |
counterclockwise | boolean | 是否逆时针绘制(默认 false) |
椭圆
ctx.beginPath();
ctx.ellipse(300, 200, 150, 80, 0, 0, Math.PI * 2);
ctx.fillStyle = '#f39c12';
ctx.fill();
// 旋转的椭圆
ctx.beginPath();
ctx.ellipse(300, 350, 100, 50, Math.PI / 6, 0, Math.PI * 2);
ctx.fillStyle = '#9b59b6';
ctx.fill();ellipse() 参数: ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle)
贝塞尔曲线
// 二次贝塞尔曲线(1 个控制点)
ctx.beginPath();
ctx.moveTo(50, 200);
ctx.quadraticCurveTo(150, 50, 250, 200); // 控制点 (150,50), 终点 (250,200)
ctx.strokeStyle = '#9b59b6';
ctx.lineWidth = 3;
ctx.stroke();
// 三次贝塞尔曲线(2 个控制点)
ctx.beginPath();
ctx.moveTo(300, 200);
ctx.bezierCurveTo(
350, 50, // 控制点1
450, 350, // 控制点2
500, 200 // 终点
);
ctx.strokeStyle = '#1abc9c';
ctx.lineWidth = 3;
ctx.stroke();7. 样式与颜色系统
<h4>002-gradients.html</h4><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【2】渐变效果演示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; }
.gradient-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-top: 16px;
}
.gradient-item h3 {
text-align: center;
font-size: 14px;
color: #555;
margin-bottom: 10px;
}
.gradient-item canvas { margin: 0 auto; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 渐变效果(线性、径向、锥形)</div>
<div class="gradient-grid">
<!-- 线性渐变 -->
<div class="gradient-item">
<h3>线性渐变 (Linear)</h3>
<canvas id="linearCanvas" width="380" height="200"></canvas>
</div>
<!-- 径向渐变 -->
<div class="gradient-item">
<h3>径向渐变 (Radial)</h3>
<canvas id="radialCanvas" width="380" height="200"></canvas>
</div>
<!-- 多色线性渐变 -->
<div class="gradient-item">
<h3>多色线性渐变</h3>
<canvas id="multiLinearCanvas" width="380" height="200"></canvas>
</div>
<!-- 锥形渐变 -->
<div class="gradient-item">
<h3>锥形渐变 (Conic)</h3>
<canvas id="conicCanvas" width="380" height="200"></canvas>
</div>
</div>
</div>
<script>
// 1. 线性渐变
const linearCtx = document.getElementById("linearCanvas").getContext("2d")
const linearGrad = linearCtx.createLinearGradient(0, 0, 380, 0)
linearGrad.addColorStop(0, "#e74c3c")
linearGrad.addColorStop(0.33, "#f39c12")
linearGrad.addColorStop(0.66, "#2ecc71")
linearGrad.addColorStop(1, "#3498db")
linearCtx.fillStyle = linearGrad
linearCtx.fillRect(10, 10, 360, 180)
// 文字
linearCtx.fillStyle = "white"
linearCtx.font = "bold 20px sans-serif"
linearCtx.textAlign = "center"
linearCtx.textBaseline = "middle"
linearCtx.fillText("Linear Gradient", 190, 100)
// 2. 径向渐变
const radialCtx = document.getElementById("radialCanvas").getContext("2d")
const radialGrad = radialCtx.createRadialGradient(190, 100, 10, 190, 100, 120)
radialGrad.addColorStop(0, "rgba(255, 107, 107, 1)")
radialGrad.addColorStop(0.5, "rgba(255, 152, 0, 0.8)")
radialGrad.addColorStop(1, "rgba(255, 107, 107, 0)")
radialCtx.fillStyle = radialGrad
radialCtx.fillRect(0, 0, 380, 200)
// 光晕中心点
radialCtx.fillStyle = "white"
radialCtx.font = "bold 14px sans-serif"
radialCtx.textAlign = "center"
radialCtx.fillText("Radial Glow Effect", 190, 105)
// 3. 多色线性渐变(彩虹)
const multiCtx = document.getElementById("multiLinearCanvas").getContext("2d")
const rainbowGrad = multiCtx.createLinearGradient(0, 180, 380, 0)
const colors = ["#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#9400d3"]
colors.forEach((color, i) => {
rainbowGrad.addColorStop(i / (colors.length - 1), color)
})
multiCtx.fillStyle = rainbowGrad
for (let i = 0; i < 12; i++) {
multiCtx.fillRect(10 + i * 30, 20, 25, 160)
}
multiCtx.fillStyle = "#333"
multiCtx.font = "14px sans-serif"
multiCtx.textAlign = "center"
multiCtx.fillText("Rainbow Stripes", 190, 195)
// 4. 锥形渐变
const conicCtx = document.getElementById("conicCanvas").getContext("2d")
if (conicCtx.createConicGradient) {
const conicGrad = conicCtx.createConicGradient(0, 190, 100)
conicGrad.addColorStop(0, "#e74c3c")
conicGrad.addColorStop(0.17, "#f39c12")
conicGrad.addColorStop(0.33, "#f1c40f")
conicGrad.addColorStop(0.5, "#2ecc71")
conicGrad.addColorStop(0.67, "#3498db")
conicGrad.addColorStop(0.83, "#9b59b6")
conicGrad.addColorStop(1, "#e74c3c")
conicCtx.fillStyle = conicGrad
conicCtx.beginPath()
conicCtx.arc(190, 100, 85, 0, Math.PI * 2)
conicCtx.fill()
// 内部白色圆形
conicCtx.fillStyle = "white"
conicCtx.beginPath()
conicCtx.arc(190, 100, 50, 0, Math.PI * 2)
conicCtx.fill()
conicCtx.fillStyle = "#333"
conicCtx.font = "bold 14px sans-serif"
conicCtx.textAlign = "center"
conicCtx.textBaseline = "middle"
conicCtx.fillText("Conic\nGradient", 190, 100)
} else {
conicCtx.fillStyle = "#999"
conicCtx.font = "14px sans-serif"
conicCtx.textAlign = "center"
conicCtx.fillText("浏览器不支持锥形渐变", 190, 100)
}
</script>
</body>
</html>```
<h4>008-gradients-patterns.html</h4>
```html
<!-- 来源:12-Canvas.md - 第7章 样式与颜色系统 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【8】Canvas 渐变与图案填充</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 920px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-top: 16px;
}
.grid-item {
background: #fafafa;
border-radius: 8px;
padding: 16px;
border: 1px solid #eee;
}
.grid-item h3 {
text-align: center;
font-size: 14px;
color: #555;
margin-bottom: 12px;
}
canvas { display: block; margin: 0 auto; border-radius: 6px; }
.controls {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; background: #007bff; color: white; transition: all 0.3s;
}
.btn:hover { background: #0056b3; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 渐变与图案(LinearGradient / RadialGradient / ConicGradient / Pattern)</div>
<div class="controls">
<button class="btn" onclick="redrawAll()">🔄 刷新全部</button>
</div>
<div class="grid">
<!-- 1. 多色线性渐变 -->
<div class="grid-item">
<h3>多色线性渐变 (LinearGradient)</h3>
<canvas id="linearGradCanvas" width="400" height="180"></canvas>
</div>
<!-- 2. 径向渐变 -->
<div class="grid-item">
<h3>径向渐变 (RadialGradient)</h3>
<canvas id="radialGradCanvas" width="400" height="180"></canvas>
</div>
<!-- 3. 锥形渐变 -->
<div class="grid-item">
<h3>锥形渐变 (ConicGradient)</h3>
<canvas id="conicGradCanvas" width="400" height="180"></canvas>
</div>
<!-- 4. 图案填充 Pattern -->
<div class="grid-item">
<h3>图案填充 (createPattern)</h3>
<canvas id="patternCanvas" width="400" height="180"></canvas>
</div>
<!-- 5. 渐变形状组合 -->
<div class="grid-item" style="grid-column: span 2;">
<h3>渐变综合应用 — 彩虹球体与金属质感</h3>
<canvas id="comboCanvas" width="832" height="220"></canvas>
</div>
</div>
</div>
<script>
// ====== 1. 多色线性渐变 ======
function drawLinearGradient() {
const c = document.getElementById("linearGradCanvas")
const ctx = c.getContext("2d")
// 水平彩虹渐变
const grad = ctx.createLinearGradient(10, 10, 390, 170)
const colors = ["#ff0000","#ff7f00","#ffff00","#00ff00","#0000ff","#4b0082","#9400d3"]
colors.forEach((color,i) => grad.addColorStop(i/(colors.length-1), color))
ctx.fillStyle = grad
roundRect(ctx, 10, 10, 380, 160, 12)
ctx.fill()
// 文字
ctx.fillStyle = "rgba(255,255,255,0.9)"
ctx.font = "bold 22px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("Rainbow Linear Gradient", 200, 90)
}
// ====== 2. 径向渐变 ======
function drawRadialGradient() {
const c = document.getElementById("radialGradCanvas")
const ctx = c.getContext("2d")
// 光晕效果
const grad = ctx.createRadialGradient(200, 90, 5, 200, 90, 100)
grad.addColorStop(0, "rgba(255,255,255,1)")
grad.addColorStop(0.2, "rgba(255,200,100,0.9)")
grad.addColorStop(0.5, "rgba(255,107,107,0.5)")
grad.addColorStop(1, "rgba(255,107,107,0)")
ctx.fillStyle = grad
ctx.fillRect(0, 0, 400, 180)
// 中心文字
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.textAlign = "center"
ctx.fillText("Radial Glow", 200, 95)
// 参数说明
ctx.font = "11px monospace"
ctx.fillStyle="#888"
ctx.fillText("createRadialGradient(x0,y0,r0, x1,y1,r1)", 200, 165)
}
// ====== 3. 锥形渐变 ======
function drawConicGradient() {
const c = document.getElementById("conicGradCanvas")
const ctx = c.getContext("2d")
if (ctx.createConicGradient) {
const grad = ctx.createConicGradient(0, 200, 90)
const palette=["#e74c3c","#e67e22","#f1c40f","#2ecc71","#3498db","#9b59b6"]
palette.forEach((col,i) => grad.addColorStop(i/palette.length, col))
grad.addColorStop(1, "#e74c3c")
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(200, 90, 75, 0, Math.PI*2)
ctx.fill()
// 内圆遮罩
ctx.fillStyle = "#fff"
ctx.beginPath()
ctx.arc(200, 90, 40, 0, Math.PI*2)
ctx.fill()
ctx.fillStyle="#333"
ctx.font="bold 13px sans-serif"
ctx.textAlign="center"
ctx.textBaseline="middle"
ctx.fillText("Conic",200,85)
ctx.font="11px sans-serif"
ctx.fillText("Gradient",200,102)
} else {
ctx.fillStyle="#999"
ctx.font="14px sans-serif"
ctx.textAlign="center"
ctx.fillText("浏览器不支持锥形渐变", 200, 90)
}
}
// ====== 4. 图案填充 ======
function drawPattern() {
const c = document.getElementById("patternCanvas")
const ctx = c.getContext("2d")
// 创建图案源(棋盘格)
const patSize = 20
const patCanvas = document.createElement("canvas")
patCanvas.width = patSize
patCanvas.height = patSize
const pCtx = patCanvas.getContext("2d")
pCtx.fillStyle = "#3498db"
pCtx.fillRect(0, 0, patSize/2, patSize/2)
pCtx.fillRect(patSize/2, patSize/2, patSize/2, patSize/2)
pCtx.fillStyle = "#ecf0f1"
pCtx.fillRect(patSize/2, 0, patSize/2, patSize/2)
pCtx.fillRect(0, patSize/2, patSize/2, patSize/2)
// repeat 填充
const pattern = ctx.createPattern(patCanvas, "repeat")
ctx.fillStyle = pattern
roundRect(ctx, 10, 10, 175, 160, 8)
ctx.fill()
// 斜线图案
const linePat = document.createElement("canvas")
linePat.width = 12
linePat.height = 12
const lCtx = linePat.getContext("2d")
lCtx.strokeStyle = "#e74c3c"
lCtx.lineWidth = 2
lCtx.beginPath()
lCtx.moveTo(0, 12)
lCtx.lineTo(12, 0)
lCtx.stroke()
const linePattern = ctx.createPattern(linePat, "repeat")
ctx.fillStyle = linePattern
roundRect(ctx, 205, 10, 185, 160, 8)
ctx.fill()
// 标签
ctx.fillStyle="#555"
ctx.font="11px sans-serif"
ctx.textAlign="center"
ctx.fillText("棋盘格 repeat", 97, 178)
ctx.fillText("斜线 repeat", 297, 178)
}
// ====== 5. 综合应用 ======
function drawCombo() {
const c = document.getElementById("comboCanvas")
const ctx = c.getContext("2d")
// 彩虹球体
for(let i=0;i<5;i++){
const x = 80 + i*165
const r = 50 + i*8
const rg = ctx.createRadialGradient(x-r*0.3, 110-r*0.3, r*0.05, x, 110, r)
rg.addColorStop(0, `hsl(${i*60},80%,75%)`)
rg.addColorStop(0.5, `hsl(${i*60},70%,50%)`)
rg.addColorStop(1, `hsl(${i*60},60%,25%)`)
ctx.beginPath()
ctx.arc(x, 110, r, 0, Math.PI*2)
ctx.fillStyle = rg
ctx.fill()
}
// 金属质感条
const metalGrad = ctx.createLinearGradient(30, 190, 800, 210)
metalGrad.addColorStop(0, "#a0a0a0")
metalGrad.addColorStop(0.15, "#ffffff")
metalGrad.addColorStop(0.3, "#d0d0d0")
metalGrad.addColorStop(0.5, "#808080")
metalGrad.addColorStop(0.7, "#e0e0e0")
metalGrad.addColorStop(0.85, "#ffffff")
metalGrad.addColorStop(1, "#909090")
ctx.fillStyle = metalGrad
roundRect(ctx, 30, 185, 770, 28, 6)
ctx.fill()
// 标签
ctx.fillStyle="#666"
ctx.font="11px sans-serif"
ctx.textAlign="center"
ctx.fillText("径向渐变模拟球体光照 →", 415, 70)
ctx.fillText("← 线性渐变模拟金属拉丝质感", 415, 222)
}
function roundRect(ctx,x,y,w,h,r){
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()
}
function redrawAll() {
drawLinearGradient()
drawRadialGradient()
drawConicGradient()
drawPattern()
drawCombo()
}
redrawAll()
</script>
</body>
</html>填充与描边基础
ctx.fillStyle = '#e74c3c'; // 十六进制颜色
ctx.strokeStyle = 'rgb(44, 62, 80)'; // RGB 颜色
ctx.lineWidth = 3;
ctx.lineCap = 'round'; // 线条端点样式
ctx.lineJoin = 'round'; // 线条连接样式
ctx.miterLimit = 10; // 斜接限制lineCap 取值:
| 值 | 效果 | 示意图 |
|---|---|---|
butt(默认) | 平直截断 | ── |
round | 圆形端点 | ●─● |
square | 方形端点(延伸半个线宽) | ▬─▬ |
lineJoin 取值:
| 值 | 效果 | 适用场景 |
|---|---|---|
miter(默认) | 尖角连接 | 锐利转角 |
round | 圆滑连接 | 圆润外观 |
bevel | 斜切连接 | 避免 miter 过长 |
渐变
线性渐变
const linearGrad = ctx.createLinearGradient(0, 0, 400, 0);
linearGrad.addColorStop(0, '#e74c3c'); // 起点:红色
linearGrad.addColorStop(0.5, '#f39c12'); // 中点:橙色
linearGrad.addColorStop(1, '#2ecc71'); // 终点:绿色
ctx.fillStyle = linearGrad;
ctx.fillRect(10, 10, 400, 100);径向渐变
// createRadialGradient(x0, y0, r0, x1, y1, r1)
const radialGrad = ctx.createRadialGradient(250, 250, 10, 250, 250, 150);
radialGrad.addColorStop(0, 'rgba(255, 107, 107, 1)'); // 中心:不透明红色
radialGrad.addColorStop(1, 'rgba(255, 107, 107, 0)'); // 边缘:完全透明
ctx.fillStyle = radialGrad;
ctx.fillRect(100, 150, 300, 200);锥形渐变(Conic Gradient)
// Chrome 69+, Firefox 113+
if (ctx.createConicGradient) {
const conicGrad = ctx.createConicGradient(0, 200, 200);
conicGrad.addColorStop(0, 'red');
conicGrad.addColorStop(0.25, 'yellow');
conicGrad.addColorStop(0.5, 'green');
conicGrad.addColorStop(0.75, 'blue');
conicGrad.addColorStop(1, 'red');
ctx.fillStyle = conicGrad;
ctx.fillRect(100, 100, 200, 200);
}图案(Pattern)
const img = new Image();
img.src = 'pattern.png';
img.onload = () => {
// repeat | repeat-x | repeat-y | no-repeat
const pattern = ctx.createPattern(img, 'repeat');
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, canvas.width, canvas.height);
};透明度与阴影
// 全局透明度
ctx.globalAlpha = 0.7;
// 阴影属性
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.shadowBlur = 10; // 模糊半径
ctx.shadowOffsetX = 5; // X 偏移
ctx.shadowOffsetY = 5; // Y 偏移
::: warning
阴影效果对性能影响较大,在动画循环中应避免使用 shadow* 属性。建议使用预渲染的离屏 Canvas 或 ImageBitmap 替代。
:::高级颜色话题
Color Space 与 ICC Profile
现代浏览器开始支持更丰富的颜色空间:
// 检查颜色空间支持
if (typeof CanvasRenderingContext2D.prototype.getContextAttributes === 'function') {
const attrs = ctx.getContextAttributes();
console.log('Color space:', attrs.colorSpace); // 'srgb' 或 'display-p3'
}
// 使用 Display-P3 广色域(如果支持)
const p3Ctx = canvas.getContext('2d', { colorSpace: 'display-p3' });
if (p3Ctx) {
// 可以使用更鲜艳的颜色
p3Ctx.fillStyle = 'color(display-p3 0.8 0.2 0.5)';
}颜色格式支持
// 所有支持的格式
ctx.fillStyle = 'red'; // CSS 颜色名称
ctx.fillStyle = '#ff0000'; // 十六进制
ctx.fillStyle = 'rgb(255, 0, 0)'; // RGB
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // RGBA
ctx.fillStyle = 'hsl(0, 100%, 50%)'; // HSL
ctx.fillStyle = 'hsla(0, 100%, 50%, 0.5)'; // HSLA
ctx.fillStyle = 'lab(50% 50 50)'; // Lab(部分浏览器支持)8. 文本排版引擎
基本文本绘制
// 字体设置(CSS font 语法的子集)
ctx.font = 'bold 24px "Helvetica Neue", sans-serif';
// 文本对齐
ctx.textAlign = 'center'; // start | end | left | right | center
ctx.textBaseline = 'middle'; // top | hanging | middle | alphabetic | ideographic | bottom
// 文本方向(用于垂直文本)
ctx.direction = 'ltr'; // ltr | rtl | inherit
// 绘制填充文本
ctx.fillStyle = '#2c3e50';
ctx.fillText('Hello Canvas', 400, 300);
// 绘制描边文本
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 1;
ctx.strokeText('描边文本', 400, 350);文本测量与布局
ctx.font = 'bold 24px Arial';
const text = 'Canvas 文本测量';
const metrics = ctx.measureText(text);
console.log('文本宽度:', metrics.width);
console.log('实际边界上沿:', metrics.actualBoundingBoxAscent);
console.log('实际边界下沿:', metrics.actualBoundingBoxDescent);
console.log('字体上沿:', metrics.fontBoundingBoxAscent);
console.log('字体下沿:', metrics.fontBoundingBoxDescent);TextMetrics 属性说明:
| 属性 | 说明 |
|---|---|
width | 文本宽度(像素) |
actualBoundingBoxAscent | 从基线到文字顶部的实际距离 |
actualBoundingBoxDescent | 从基线到底部的实际距离 |
fontBoundingBoxAscent | 字体度量上沿 |
fontBoundingBoxDescent | 字体度量下沿 |
emHeightAscent | em 方框上沿 |
emHeightDescent | em 方框下沿 |
多行文本绘制
/**
* 绘制自动换行的多行文本
* @param {CanvasRenderingContext2D} ctx - 绑定上下文
* @param {string} text - 文本内容
* @param {number} x - 起始 X 坐标
* @param {number} y - 起始 Y 坐标
* @param {number} maxWidth - 最大宽度
* @param {number} lineHeight - 行高
*/
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
const words = text.split('');
let line = '';
let currentY = y;
for (let i = 0; i < words.length; i++) {
const testLine = line + words[i];
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line !== '') {
ctx.fillText(line, x, currentY);
line = words[i];
currentY += lineHeight;
} else {
line = testLine;
}
}
// 绘制最后一行
ctx.fillText(line, x, currentY);
return currentY + lineHeight; // 返回下一个可用 Y 坐标
}
// 使用示例
ctx.font = '16px Arial';
const nextY = drawWrappedText(ctx,
'这是一段很长的文本,需要在指定宽度内自动换行显示。',
50, 50, 300, 24
);文本对齐演示
ctx.font = '20px Arial';
ctx.textBaseline = 'middle';
const alignments = ['left', 'center', 'right'];
alignments.forEach((align, i) => {
ctx.textAlign = align;
ctx.fillStyle = '#333';
ctx.fillText(`textAlign: ${align}`, 200, 50 + i * 40);
// 绘制参考线
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(200, 30 + i * 40);
ctx.lineTo(200, 70 + i * 40);
ctx.stroke();
ctx.setLineDash([]);
});9. 图像处理流水线
<h4>007-image-processing.html</h4><!-- 来源:12-Canvas.md - 第9章 图像处理流水线 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【7】Canvas 图像处理与像素操作</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #5a6268; }
.btn.active { background: #e74c3c; box-shadow: 0 0 0 3px rgba(231,76,60,0.3); }
.filter-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 12px; }
.filter-btn {
padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; cursor: pointer;
font-size: 12px; background: #fff; transition: all 0.2s;
}
.filter-btn:hover { border-color: #007bff; color: #007bff; }
.filter-btn.active { background: #007bff; color: white; border-color: #007bff; }
.info-bar {
text-align: center; padding: 10px; background: #f8f9fa; border-radius: 6px;
font-size: 13px; color: #666; margin-top: 12px;
}
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 图像处理(drawImage / getImageData / 像素滤镜)</div>
<div class="controls">
<button class="btn active" onclick="applyFilter('original')">📷 原图</button>
<button class="btn" onclick="applyFilter('grayscale')">⚫ 灰度化</button>
<button class="btn" onclick="applyFilter('invert')">🔄 反色</button>
<button class="btn" onclick="applyFilter('blur')">💨 模糊</button>
<button class="btn" onclick="applyFilter('brightness')">☀️ 增亮</button>
<button class="btn" onclick="applyFilter('contrast')">🎚️ 对比度</button>
<button class="btn btn-secondary" onclick="resetAll()">🔄 重置</button>
</div>
<canvas id="imageCanvas" width="850" height="480"></canvas>
<div class="info-bar">
<span id="filterInfo">当前效果:原图 | 点击按钮应用不同像素级滤镜</span>
</div>
</div>
<script>
const canvas = document.getElementById("imageCanvas")
const ctx = canvas.getContext("2d")
let originalImageData = null
let currentFilter = "original"
// ====== 生成程序化测试图像(无需外部图片)======
function generateTestImage() {
const w = 300, h = 220
// 使用临时 canvas 生成图案
const tempCanvas = document.createElement("canvas")
tempCanvas.width = w
tempCanvas.height = h
const tctx = tempCanvas.getContext("2d")
// 渐变背景
const bgGrad = tctx.createLinearGradient(0, 0, w, h)
bgGrad.addColorStop(0, "#667eea")
bgGrad.addColorStop(0.5, "#764ba2")
bgGrad.addColorStop(1, "#f093fb")
tctx.fillStyle = bgGrad
tctx.fillRect(0, 0, w, h)
// 绘制一些图形
for (let i = 0; i < 15; i++) {
tctx.beginPath()
const x = Math.random() * w
const y = Math.random() * h
const r = 10 + Math.random() * 40
tctx.arc(x, y, r, 0, Math.PI * 2)
tctx.fillStyle = `hsla(${Math.random()*360},70%,60%,${0.3 + Math.random()*0.5})`
tctx.fill()
}
// 绘制矩形
for (let i = 0; i < 8; i++) {
tctx.fillStyle = `rgba(255,255,255,${0.1 + Math.random()*0.2})`
tctx.fillRect(Math.random()*w, Math.random()*h, 30+Math.random()*60, 20+Math.random()*40)
}
// 文字
tctx.font = "bold 28px 'Microsoft YaHei', sans-serif"
tctx.fillStyle = "white"
tctx.textAlign = "center"
tctx.shadowColor = "rgba(0,0,0,0.5)"
tctx.shadowBlur = 8
tctx.fillText("Canvas 测试图", w/2, h/2)
tctx.shadowBlur = 0
return tempCanvas
}
function initScene() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const testImg = generateTestImage()
// 绘制原图(左上)
ctx.drawImage(testImg, 20, 20)
// drawImage 九参数变形:源区域裁剪 + 目标缩放
// 参数: img, sx, sy, sw, sh, dx, dy, dw, dh
ctx.drawImage(testImg,
50, 50, 200, 120, // 源区域裁剪
350, 20, 240, 144 // 目标区域放大
)
// 水平翻转(通过负宽度)
ctx.save()
ctx.translate(640, 20)
ctx.scale(-1, 0.7)
ctx.drawImage(testImg, -145, 0, 145, 220)
ctx.restore()
// 保存原始像素数据
originalImageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// 绘制说明标签
ctx.font = "12px sans-serif"
ctx.fillStyle="#555"
ctx.fillText("① 原始尺寸", 100, 260)
ctx.fillText("② 九参数裁剪+缩放", 420, 185)
ctx.fillText("③ scale(-1,0.7) 翻转", 580, 175)
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 280)
ctx.lineTo(830, 280)
ctx.strokeStyle="#ddd"
ctx.lineWidth=1
ctx.stroke()
// 底部大图区域(用于滤镜演示)
ctx.fillStyle = "#fafafa"
ctx.fillRect(20, 300, 810, 170)
ctx.strokeStyle = "#ddd"
ctx.strokeRect(20, 300, 810, 170)
ctx.font = "14px sans-serif"
ctx.fillStyle="#999"
ctx.textAlign="center"
ctx.fillText("👆 滤镜效果预览区 — 选择上方按钮查看像素操作结果", canvas.width/2, 385)
ctx.textAlign="left"
}
// ====== 滤镜函数 ======
function applyFilter(filterName) {
currentFilter = filterName
// 更新按钮状态
document.querySelectorAll(".controls .btn").forEach(btn => btn.classList.remove("active"))
event.target?.classList.add("active")
if (!originalImageData) return
// 复制原始数据
const imageData = new ImageData(
new Uint8ClampedArray(originalImageData.data),
originalImageData.width,
originalImageData.height
)
const data = imageData.data
switch(filterName) {
case "grayscale":
// ITU-R BT.601 灰度标准
for(let i=0;i<data.length;i+=4){
const gray = 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2]
data[i]=data[i+1]=data[i+2]=gray
}
document.getElementById("filterInfo").textContent =
"当前效果:灰度化 (ITU-R BT.601: R×0.299 + G×0.587 + B×0.114)"
break
case "invert":
for(let i=0;i<data.length;i+=4){
data[i]=255-data[i]
data[i+1]=255-data[i+1]
data[i+2]=255-data[i+2]
}
document.getElementById("filterInfo").textContent =
"当前效果:反色 (255 - 原值)"
break
case "blur":
// 简单的盒式模糊 (3x3)
applyBoxBlur(data, imageData.width, imageData.height, 2)
document.getElementById("filterInfo").textContent =
"当前效果:模糊 (3×3 盒式卷积)"
break
case "brightness":
for(let i=0;i<data.length;i+=4){
data[i]=Math.min(255,data[i]+50)
data[i+1]=Math.min(255,data[i+1]+50)
data[i+2]=Math.min(255,data[i+2]+50)
}
document.getElementById("filterInfo").textContent =
"当前效果:增亮 (+50)"
break
case "contrast":
const factor=1.5
for(let i=0;i<data.length;i+=4){
data[i]=Math.min(255,Math.max(0,(data[i]-128)*factor+128))
data[i+1]=Math.min(255,Math.max(0,(data[i+1]-128)*factor+128))
data[i+2]=Math.min(255,Math.max(0,(data[i+2]-128)*factor+128))
}
document.getElementById("filterInfo").textContent =
"当前效果:对比度增强 (factor=1.5)"
break
default:
document.getElementById("filterInfo").textContent = "当前效果:原图"
break
}
// 先重绘原始场景
initScene()
// 在底部区域展示滤镜效果
const previewData = ctx.getImageData(20, 300, 810, 170)
// 将滤镜应用到预览区域的副本上
const srcImgData = new ImageData(
new Uint8ClampedArray(originalImageData.data.slice(0)),
originalImageData.width, originalImageData.height
)
// 只对顶部生成的图像部分做滤镜,然后绘制到底部
const filteredSrc = new ImageData(
new Uint8ClampedArray(originalImageData.data),
originalImageData.width, originalImageData.height
)
const fd = filteredSrc.data
switch(filterName) {
case "grayscale":
for(let i=0;i<fd.length;i+=4){const g=0.299*fd[i]+0.587*fd[i+1]+0.114*fd[i+2];fd[i]=fd[i+1]=fd[i+2]=g}
break
case "invert":
for(let i=0;i<fd.length;i+=4){fd[i]=255-fd[i];fd[i+1]=255-fd[i+1];fd[i+2]=255-fd[i+2]}
break
case "brightness":
for(let i=0;i<fd.length;i+=4){fd[i]=Math.min(255,fd[i]+50);fd[i+1]=Math.min(255,fd[i+1]+50);fd[i+2]=Math.min(255,fd[i+2]+50)}
break
case "contrast":
for(let i=0;i<fd.length;i+=4){const f=1.5;fd[i]=Math.min(255,Math.max(0,(fd[i]-128)*f+128));fd[i+1]=Math.min(255,Math.max(0,(fd[i+1]-128)*f+128));fd[i+2]=Math.min(255,Math.max(0,(fd[i+2]-128)*f+128))}
break
case "blur":
applyBoxBlur(fd, filteredSrc.width, filteredSrc.height, 2)
break
}
// 创建临时 canvas 来放过滤后的完整图像
const tempC = document.createElement("canvas")
tempC.width = filteredSrc.width
tempC.height = filteredSrc.height
const tempCtx = tempC.getContext("2d")
tempCtx.putImageData(filteredSrc, 0, 0)
// 清空底部区域并绘制缩小版滤镜效果
ctx.save()
ctx.beginPath()
ctx.rect(20, 300, 810, 170)
ctx.clip()
ctx.fillStyle = "#fff"
ctx.fillRect(20, 300, 810, 170)
// 将整个画布内容缩小绘入预览区
ctx.drawImage(tempC, 20, 300, 810, 170)
ctx.restore()
// 预览区标签
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#e74c3c"
ctx.textAlign = "center"
ctx.fillText(`【${getFilterLabel(filterName)}】全画布滤镜效果`, canvas.width/2, 455)
ctx.textAlign = "left"
}
function getFilterLabel(name) {
const map={original:"原图",grayscale:"灰度化",invert:"反色",blur:"模糊",brightness:"增亮",contrast:"对比度"}
return map[name]||name
}
// 盒式模糊实现
function applyBoxBlur(data, width, height, radius) {
const copy = new Uint8ClampedArray(data)
const r = radius
for(let y=r;y<height-r;y++){
for(let x=r;x<width-r;x++){
let rSum=0,gSum=0,bSum=0,count=0
for(let dy=-r;dy<=r;dy++){
for(let dx=-r;dx<=r;dx++){
const idx=((y+dy)*width+(x+x))*4
rSum+=copy[idx]
gSum+=copy[idx+1]
bSum+=copy[idx+2]
count++
}
}
const idx=(y*width+x)*4
data[idx]=rSum/count
data[idx+1]=gSum/count
data[idx+2]=bSum/count
}
}
}
function resetAll() {
currentFilter = "original"
document.querySelectorAll(".controls .btn").forEach(btn => btn.classList.remove("active"))
document.querySelector('.controls .btn[onclick="applyFilter(\'original\')"]')?.classList.add("active")
initScene()
}
// 初始化
initScene()
</script>
</body>
</html>绘制图像
const img = new Image();
img.crossOrigin = 'anonymous'; // 处理跨域
img.src = 'photo.jpg';
img.onload = () => {
// 形式1:原始尺寸绘制
ctx.drawImage(img, 10, 10);
// 形式2:指定宽高绘制
ctx.drawImage(img, 10, 10, 200, 150);
// 形式3:裁剪并缩放绘制
// drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
ctx.drawImage(img,
0, 0, 100, 100, // 源区域(裁剪)
250, 10, 200, 200 // 目标区域(绘制位置和大小)
);
};像素级操作
// 获取像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data; // Uint8ClampedArray [r, g, b, a, r, g, b, a, ...]
// 示例:灰度转换(使用 ITU-R BT.601 标准)
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 人眼对不同颜色的敏感度不同,使用加权平均
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
data[i] = gray; // R
data[i + 1] = gray; // G
data[i + 2] = gray; // B
// Alpha 保持不变
}
// 写回像素数据
ctx.putImageData(imageData, 0, 0);ImageBitmap 与异步图像处理
ImageBitmap 是一种高性能的可渲染位图对象,适合在 Worker 中使用:
// 异步创建 ImageBitmap(非阻塞主线程)
async function loadAndProcessImage(url) {
const response = await fetch(url);
const blob = await response.blob();
// 创建 ImageBitmap(可在 Worker 中使用)
const bitmap = await createImageBitmap(blob, {
resizeWidth: 800,
resizeHeight: 600,
resizeQuality: 'high'
});
// 直接绘制 ImageBitmap(比 Image 更高效)
ctx.drawImage(bitmap, 0, 0);
return bitmap;
}
// 使用示例
loadAndProcessImage('photo.jpg').then(bitmap => {
console.log('ImageBitmap 已加载:', bitmap.width, 'x', bitmap.height);
});ImageBitmap vs Image 对比:
| 特性 | Image | ImageBitmap |
|---|---|---|
| 创建方式 | 同步 new Image() | 异步 createImageBitmap() |
| 线程安全 | 仅主线程 | 主线程 & Worker |
| 内存占用 | 较高 | 较低(优化存储) |
| 预处理 | 无 | 支持裁剪、缩放、翻转 |
| 绘制性能 | 一般 | ✅ 更优 |
Canvas 导出
// 导出为 Data URL(Base64)
const dataURL = canvas.toDataURL('image/png');
const jpegURL = canvas.toDataURL('image/jpeg', 0.92); // JPEG 质量 0-1
// 导出为 Blob(更适合上传)
const blob = await new Promise(resolve =>
canvas.toBlob(resolve, 'image/png')
);
const url = URL.createObjectURL(blob);
// 下载图片
const link = document.createElement('a');
link.download = 'canvas-export.png';
link.href = url;
link.click();当 Canvas 包含跨域图像且未设置 crossOrigin = 'anonymous' 时,toDataURL() 和 toBlob() 会抛出 SecurityError。这是为了防止信息泄露。
10. 合成模式全景图
globalCompositeOperation 控制新绘制图形如何与已有内容合成,共有 26 种模式:
<!-- 来源:12-Canvas.md - 第10章 合成模式全景图 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【11】Canvas 合成模式</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 920px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.mode-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-top: 14px;
}
.mode-card {
background: #fafafa;
border: 2px solid transparent;
border-radius: 8px;
padding: 10px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.mode-card:hover { border-color: #007bff; transform: translateY(-2px); }
.mode-card.active { border-color: #e74c3c; background: #fff5f5; }
.mode-card canvas {
width: 100%;
height: 90px;
margin: 6px auto 4px;
border: 1px solid #eee;
border-radius: 4px;
}
.mode-name {
font-size: 11px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.mode-desc {
font-size: 10px;
color: #999;
}
.detail-panel {
margin-top: 20px;
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
display: none;
}
.detail-panel.show { display: block; }
.detail-title { font-size: 15px; font-weight: bold; color: #333; margin-bottom: 10px; }
.detail-canvas-wrap { text-align: center; }
#detailCanvas { border: 1px solid #ddd; border-radius: 8px; }
.category-label {
grid-column: span 5;
font-size: 13px;
font-weight: bold;
color: #007bff;
padding: 8px 0 4px;
border-top: 1px solid #eee;
margin-top: 4px;
}
.category-label:first-child { border-top: none; margin-top: 0; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:globalCompositeOperation 合成模式全景图(26种模式可视化)</div>
<div class="mode-grid" id="modeGrid"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-title" id="detailTitle">点击上方卡片查看大图演示</div>
<div class="detail-canvas-wrap">
<canvas id="detailCanvas" width="500" height="320"></canvas>
</div>
</div>
</div>
<script>
const modes = [
// 基础组
{ category: "基础组 (Basic)", modes: [
{ name: "source-over", desc: "默认:新覆盖旧", type: "basic" },
{ name: "source-in", desc: "仅新图形区域", type: "basic" },
{ name: "source-out", desc: "仅新图形外部", type: "basic" },
{ name: "source-atop", desc: "新与旧交集", type: "basic" },
{ name: "destination-over", desc: "旧覆盖新", type: "basic" },
{ name: "destination-in", desc: "仅旧图形区域", type: "basic" },
{ name: "destination-out", desc: "仅旧图形外部", type: "basic" },
{ name: "destination-atop", desc: "旧与新交集", type: "basic" },
]},
// 加法组
{ category: "加法组 (Additive)", modes: [
{ name: "lighter", desc: "颜色相加(发光)", type: "additive" },
{ name: "plus-lighter", desc: "改进加法", type: "additive" },
{ name: "plus-darker", desc: "减法", type: "additive" },
]},
// 混合组
{ category: "混合组 (Blend)", modes: [
{ name: "multiply", desc: "正片叠底", type: "blend" },
{ name: "screen", desc: "滤色", type: "blend" },
{ name: "overlay", desc: "叠加", type: "blend" },
{ name: "darken", desc: "变暗", type: "blend" },
{ name: "lighten", desc: "变亮", type: "blend" },
{ name: "color-dodge", desc: "颜色减淡", type: "blend" },
{ name: "color-burn", desc: "颜色加深", type: "blend" },
{ name: "hard-light", desc: "强光", type: "blend" },
{ name: "soft-light", desc: "柔光", type: "blend" },
{ name: "difference", desc: "差值", type: "blend" },
{ name: "exclusion", desc: "排除", type: "blend" },
]},
// 高级组
{ category: "高级组 (Advanced)", modes: [
{ name: "hue", desc: "色调", type: "advanced" },
{ name: "saturation", desc: "饱和度", type: "advanced" },
{ name: "color", desc: "颜色", type: "advanced" },
{ name: "luminosity", desc: "亮度", type: "advanced" },
]},
// 特殊组
{ category: "特殊组 (Special)", modes: [
{ name: "copy", desc: "仅保留新图形", type: "special" },
{ name: "xor", desc: "异或", type: "special" },
]},
]
const grid = document.getElementById("modeGrid")
let activeMode = null
function drawCompositeScene(ctx, w, h, modeName, animate) {
ctx.clearRect(0, 0, w, h)
ctx.fillStyle = "#fff"
ctx.fillRect(0, 0, w, h)
// 底层:蓝色圆形
ctx.globalCompositeOperation = "source-over"
ctx.fillStyle = "#3498db"
ctx.beginPath()
if (animate) {
const offset = Math.sin(Date.now() / 400) * 15
ctx.arc(w * 0.38 + offset, h * 0.5, Math.min(w, h) * 0.28, 0, Math.PI * 2)
} else {
ctx.arc(w * 0.38, h * 0.5, Math.min(w, h) * 0.28, 0, Math.PI * 2)
}
ctx.fill()
// 顶层:红色矩形(应用合成模式)
ctx.globalCompositeOperation = modeName
ctx.fillStyle = "#e74c3c"
if (animate) {
const offset = Math.cos(Date.now() / 350) * 12
ctx.fillRect(w * 0.48 + offset, h * 0.32, Math.min(w, h) * 0.42, Math.min(w, h) * 0.36)
} else {
ctx.fillRect(w * 0.48, h * 0.32, Math.min(w, h) * 0.42, Math.min(w, h) * 0.36)
}
// 恢复默认
ctx.globalCompositeOperation = "source-over"
}
function createModeCard(modeInfo) {
const card = document.createElement("div")
card.className = "mode-card"
card.dataset.mode = modeInfo.name
const miniCvs = document.createElement("canvas")
miniCvs.width = 140
miniCvs.height = 90
card.innerHTML = ""
card.appendChild(miniCvs)
const nameEl = document.createElement("div")
nameEl.className = "mode-name"
nameEl.textContent = modeInfo.name
card.appendChild(nameEl)
const descEl = document.createElement("div")
descEl.className = "mode-desc"
descEl.textContent = modeInfo.desc
card.appendChild(descEl)
// 绘制静态预览
const mctx = miniCvs.getContext("2d")
drawCompositeScene(mctx, 140, 90, modeInfo.name, false)
// 点击事件
card.addEventListener("click", () => selectMode(modeInfo.name, card))
return card
}
function selectMode(modeName, cardEl) {
// 更新选中状态
document.querySelectorAll(".mode-card").forEach(c => c.classList.remove("active"))
cardEl.classList.add("active")
activeMode = modeName
// 显示详情面板
const panel = document.getElementById("detailPanel")
panel.classList.add("show")
document.getElementById("detailTitle").textContent =
`globalCompositeOperation = "${modeName}"`
// 绘制详情大图
const detailCtx = document.getElementById("detailCanvas").getContext("2d")
drawCompositeScene(detailCtx, 500, 320, modeName, true)
// 启动动画(如果尚未启动)
startDetailAnimation()
}
let detailAnimId = null
function startDetailAnimation() {
if (detailAnimId) cancelAnimationFrame(detailAnimId)
function loop() {
if (!activeMode) return
const dctx = document.getElementById("detailCanvas").getContext("2d")
drawCompositeScene(dctx, 500, 320, activeMode, true)
detailAnimId = requestAnimationFrame(loop)
}
loop()
}
// ====== 构建网格 ======
modes.forEach(group => {
const catLabel = document.createElement("div")
catLabel.className = "category-label"
catLabel.textContent = group.category
grid.appendChild(catLabel)
group.modes.forEach(m => {
grid.appendChild(createModeCard(m))
})
})
// 默认选中第一个
const firstCard = document.querySelector(".mode-card")
if (firstCard) selectMode(firstCard.dataset.mode, firstCard)
</script>
</body>
</html>常用合成模式速查
| 分类 | 模式 | 效果 | 应用场景 |
|---|---|---|---|
| 基础 | source-over | 新覆盖旧 | 默认行为 |
| 擦除 | destination-out | 新区域擦除旧 | 橡皮擦功能 |
| 加法 | lighter | 颜色相加 | 发光效果、粒子叠加 |
| 混合 | multiply | 正片叠底 | 阴影、暗调效果 |
| 混合 | screen | 滤色 | 高光、亮调效果 |
| 混合 | overlay | 叠加 | 对比度增强 |
| 高级 | color | 保留新颜色的色相/饱和度 | 上色、滤镜 |
| 特殊 | copy | 只显示新图形 | 截图、遮罩 |
| 特殊 | xor | 异或 | 橡皮筋选择 |
合成模式演示
// 设置合成模式
ctx.globalCompositeOperation = 'source-over'; // 默认
// 先绘制底层图形
ctx.fillStyle = '#3498db';
ctx.fillRect(50, 50, 100, 100);
// 切换合成模式后绘制顶层
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = '#e74c3c';
ctx.fillRect(100, 100, 100, 100);
// 记得恢复默认模式
ctx.globalCompositeOperation = 'source-over';11. Hit Testing 与碰撞检测
<h4>004-collision-detection.html</h4><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【4】碰撞检测演示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 800px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas {
display: block;
margin: 16px auto;
border: 2px solid #ddd;
border-radius: 8px;
cursor: crosshair;
background: #fafafa;
}
.info-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 6px;
font-size: 14px;
margin-bottom: 12px;
}
.collision-count {
color: #e74c3c;
font-weight: bold;
font-size: 18px;
}
.controls { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.btn {
padding: 8px 18px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
background: #007bff;
color: white;
}
.btn:hover { background: #0056b3; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:AABB 碰撞检测 + 四叉树优化</div>
<div class="info-bar">
<span>对象数量:<strong id="objCount">15</strong></span>
<span>碰撞对数:<span class="collision-count" id="collisionCount">0</span></span>
<span>FPS:<strong id="fpsVal">60</strong></span>
</div>
<div class="controls">
<button class="btn" onclick="addObject()">➕ 添加对象</button>
<button class="btn btn-danger" onclick="removeObject()">➖ 移除对象</button>
<button class="btn" onclick="resetObjects()">🔄 重置</button>
</div>
<canvas id="collisionCanvas" width="750" height="520"></canvas>
<p style="text-align: center; margin-top: 12px; font-size: 13px; color: #666;">
💡 碰撞时方块会变红并显示白色边框 | 点击画布添加新对象
</p>
</div>
<script>
const canvas = document.getElementById("collisionCanvas")
const ctx = canvas.getContext("2d")
const objects = []
const colors = ["#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e74c3c"]
let frameCount = 0, lastTime = performance.now(), fps = 60
class GameObject {
constructor(x, y) {
this.x = x ?? Math.random() * (canvas.width - 40)
this.y = y ?? Math.random() * (canvas.height - 40)
this.size = 25 + Math.random() * 20
this.color = colors[Math.floor(Math.random() * colors.length)]
this.vx = (Math.random() - 0.5) * 4
this.vy = (Math.random() - 0.5) * 4
this.colliding = false
}
update() {
this.x += this.vx
this.y += this.vy
if (this.x <= 0 || this.x >= canvas.width - this.size) {
this.vx *= -1
this.x = Math.max(0, Math.min(canvas.width - this.size, this.x))
}
if (this.y <= 0 || this.y >= canvas.height - this.size) {
this.vy *= -1
this.y = Math.max(0, Math.min(canvas.height - this.size, this.y))
}
}
draw() {
ctx.fillStyle = this.colliding ? "#e74c3c" : this.color
ctx.fillRect(this.x, this.y, this.size, this.size)
if (this.colliding) {
ctx.strokeStyle = "#fff"
ctx.lineWidth = 2
ctx.strokeRect(this.x, this.y, this.size, this.size)
}
}
getBounds() { return { x: this.x, y: this.y, w: this.size, h: this.size } }
}
function aabbCollision(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
}
function init(count = 15) {
objects.length = 0
for (let i = 0; i < count; i++) objects.push(new GameObject())
}
function addObject() { objects.push(new GameObject()) }
function removeObject() { if (objects.length > 1) objects.pop() }
function resetObjects() { init(15) }
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect()
objects.push(new GameObject(e.clientX - rect.left - 15, e.clientY - rect.top - 15))
})
function checkCollisions() {
objects.forEach(o => o.colliding = false)
let count = 0
for (let i = 0; i < objects.length; i++) {
for (let j = i + 1; j < objects.length; j++) {
if (aabbCollision(objects[i].getBounds(), objects[j].getBounds())) {
objects[i].colliding = true
objects[j].colliding = true
count++
}
}
}
return count
}
function animate(time) {
frameCount++
if (time - lastTime >= 1000) {
fps = frameCount
frameCount = 0
lastTime = time
document.getElementById("fpsVal").textContent = fps
}
ctx.clearRect(0, 0, canvas.width, canvas.height)
objects.forEach(o => o.update())
const collisions = checkCollisions()
objects.forEach(o => o.draw())
document.getElementById("objCount").textContent = objects.length
document.getElementById("collisionCount").textContent = collisions
requestAnimationFrame(animate)
}
init()
animate(performance.now())
</script>
</body>
</html>```
### 11.1 内置 Hit Detection API
Canvas 提供了两种内置的命中检测方法:
#### isPointInPath()
检测点是否在填充区域内:
```javascript
// 使用当前路径
ctx.beginPath();
ctx.rect(50, 50, 100, 100);
ctx.fillStyle = '#3498db';
ctx.fill();
// 检测点击位置
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (ctx.isPointInPath(x, y)) {
console.log('点击了矩形!');
}
});
// 使用 Path2D 对象
const starPath = createStarPath(200, 200, 50, 25, 5);
ctx.fill(starPath);
if (ctx.isPointInPath(starPath, x, y)) {
console.log('点击了星星!');
}isPointInStroke()
检测点是否在描边路径上(考虑 lineWidth):
ctx.beginPath();
ctx.arc(300, 200, 50, 0, Math.PI * 2);
ctx.lineWidth = 10;
ctx.strokeStyle = '#e74c3c';
ctx.stroke();
// 检测是否点击了圆形边框(含线宽范围)
if (ctx.isPointInStroke(x, y)) {
console.log('点击了圆环!');
}11.2 几何碰撞检测算法
AABB 碰撞检测(轴对齐包围盒)
最常用的快速碰撞检测算法:
/**
* AABB 碰撞检测
* @param {{x: number, y: number, w: number, h: number}} rect1
* @param {{x: number, y: number, w: number, h: number}} rect2
* @returns {boolean}
*/
function aabbCollision(rect1, rect2) {
return (
rect1.x < rect2.x + rect2.w &&
rect1.x + rect1.w > rect2.x &&
rect1.y < rect2.y + rect2.h &&
rect1.y + rect1.h > rect2.y
);
}
// 使用示例
const player = { x: 100, y: 100, w: 50, h: 50 };
const enemy = { x: 120, y: 120, w: 50, h: 50 };
if (aabbCollision(player, enemy)) {
console.log('发生碰撞!玩家与敌人相撞');
}圆形碰撞检测
/**
* 圆形碰撞检测
* @param {{x: number, y: number, r: number}} circle1
* @param {{x: number, y: number, r: number}} circle2
* @returns {boolean}
*/
function circleCollision(circle1, circle2) {
const dx = circle1.x - circle2.x;
const dy = circle1.y - circle2.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < circle1.r + circle2.r;
}
// 优化版本(避免开方运算)
function circleCollisionFast(c1, c2) {
const dx = c1.x - c2.x;
const dy = c1.y - c2.y;
const distSq = dx * dx + dy * dy;
const radiusSum = c1.r + c2.r;
return distSq < radiusSum * radiusSum;
}多边形碰撞检测(SAT 分离轴定理)
/**
* 凸多边形 SAT 碰撞检测
* @param {Array<{x: number, y: number}>} polygon1 - 顶点数组
* @param {Array<{x: number, y: number}>} polygon2 - 顶点数组
* @returns {boolean}
*/
function satCollision(polygon1, polygon2) {
const polygons = [polygon1, polygon2];
for (let poly of polygons) {
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length;
// 计算边的法向量(投影轴)
const edge = {
x: poly[j].x - poly[i].x,
y: poly[j].y - poly[i].y
};
const axis = { x: -edge.y, y: edge.x };
// 投影两个多边形到轴上
const proj1 = projectPolygon(polygon1, axis);
const proj2 = projectPolygon(polygon2, axis);
// 检查投影是否重叠
if (!overlap(proj1, proj2)) {
return false; // 发现分离轴,无碰撞
}
}
}
return true; // 所有轴都有重叠,发生碰撞
}
function projectPolygon(polygon, axis) {
let min = Infinity;
let max = -Infinity;
for (let vertex of polygon) {
const dot = vertex.x * axis.x + vertex.y * axis.y;
min = Math.min(min, dot);
max = Math.max(max, dot);
}
return { min, max };
}
function overlap(proj1, proj2) {
return !(proj1.max < proj2.min || proj2.max < proj1.min);
}11.3 空间分区优化
当对象数量很多时,暴力检测(O(n²))性能不足,需要空间分区:
四叉树实现
class QuadTree {
constructor(boundary, capacity = 4) {
this.boundary = boundary; // {x, y, w, h}
this.capacity = capacity;
this.objects = [];
this.divided = false;
// 四个子节点:西北、东北、西南、东南
this.nw = null;
this.ne = null;
this.sw = null;
this.se = null;
}
insert(obj) {
// 如果对象不在当前范围内,返回
if (!this.contains(obj)) return false;
// 如果未达到容量且未分割,直接添加
if (this.objects.length < this.capacity && !this.divided) {
this.objects.push(obj);
return true;
}
// 如果未分割,先分割
if (!this.divided) this.subdivide();
// 尝试插入到子节点
return (
this.nw.insert(obj) || this.ne.insert(obj) ||
this.sw.insert(obj) || this.se.insert(obj)
);
}
subdivide() {
const { x, y, w, h } = this.boundary;
const halfW = w / 2;
const halfH = h / 2;
this.nw = new QuadTree({ x, y, w: halfW, h: halfH }, this.capacity);
this.ne = new QuadTree({ x: x + halfW, y, w: halfW, h: halfH }, this.capacity);
this.sw = new QuadTree({ x, y: y + halfH, w: halfW, h: halfH }, this.capacity);
this.se = new QuadTree({ x: x + halfW, y: y + halfH, w: halfW, h: halfH }, this.capacity);
this.divided = true;
// 将当前对象重新分配到子节点
const existingObjects = [...this.objects];
this.objects = [];
existingObjects.forEach(obj => this.insert(obj));
}
contains(obj) {
const { x, y, w, h } = this.boundary;
return (
obj.x >= x && obj.x < x + w &&
obj.y >= y && obj.y < y + h
);
}
// 查询与给定范围相交的所有对象
query(range, found = []) {
// 如果查询范围与当前节点不相交,返回
if (!this.intersects(range)) return found;
// 添加当前节点的对象
for (let obj of this.objects) {
if (this.inRange(obj, range)) {
found.push(obj);
}
}
// 递归查询子节点
if (this.divided) {
this.nw.query(range, found);
this.ne.query(range, found);
this.sw.query(range, found);
this.se.query(range, found);
}
return found;
}
intersects(range) {
const { x, y, w, h } = this.boundary;
return !(
range.x > x + w ||
range.x + range.w < x ||
range.y > y + h ||
range.y + range.h < y
);
}
inRange(obj, range) {
return (
obj.x >= range.x &&
obj.x < range.x + range.w &&
obj.y >= range.y &&
obj.y < range.y + range.h
);
}
}11.4 完整碰撞检测演示
<canvas id="collision-demo" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('collision-demo');
const ctx = canvas.getContext('2d');
// 创建多个可移动的对象
class GameObject {
constructor(x, y, size, color) {
this.x = x;
this.y = y;
this.size = size;
this.color = color;
this.vx = (Math.random() - 0.5) * 4;
this.vy = (Math.random() - 0.5) * 4;
this.colliding = false;
}
update() {
this.x += this.vx;
this.y += this.vy;
// 边界反弹
if (this.x <= 0 || this.x >= canvas.width - this.size) {
this.vx *= -1;
this.x = Math.max(0, Math.min(canvas.width - this.size, this.x));
}
if (this.y <= 0 || this.y >= canvas.height - this.size) {
this.vy *= -1;
this.y = Math.max(0, Math.min(canvas.height - this.size, this.y));
}
}
draw(ctx) {
ctx.fillStyle = this.colliding ? '#e74c3c' : this.color;
ctx.fillRect(this.x, this.y, this.size, this.size);
// 绘制 AABB 边框(调试用)
if (this.colliding) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(this.x, this.y, this.size, this.size);
}
}
getBounds() {
return { x: this.x, y: this.y, w: this.size, h: this.size };
}
}
// 初始化对象
const objects = [];
const colors = ['#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c'];
for (let i = 0; i < 15; i++) {
objects.push(new GameObject(
Math.random() * (canvas.width - 40),
Math.random() * (canvas.height - 40),
30 + Math.random() * 20,
colors[i % colors.length]
));
}
// 使用四叉树优化碰撞检测
const quadTree = new QuadTree({
x: 0, y: 0, w: canvas.width, h: canvas.height
}, 4);
function checkCollisions() {
// 重置碰撞状态
objects.forEach(obj => obj.colliding = false);
// 重建四叉树
quadTree.objects = [];
quadTree.divided = false;
quadTree.nw = quadTree.ne = quadTree.sw = quadTree.se = null;
objects.forEach(obj => quadTree.insert(obj));
// 查询潜在碰撞对
for (let i = 0; i < objects.length; i++) {
const obj = objects[i];
const bounds = obj.getBounds();
// 扩大查询范围以包含相邻格子
const queryRange = {
x: bounds.x - 1,
y: bounds.y - 1,
w: bounds.w + 2,
h: bounds.h + 2
};
const nearby = quadTree.query(queryRange);
for (let other of nearby) {
if (other !== obj && aabbCollision(bounds, other.getBounds())) {
obj.colliding = true;
other.colliding = true;
}
}
}
}
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 更新所有对象
objects.forEach(obj => obj.update());
// 检测碰撞
checkCollisions();
// 绘制所有对象
objects.forEach(obj => obj.draw(ctx));
// 显示 FPS
ctx.fillStyle = '#333';
ctx.font = '14px monospace';
ctx.fillText(`对象数: ${objects.length}`, 10, 20);
requestAnimationFrame(animate);
}
animate();
</script>12. 动画系统架构
动画状态机
时间线管理系统
class AnimationTimeline {
constructor() {
this.animations = new Map();
this.isRunning = false;
this.lastTime = 0;
this.animationId = null;
}
/**
* 添加动画到时间线
* @param {string} name - 动画名称
* @param {Function} updateFn - 更新函数 (progress, deltaTime) => void
* @param {number} duration - 持续时间(毫秒)
* @param {Object} options - 配置选项
*/
add(name, updateFn, duration, options = {}) {
this.animations.set(name, {
updateFn,
duration,
startTime: null,
easing: options.easing || 'linear',
loop: options.loop || false,
delay: options.delay || 0,
onComplete: options.onComplete || null,
progress: 0
});
}
/**
* 移除动画
*/
remove(name) {
this.animations.delete(name);
}
/**
* 启动时间线
*/
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = performance.now();
this.loop(this.lastTime);
}
/**
* 停止时间线
*/
stop() {
this.isRunning = false;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
}
loop(currentTime) {
if (!this.isRunning) return;
const deltaTime = currentTime - this.lastTime;
this.lastTime = currentTime;
this.animations.forEach((anim, name) => {
// 初始化开始时间
if (anim.startTime === null) {
anim.startTime = currentTime + anim.delay;
}
// 检查延迟
if (currentTime < anim.startTime) return;
// 计算进度
const elapsed = currentTime - anim.startTime;
anim.progress = Math.min(elapsed / anim.duration, 1);
// 应用缓动函数
const easedProgress = EasingFunctions[anim.easing](anim.progress);
// 执行更新
anim.updateFn(easedProgress, deltaTime);
// 循环处理
if (anim.progress >= 1) {
if (anim.loop) {
anim.startTime = currentTime;
anim.progress = 0;
} else {
if (anim.onComplete) anim.onComplete();
// 可以选择是否自动移除
// this.remove(name);
}
}
});
this.animationId = requestAnimationFrame((t) => this.loop(t));
}
}完整缓动函数库
/**
* 缓动函数库(Easing Functions)
* 基于 Robert Penner 的缓动方程
* 参考: https://easings.net/
*/
const EasingFunctions = {
// 线性(无缓动)
linear: t => t,
// 二次方
easeInQuad: t => t * t,
easeOutQuad: t => t * (2 - t),
easeInOutQuad: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
// 三次方
easeInCubic: t => t * t * t,
easeOutCubic: t => (--t) * t * t + 1,
easeInOutCubic: t => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
// 四次方
easeInQuart: t => t * t * t * t,
easeOutQuart: t => 1 - (--t) * t * t * t,
easeInOutQuart: t => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
// 正弦波
easeInSine: t => 1 - Math.cos((t * Math.PI) / 2),
easeOutSine: t => Math.sin((t * Math.PI) / 2),
easeInOutSine: t => -(Math.cos(Math.PI * t) - 1) / 2,
// 指数
easeInExpo: t => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
easeOutExpo: t => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
easeInOutExpo: t => {
if (t === 0 || t === 1) return t;
return t < 0.5
? Math.pow(2, 20 * t - 10) / 2
: (2 - Math.pow(2, -20 * t + 10)) / 2;
},
// 弹跳
easeOutBounce: t => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
else if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
else if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
else return n1 * (t -= 2.625 / d1) * t + 0.984375;
},
// 弹性
easeOutElastic: t => {
const c4 = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 :
Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},
// 回弹
easeOutBack: t => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
};
// 使用示例
const timeline = new AnimationTimeline();
timeline.add('moveRight',
(progress) => {
const x = 0 + progress * 500; // 从 0 移动到 500
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(x, 200, 50, 50);
},
2000, // 2秒
{ easing: 'easeOutBounce', loop: true }
);
timeline.start();requestAnimationFrame 基础用法
let x = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#e74c3c';
ctx.fillRect(x, 200, 50, 50);
x += 2;
if (x > canvas.width) x = -50;
requestAnimationFrame(animate);
}
animate();帧率控制
const TARGET_FPS = 60;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
let lastFrameTime = 0;
function animate(currentTime) {
requestAnimationFrame(animate);
const delta = currentTime - lastFrameTime;
if (delta < FRAME_INTERVAL) return;
lastFrameTime = currentTime - (delta % FRAME_INTERVAL);
update(delta); // 传递帧间隔时间
draw();
}
requestAnimationFrame(animate);13. OffscreenCanvas 与多线程渲染
OffscreenCanvas vs 普通 Canvas 对比
| 特性 | 普通 Canvas | OffscreenCanvas |
|---|---|---|
| 线程 | 仅主线程 | 主线程 & Web Worker |
| 渲染上下文 | getContext('2d') | getContext('2d') |
| DOM 关联 | 必须关联 <canvas> 元素 | 可独立存在 |
| 控制权转移 | N/A | transferControlToOffscreen() |
| 同步渲染 | ✅ 支持 | ✅ 支持 |
| 浏览器支持 | 全部 | Chrome 69+, Firefox 105+, Safari 16.4+ |
Worker 渲染流程(Mermaid 序列图)
transferControlToOffscreen 详解
// 主线程代码
const canvas = document.getElementById('myCanvas');
// 1. 转移控制权到 OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
// 2. 创建 Worker 并发送 OffscreenCanvas
const worker = new Worker('canvas-worker.js');
// 3. 使用 Transferable 对象转移所有权(零拷贝)
worker.postMessage({
type: 'init',
canvas: offscreen,
width: 800,
height: 600
}, [offscreen]); // [offscreen] 表示转移所有权
// 4. 之后可以向 Worker 发送消息来控制渲染
worker.postMessage({ type: 'setColor', color: '#e74c3c' });
worker.postMessage({ type: 'startAnimation' });Worker 渲染框架(完整代码)
canvas-worker.js:
/**
* Canvas Worker 渲染框架
* 支持双缓冲、消息协议设计
*/
self.onmessage = function(e) {
const { type, data } = e.data;
switch (type) {
case 'init':
initWorker(data);
break;
case 'update':
updateState(data);
break;
case 'startAnimation':
startAnimation();
break;
case 'stopAnimation':
stopAnimation();
break;
case 'resize':
handleResize(data);
break;
}
};
// ========== 状态管理 ==========
let ctx = null;
let canvas = null;
let animationId = null;
let isAnimating = false;
// 双缓冲相关
let backBuffer = null;
let backCtx = null;
let useDoubleBuffering = false;
// 渲染状态
const state = {
backgroundColor: '#1a1a2e',
objects: [],
camera: { x: 0, y: 0 },
lastFrameTime: 0,
fps: 0,
frameCount: 0,
};
// ========== 初始化 ==========
function initWorker(data) {
canvas = data.canvas;
ctx = canvas.getContext('2d');
// 配置双缓冲(可选)
useDoubleBuffering = data.doubleBuffering || false;
if (useDoubleBuffering) {
backBuffer = new OffscreenCanvas(data.width, data.height);
backCtx = backBuffer.getContext('2d');
}
// 初始化画布尺寸
canvas.width = data.width;
canvas.height = data.height;
// 向主线程报告就绪
self.postMessage({
type: 'ready',
message: 'Worker 初始化完成'
});
}
// ========== 动画循环 ==========
function startAnimation() {
if (isAnimating) return;
isAnimating = true;
state.lastFrameTime = performance.now();
loop(state.lastFrameTime);
}
function stopAnimation() {
isAnimating = false;
if (animationId) {
cancelAnimationFrame(animationId);
}
}
function loop(currentTime) {
if (!isAnimating) return;
animationId = requestAnimationFrame(loop);
// 计算 delta time
const delta = currentTime - state.lastFrameTime;
state.lastFrameTime = currentTime;
// 更新 FPS 统计
state.frameCount++;
if (state.frameCount % 60 === 0) {
state.fps = Math.round(1000 / delta);
self.postMessage({ type: 'fps', value: state.fps });
}
// 选择渲染目标
const renderCtx = useDoubleBuffering ? backCtx : ctx;
// 更新逻辑
update(delta);
// 渲染
render(renderCtx);
// 如果使用双缓冲,交换缓冲区
if (useDoubleBuffering) {
ctx.drawImage(backBuffer, 0, 0);
}
}
// ========== 更新逻辑 ==========
function update(delta) {
// 更新所有对象
state.objects.forEach(obj => {
if (obj.update) obj.update(delta);
});
// 相机跟随等逻辑可以在这里实现
}
// ========== 渲染逻辑 ==========
function render(renderCtx) {
const { width, height } = canvas;
// 清除画布
renderCtx.fillStyle = state.backgroundColor;
renderCtx.fillRect(0, 0, width, height);
// 应用相机变换
renderCtx.save();
renderCtx.translate(-state.camera.x, -state.camera.y);
// 渲染所有对象(按层级排序)
const sortedObjects = [...state.objects].sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
sortedObjects.forEach(obj => {
if (obj.draw) obj.draw(renderCtx);
});
renderCtx.restore();
// 渲染 UI(不受相机影响)
renderUI(renderCtx);
}
function renderUI(renderCtx) {
// FPS 显示
renderCtx.fillStyle = '#00ff00';
renderCtx.font = '14px monospace';
renderCtx.fillText(`FPS: ${state.fps}`, 10, 20);
}
// ========== 消息处理 ==========
function updateState(data) {
Object.assign(state, data);
}
function handleResize(data) {
canvas.width = data.width;
canvas.height = data.height;
if (useDoubleBuffering) {
backBuffer.width = data.width;
backBuffer.height = data.height;
}
}
// ========== 工具函数 ==========
// 向 Worker 暴露的工具函数供外部调用
self.createRenderObject = function(config) {
const obj = {
x: config.x || 0,
y: config.y || 0,
width: config.width || 50,
height: config.height || 50,
color: config.color || '#3498db',
velocity: config.velocity || { x: 0, y: 0 },
zIndex: config.zIndex || 0,
update(delta) {
this.x += this.velocity.x * delta / 16;
this.y += this.velocity.y * delta / 16;
},
draw(ctx) {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.width, this.height);
}
};
state.objects.push(obj);
return obj;
};主线程集成代码:
class CanvasWorkerManager {
constructor(canvasElement, workerUrl) {
this.canvas = canvasElement;
this.worker = null;
this.pendingMessages = [];
this.isReady = false;
this.init(workerUrl);
}
async init(workerUrl) {
// 转移控制权
const offscreen = this.canvas.transferControlToOffscreen();
// 创建 Worker
this.worker = new Worker(workerUrl);
// 监听来自 Worker 的消息
this.worker.onmessage = (e) => {
this.handleWorkerMessage(e.data);
};
// 发送初始化消息
this.worker.postMessage({
type: 'init',
canvas: offscreen,
width: this.canvas.width,
height: this.canvas.height,
doubleBuffering: true
}, [offscreen]);
// 等待 Worker 就绪
await new Promise(resolve => {
this._readyResolve = resolve;
});
}
handleWorkerMessage(message) {
switch (message.type) {
case 'ready':
this.isReady = true;
if (this._readyResolve) {
this._readyResolve();
this._readyResolve = null;
}
break;
case 'fps':
this.onFpsUpdate?.(message.value);
break;
default:
this.onMessage?.(message);
}
}
// 发送消息到 Worker
send(type, data) {
if (!this.worker) return;
if (this.isReady) {
this.worker.postMessage({ type, data });
} else {
this.pendingMessages.push({ type, data });
}
}
// 启动动画
startAnimation() {
this.send('startAnimation');
}
// 停止动画
stopAnimation() {
this.send('stopAnimation');
}
// 销毁
destroy() {
this.stopAnimation();
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
}
}
// 使用示例
const manager = new CanvasWorkerManager(
document.getElementById('game-canvas'),
'canvas-worker.js'
);
await manager.ready;
manager.startAnimation();
manager.onFpsUpdate = (fps) => {
console.log('当前 FPS:', fps);
};14. WebGL 基础
WebGL vs Canvas 2D 对比
| 维度 | Canvas 2D | WebGL |
|---|---|---|
| 渲染方式 | 软件/CPU 光栅化 | GPU 硬件加速 |
| API 风格 | 即时模式命令式 | 状态机 + 着色器 |
| 学习难度 | ⭐⭐ | ⭐⭐⭐⭐ |
| 性能 | 适中(CPU 限制) | 极高(GPU 并行) |
| 适用场景 | 2D UI、简单动画 | 3D 场景、大量粒子、特效 |
| 纹理限制 | 无明显限制 | 受 GPU 显存限制 |
| 调试难度 | 简单 | 复杂(需了解 GPU 流水线) |
WebGL 渲染管线
获取 WebGL 上下文
const canvas = document.getElementById('webgl-canvas');
// 尝试获取 WebGL2 上下文(首选)
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) {
console.error('WebGL 不可用');
throw new Error('WebGL not supported');
}
// 配置上下文属性
// canvas.getContext('webgl', {
// alpha: false, // 禁用透明通道(性能更好)
// antialias: true, // 开启抗锯齿
// premultipliedAlpha: false,
// preserveDrawingBuffer: false, // 不保留绘制缓冲
// powerPreference: 'high-performance' // 请求高性能 GPU
// });着色器基础
着色器是运行在 GPU 上的小程序,使用 GLSL(OpenGL Shading Language)编写。
顶点着色器(Vertex Shader)
负责处理每个顶点的位置和属性:
// 顶点着色器 - 处理顶点位置
attribute vec2 a_position; // 顶点位置属性
uniform vec2 u_resolution; // 画布分辨率
uniform vec2 u_translation; // 平移量
void main() {
// 将像素坐标转换为裁剪空间坐标 (-1 到 +1)
vec2 position = a_position + u_translation;
// 转换到 0.0 -> 1.0 空间,再转换到 -1.0 -> +1.0(裁剪空间)
vec2 clipSpace = (position / u_resolution) * 2.0 - 1.0;
// WebGL 中 Y 轴是反的,所以取反
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
}片段着色器(Fragment Shader)
负责计算每个像素的颜色:
// 片段着色器 - 计算像素颜色
precision mediump float; // 浮点精度
uniform vec4 u_color; // 颜色 uniform
void main() {
gl_FragColor = u_color; // 输出颜色
}WebGL 渲染流程完整示例
/**
* WebGL 基础示例:绘制彩色矩形
*/
// ========== 1. 初始化 ==========
const canvas = document.getElementById('webgl-canvas');
const gl = canvas.getContext('webgl');
// 设置画布尺寸
canvas.width = 800;
canvas.height = 600;
gl.viewport(0, 0, canvas.width, canvas.height);
// ========== 2. 创建着色器程序 ==========
// 顶点着色器源码
const vsSource = `
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
void main() {
vec2 position = a_position + u_translation;
vec2 clipSpace = (position / u_resolution) * 2.0 - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
}
`;
// 片段着色器源码
const fsSource = `
precision mediump float;
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
`;
// 编译着色器
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('着色器编译错误:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
// 创建程序
function createProgram(gl, vsSource, fsSource) {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('程序链接错误:', gl.getProgramInfoLog(program));
return null;
}
return program;
}
const program = createProgram(gl, vsSource, fsSource);
gl.useProgram(program);
// ========== 3. 设置几何数据 ==========
// 矩形的四个顶点(两个三角形组成)
const positions = [
0, 0, // 左上
200, 0, // 右上
0, 200, // 左下
0, 200, // 左下
200, 0, // 右上
200, 200, // 右下
];
// 创建缓冲区
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
// 获取 attribute 位置并启用
const positionAttributeLocation = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(
positionAttributeLocation,
2, // 每个顶点 2 个分量
gl.FLOAT,
false, // 不归一化
0, // 步长
0 // 偏移
);
// ========== 4. 设置 Uniforms ==========
// 分辨率 uniform
const resolutionUniformLocation = gl.getUniformLocation(program, 'u_resolution');
gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);
// 颜色 uniform
const colorUniformLocation = gl.getUniformLocation(program, 'u_color');
gl.uniform4f(colorUniformLocation, 0.2, 0.6, 0.9, 1.0); // RGBA
// 平移 uniform
const translationUniformLocation = gl.getUniformLocation(program, 'u_translation');
gl.uniform2f(translationUniformLocation, 100, 100);
// ========== 5. 渲染 ==========
// 清除画布
gl.clearColor(0.1, 0.1, 0.15, 1.0); // 深灰色背景
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, 6); // 6 个顶点 = 2 个三角形WebGL 资源管理最佳实践
class WebGLRenderer {
constructor(canvas) {
this.gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
this.programs = new Map();
this.buffers = new Map();
this.textures = new Map();
if (!this.gl) {
throw new Error('WebGL 不可用');
}
}
// 创建并缓存着色器程序
createProgram(name, vsSource, fsSource) {
if (this.programs.has(name)) {
return this.programs.get(name);
}
const program = createProgram(this.gl, vsSource, fsSource);
this.programs.set(name, program);
return program;
}
// 创建缓冲区
createBuffer(name, data, usage = this.gl.STATIC_DRAW) {
const buffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, data, usage);
this.buffers.set(name, buffer);
return buffer;
}
// 释放资源
dispose() {
// 删除所有程序
this.programs.forEach(program => this.gl.deleteProgram(program));
// 删除所有缓冲区
this.buffers.forEach(buffer => this.gl.deleteBuffer(buffer));
// 删除所有纹理
this.textures.forEach(texture => this.gl.deleteTexture(texture));
// 清空引用
this.programs.clear();
this.buffers.clear();
this.textures.clear();
}
}从 Canvas 2D 到 WebGL 的迁移路径
对于大多数 2D 应用,Canvas 2D 已经足够。只有在以下情况才考虑迁移到 WebGL:
- 需要同时渲染 10,000+ 个对象
- 需要复杂的像素着色效果
- 需要 3D 变换能力
- Canvas 2D 性能已成为瓶颈
15. 性能优化深度指南
性能瓶颈分析
15.1 分层渲染(Layer Rendering)
将场景分为多个图层,只重绘变化的层:
class LayeredRenderer {
constructor(mainCanvas) {
this.mainCanvas = mainCanvas;
this.mainCtx = mainCanvas.getContext('2d');
this.layers = new Map();
}
/**
* 创建图层
* @param {string} name - 图层名称
* @param {number} zIndex - 层级(数值越大越靠上)
*/
createLayer(name, zIndex = 0) {
const layerCanvas = document.createElement('canvas');
layerCanvas.width = this.mainCanvas.width;
layerCanvas.height = this.mainCanvas.height;
this.layers.set(name, {
canvas: layerCanvas,
ctx: layerCanvas.getContext('2d'),
zIndex,
dirty: true, // 脏标记
static: false // 是否为静态层
});
return this.layers.get(name);
}
/**
* 标记图层为"脏"(需要重绘)
*/
markDirty(layerName) {
const layer = this.layers.get(layerName);
if (layer) layer.dirty = true;
}
/**
* 设置图层为静态(不会每帧清除)
*/
setStatic(layerName, isStatic) {
const layer = this.layers.get(layerName);
if (layer) layer.static = isStatic;
}
/**
* 获取图层上下文用于绘制
*/
getLayerContext(layerName) {
const layer = this.layers.get(layerName);
return layer ? layer.ctx : null;
}
/**
* 合成所有图层到主画布
*/
composite() {
const mainCtx = this.mainCtx;
mainCtx.clearRect(0, 0, this.mainCanvas.width, this.mainCanvas.height);
// 按 zIndex 排序
const sortedLayers = [...this.layers.entries()]
.sort(([, a], [, b]) => a.zIndex - b.zIndex);
for (const [name, layer] of sortedLayers) {
if (layer.dirty) {
if (!layer.static) {
// 动态层:每帧清除并重绘
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
}
// 触发该层的绘制回调
this.onLayerDraw?.(name, layer.ctx);
layer.dirty = false;
}
// 将图层绘制到主画布
mainCtx.drawImage(layer.canvas, 0, 0);
}
}
}
// 使用示例
const renderer = new LayeredRenderer(canvas);
// 创建背景层(静态,很少变化)
renderer.createLayer('background', 0);
renderer.setStatic('background', true);
renderer.markDirty('background'); // 只在需要时标记
// 创建游戏对象层(动态,每帧更新)
renderer.createLayer('gameObjects', 1);
// 创建 UI 层(最高优先级)
renderer.createLayer('ui', 2);
// 渲染循环
function renderLoop() {
// 标记动态层为脏
renderer.markDirty('gameObjects');
renderer.markDirty('ui');
// 合成所有层
renderer.composite();
requestAnimationFrame(renderLoop);
}15.2 脏矩形(Dirty Rectangle)算法
只重绘画面中实际发生变化的部分:
class DirtyRectRenderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.dirtyRegions = []; // 脏区域列表
this.prevFrameData = null;
}
/**
* 添加脏区域
* @param {number} x - X 坐标
* @param {number} y - Y 坐标
* @param {number} w - 宽度
* @param {number} h - 高度
*/
addDirtyRegion(x, y, w, h) {
this.dirtyRegions.push({ x, y, w, h });
}
/**
* 合并脏区域(优化:减少绘制次数)
*/
mergeDirtyRegions() {
if (this.dirtyRegions.length <= 1) return this.dirtyRegions;
const merged = [];
const sorted = [...this.dirtyRegions].sort((a, b) => a.x - b.x);
let current = { ...sorted[0] };
for (let i = 1; i < sorted.length; i++) {
const next = sorted[i];
// 检查是否可以水平合并
if (next.x <= current.x + current.w + 10) { // 10px 容差
// 合并
current.w = Math.max(current.x + current.w, next.x + next.w) - current.x;
current.y = Math.min(current.y, next.y);
current.h = Math.max(current.y + current.h, next.y + next.h) - current.y;
} else {
merged.push(current);
current = { ...next };
}
}
merged.push(current);
this.dirtyRegions = merged;
return merged;
}
/**
* 只清除和重绘脏区域
*/
render(drawCallback) {
if (this.dirtyRegions.length === 0) {
// 没有脏区域,不需要重绘
return;
}
// 合并相邻的脏区域以提高效率
const regions = this.mergeDirtyRegions();
for (const region of regions) {
// 只清除脏区域
this.ctx.clearRect(region.x, region.y, region.w, region.h);
// 保存状态并设置裁剪
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(region.x, region.y, region.w, region.h);
this.ctx.clip();
// 调用绘制回调(只绘制该区域内的内容)
drawCallback(this.ctx, region);
this.ctx.restore();
}
// 清空脏区域列表
this.dirtyRegions = [];
}
}
// 使用示例
const dirtyRenderer = new DirtyRectRenderer(canvas);
// 游戏对象移动时,标记其新旧位置为脏
function moveObject(obj, newX, newY) {
// 旧位置变脏
dirtyRenderer.addDirtyRegion(obj.x, obj.y, obj.width, obj.height);
// 更新位置
obj.x = newX;
obj.y = newY;
// 新位置也变脏
dirtyRenderer.addDirtyRegion(obj.x, obj.y, obj.width, obj.height);
}
// 渲染循环
function renderLoop() {
dirtyRenderer.render((ctx, region) => {
// 只绘制与脏区域相交的对象
objects.forEach(obj => {
if (rectIntersects(region, {
x: obj.x, y: obj.y, w: obj.width, h: obj.height
})) {
obj.draw(ctx);
}
});
});
requestAnimationFrame(renderLoop);
}15.3 对象池(Object Pool)模式
避免在动画循环中频繁创建和销毁对象,减少垃圾回收压力:
/**
* 通用对象池实现
* @template T
*/
class ObjectPool {
/**
* @param {() => T} createFn - 创建新对象的工厂函数
* @param {(obj: T) => void} resetFn - 重置对象状态的函数
* @param {number} initialSize - 初始池大小
*/
constructor(createFn, resetFn, initialSize = 10) {
this.createFn = createFn;
this.resetFn = resetFn;
this.pool = [];
this.active = new Set();
// 预创建对象
for (let i = 0; i < initialSize; i++) {
this.pool.push(this.createFn());
}
// 统计信息
this.stats = {
created: initialSize,
reused: 0,
peakActive: 0,
};
}
/**
* 从池中获取一个对象
* @returns {T}
*/
acquire() {
let obj;
if (this.pool.length > 0) {
// 复用池中的对象
obj = this.pool.pop();
this.stats.reused++;
} else {
// 池已满,创建新对象
obj = this.createFn();
this.stats.created++;
}
// 重置对象状态
this.resetFn(obj);
// 标记为活跃
this.active.add(obj);
// 更新峰值统计
if (this.active.size > this.stats.peakActive) {
this.stats.peakActive = this.active.size;
}
return obj;
}
/**
* 将对象归还到池中
* @param {T} obj - 要归还的对象
*/
release(obj) {
if (this.active.has(obj)) {
this.active.delete(obj);
this.pool.push(obj);
}
}
/**
* 释放所有活跃对象
*/
releaseAll() {
for (const obj of this.active) {
this.pool.push(obj);
}
this.active.clear();
}
/**
* 获取池统计信息
*/
getStats() {
return {
...this.stats,
poolSize: this.pool.length,
activeCount: this.active.size,
};
}
}
// ========== 粒子系统的对象池应用 ==========
class ParticlePool extends ObjectPool {
constructor(initialSize = 100) {
super(
// 工厂函数:创建新粒子
() => ({
x: 0,
y: 0,
vx: 0,
vy: 0,
life: 1,
decay: 0.01,
size: 3,
color: '#fff',
}),
// 重置函数:重置粒子状态
(particle) => {
particle.x = 0;
particle.y = 0;
particle.vx = 0;
particle.vy = 0;
particle.life = 1;
particle.decay = 0.01 + Math.random() * 0.02;
particle.size = 2 + Math.random() * 4;
particle.color = `hsl(${Math.random() * 360}, 100%, 60%)`;
},
initialSize
);
}
/**
* 发射粒子
*/
emit(x, y, count = 1) {
const particles = [];
for (let i = 0; i < count; i++) {
const p = this.acquire();
p.x = x;
p.y = y;
p.vx = (Math.random() - 0.5) * 6;
p.vy = (Math.random() - 0.5) * 6;
particles.push(p);
}
return particles;
}
}
// 使用示例
const particlePool = new ParticlePool(200);
canvas.addEventListener('mousemove', (e) => {
// 从池中获取粒子而不是 new Particle()
particlePool.emit(e.offsetX, e.offsetY, 3);
});
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 更新和绘制所有活跃粒子
for (const particle of particlePool.active) {
particle.x += particle.vx;
particle.y += particle.vy;
particle.life -= particle.decay;
particle.vy += 0.05; // 重力
if (particle.life <= 0) {
// 归还到池中而不是 splice/delete
particlePool.release(particle);
} else {
ctx.globalAlpha = particle.life;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
requestAnimationFrame(animate);
}
animate();
// 定期打印统计信息
setInterval(() => {
console.log('对象池状态:', particlePool.getStats());
}, 5000);15.4 其他优化技巧
批量绘制优化
// ❌ 差:频繁切换状态
function badDraw(rects) {
rects.forEach(rect => {
ctx.fillStyle = rect.color; // 每次都切换颜色
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
});
}
// ✅ 好:按状态分组批量绘制
function goodDraw(rects) {
// 按颜色分组
const groups = new Map();
rects.forEach(rect => {
if (!groups.has(rect.color)) groups.set(rect.color, []);
groups.get(rect.color).push(rect);
});
// 批量绘制每组
for (const [color, group] of groups) {
ctx.fillStyle = color; // 只设置一次
group.forEach(rect => {
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
});
}
}避免浮点坐标
// ❌ 差:浮点坐标导致亚像素渲染和模糊
ctx.fillRect(10.5, 10.5, 100, 100);
// ✅ 好:使用整数坐标
ctx.fillRect(Math.round(10.5), Math.round(10.5), 100, 100);
// ✅ 更好:预先计算好整数坐标
const x = Math.round(someCalculation());
const y = Math.round(anotherCalculation());
ctx.fillRect(x, y, width, height);使用 Typed Array
// ❌ 差:普通数组,类型不确定
const positions = [];
positions.push(x, y);
// ✅ 好:Typed Array,内存连续且类型固定
const positions = new Float32Array(maxObjects * 2); // 每个对象 x, y
let index = 0;
positions[index++] = x;
positions[index++] = y;
// 对于像素操作,必须使用 Uint8ClampedArray
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data; // Uint8ClampedArray15.5 内存管理最佳实践
// ========== 避免内存泄漏的最佳实践 ==========
// 1. 及时释放大对象
function processLargeImage() {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// ... 处理 imageData ...
// 处理完成后,解除引用以便 GC 回收
imageData = null;
}
// 2. 避免闭包导致的隐式引用
// ❌ 差:闭包持有 canvas 和 ctx 的引用
function badClosure() {
let frame = 0;
return function animate() {
ctx.fillRect(frame++, 0, 1, 1); // ctx 被闭包捕获
requestAnimationFrame(animate);
};
}
// ✅ 好:显式清理
function goodClosure() {
let frame = 0;
let animId = null;
function animate() {
ctx.fillRect(frame++, 0, 1, 1);
animId = requestAnimationFrame(animate);
}
// 提供停止方法
return {
start: () => animate(),
stop: () => {
if (animId) cancelAnimationFrame(animId);
// 清理引用
animId = null;
frame = 0;
}
};
}
// 3. 复用 ImageData
let reusableImageData = null;
function getReusableImageData(width, height) {
if (!reusableImageData ||
reusableImageData.width !== width ||
reusableImageData.height !== height) {
reusableImageData = ctx.createImageData(width, height);
}
return reusableImageData;
}
// 4. 使用 WeakMap 存储临时数据
const tempCache = new WeakMap();
function getCachedData(obj) {
if (!tempCache.has(obj)) {
tempCache.set(obj, computeExpensiveData(obj));
}
return tempCache.get(obj);
}
// 当 obj 不被其他地方引用时,缓存的数据会被自动 GC15.6 Canvas Profiling 工具和方法
/**
* Canvas 性能监控器
*/
class CanvasProfiler {
constructor(canvas) {
this.canvas = canvas;
this.metrics = {
fps: 0,
frameTime: 0,
drawCalls: 0,
stateChanges: 0,
memoryUsage: 0,
};
this.frameTimestamps = [];
this.lastMeasureTime = performance.now();
}
/** 开始记录一帧 */
beginFrame() {
this.metrics.drawCalls = 0;
this.metrics.stateChanges = 0;
this._frameStart = performance.now();
}
/** 记录一次绘制调用 */
recordDrawCall() {
this.metrics.drawCalls++;
}
/** 记录一次状态变更 */
recordStateChange() {
this.metrics.stateChanges++;
}
/** 结束一帧并更新指标 */
endFrame() {
const now = performance.now();
this.frameTimestamps.push(now);
// 保留最近 60 帧的时间戳
if (this.frameTimestamps.length > 60) {
this.frameTimestamps.shift();
}
// 每秒更新一次 FPS
if (now - this.lastMeasureTime >= 1000) {
const frames = this.frameTimestamps.filter(t => t > now - 1000).length;
this.metrics.fps = frames;
this.lastMeasureTime = now;
// 打印性能报告
this.printReport();
}
this.metrics.frameTime = now - this._frameStart;
}
/** 打印性能报告 */
printReport() {
console.log(`%c[Canvas Profiler]`, 'color: #3498db; font-weight: bold;');
console.log(` FPS: ${this.metrics.fps}`);
console.log(` Frame Time: ${this.metrics.frameTime.toFixed(2)}ms`);
console.log(` Draw Calls: ${this.metrics.drawCalls}`);
console.log(` State Changes: ${this.metrics.stateChanges}`);
// 内存使用(如果可用)
if (performance.memory) {
const mb = performance.memory.usedJSHeapSize / 1024 / 1024;
console.log(` Memory: ${mb.toFixed(2)} MB`);
}
}
/** 在画布上显示性能指标 */
drawOverlay(ctx) {
ctx.save();
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(5, 5, 180, 80);
ctx.fillStyle = '#00ff00';
ctx.font = '12px monospace';
ctx.fillText(`FPS: ${this.metrics.fps}`, 10, 22);
ctx.fillText(`Frame: ${this.metrics.frameTime.toFixed(1)}ms`, 10, 38);
ctx.fillText(`Draw Calls: ${this.metrics.drawCalls}`, 10, 54);
ctx.fillText(`State Changes: ${this.metrics.stateChanges}`, 10, 70);
ctx.restore();
}
}
// 使用示例
const profiler = new CanvasProfiler(canvas);
function renderLoop() {
profiler.beginFrame();
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 在绘制调用处记录
profiler.recordDrawCall();
ctx.fillRect(10, 10, 50, 50);
profiler.recordDrawCall();
ctx.drawImage(someImage, 100, 100);
// 显示性能覆盖层
profiler.drawOverlay(ctx);
profiler.endFrame();
requestAnimationFrame(renderLoop);
}16. 实战案例集
<h4>003-particle-system.html</h4><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【3】粒子动画系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #1a1a2e; color: #fff; }
.demo-container { max-width: 900px; margin: 0 auto; background: #16213e; padding: 24px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #e94560; border-bottom: 2px solid #e94560; padding-bottom: 8px; }
canvas {
display: block;
margin: 16px auto;
border-radius: 12px;
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
}
.controls { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
.btn {
padding: 8px 18px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.3s;
background: #e94560;
color: white;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(233,69,96,0.4); }
.btn-secondary { background: #533483; }
.btn-secondary:hover { box-shadow: 0 4px 12px rgba(83,52,131,0.4); }
.stats {
display: flex;
gap: 20px;
justify-content: center;
padding: 12px;
background: rgba(255,255,255,0.05);
border-radius: 8px;
font-size: 13px;
font-family: monospace;
color: #00d9ff;
}
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:粒子动画系统(鼠标交互)</div>
<div class="controls">
<button class="btn" onclick="addParticles(50)">+ 添加 50 个粒子</button>
<button class="btn" onclick="addParticles(200)">+ 添加 200 个粒子</button>
<button class="btn btn-secondary" onclick="toggleGravity()">切换重力</button>
<button class="btn btn-secondary" onclick="clearAll()">清空全部</button>
</div>
<canvas id="particleCanvas" width="850" height="550"></canvas>
<div class="stats">
<span>粒子数:<strong id="particleCount">0</strong></span>
<span>FPS:<strong id="fpsDisplay">60</strong></span>
<span>重力:<strong id="gravityStatus">关闭</strong></span>
</div>
</div>
<script>
const canvas = document.getElementById("particleCanvas")
const ctx = canvas.getContext("2d")
const particles = []
let gravity = false
let mouseX = -1000
let mouseY = -1000
// FPS 计算
let lastTime = performance.now()
let frameCount = 0
let fps = 60
class Particle {
constructor(x, y) {
this.x = x || Math.random() * canvas.width
this.y = y || Math.random() * canvas.height * 0.5
this.vx = (Math.random() - 0.5) * 6
this.vy = (Math.random() - 0.5) * 6
this.radius = 2 + Math.random() * 6
this.color = `hsl(${Math.random() * 360}, 80%, 60%)`
this.alpha = 1
this.decay = 0.0005 + Math.random() * 0.002
this.life = 1
}
update() {
// 鼠标吸引力
const dx = mouseX - this.x
const dy = mouseY - this.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 150 && dist > 0) {
const force = (150 - dist) / 150 * 0.5
this.vx += (dx / dist) * force
this.vy += (dy / dist) * force
}
// 重力
if (gravity) this.vy += 0.15
// 更新位置
this.x += this.vx
this.y += this.vy
// 摩擦力
this.vx *= 0.99
this.vy *= 0.99
// 边界反弹
if (this.x <= this.radius || this.x >= canvas.width - this.radius) {
this.vx *= -0.7
this.x = Math.max(this.radius, Math.min(canvas.width - this.radius, this.x))
}
if (this.y >= canvas.height - this.radius) {
this.vy *= -0.7
this.y = canvas.height - this.radius
}
if (this.y < this.radius) {
this.vy *= -0.7
this.y = this.radius
}
// 生命值衰减
this.life -= this.decay
this.alpha = this.life
}
draw(ctx) {
ctx.save()
ctx.globalAlpha = this.alpha
// 发光效果
const gradient = ctx.createRadialGradient(
this.x, this.y, 0,
this.x, this.y, this.radius * 2
)
gradient.addColorStop(0, this.color)
gradient.addColorStop(1, "transparent")
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius * 2, 0, Math.PI * 2)
ctx.fill()
// 核心
ctx.fillStyle = "white"
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius * 0.5, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
function addParticles(count) {
for (let i = 0; i < count; i++) {
particles.push(new Particle())
}
}
function toggleGravity() {
gravity = !gravity
document.getElementById("gravityStatus").textContent = gravity ? "开启" : "关闭"
}
function clearAll() {
particles.length = 0
}
// 鼠标事件
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect()
mouseX = e.clientX - rect.left
mouseY = e.clientY - rect.top
})
canvas.addEventListener("mouseleave", () => {
mouseX = -1000
mouseY = -1000
})
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
for (let i = 0; i < 30; i++) {
particles.push(new Particle(x, y))
}
})
function animate(currentTime) {
// FPS 计算
frameCount++
if (currentTime - lastTime >= 1000) {
fps = frameCount
frameCount = 0
lastTime = currentTime
document.getElementById("fpsDisplay").textContent = fps
}
// 半透明覆盖实现拖尾效果
ctx.fillStyle = "rgba(15, 52, 96, 0.15)"
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 更新和绘制粒子
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update()
particles[i].draw(ctx)
if (particles[i].life <= 0) {
particles.splice(i, 1)
}
}
document.getElementById("particleCount").textContent = particles.length
requestAnimationFrame(animate)
}
// 初始化一些粒子
addParticles(80)
animate(performance.now())
</script>
</body>
</html>```
### 案例 1:柱状图
```javascript
function drawBarChart(data) {
const padding = 40;
const chartWidth = canvas.width - padding * 2;
const chartHeight = canvas.height - padding * 2;
const barWidth = chartWidth / data.length * 0.6;
const gap = chartWidth / data.length * 0.4;
const maxVal = Math.max(...data.map(d => d.value));
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制坐标轴
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, canvas.height - padding);
ctx.lineTo(canvas.width - padding, canvas.height - padding);
ctx.stroke();
// 绘制柱状图
data.forEach((item, i) => {
const x = padding + i * (barWidth + gap) + gap / 2;
const barHeight = (item.value / maxVal) * chartHeight;
const y = canvas.height - padding - barHeight;
// 渐变填充
const gradient = ctx.createLinearGradient(x, y, x, y + barHeight);
gradient.addColorStop(0, item.color || '#4ecdc4');
gradient.addColorStop(1, shadeColor(item.color || '#4ecdc4', -20));
ctx.fillStyle = gradient;
// 圆角矩形(模拟)
roundedRect(ctx, x, y, barWidth, barHeight, 4);
ctx.fill();
// 标签
ctx.fillStyle = '#333';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(item.label, x + barWidth / 2, canvas.height - padding + 20);
ctx.fillText(item.value, x + barWidth / 2, y - 8);
});
}
/** 绘制圆角矩形的辅助函数 */
function roundedRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
/** 颜色变亮/变暗辅助函数 */
function shadeColor(color, percent) {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = ((num >> 8) & 0x00FF) + amt;
const B = (num & 0x0000FF) + amt;
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1);
}
drawBarChart([
{ label: '一月', value: 120, color: '#e74c3c' },
{ label: '二月', value: 200, color: '#f39c12' },
{ label: '三月', value: 150, color: '#2ecc71' },
{ label: '四月', value: 280, color: '#3498db' },
{ label: '五月', value: 220, color: '#9b59b6' },
]);案例 2:粒子系统
class Particle {
constructor(x, y) {
this.x = x;
this.y = y;
this.vx = (Math.random() - 0.5) * 4;
this.vy = (Math.random() - 0.5) * 4;
this.life = 1;
this.decay = 0.01 + Math.random() * 0.02;
this.size = 2 + Math.random() * 4;
this.color = `hsl(${Math.random() * 60 + 10}, 100%, 60%)`;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.life -= this.decay;
this.vy += 0.05; // 重力
}
draw(ctx) {
ctx.globalAlpha = this.life;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
const particles = [];
canvas.addEventListener('mousemove', (e) => {
for (let i = 0; i < 3; i++) {
particles.push(new Particle(e.offsetX, e.offsetY));
}
});
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
particles[i].draw(ctx);
if (particles[i].life <= 0) {
particles.splice(i, 1);
}
}
ctx.globalAlpha = 1;
requestAnimationFrame(animate);
}
animate();案例 3:图片滤镜
function applyFilter(filterType) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
switch (filterType) {
case 'grayscale':
// 灰度滤镜(ITU-R BT.601 标准)
for (let i = 0; i < data.length; i += 4) {
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
data[i] = data[i + 1] = data[i + 2] = gray;
}
break;
case 'invert':
// 反色滤镜
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i]; // R
data[i + 1] = 255 - data[i + 1]; // G
data[i + 2] = 255 - data[i + 2]; // B
}
break;
case 'sepia':
// 怀旧褐色滤镜
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
data[i] = Math.min(255, r * 0.393 + g * 0.769 + b * 0.189);
data[i + 1] = Math.min(255, r * 0.349 + g * 0.686 + b * 0.168);
data[i + 2] = Math.min(255, r * 0.272 + g * 0.534 + b * 0.131);
}
break;
case 'brightness':
// 亮度增强 (+50)
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, data[i] + 50);
data[i + 1] = Math.min(255, data[i + 1] + 50);
data[i + 2] = Math.min(255, data[i + 2] + 50);
}
break;
case 'contrast':
// 对比度增强(因子 1.5)
const factor = (1.5 * 256) / 256;
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
}
break;
case 'blur':
// 简单的 3x3 盒式模糊
const w = imageData.width;
const h = imageData.height;
const copy = new Uint8ClampedArray(data);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const idx = (y * w + x) * 4;
for (let c = 0; c < 3; c++) {
let sum = 0;
// 3x3 邻域平均
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
sum += copy[((y + dy) * w + (x + dx)) * 4 + c];
}
}
data[idx + c] = sum / 9;
}
}
}
break;
}
ctx.putImageData(imageData, 0, 0);
}案例 4:简易游戏引擎骨架
这是一个完整的 2D 游戏引擎框架,包含实体组件系统(ECS)、碰撞检测和渲染循环:
/**
* 简易 2D 游戏引擎
* 基于 ECS(Entity-Component-System)架构
*/
// ========== 组件定义 ==========
/** 位置组件 */
class TransformComponent {
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
this.rotation = 0;
this.scaleX = 1;
this.scaleY = 1;
}
}
/** 渲染组件 */
class RenderComponent {
constructor(options = {}) {
this.width = options.width || 32;
this.height = options.height || 32;
this.color = options.color || '#3498db';
this.shape = options.shape || 'rect'; // 'rect' | 'circle' | 'sprite'
this.sprite = options.sprite || null;
this.zIndex = options.zIndex || 0;
}
}
/** 物理组件 */
class PhysicsComponent {
constructor(options = {}) {
this.vx = options.vx || 0;
this.vy = options.vy || 0;
this.ax = options.ax || 0; // 加速度
this.ay = options.ay || 0;
this.mass = options.mass || 1;
this.friction = options.friction || 0.98;
this.bounce = options.bounce || 0.5; // 弹性系数
this.isStatic = options.isStatic || false;
}
}
/** 碰撞组件 */
class ColliderComponent {
constructor(options = {}) {
this.type = options.type || 'aabb'; // 'aabb' | 'circle'
this.offsetX = options.offsetX || 0;
this.offsetY = options.offsetY || 0;
this.radius = options.radius || 0;
this.isTrigger = options.isTrigger || false; // 触发器(不产生物理反应)
this.layer = options.layer || 0; // 碰撞层
this.mask = options.mask || 0xFFFFFFFF; // 碰撞掩码
}
}
/** 输入组件 */
class InputComponent {
constructor() {
this.keys = {};
this.mousePos = { x: 0, y: 0 };
this.mouseDown = false;
}
}
// ========== 实体(Entity)==========
class Entity {
constructor(id) {
this.id = id;
this.components = new Map();
this.active = true;
}
addComponent(type, component) {
this.components.set(type, component);
return this; // 支持链式调用
}
getComponent(type) {
return this.components.get(type);
}
hasComponent(type) {
return this.components.has(type);
}
removeComponent(type) {
this.components.delete(type);
}
}
// ========== 系统(System)==========
/** 渲染系统 */
class RenderSystem {
constructor(ctx) {
this.ctx = ctx;
}
update(entities) {
// 按 zIndex 排序
const sorted = entities
.filter(e => e.active && e.hasComponent('render'))
.sort((a, b) => {
const ra = a.getComponent('render');
const rb = b.getComponent('render');
return ra.zIndex - rb.zIndex;
});
for (const entity of sorted) {
const transform = entity.getComponent('transform');
const render = entity.getComponent('render');
if (!transform || !render) continue;
this.ctx.save();
this.ctx.translate(transform.x, transform.y);
this.ctx.rotate(transform.rotation);
this.ctx.scale(transform.scaleX, transform.scaleY);
this.ctx.fillStyle = render.color;
switch (render.shape) {
case 'rect':
this.ctx.fillRect(
-render.width / 2,
-render.height / 2,
render.width,
render.height
);
break;
case 'circle':
this.ctx.beginPath();
this.ctx.arc(0, 0, render.width / 2, 0, Math.PI * 2);
this.ctx.fill();
break;
case 'sprite':
if (render.sprite) {
this.ctx.drawImage(
render.sprite,
-render.width / 2,
-render.height / 2,
render.width,
render.height
);
}
break;
}
this.ctx.restore();
}
}
}
/** 物理系统 */
class PhysicsSystem {
constructor(bounds) {
this.bounds = bounds; // { width, height }
this.gravity = 0.5;
}
update(entities, dt) {
for (const entity of entities) {
if (!entity.active) continue;
const transform = entity.getComponent('transform');
const physics = entity.getComponent('physics');
if (!transform || !physics || physics.isStatic) continue;
// 应用重力
physics.vy += this.gravity;
// 应用加速度
physics.vx += physics.ax;
physics.vy += physics.ay;
// 应用摩擦力
physics.vx *= physics.friction;
physics.vy *= physics.friction;
// 更新位置
transform.x += physics.vx * dt;
transform.y += physics.vy * dt;
// 边界碰撞
const hw = (entity.getComponent('render')?.width || 32) / 2;
const hh = (entity.getComponent('render')?.height || 32) / 2;
if (transform.x - hw < 0) {
transform.x = hw;
physics.vx *= -physics.bounce;
}
if (transform.x + hw > this.bounds.width) {
transform.x = this.bounds.width - hw;
physics.vx *= -physics.bounce;
}
if (transform.y - hh < 0) {
transform.y = hh;
physics.vy *= -physics.bounce;
}
if (transform.y + hh > this.bounds.height) {
transform.y = this.bounds.height - hh;
physics.vy *= -physics.bounce;
}
}
}
}
/** 碰撞系统 */
class CollisionSystem {
constructor() {
this.handlers = new Map(); // 碰撞事件处理器
}
onCollision(entityA, entityB, callback) {
const key = `${entityA.id}-${entityB.id}`;
this.handlers.set(key, callback);
}
update(entities) {
const collidables = entities.filter(e =>
e.active && e.hasComponent('collider') && e.hasComponent('transform')
);
for (let i = 0; i < collidables.length; i++) {
for (let j = i + 1; j < collidables.length; j++) {
const a = collidables[i];
const b = collidables[j];
if (this.checkCollision(a, b)) {
// 触发碰撞事件
const key1 = `${a.id}-${b.id}`;
const key2 = `${b.id}-${a.id}`;
const handler = this.handlers.get(key1) || this.handlers.get(key2);
if (handler) handler(a, b);
// 如果不是触发器,分离物体
const colliderA = a.getComponent('collider');
const colliderB = b.getComponent('collider');
if (!colliderA.isTrigger && !colliderB.isTrigger) {
this.separate(a, b);
}
}
}
}
}
checkCollision(a, b) {
const ta = a.getComponent('transform');
const tb = b.getComponent('transform');
const ca = a.getComponent('collider');
const cb = b.getComponent('collider');
const ax = ta.x + ca.offsetX;
const ay = ta.y + ca.offsetY;
const bx = tb.x + cb.offsetX;
const by = tb.y + cb.offsetY;
if (ca.type === 'aabb' && cb.type === 'aabb') {
const wa = (a.getComponent('render')?.width || 32) / 2;
const ha = (a.getComponent('render')?.height || 32) / 2;
const wb = (b.getComponent('render')?.width || 32) / 2;
const hb = (b.getComponent('render')?.height || 32) / 2;
return aabbCollision(
{ x: ax - wa, y: ay - ha, w: wa * 2, h: ha * 2 },
{ x: bx - wb, y: by - hb, w: wb * 2, h: hb * 2 }
);
}
if (ca.type === 'circle' && cb.type === 'circle') {
return circleCollision(
{ x: ax, y: ay, r: ca.radius },
{ x: bx, y: by, r: cb.radius }
);
}
return false;
}
separate(a, b) {
// 简单的弹性分离
const ta = a.getComponent('transform');
const tb = b.getComponent('transform');
const pa = a.getComponent('physics');
const pb = b.getComponent('physics');
if (pa && !pa.isStatic) {
pa.vx *= -0.5;
pa.vy *= -0.5;
}
if (pb && !pb.isStatic) {
pb.vx *= -0.5;
pb.vy *= -0.5;
}
}
}
// ========== 游戏引擎主类 ==========
class GameEngine {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.entities = [];
this.systems = {};
this.entityCounter = 0;
this.running = false;
this.lastTime = 0;
// 注册默认系统
this.registerSystem('physics', new PhysicsSystem({
width: canvas.width,
height: canvas.height
}));
this.registerSystem('collision', new CollisionSystem());
this.registerSystem('render', new RenderSystem(this.ctx));
}
registerSystem(name, system) {
this.systems[name] = system;
}
createEntity() {
const entity = new Entity(++this.entityCounter);
this.entities.push(entity);
return entity;
}
removeEntity(id) {
const idx = this.entities.findIndex(e => e.id === id);
if (idx !== -1) this.entities.splice(idx, 1);
}
start() {
this.running = true;
this.lastTime = performance.now();
this.loop(this.lastTime);
}
stop() {
this.running = false;
}
loop(currentTime) {
if (!this.running) return;
requestAnimationFrame((t) => this.loop(t));
const dt = (currentTime - this.lastTime) / 16.67; // 归一化为 ~60fps
this.lastTime = currentTime;
// 更新所有系统
if (this.systems.physics) this.systems.physics.update(this.entities, dt);
if (this.systems.collision) this.systems.collision.update(this.entities);
// 渲染
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (this.systems.render) this.systems.render.update(this.entities);
}
}
// ========== 使用示例 ==========
const engine = new GameEngine(document.getElementById('game-canvas'));
// 创建玩家
const player = engine.createEntity()
.addComponent('transform', new TransformComponent(400, 300))
.addComponent('render', new RenderComponent({
width: 40, height: 40, color: '#e74c3c', shape: 'rect'
}))
.addComponent('physics', new PhysicsComponent({ bounce: 0.8 }))
.addComponent('collider', new ColliderComponent({ type: 'aabb' }));
// 创建地面(静态)
const ground = engine.createEntity()
.addComponent('transform', new TransformComponent(400, 580))
.addComponent('render', new RenderComponent({
width: 800, height: 40, color: '#2ecc71'
}))
.addComponent('physics', new PhysicsComponent({ isStatic: true }))
.addComponent('collider', new ColliderComponent({ type: 'aabb' }));
// 给玩家一个初始速度
player.getComponent('physics').vy = -10;
// 注册碰撞处理器
engine.systems.collision.onCollision(player, ground, (a, b) => {
console.log('玩家碰到地面!');
});
engine.start();案例 5:实时数据可视化大屏
这是一个完整的数据可视化大屏案例,包含动态图表、动画过渡和响应式布局:
/**
* 数据可视化大屏
* 特点:
* - 响应式布局(自适应窗口大小)
* - 动态数据更新(WebSocket/轮询模拟)
* - 平滑动画过渡
* - 多种图表类型
*/
class DataDashboard {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.canvas = document.createElement('canvas');
this.container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
// 配置
this.config = {
bgColor: '#0a0e27',
primaryColor: '#00d4ff',
secondaryColor: '#ff6b6b',
accentColor: '#ffd93d',
gridColor: 'rgba(255, 255, 255, 0.05)',
textColor: 'rgba(255, 255, 255, 0.8)',
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
};
// 数据存储
this.charts = [];
this.animations = new Map();
// 初始化
this.resize();
window.addEventListener('resize', () => this.resize());
// 模拟实时数据
this.startDataStream();
}
/** 响应式调整尺寸 */
resize() {
const rect = this.container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
this.ctx.scale(dpr, dpr);
this.width = rect.width;
this.height = rect.height;
// 重新布局
this.layoutCharts();
}
/** 布局图表 */
layoutCharts() {
const padding = 20;
const headerHeight = 60;
// 定义图表区域
this.regions = {
header: { x: padding, y: padding, w: this.width - padding * 2, h: headerHeight },
mainChart: { x: padding, y: headerHeight + padding * 2, w: (this.width - padding * 3) / 2, h: this.height - headerHeight - padding * 3 },
sidePanel1: { x: this.width / 2 + padding / 2, y: headerHeight + padding * 2, w: (this.width - padding * 3) / 2, h: (this.height - headerHeight - padding * 3) / 2 - padding / 2 },
sidePanel2: { x: this.width / 2 + padding / 2, y: this.height / 2 + padding / 2, w: (this.width - padding * 3) / 2, h: (this.height - headerHeight - padding * 3) / 2 - padding / 2 },
footer: { x: padding, y: this.height - 40, w: this.width - padding * 2, h: 30 },
};
}
/** 模拟数据流 */
startDataStream() {
// 模拟数据
this.data = {
lineChart: Array.from({ length: 24 }, (_, i) => ({
label: `${i}:00`,
value: Math.random() * 100 + 50,
})),
barChart: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(day => ({
label: day,
value: Math.random() * 1000,
})),
gaugeValue: 72,
stats: [
{ label: '总用户', value: 123456, icon: '👥' },
{ label: '日活', value: 23456, icon: '📈' },
{ label: '转化率', value: 12.8, suffix: '%', icon: '💰' },
{ label: '收入', value: 98765, prefix: '¥', icon: '💵' },
],
};
// 定时更新数据
setInterval(() => this.updateData(), 2000);
}
/** 更新数据(带动画) */
updateData() {
// 更新折线图数据
this.data.lineChart.shift();
this.data.lineChart.push({
label: '',
value: Math.random() * 100 + 50,
});
// 更新仪表盘值
this.data.gaugeValue = 50 + Math.random() * 50;
// 触发动画
this.animateValue('lineChart', 500);
this.animateValue('gauge', 500);
}
/** 数值动画 */
animateValue(key, duration) {
const startTime = performance.now();
const animate = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
this.animations.set(key, eased);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
/** 主渲染方法 */
render() {
const ctx = this.ctx;
// 清除画布
ctx.fillStyle = this.config.bgColor;
ctx.fillRect(0, 0, this.width, this.height);
// 绘制各区域
this.drawHeader();
this.drawLineChart();
this.drawBarChart();
this.drawGauge();
this.drawStats();
this.drawFooter();
}
/** 绘制标题栏 */
drawHeader() {
const { x, y, w, h } = this.regions.header;
const ctx = this.ctx;
// 背景
ctx.fillStyle = 'rgba(0, 212, 255, 0.1)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = this.config.primaryColor;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
// 标题
ctx.fillStyle = this.config.primaryColor;
ctx.font = `bold 24px ${this.config.fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('📊 实时数据监控中心', x + 20, y + h / 2);
// 时间
ctx.fillStyle = this.config.textColor;
ctx.font = `14px ${this.config.fontFamily}`;
ctx.textAlign = 'right';
ctx.fillText(new Date().toLocaleString(), x + w - 20, y + h / 2);
}
/** 绘制折线图 */
drawLineChart() {
const { x, y, w, h } = this.regions.mainChart;
const ctx = this.ctx;
const data = this.data.lineChart;
const pad = 40;
// 背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
ctx.strokeRect(x, y, w, h);
// 标题
ctx.fillStyle = this.config.textColor;
ctx.font = `14px ${this.config.fontFamily}`;
ctx.textAlign = 'left';
ctx.fillText('📈 实时流量趋势', x + 10, y + 20);
// 绘制网格
ctx.strokeStyle = this.config.gridColor;
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const gy = y + pad + (h - pad * 2) * i / 4;
ctx.beginPath();
ctx.moveTo(x + pad, gy);
ctx.lineTo(x + w - pad, gy);
ctx.stroke();
}
// 绘制折线
const maxVal = Math.max(...data.map(d => d.value));
const minVal = Math.min(...data.map(d => d.value));
const range = maxVal - minVal || 1;
// 渐变填充区域
const gradient = ctx.createLinearGradient(x, y, x, y + h);
gradient.addColorStop(0, 'rgba(0, 212, 255, 0.3)');
gradient.addColorStop(1, 'rgba(0, 212, 255, 0)');
ctx.beginPath();
data.forEach((point, i) => {
const px = x + pad + (w - pad * 2) * i / (data.length - 1);
const py = y + h - pad - (point.value - minVal) / range * (h - pad * 2);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
// 闭合路径形成填充区域
ctx.lineTo(x + w - pad, y + h - pad);
ctx.lineTo(x + pad, y + h - pad);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// 绘制线条
ctx.beginPath();
data.forEach((point, i) => {
const px = x + pad + (w - pad * 2) * i / (data.length - 1);
const py = y + h - pad - (point.value - minVal) / range * (h - pad * 2);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.strokeStyle = this.config.primaryColor;
ctx.lineWidth = 2;
ctx.stroke();
// 绘制最后一个点(发光效果)
const lastPoint = data[data.length - 1];
const lpx = x + w - pad;
const lpy = y + h - pad - (lastPoint.value - minVal) / range * (h - pad * 2);
// 外发光
ctx.beginPath();
ctx.arc(lpx, lpy, 8, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 212, 255, 0.3)';
ctx.fill();
// 内圆
ctx.beginPath();
ctx.arc(lpx, lpy, 4, 0, Math.PI * 2);
ctx.fillStyle = this.config.primaryColor;
ctx.fill();
}
/** 绘制柱状图 */
drawBarChart() {
const { x, y, w, h } = this.regions.sidePanel1;
const ctx = this.ctx;
const data = this.data.barChart;
const pad = 30;
// 背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = 'rgba(255, 107, 107, 0.3)';
ctx.strokeRect(x, y, w, h);
// 标题
ctx.fillStyle = this.config.textColor;
ctx.font = `14px ${this.config.fontFamily}`;
ctx.textAlign = 'left';
ctx.fillText('📊 周销售额', x + 10, y + 20);
// 绘制柱状图
const maxVal = Math.max(...data.map(d => d.value));
const barWidth = (w - pad * 2) / data.length * 0.6;
const gap = (w - pad * 2) / data.length * 0.4;
data.forEach((item, i) => {
const bx = x + pad + i * (barWidth + gap) + gap / 2;
const bh = (item.value / maxVal) * (h - pad * 2 - 20);
const by = y + h - pad - bh;
// 渐变色
const gradient = ctx.createLinearGradient(bx, by, bx, by + bh);
gradient.addColorStop(0, this.config.secondaryColor);
gradient.addColorStop(1, 'rgba(255, 107, 107, 0.3)');
// 圆角矩形
this.roundRect(bx, by, barWidth, bh, 4, gradient);
// 标签
ctx.fillStyle = this.config.textColor;
ctx.font = '11px ' + this.config.fontFamily;
ctx.textAlign = 'center';
ctx.fillText(item.label, bx + barWidth / 2, y + h - 10);
// 数值
ctx.fillStyle = this.config.secondaryColor;
ctx.fillText(Math.round(item.value), bx + barWidth / 2, by - 5);
});
}
/** 绘制仪表盘 */
drawGauge() {
const { x, y, w, h } = this.regions.sidePanel2;
const ctx = this.ctx;
const value = this.data.gaugeValue;
// 背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = 'rgba(255, 217, 61, 0.3)';
ctx.strokeRect(x, y, w, h);
// 标题
ctx.fillStyle = this.config.textColor;
ctx.font = `14px ${this.config.fontFamily}`;
ctx.textAlign = 'left';
ctx.fillText('⚡ 系统负载', x + 10, y + 20);
// 仪表盘参数
const cx = x + w / 2;
const cy = y + h / 2 + 10;
const radius = Math.min(w, h) / 2 - 30;
const startAngle = 0.75 * Math.PI;
const endAngle = 2.25 * Math.PI;
// 背景弧
ctx.beginPath();
ctx.arc(cx, cy, radius, startAngle, endAngle);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 15;
ctx.lineCap = 'round';
ctx.stroke();
// 值弧
const valueAngle = startAngle + (endAngle - startAngle) * (value / 100);
const grad = ctx.createLinearGradient(cx - radius, cy, cx + radius, cy);
grad.addColorStop(0, this.config.accentColor);
grad.addColorStop(0.5, this.config.primaryColor);
grad.addColorStop(1, this.config.secondaryColor);
ctx.beginPath();
ctx.arc(cx, cy, radius, startAngle, valueAngle);
ctx.strokeStyle = grad;
ctx.lineWidth = 15;
ctx.lineCap = 'round';
ctx.stroke();
// 中心数值
ctx.fillStyle = '#fff';
ctx.font = `bold 28px ${this.config.fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(value.toFixed(1) + '%', cx, cy);
}
/** 绘制统计卡片 */
drawStats() {
const ctx = this.ctx;
const stats = this.data.stats;
const cardWidth = (this.width - 100) / 4;
const cardHeight = 70;
const startY = 90;
stats.forEach((stat, i) => {
const x = 20 + i * (cardWidth + 20);
const y = startY;
// 卡片背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
this.roundRect(x, y, cardWidth, cardHeight, 8, null, 'rgba(255, 255, 255, 0.05)');
// 图标
ctx.font = '24px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(stat.icon, x + 15, y + 12);
// 标签
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.font = '12px ' + this.config.fontFamily;
ctx.fillText(stat.label, x + 50, y + 15);
// 数值
ctx.fillStyle = '#fff';
ctx.font = `bold 20px ${this.config.fontFamily}`;
const displayValue = (stat.prefix || '') +
stat.value.toLocaleString() +
(stat.suffix || '');
ctx.fillText(displayValue, x + 50, y + 38);
});
}
/** 绘制底部信息 */
drawFooter() {
const { x, y, w, h } = this.regions.footer;
const ctx = this.ctx;
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = `12px ${this.config.fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
`© 2024 Data Dashboard | 最后更新: ${new Date().toLocaleTimeString()} | 刷新率: 2s`,
x + w / 2, y + h / 2
);
}
/** 圆角矩形辅助函数 */
roundRect(x, y, w, h, r, fillStyle = null, strokeStyle = null) {
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();
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
if (strokeStyle) {
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = 1;
ctx.stroke();
}
}
/** 启动渲染循环 */
start() {
const loop = () => {
this.render();
requestAnimationFrame(loop);
};
loop();
}
}
// 初始化大屏
const dashboard = new DataDashboard('dashboard-container');
dashboard.start();17. 安全考虑
Canvas 指纹识别风险
Canvas 可以被用来生成设备指纹,因为不同的设备/GPU/驱动组合会产生略微不同的渲染结果:
// 指纹识别原理示例
function generateCanvasFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 绘制特定文本和图形
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('BrowserLeaks,com <canvas> ID', 2, 15);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.fillText('BrowserLeaks,com <canvas> ID', 4, 17);
// 提取像素数据作为指纹
return canvas.toDataURL();
}防护措施:
- 使用 Tor Browser 或隐私模式(会添加噪声到 Canvas 输出)
- 服务端不要依赖 Canvas 指纹作为唯一身份标识
- 了解浏览器隐私 API(如
Privacy Sandbox)对 Canvas 的限制
CORS 跨域安全
当 Canvas 绘制了跨域图像后,Canvas 会变为 "tainted"(被污染) 状态,此时:
toDataURL()会抛出SecurityErrortoBlob()会抛出SecurityErrorgetImageData()会抛出SecurityError
// ❌ 错误:直接绘制跨域图像会导致 Canvas 污染
const img = new Image();
img.crossOrigin = 'anonymous'; // 必须设置此属性
img.src = 'https://cdn.example.com/image.png';
img.onload = () => {
ctx.drawImage(img, 0, 0);
// 如果服务器未返回正确的 CORS 头,这里会报错
try {
const dataUrl = canvas.toDataURL(); // SecurityError!
} catch (e) {
console.error('Canvas 被污染,无法导出:', e.message);
}
};
// ✅ 正确:使用代理或确保 CORS 配置正确
async function safeDrawImage(ctx, url) {
const response = await fetch(url);
if (!response.ok) throw new Error('图像加载失败');
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, 0);
// 现在可以安全导出
return canvas.toDataURL();
}toDataURL 安全限制
| 操作 | 同源资源 | 跨域+CORS | 跨域无CORS |
|---|---|---|---|
toDataURL() | ✅ 允许 | ✅ 允许* | ❌ SecurityError |
toBlob() | ✅ 允许 | ✅ 允许* | ❌ SecurityError |
getImageData() | ✅ 允许 | ✅ 允许* | ❌ SecurityError |
drawImage() | ✅ 允许 | ✅ 允许 | ✅ 允许 |
*需要服务器返回正确的 CORS 响应头且
crossOrigin属性已设置
其他安全实践
- 输入验证:所有传入 Canvas 的用户数据(如文本、坐标)必须进行 sanitize,防止 XSS
- 内存限制:避免创建过大的 Canvas(建议单张不超过 4096×4096),防止 OOM 攻击
- WebGL 安全:WebGL 着色器代码需严格审核,防止 GPU 驱动漏洞利用
- 清理敏感数据:处理完敏感图像后调用
canvas.width = 0清除像素缓冲区
// 清除 Canvas 敏感数据的最佳实践
function secureClearCanvas(canvas) {
const ctx = canvas.getContext('2d');
// 方法1: 重置尺寸(最彻底)
canvas.width = canvas.width; // 触发完全重置
// 方法2: 用随机数据覆盖(如果需要保留尺寸)
const imageData = ctx.createImageData(canvas.width, canvas.height);
crypto.getRandomValues(imageData.data); // 用随机数填充
ctx.putImageData(imageData, 0, 0);
// 方法3: 多次覆盖(高安全性场景)
for (let i = 0; i < 3; i++) {
ctx.fillStyle = i % 2 === 0 ? '#fff' : '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}18. 可访问性(Accessibility)
Canvas 本身是位图画布,屏幕阅读器无法感知其内容。以下是提升可访问性的策略:
ARIA 角色与标签
<!-- ✅ 正确:添加语义化角色和描述 -->
<canvas
id="chart-canvas"
width="800"
height="600"
role="img"
aria-label="2024年月度销售趋势柱状图,显示1-12月的销售额变化">
<!-- 替代内容:当 Canvas 不支持时显示 -->
<p>您的浏览器不支持 Canvas。请查看<a href="/chart-data">表格版本</a>。</p>
</canvas>
<!-- 动态更新 ARIA 描述 -->
<script>
const canvas = document.getElementById('chart-canvas');
// 当图表数据变化时更新描述
function updateChartAccessibility(data) {
canvas.setAttribute('aria-label',
`柱状图显示:最高值 ${Math.max(...data)} 出现在第${data.indexOf(Math.max(...data))+1}个月`
);
// 使用 live region 通知屏幕阅读器
const liveRegion = document.getElementById('chart-live-region');
if (liveRegion) {
liveRegion.textContent = `图表已更新,共 ${data.length} 个数据点`;
}
}
</script>
<div id="chart-live-region" role="status" aria-live="polite" class="sr-only"></div>键盘导航支持
class AccessibleChart {
constructor(canvasId, data) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.data = data;
this.focusedIndex = -1;
this.setupKeyboardNav();
this.setupFocusRing();
}
/** 设置键盘导航 */
setupKeyboardNav() {
this.canvas.tabIndex = 0; // 使 Canvas 可聚焦
this.canvas.addEventListener('keydown', (e) => {
switch(e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
this.focusNext();
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
this.focusPrev();
break;
case 'Enter':
case ' ':
e.preventDefault();
this.activateFocused();
break;
case 'Home':
e.preventDefault();
this.focusedIndex = 0;
this.render();
break;
case 'End':
e.preventDefault();
this.focusedIndex = this.data.length - 1;
this.render();
break;
}
});
}
focusNext() {
if (this.focusedIndex < this.data.length - 1) {
this.focusedIndex++;
this.announceFocus();
this.render();
}
}
focusPrev() {
if (this.focusedIndex > 0) {
this.focusedIndex--;
this.announceFocus();
this.render();
}
}
announceFocus() {
// 通过 live region 向屏幕阅读器通报
const item = this.data[this.focusedIndex];
const announcer = document.getElementById('chart-announcer');
if (announcer) {
announcer.textContent =
`第 ${this.focusedIndex + 1} 项,${item.label},数值 ${item.value}`;
}
}
/** 绘制焦点环 */
setupFocusRing() {
this.canvas.addEventListener('focus', () => {
if (this.focusedIndex === -1) this.focusedIndex = 0;
this.render();
});
this.canvas.addEventListener('blur', () => {
this.focusedIndex = -1;
this.render();
});
}
render() {
// ... 正常绘制逻辑 ...
// 绘制焦点指示器
if (this.focusedIndex >= 0) {
const item = this.data[this.focusedIndex];
const x = this.getXPosition(this.focusedIndex);
// 高亮当前项
this.ctx.save();
this.ctx.strokeStyle = '#0066cc';
this.ctx.lineWidth = 3;
this.ctx.setLineDash([5, 5]);
this.ctx.strokeRect(x - 5, 0, 40, this.canvas.height);
// 焦点提示框
this.drawTooltip(x, item);
this.ctx.restore();
}
}
}替代内容策略
| 场景 | 替代方案 | 实现方式 |
|---|---|---|
| 数据图表 | 表格/列表 | <table> 或 <ul> 在 <canvas> 内部 |
| 游戏画面 | 文字描述 | 动态更新 aria-label |
| 图像编辑器 | 操作说明 | 侧边栏面板 + 快捷键列表 |
| 可视化仪表盘 | 数据摘要 | 底部统计卡片 + role="status" |
| 交互式图形 | SVG 降级 | 检测 Canvas 支持并 fallback |
/* 屏幕阅读器专用样式 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 焦点样式 - 确保键盘可见焦点 */
canvas:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}19. 浏览器兼容性
核心API兼容性表格
| API / 特性 | Chrome | Firefox | Safari | Edge | Node.js |
|---|---|---|---|---|---|
| 基础Canvas 2D | 1+ | 1.5+ | 2+ | 12+ | ✅ (canvas 包) |
getContext('2d') | 1+ | 1.5+ | 2+ | 12+ | ✅ |
| Path2D | 36+ | 13+ | 8+ | 12+ | ✅ |
Path2D() 构造函数 | 36+ | 13+ | 8+ | 12+ | ✅ |
Path2D.addPath() | 37+ | 31+ | 11+ | 12+ | ✅ |
| OffscreenCanvas | 69+ | 105+ | 16.4+ | 79+ | ✅ |
transferControlToOffscreen() | 69+ | 96+ (部分) | 16.4+ | 79+ | N/A |
| Worker中的OffscreenCanvas | 69+ | 105+ | 16.4+ | 79+ | ✅ |
| 图像处理 | |||||
createImageBitmap() | 35+ | 42+ | 15+ | 79+ | ✅ |
getImageData() | 1+ | 2+ | 3.1+ | 12+ | ✅ |
putImageData() | 1+ | 2+ | 3.1+ | 12+ | ✅ |
toBlob() | 19+ | 50+ | 11+ | 12+ | ✅ |
| 合成模式 | |||||
globalCompositeOperation | 1+ | 1.5+ | 2+ | 12+ | ✅ |
globalAlpha | 1+ | 1.5+ | 2+ | 12+ | ✅ |
| 滤镜效果 | |||||
filter 属性 | 52+ | 49+ | 17.2+ (部分) | 79+ | ❌ |
| 颜色管理 | |||||
colorSpace 参数 ('display-p3') | 111+ | 114+ | 16.4+ | 111+ | ❌ |
| sRGB | ✅ 默认 | ✅ 默认 | ✅ 默认 | ✅ 默认 | ✅ |
| Hit Testing | |||||
isPointInPath() | 1+ | 2+ | 3.1+ | 12+ | ✅ |
isPointInStroke() | 1+ | 2+ | 6+ | 12+ | ✅ |
| 文本渲染 | |||||
TextMetrics 全属性 | 77+ | 110+ | 14.1+ | 79+ | 部分 |
letterSpacing | 94+ | 118+ | 17.2+ | 94+ | ❌ |
wordSpacing | 94+ | 118+ | 17.2+ | 94+ | ❌ |
| 高级特性 | |||||
ctx.roundRect() | 99+ | 112+ | 17.2+ | 99+ | ❌ |
ctx.ellipse() | 31+ | 48+ | 9+ | 12+ | ✅ |
ctx.conicGradient() | 113+ | 118+ | 17.2+ | 113+ | ❌ |
willReadFrequently | 102+ | 107+ | 17.2+ | 102+ | ❌ |
| WebGL | |||||
| WebGL 1.0 | 9+ | 4+ | 5.1+ | 12+ | ✅ |
| WebGL 2.0 | 56+ | 25+ | 15+ | 79+ | ✅ |
| WebGPU | 113+ (实验性) | ❌ (尚未实现) | 117+ (实验性) | 113+ (实验性) | ❌ |
📊 最后更新: 2024年12月 | 测试环境: 最新稳定版浏览器
Polyfill 与降级方案
/**
* Canvas Feature Detection & Polyfill Loader
* Canvas 特性检测与 Polyfill 加载器
*/
class CanvasCompatibility {
constructor() {
this.features = this.detectFeatures();
}
/** 检测所有 Canvas 相关特性 */
detectFeatures() {
const canvas = document.createElement('canvas');
const ctx = canvas?.getContext('2d');
return {
// 基础支持
basic: !!ctx,
// Path2D 支持
path2D: typeof Path2D !== 'undefined',
// OffscreenCanvas 支持
offscreenCanvas: typeof OffscreenCanvas !== 'undefined',
transferControl: !!canvas?.transferControlToOffscreen,
// 图像处理
imageBitmap: typeof createImageBitmap !== 'function',
toBlob: !!canvas?.toBlob,
// 高级特性
filter: 'filter' in (ctx || {}),
ellipse: typeof ctx?.ellipse === 'function',
roundRect: typeof ctx?.roundRect === 'function',
conicGradient: typeof ctx?.createConicGradient === 'function',
// 颜色管理
colorSpace: (() => {
try {
ctx?.getContextAttributes()?.colorSpace;
return true;
} catch { return false; }
})(),
// Hit Testing
isPointInPath: typeof ctx?.isPointInPath === 'function',
isPointInStroke: typeof ctx?.isPointInStroke === 'function',
// WebGL
webgl: (() => {
try {
return !!canvas?.getContext('webgl2') || !!canvas?.getContext('webgl');
} catch { return false; }
})(),
// WebGPU
webgpu: typeof navigator?.gpu !== 'undefined'
};
}
/** 获取推荐渲染上下文 */
getRecommendedContext(options = {}) {
const { preferWebGL = false, needWorker = false } = options;
// WebGPU 优先(如果可用且需要高性能)
if (this.features.webgpu && !preferWebGL) {
console.log('✅ 推荐使用 WebGPU');
return 'webgpu';
}
// OffscreenCanvas + Worker(需要多线程)
if (needWorker && this.features.offscreenCanvas) {
console.log('✅ 推荐使用 OffscreenCanvas + WebWorker');
return 'offscreen-2d';
}
// WebGL(需要3D或高性能2D)
if (preferWebGL && this.features.webgl) {
const gl2 = document.createElement('canvas').getContext('webgl2');
console.log(`✅ 推荐使用 WebGL ${gl2 ? '2.0' : '1.0'}`);
return gl2 ? 'webgl2' : 'webgl';
}
// 标准 Canvas 2D
console.log('✅ 使用标准 Canvas 2D');
return '2d';
}
/** 打印兼容性报告 */
printReport() {
console.group('📊 Canvas 兼容性报告');
Object.entries(this.features).forEach(([feature, supported]) => {
const icon = supported ? '✅' : '❌';
console.log(`${icon} ${feature}: ${supported}`);
});
console.groupEnd();
return this.features;
}
}
// 使用示例
const compat = new CanvasCompatibility();
compat.printReport();
// 根据兼容性选择策略
if (!compat.features.path2D) {
console.warn('⚠️ 当前浏览器不支持 Path2D,将加载 polyfill');
// import('path2d-polyfill'); // 或手动实现简化版
}
if (!compat.features.offscreenCanvas) {
console.info('ℹ️ 回退到主线程渲染模式');
}20. 最佳实践(Best Practices)
20.1 性能相关实践
✅ 1. 避免阻塞主线程
// ❌ 错误:在主线程执行大量计算
function badRender() {
for (let i = 0; i < 100000; i++) {
complexCalculation(); // 阻塞主线程
}
renderFrame();
}
// ✅ 正确:使用 WebWorker 分离计算
function goodRender() {
worker.postMessage({ type: 'calculate', data: heavyData });
worker.onmessage = (e) => {
renderFrame(e.result);
};
}
// ✅ 更优:使用 requestIdleCallback 执行非关键任务
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && hasMoreWork()) {
doChunkOfWork();
}
});✅ 2. 图像预加载策略
class ImagePreloader {
constructor() {
this.cache = new Map(); // 图像缓存
this.loading = new Map(); // 正在加载的Promise
}
/**
* 预加载单个图像
* @param {string} src - 图像URL
* @returns {Promise<HTMLImageElement>}
*/
async preload(src) {
// 缓存命中
if (this.cache.has(src)) {
return this.cache.get(src);
}
// 防止重复请求
if (this.loading.has(src)) {
return this.loading.get(src);
}
const promise = new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous'; // 启用CORS
img.onload = () => {
this.cache.set(src, img);
this.loading.delete(src);
resolve(img);
};
img.onerror = () => {
this.loading.delete(src);
reject(new Error(`图像加载失败: ${src}`));
};
img.src = src;
});
this.loading.set(src, promise);
return promise;
}
/**
* 批量预加载
* @param {string[]} urls - 图像URL数组
* @param {Function} onProgress - 进度回调 (loaded, total)
*/
async preloadBatch(urls, onProgress) {
let loaded = 0;
await Promise.all(urls.map(async (url) => {
await this.preload(url);
loaded++;
onProgress?.(loaded, urls.length);
}));
return Array.from(this.cache.values());
}
}
// 使用示例
const preloader = new ImagePreloader();
await preloader.preloadBatch(
['sprite1.png', 'sprite2.png', 'background.jpg'],
(loaded, total) => console.log(`加载进度: ${loaded}/${total}`)
);✅ 3. 合理使用 WASM 加速
// 对于像素级操作,WASM 比 JavaScript 快 10-20 倍
class WasmPixelProcessor {
constructor() {
this.wasmModule = null;
this.instance = null;
}
async init() {
// 加载编译好的 WASM 模块(假设已有 image-processor.wasm)
this.wasmModule = await WebAssembly.instantiateStreaming(
fetch('image-processor.wasm'),
{
env: {
memory: new WebAssembly.Memory({ initial: 256 }) // 16MB
}
}
);
this.instance = this.wasmModule.instance;
}
/**
* 使用 WASM 处理像素数据
* @param {ImageData} imageData - 输入像素数据
* @returns {ImageData} 处理后的像素数据
*/
processPixels(imageData) {
if (!this.instance) throw new Error('WASM 未初始化');
const { data, width, height } = imageData;
const process = this.instance.exports.processGrayscale;
// 将像素数据写入 WASM 内存
const wasmMemory = new Uint8Array(
this.instance.exports.memory.buffer,
0,
width * height * 4
);
wasmMemory.set(data);
// 调用 WASM 函数处理
process(0, width, height);
// 取回结果
const result = new ImageData(width, height);
result.data.set(wasmMemory.slice(0, width * height * 4));
return result;
}
}✅ 4. 离屏Canvas选择指南
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 预渲染静态背景 | 普通 document.createElement('canvas') | 简单易用 |
| 大规模粒子系统 | OffscreenCanvas + Worker | 不阻塞UI |
| 实时图像处理 | OffscreenCanvas + WASM | 计算密集型 |
| 复杂路径绘制 | 主线程 Canvas 2D | Path2D API完善 |
| 3D/WebGL应用 | WebGL Context | GPU加速必要 |
| Node.js服务端渲染 | node-canvas 包 | 服务端DOM模拟 |
✅ 5. 纹理压缩与优化
// 对于 WebGL 应用,使用压缩纹理减少内存占用
class TextureManager {
constructor(gl) {
this.gl = gl;
this.textures = new Map();
this.compressionFormats = this.detectCompressionSupport();
}
/** 检测支持的纹理压缩格式 */
detectCompressionSupport() {
const gl = this.gl;
return {
astc: !!gl.getExtension('WEBGL_compressed_texture_astc'),
etc: !!gl.getExtension('WEBGL_compressed_texture_etc1'),
etc2: !!gl.getExtension('WEBGL_compressed_texture_etc'),
ptc: !!gl.getExtension('WEBGL_compressed_texture_pvrtc'),
s3tc: !!gl.getExtension('WEBGL_compressed_texture_s3tc'),
s3tc_srgb: !!gl.getExtension('WEBGL_compressed_texture_s3tc_srgb'),
bc7: !!gl.getExtension('EXT_texture_compression_bptc')
};
}
/**
* 加载最优格式纹理
* @param {string} url - 纹理URL
* @returns {WebGLTexture}
*/
async loadOptimizedTexture(url) {
const gl = this.gl;
const texture = gl.createTexture();
// 尝试按优先级加载压缩纹理
const formatPriority = [
{ ext: '.astc', check: this.compressionFormats.astc },
{ ext: '.bc7', check: this.compressionFormats.bc7 },
{ ext: '.ktx', check: this.compressionFormats.s3tc }, // KTX容器
{ ext: '.png', check: true } // Fallback 到未压缩
];
for (const { ext, check } of formatPriority) {
if (!check) continue;
try {
const compressedUrl = url.replace(/\.\w+$/, ext);
const response = await fetch(compressedUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
this.uploadCompressedTexture(texture, buffer, ext);
return texture;
}
} catch (e) {
console.warn(`尝试 ${ext} 格式失败:`, e);
continue;
}
}
// 最终回退到普通纹理
return this.loadRegularTexture(texture, url);
}
}20.2 代码质量实践
✅ 6. TypeScript 类型安全
// 定义强类型 Canvas 工具类
interface Point2D {
x: number;
y: number;
}
interface RenderOptions {
fillStyle?: string | CanvasGradient | CanvasPattern;
strokeStyle?: string | CanvasGradient | CanvasPattern;
lineWidth?: number;
globalAlpha?: number;
shadowColor?: string;
shadowBlur?: number;
lineCap?: CanvasLineCap;
lineJoin?: CanvasLineJoin;
}
interface AnimationConfig {
duration: number;
easing: (t: number) => number;
onUpdate: (progress: number) => void;
onComplete?: () => void;
}
class TypedCanvasRenderer {
private ctx: CanvasRenderingContext2D;
private width: number;
private height: number;
constructor(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d', {
willReadFrequently: true // 根据用途配置
});
if (!ctx) throw new Error('无法获取 2D 上下文');
this.ctx = ctx;
this.width = canvas.width;
this.height = canvas.height;
}
/** 绘制带类型检查的矩形 */
drawRect(rect: DOMRect, options: RenderOptions): void {
const { ctx } = this;
ctx.save();
this.applyOptions(options);
if (options.fillStyle) {
ctx.fillStyle = options.fillStyle;
ctx.fill(rect.x, rect.y, rect.width, rect.height);
}
if (options.strokeStyle) {
ctx.strokeStyle = options.strokeStyle;
ctx.lineWidth = options.lineWidth ?? 1;
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
}
ctx.restore();
}
/** 类型安全的动画方法 */
animate(config: AnimationConfig): void {
const startTime = performance.now();
const frame = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / config.duration, 1);
const easedProgress = config.easing(progress);
config.onUpdate(easedProgress);
if (progress < 1) {
requestAnimationFrame(frame);
} else {
config.onComplete?.();
}
};
requestAnimationFrame(frame);
}
private applyOptions(options: RenderOptions): void {
const { ctx } = this;
Object.entries(options).forEach(([key, value]) => {
if (value !== undefined && key in ctx) {
(ctx as any)[key] = value;
}
});
}
}✅ 7. 错误边界与恢复机制
/**
* Canvas Error Boundary
* Canvas 错误边界包装器 - 自动捕获渲染错误并提供恢复选项
*/
class CanvasErrorBoundary {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.options = {
maxRetries: 3,
retryDelay: 1000,
fallbackRenderer: null,
onError: console.error,
...options
};
this.retryCount = 0;
this.isRecovering = false;
this.lastError = null;
this.setupGlobalHandler();
}
/** 设置全局错误捕获 */
setupGlobalHandler() {
this.boundErrorHandler = this.handleError.bind(this);
window.addEventListener('error', this.boundErrorHandler);
}
/** 包装渲染函数 */
wrapRender(renderFn) {
return (...args) => {
try {
if (this.isRecovering) {
this.renderFallback();
return;
}
renderFn.apply(this, args);
this.retryCount = 0; // 成功后重置计数
} catch (error) {
this.handleError(error);
}
};
}
/** 错误处理逻辑 */
handleError(error) {
this.lastError = error;
this.options.onError(error);
// 检查是否是可恢复的错误
if (this.isRecoverable(error) && this.retryCount < this.options.maxRetries) {
this.scheduleRetry();
} else {
this.enterRecoveryMode();
}
}
/** 判断错误是否可恢复 */
isRecoverable(error) {
const recoverablePatterns = [
/OutOfMemoryError/i,
/ContextLost/i,
/WebGL: context lost/i,
/null is not an object/i // 临时性空引用
];
return recoverablePatterns.some(pattern => pattern.test(error.message));
}
/** 安排重试 */
scheduleRetry() {
this.isRecovering = true;
this.retryCount++;
console.warn(`⚠️ Canvas 渲染失败,第 ${this.retryCount} 次重试...`);
setTimeout(() => {
this.isRecovering = false;
// 重新初始化上下文
this.reinitializeContext();
}, this.options.retryDelay * this.retryCount); // 指数退避
}
/** 重新初始化上下文 */
reinitializeContext() {
try {
// 尝试重新获取上下文
const newCtx = this.canvas.getContext('2d');
if (newCtx) {
this.ctx = newCtx;
console.log('✅ Canvas 上下文已重新初始化');
}
} catch (e) {
console.error('❌ 上下文重新初始化失败:', e);
}
}
/** 进入恢复模式 - 显示错误信息 */
enterRecoveryMode() {
this.isRecovering = true;
console.error('❌ Canvas 进入恢复模式,显示备用渲染');
if (this.options.fallbackRenderer) {
this.options.fallbackRenderer(this.ctx, this.lastError);
} else {
this.renderDefaultErrorScreen();
}
}
/** 默认错误界面 */
renderDefaultErrorScreen() {
const { ctx, canvas } = this;
ctx.save();
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#e94560';
ctx.font = 'bold 24px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('⚠️ 渲染出现异常', canvas.width / 2, canvas.height / 2 - 30);
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('请刷新页面或稍后重试', canvas.width / 2, canvas.height / 2 + 10);
ctx.font = '12px monospace';
ctx.fillStyle = '#666';
ctx.fillText(this.lastError?.message || '未知错误', canvas.width / 2, canvas.height / 2 + 40);
ctx.restore();
}
/** 清理资源 */
destroy() {
window.removeEventListener('error', this.boundErrorHandler);
this.ctx = null;
}
}
// 使用示例
const boundary = new CanvasErrorBoundary(canvas, {
maxRetries: 3,
onError: (err) => trackError('canvas_render', err),
fallbackRenderer: (ctx, err) => {
// 自定义备用渲染
}
});
// 包装你的渲染函数
const safeRender = boundary.wrapRender(function renderScene() {
// 你的正常渲染逻辑...
});20.3 架构设计实践
✅ 8. MVC/MVVM 分离
/**
* Canvas MVVM Architecture Example
* Canvas MVVM 架构示例 - 将视图、数据、逻辑分离
*/
// Model: 数据层
class ChartDataModel {
constructor() {
this._data = [];
this._listeners = [];
this._metadata = {
title: '',
unit: '',
lastUpdated: null
};
}
// 观察者模式
subscribe(listener) {
this._listeners.push(listener);
return () => {
this._listeners = this._listeners.filter(l => l !== listener);
};
}
notify(type, payload) {
this._listeners.forEach(listener => listener(type, payload));
}
set data(newData) {
this._data = newData;
this._metadata.lastUpdated = new Date();
this.notify('dataChange', newData);
}
get data() { return this._data; }
updateValue(index, value) {
if (index >= 0 && index < this._data.length) {
this._data[index] = { ...this._data[index], value };
this.notify('itemUpdate', { index, value });
}
}
}
// View: 视图层(Canvas渲染)
class ChartCanvasView {
constructor(canvas, model) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.model = model;
this.animationQueue = [];
// 监听模型变化自动重绘
this.unsubscribe = model.subscribe((type, payload) => {
this.handleModelChange(type, payload);
});
}
handleModelChange(type, payload) {
switch (type) {
case 'dataChange':
this.animateTransition(payload);
break;
case 'itemUpdate':
this.updateSingleItem(payload.index, payload.value);
break;
}
}
render() {
// 根据 model.data 渲染
this.clear();
this.drawAxes();
this.drawDataSeries(this.model.data);
}
destroy() {
this.unsubscribe();
}
}
// ViewModel: 视图模型层
class ChartViewModel {
constructor(model) {
this.model = model;
this.selectedItem = null;
this.hoveredItem = null;
this.filterRange = null;
}
// 计算属性:过滤后的数据
get filteredData() {
let data = this.model.data;
if (this.filterRange) {
data = data.slice(this.filterRange.start, this.filterRange.end);
}
return data;
}
// 用户交互处理
selectItem(index) {
this.selectedItem = index;
// 可以在这里触发额外的业务逻辑
}
// 数据转换:为视图准备格式化数据
get chartDimensions() {
const data = this.filteredData;
return {
maxValue: Math.max(...data.map(d => d.value)),
minValue: Math.min(...data.map(d => d.value)),
itemCount: data.length
};
}
}✅ 9. 状态管理集成(Redux/Vuex/Pinia)
/**
* Canvas + Redux Store Integration
* Canvas 与状态管理库集成示例
*/
// Redux Action Types
const CANVAS_ACTIONS = {
ADD_OBJECT: 'canvas/addObject',
REMOVE_OBJECT: 'canvas/removeObject',
UPDATE_OBJECT: 'canvas/updateObject',
SET_SELECTION: 'canvas/setSelection',
SET_VIEWPORT: 'canvas/setViewport',
UNDO: 'canvas/undo',
REDO: 'canvas/redo'
};
// Redux Reducer
function canvasReducer(state = initialState, action) {
switch (action.type) {
case CANVAS_ACTIONS.ADD_OBJECT:
return {
...state,
objects: [...state.objects, action.payload],
history: [...state.history, state.objects]
};
case CANVAS_ACTIONS.UPDATE_OBJECT:
return {
...state,
objects: state.objects.map(obj =>
obj.id === action.payload.id
? { ...obj, ...action.payload.changes }
: obj
)
};
case CANVAS_ACTIONS.UNDO:
if (state.history.length > 0) {
const previous = state.history[state.history.length - 1];
return {
...state,
objects: previous,
history: state.history.slice(0, -1)
};
}
return state;
default:
return state;
}
}
// Canvas 组件连接 Redux
class ReduxCanvas {
constructor(store, canvasElement) {
this.store = store;
this.canvas = canvasElement;
this.ctx = canvasElement.getContext('2d');
this.previousState = null;
// 订阅 store 变化
this.unsubscribe = store.subscribe(() => this.onStoreChange());
// 初始渲染
this.render();
}
onStoreChange() {
const currentState = this.store.getState().canvas;
// 浅比较避免不必要的重绘
if (currentState !== this.previousState) {
this.previousState = currentState;
this.render();
}
}
render() {
const { objects, viewport, selection } = this.store.getState().canvas;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.applyViewportTransform(viewport);
objects.forEach(obj => {
this.drawObject(obj, obj.id === selection);
});
}
dispatch(action) {
this.store.dispatch(action);
}
destroy() {
this.unsubscribe();
}
}✅ 10. 单元测试策略
/**
* Canvas Unit Testing with Jest + jsdom
* Canvas 单元测试示例(使用 Jest + jsdom)
*/
// 安装依赖: npm install --save-dev jest jest-canvas-mock
describe('CanvasRenderer', () => {
let canvas, ctx, renderer;
beforeEach(() => {
// jest-canvas-mock 自动 mock Canvas API
canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 600;
ctx = canvas.getContext('2d');
renderer = new CanvasRenderer(canvas);
});
test('应正确初始化画布尺寸', () => {
expect(renderer.width).toBe(800);
expect(renderer.height).toBe(600);
});
test('drawRect 应调用正确的绑定方法', () => {
renderer.drawRect(10, 20, 100, 50, { fill: '#ff0000' });
// 验证 Canvas 方法被正确调用
expect(ctx.beginPath).toHaveBeenCalled();
expect(ctx.rect).toHaveBeenCalledWith(10, 20, 100, 50);
expect(ctx.fillStyle).toBe('#ff0000');
expect(ctx.fill).toHaveBeenCalled();
});
test('clear 应清除整个画布', () => {
renderer.clear();
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 800, 600);
});
test('变换操作应可堆叠和恢复', () => {
renderer.save();
renderer.translate(50, 50);
renderer.rotate(Math.PI / 4);
renderer.drawRect(0, 0, 30, 30);
renderer.restore();
expect(ctx.save).toHaveBeenCalledTimes(1);
expect(ctx.translate).toHaveBeenCalledWith(50, 50);
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
expect(ctx.restore).toHaveBeenCalledTimes(1);
});
test('边界条件:超出画布范围的绘制不应报错', () => {
expect(() => {
renderer.drawRect(-100, -100, 2000, 2000);
}).not.toThrow();
});
describe('Path2D 操作', () => {
test('复杂路径应正确构建', () => {
const path = renderer.createComplexPath([
{ type: 'moveTo', x: 0, y: 0 },
{ type: 'lineTo', x: 100, y: 100 },
{ type: 'quadraticCurveTo', cpX: 150, cpY: 50, x: 200, y: 100 }
]);
expect(path).toBeInstanceOf(Path2D);
});
});
describe('性能监控', () => {
test('大量绘制操作应在合理时间内完成', () => {
const startTime = performance.now();
for (let i = 0; i < 1000; i++) {
renderer.drawRect(
Math.random() * 800,
Math.random() * 600,
10,
10
);
}
const endTime = performance.now();
expect(endTime - startTime).toBeLessThan(100); // 100ms内完成
});
});
});
// 集成测试:验证完整渲染流程
describe('Chart Integration Tests', () => {
test('完整图表渲染流程', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const chart = new BarChart(container, {
data: [10, 25, 18, 30, 22, 15, 28]
});
chart.render();
// 验证 DOM 结构
expect(container.querySelector('canvas')).toBeTruthy();
// 验证绘制调用
const ctx = chart.ctx;
expect(ctx.fillRect).toHaveBeenCalledTimes(7); // 7个柱子
chart.destroy();
});
});21. FAQ 常见问题解答
Q1: Canvas 绘制的文字/线条为什么模糊?
原因分析:
解决方案代码:
// ✅ 完整的高分辨率Canvas设置
function setupHiDPICanvas(canvas, width, height) {
const dpr = window.devicePixelRatio || 1;
// 设置实际像素尺寸(CSS显示尺寸 × DPR)
canvas.width = width * dpr;
canvas.height = height * dpr;
// CSS尺寸保持逻辑值
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 缩放上下文以匹配DPR
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return { ctx, dpr, logicalWidth: width, logicalHeight: height };
}
// 使用
const { ctx } = setupHiDPICanvas(myCanvas, 800, 600);
// 之后所有绘制都使用逻辑坐标,自动高清Q2: 什么时候应该用 OffscreenCanvas?浏览器支持如何?
决策指南:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 简单动画/图表 | ❌ 普通Canvas | 开销更小 |
| 1000+粒子系统 | ✅ OffscreenCanvas | 不阻塞主线程 |
| 实时视频处理 | ✅ OffscreenCanvas + WASM | 计算密集 |
| 需要 getImageData 频繁读取 | ❌ 普通Canvas + willReadFrequently | Worker中读取有延迟 |
| Node.js 服务端渲染 | ✅ OffscreenCanvas | 无DOM依赖 |
浏览器支持现状(2024):
// 特性检测
function checkOffscreenCanvasSupport() {
const support = {
basic: typeof OffscreenCanvas !== 'undefined',
inWorker: false, // 需要在Worker中测试
transferControl: false,
toBlob: false
};
if (support.basic) {
const canvas = new OffscreenCanvas(1, 1);
support.transferControl = typeof canvas.transferControlToOffscreen === 'function';
support.toBlob = typeof canvas.toBlob === 'function';
}
console.table(support);
return support;
}
// Polyfill 方案(不支持的浏览器)
if (typeof OffscreenCanvas === 'undefined') {
console.warn('当前浏览器不支持 OffscreenCanvas,回退到主线程模式');
// 使用普通Canvas替代,或将计算移至Worker但通过postMessage传递数据
}Q3: 如何定位 Canvas 性能瓶颈?
诊断步骤:
实用诊断工具:
class CanvasPerformanceAnalyzer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.metrics = {
frameTimes: [],
drawCalls: 0,
stateChanges: 0,
memoryUsage: []
};
this.originalMethods = this.hookMethods();
}
/** Hook核心方法收集指标 */
hookMethods() {
const ctx = this.ctx;
const self = this;
const originals = {};
// Hook 绑定方法
const methodsToHook = [
'fillRect', 'strokeRect', 'fill', 'stroke',
'drawImage', 'putImageData', 'fillText', 'strokeText',
'clearRect', 'beginPath', 'moveTo', 'lineTo', 'arc',
'save', 'restore', 'translate', 'rotate', 'scale'
];
methodsToHook.forEach(method => {
originals[method] = ctx[method];
ctx[method] = function(...args) {
self.metrics.drawCalls++;
return originals[method].apply(this, args);
};
});
// Hook 状态修改
const stateProps = ['fillStyle', 'strokeStyle', 'globalAlpha',
'font', 'lineWidth', 'shadowBlur'];
stateProps.forEach(prop => {
let descriptor = Object.getOwnPropertyDescriptor(
CanvasRenderingContext2D.prototype, prop
);
if (descriptor && descriptor.set) {
const originalSet = descriptor.set;
Object.defineProperty(ctx, prop, {
set: function(value) {
self.metrics.stateChanges++;
originalSet.call(this, value);
},
get: descriptor.get,
configurable: true
});
}
});
return originals;
}
/** 开始记录一帧 */
beginFrame() {
this.frameStart = performance.now();
this.metrics.drawCalls = 0;
this.metrics.stateChanges = 0;
}
/** 结束记录一帧 */
endFrame() {
const frameTime = performance.now() - this.frameStart;
this.metrics.frameTimes.push(frameTime);
// 保留最近60帧数据
if (this.metrics.frameTimes.length > 60) {
this.metrics.frameTimes.shift();
}
// 每60帧输出一次报告
if (this.metrics.frameTimes.length % 60 === 0) {
this.printReport();
}
}
/** 打印性能报告 */
printReport() {
const times = this.metrics.frameTimes;
const avg = times.reduce((a, b) => a + b, 0) / times.length;
const fps = 1000 / avg;
const min = Math.min(...times);
const max = Math.max(...times);
console.group('📊 Canvas 性能报告');
console.log(`FPS: ${fps.toFixed(1)} (目标: 60)`);
console.log(`平均帧时间: ${avg.toFixed(2)}ms`);
console.log(`最短/最长帧: ${min.toFixed(2)}ms / ${max.toFixed(2)}ms`);
console.log(`每帧绘制调用: ${this.metrics.drawCalls}`);
console.log(`每帧状态切换: ${this.metrics.stateChanges}`);
// 内存信息(如果可用)
if (performance.memory) {
const mb = bytes => (bytes / 1024 / 1024).toFixed(2);
console.log(`JS堆内存: ${mb(performance.memory.usedJSHeapSize)}MB / ${mb(performance.memory.jsHeapSizeLimit)}MB`);
}
// 性能建议
if (fps < 30) {
console.warn('⚠️ FPS过低!建议:');
if (this.metrics.drawCalls > 500) console.warn(' - 绘制调用过多,考虑批量合并');
if (this.metrics.stateChanges > 100) console.warn(' - 状态切换频繁,按状态分组绘制');
}
console.groupEnd();
}
}Q4: WebGL 上下文丢失怎么办?
原因与解决方案:
class WebGLContextHandler {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.options = options;
this.gl = null;
this.resources = []; // 跟踪GPU资源
this.init();
}
init() {
// 优先获取 WebGL2
this.gl = this.canvas.getContext('webgl2', this.options) ||
this.canvas.getContext('webgl', this.options);
if (!this.gl) {
throw new Error('WebGL 不可用');
}
// 注册上下文丢失事件
this.canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // 阻止默认行为
console.warn('⚠️ WebGL 上下文丢失!');
this.handleContextLost();
}, false);
// 注册上下文恢复事件
this.canvas.addEventListener('webglcontextrestored', () => {
console.log('✅ WebGL 上下文已恢复');
this.handleContextRestored();
}, false);
// 强制提前丢失用于测试(仅开发环境)
if (process.env.NODE_ENV === 'development') {
window.__loseWebGLContext = () => {
const loseCtxExt = this.gl.getExtension('WEBGL_lose_context');
if (loseCtxExt) {
loseCtxExt.loseContext();
}
};
window.__restoreWebGLContext = () => {
const loseCtxExt = this.gl.getExtension('WEBGL_lose_context');
if (loseCtxExt) {
loseCtxExt.restoreContext();
}
};
}
}
handleContextLost() {
this.isContextLost = true;
// 停止渲染循环
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
// 通知上层应用
this.onContextLost?.();
// 显示恢复提示UI
this.showRecoveryUI();
}
handleContextRestored() {
this.isContextLost = false;
// 重新初始化WebGL状态
this.initializeState();
// 重新上传所有GPU资源
this.reloadResources();
// 恢复渲染循环
this.startRenderLoop();
// 隐藏恢复提示
this.hideRecoveryUI();
this.onContextRestored?.();
}
/** 初始化WebGL状态(viewport、清除色等) */
initializeState() {
const gl = this.gl;
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
gl.clearColor(0, 0, 0, 1);
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// ...其他状态初始化
}
/** 重新加载所有GPU资源 */
reloadResources() {
// 重新编译着色器程序
this.shaderPrograms.forEach(program => this.compileProgram(program));
// 重新创建缓冲区和纹理
this.buffers.forEach(buffer => this.createBuffer(buffer));
this.textures.forEach(tex => this.loadTexture(tex));
}
showRecoveryUI() {
const ctx = this.canvas.getContext('2d'); // 临时使用2d上下文
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🔄 GPU上下文丢失,正在恢复...',
this.canvas.width / 2, this.canvas.height / 2);
}
hideRecoveryUI() {
// 切换回WebGL上下文后会自动清除
}
// 资源注册(用于跟踪和重建)
registerShader(program) { this.shaderPrograms.push(program); }
registerBuffer(buffer) { this.buffers.push(buffer); }
registerTexture(texture) { this.textures.push(texture); }
}常见触发上下文丢失的原因:
- GPU进程崩溃(驱动bug、过热、显存不足)
- 多个标签页竞争GPU资源
- 切换显卡(集显/独显切换)
- VRAM耗尽(创建过大纹理/缓冲区)
Q5: Canvas 内存泄漏如何排查?
常见泄漏场景与排查工具:
/**
* Canvas Memory Leak Detector
* Canvas 内存泄漏检测器
*/
class MemoryLeakDetector {
constructor(canvas) {
this.canvas = canvas;
this.snapshots = [];
this.isRecording = false;
this.intervalId = null;
}
/** 开始录制内存快照 */
startRecording(intervalMs = 5000) {
if (!performance.memory) {
console.warn('⚠️ 当前浏览器不支持 performance.memory API');
console.info('建议在 Chrome 中启用: --enable-precise-memory-info');
return;
}
this.isRecording = true;
this.snapshots = [];
// 定期采集快照
this.intervalId = setInterval(() => {
this.takeSnapshot();
}, intervalMs);
console.log('🔍 内存泄漏检测已启动...');
}
stopRecording() {
this.isRecording = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.analyze();
}
takeSnapshot() {
const snapshot = {
timestamp: Date.now(),
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
canvasMemory: this.estimateCanvasMemory()
};
this.snapshots.push(snapshot);
// 实时显示当前内存
const mb = bytes => (bytes / 1024 / 1024).toFixed(1);
console.log(
`[${new Date().toLocaleTimeString()}] ` +
`堆内存: ${mb(snapshot.usedJSHeapSize)}MB | ` +
`Canvas估算: ${mb(snapshot.canvasMemory)}MB`
);
}
/** 估算Canvas占用的内存 */
estimateCanvasMemory() {
const { width, height } = this.canvas;
// RGBA 4字节/像素
return width * height * 4;
}
/** 分析泄漏趋势 */
analyze() {
if (this.snapshots.length < 2) {
console.log('数据不足,至少需要2个快照');
return;
}
console.group('📈 内存泄漏分析报告');
const first = this.snapshots[0];
const last = this.snapshots[this.snapshots.length - 1];
const duration = (last.timestamp - first.timestamp) / 1000; // 秒
const heapGrowth = last.usedJSHeapSize - first.usedJSHeapSize;
const growthRate = heapGrowth / duration; // 字节/秒
console.log(`监测时长: ${duration.toFixed(1)}秒`);
console.log(`初始堆内存: ${(first.usedJSHeapSize / 1024 / 1024).toFixed(1)}MB`);
console.log(`最终堆内存: ${(last.usedJSHeapSize / 1024 / 1024).toFixed(1)}MB`);
console.log(`净增长: ${(heapGrowth / 1024 / 1024).toFixed(1)}MB`);
console.log(`增长速率: ${(growthRate / 1024).toFixed(1)}KB/s`);
// 判断是否存在泄漏
if (growthRate > 50 * 1024) { // >50KB/s
console.error('🚨 检测到可能的内存泄漏!增长速度异常');
console.error('建议排查:');
console.error(' 1. 是否有定时器未清除?');
console.error(' 2. 事件监听器是否正确移除?');
console.error(' 3. 闭包是否持有大对象引用?');
console.error(' 4. ImageData/canvas是否及时释放?');
console.error(' 5. 是否存在循环引用?');
} else if (growthRate > 0) {
console.warn('⚠️ 存在轻微内存增长,可能是GC未及时回收');
} else {
console.log('✅ 未检测到明显内存泄漏');
}
// 打印详细时间线
console.table(this.snapshots.map(s => ({
时间: new Date(s.timestamp).toLocaleTimeString(),
'堆内存(MB)': (s.usedJSHeapSize / 1024 / 1024).toFixed(1),
'Canvas(MB)': (s.canvasMemory / 1024 / 1024).toFixed(1)
})));
console.groupEnd();
}
}
// 使用示例
const detector = new MemoryLeakDetector(myCanvas);
detector.startRecording(3000); // 每3秒采样
// 运行一段时间后...
setTimeout(() => detector.stopRecording(), 60000); // 1分钟后停止分析常见Canvas内存泄漏清单:
| 泄漏类型 | 示例代码 | 修复方案 |
|---|---|---|
| 定时器未清除 | setInterval(render, 16) | clearInterval(id) 在destroy时 |
| 事件监听器残留 | canvas.addEventListener('click', fn) | removeEventListener 或 AbortController |
| 闭包引用大对象 | function() { ctx.drawImage(hugeImage) } | 显式设为null |
| ImageData堆积 | ctx.getImageData() 循环中 | 复用单个ImageData对象 |
| Canvas元素堆积 | 循环中 document.createElement('canvas') | 使用对象池复用 |
| WebGL资源未释放 | Buffer/Texture创建后从未delete | 维护资源列表统一dispose |
Q6: requestAnimationFrame 动画卡顿怎么办?
系统性解决方案:
/**
* Robust Animation Loop
* 健壮的动画循环实现 - 解决卡顿、掉帧、后台标签页等问题
*/
class SmoothAnimationLoop {
constructor(options = {}) {
this.targetFPS = options.targetFPS || 60;
this.frameInterval = 1000 / this.targetFPS;
this.callback = options.callback;
this.useWorker = options.useWorker || false;
this.lastTime = 0;
this.accumulatedTime = 0;
this.animationId = null;
this.isRunning = false;
this.fpsHistory = [];
this.currentFPS = 0;
// 自适应帧率
this.adaptiveFPS = options.adaptiveFPS || false;
this.minFPS = options.minFPS || 15;
this.maxFrameSkip = options.maxFrameSkip || 5;
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = performance.now();
this.loop(this.lastTime);
}
stop() {
this.isRunning = false;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
loop(currentTime) {
if (!this.isRunning) return;
this.animationId = requestAnimationFrame((t) => this.loop(t));
// 计算delta时间(限制最大跳跃,防止切回标签页时的巨大delta)
let deltaTime = currentTime - this.lastTime;
deltaTime = Math.min(deltaTime, 100); // 最大100ms
this.lastTime = currentTime;
// 累积时间(固定时间步长)
this.accumulatedTime += deltaTime;
// 固定步长更新(物理稳定性)
let updateCount = 0;
while (this.accumulatedTime >= this.frameInterval) {
this.callback(this.frameInterval / 1000, this.currentFPS);
this.accumulatedTime -= this.frameInterval;
updateCount++;
// 防止死循环(极端情况跳过多帧)
if (updateCount >= this.maxFrameSkip) {
this.accumulatedTime = 0;
break;
}
}
// 计算实际FPS
this.updateFPSCounter(currentTime);
// 自适应帧率调整
if (this.adaptiveFPS) {
this.adjustFrameRate();
}
}
updateFPSCounter(time) {
this.fpsHistory.push(time);
// 保留最近1秒的数据
const oneSecondAgo = time - 1000;
while (this.fpsHistory.length > 0 && this.fpsHistory[0] < oneSecondAgo) {
this.fpsHistory.shift();
}
this.currentFPS = this.fpsHistory.length;
}
adjustFrameRate() {
if (this.currentFPS < this.minFPS) {
// 降低画质/粒子数量等
this.onPerformanceDegraded?.(this.currentFPS);
console.warn(`FPS降至 ${this.currentFPS},触发降级策略`);
}
}
getStats() {
return {
currentFPS: this.currentFPS,
targetFPS: this.targetFPS,
isRunning: this.isRunning,
accumulatedLag: this.accumulatedTime
};
}
}
// 使用示例
const animLoop = new SmoothAnimationLoop({
targetFPS: 60,
adaptiveFPS: true,
callback: (delta, fps) => {
game.update(delta);
game.render();
},
onPerformanceDegraded: (currentFPS) => {
// 降低粒子数量
particleSystem.halfEmissionRate();
// 降低渲染距离
renderer.reduceDrawDistance();
// 关闭特效
effects.disablePostProcessing();
}
});
animLoop.start();Q7: Canvas 尺寸有限制吗?最大能画多大?
各浏览器限制:
| 浏览器 | 最大宽度 | 最大高度 | 最大面积 | 备注 |
|---|---|---|---|---|
| Chrome | 16384 | 16384 | 268M px | 超出会抛出异常 |
| Firefox | 11111 | 11111 | 123M px | 超出静默失败 |
| Safari | 16384 | 16384 | 268M px | 类似Chrome |
| Edge (Chromium) | 16384 | 16384 | 268M px | 与Chrome一致 |
| Mobile Chrome | 4096 | 4096 | 16M px | 移动端更保守 |
/**
* Safe Canvas Size Manager
* 安全的Canvas尺寸管理器 - 自动检测和适配限制
*/
class CanvasSizeManager {
static LIMITS = {
default: { maxWidth: 8192, maxHeight: 8192, maxArea: 16777216 }, // 16M
mobile: { maxWidth: 4096, maxHeight: 4096, maxArea: 16777216 }
};
/**
* 计算安全的Canvas尺寸
* @param {number} requestedWidth - 期望宽度
* @param {number} requestedHeight - 期望高度
* @returns {{ width: number, height: number, scaled: boolean, scale: number }}
*/
static calculateSafeSize(requestedWidth, requestedHeight) {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const limits = isMobile ? CanvasSizeManager.LIMITS.mobile : CanvasSizeManager.LIMITS.default;
let width = requestedWidth;
let height = requestedHeight;
let scaled = false;
let scale = 1;
// 检查单边限制
if (width > limits.maxWidth) {
scale = Math.min(scale, limits.maxWidth / width);
scaled = true;
}
if (height > limits.maxHeight) {
scale = Math.min(scale, limits.maxHeight / height);
scaled = true;
}
// 检查总面积限制
const area = width * height;
if (area > limits.maxArea) {
const areaScale = Math.sqrt(limits.maxArea / area);
scale = Math.min(scale, areaScale);
scaled = true;
}
if (scaled) {
width = Math.floor(requestedWidth * scale);
height = Math.floor(requestedHeight * scale);
console.warn(
`⚠️ Canvas尺寸已缩放: ${requestedWidth}×${requestedHeight} → ${width}×${height} (${(scale*100).toFixed(1)}%)`
);
}
return { width, height, scaled, scale };
}
/**
* 创建安全尺寸的Canvas
*/
static createSafeCanvas(width, height) {
const { width: safeWidth, height: safeHeight, scaled, scale } =
CanvasSizeManager.calculateSafeSize(width, height);
const canvas = document.createElement('canvas');
try {
canvas.width = safeWidth;
canvas.height = safeHeight;
// 验证是否真正设置成功
if (canvas.width === 0 || canvas.height === 0) {
throw new Error('Canvas尺寸设置失败');
}
// 测试是否能获取上下文
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取渲染上下文');
}
return { canvas, ctx, scaled, scale, originalSize: { width, height } };
} catch (error) {
console.error('Canvas创建失败:', error.message);
// 进一步降低尺寸重试
return CanvasSizeManager.createSafeCanvas(
Math.floor(safeWidth * 0.5),
Math.floor(safeHeight * 0.5)
);
}
}
}
// 使用示例
const { canvas, ctx, scaled } = CanvasSizeManager.createSafeCanvas(12000, 8000);
console.log(`实际创建尺寸: ${canvas.width}×${canvas.height}, 是否缩放: ${scaled}`);Q8: 如何在 Canvas 中处理高 DPI (Retina) 屏幕?
完整 Retina 处理方案:
class RetinaCanvas {
constructor(container, baseWidth, baseHeight) {
this.container = container;
this.baseWidth = baseWidth;
this.baseHeight = baseHeight;
this.dpr = window.devicePixelRatio || 1;
this.canvas = document.createElement('canvas');
this.container.appendChild(this.canvas);
this.setupRetina();
this.bindResizeEvent();
}
setupRetina() {
const { canvas, dpr, baseWidth, baseHeight } = this;
// 设置物理像素尺寸
canvas.width = baseWidth * dpr;
canvas.height = baseHeight * dpr;
// 设置CSS显示尺寸
canvas.style.width = `${baseWidth}px`;
canvas.style.height = `${baseHeight}px`;
// 获取上下文并缩放
this.ctx = canvas.getContext('2d');
this.ctx.scale(dpr, dpr);
// 存储逻辑尺寸供后续使用
this.logicalWidth = baseWidth;
this.logicalHeight = baseHeight;
}
bindResizeEvent() {
// 监听DPR变化(某些设备在窗口移动时会改变DPR)
let lastDPR = this.dpr;
const mediaQuery = window.matchMedia(`(resolution: ${lastDPR}dppx)`);
mediaQuery.addEventListener('change', (e) => {
const newDPR = window.devicePixelRatio || 1;
if (newDPR !== lastDPR) {
console.log(`DPR变化: ${lastDPR} → ${newDPR}`);
lastDPR = newDPR;
this.dpr = newDPR;
this.setupRetina(); // 重新初始化
this.onResize?.(this.logicalWidth, this.logicalHeight);
}
});
// 窗口resize
window.addEventListener('resize', () => {
this.handleResize();
});
}
handleResize() {
const parentRect = this.container.getBoundingClientRect();
this.baseWidth = parentRect.width;
this.baseHeight = parentRect.height;
this.setupRetina();
this.onResize?.(this.logicalWidth, this.logicalHeight);
}
// 所有绘制方法使用逻辑坐标
drawCircle(x, y, radius) {
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.fill();
}
// 获取鼠标的逻辑坐标(处理DPR)
getLogicalCoordinates(event) {
const rect = this.canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
// 注意:因为已经scale了DPR,所以不需要再除以DPR
};
}
destroy() {
this.canvas.remove();
}
}Q9: Canvas 的 getImageData 跨域问题怎么解决?
完整跨域解决方案矩阵:
代码实现:
/**
* Cross-Origin Image Handler
* 跨域图像处理器 - 提供多种解决方案
*/
class CrossOriginImageLoader {
constructor(options = {}) {
this.proxyUrl = options.proxyUrl; // 代理服务器地址
this.cache = new Map();
}
/**
* 方案1: 标准CORS加载
* 要求: 服务器返回 Access-Control-Allow-Origin 头
*/
async loadWithCORS(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous'; // 关键!
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`CORS加载失败: ${url}`));
img.src = url;
});
}
/**
* 方案2: 通过代理服务器
* 适用: 无法控制目标服务器CORS配置
*/
async loadViaProxy(url) {
if (!this.proxyUrl) {
throw new Error('未配置代理服务器URL');
}
const proxyUrl = `${this.proxyUrl}?url=${encodeURIComponent(url)}`;
return this.loadWithCORS(proxyUrl); // 代理是同源的
}
/**
* 方案3: Fetch + Blob(现代浏览器推荐)
* 原理: fetch API遵循CORS,blob URL是同源的
*/
async loadAsBlob(url) {
const response = await fetch(url, {
mode: 'cors',
credentials: 'omit'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${url}`);
}
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
return bitmap; // ImageBitmap 自动同源
}
/**
* 方案4: 后端转换为Data URL
* 适用: 完全无法前端解决的场景
*/
async loadAsDataURL(apiEndpoint, imageUrl) {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: imageUrl })
});
const { dataURL } = await response.json();
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = reject;
img.src = dataURL; // Data URL天然同源
});
}
/**
* 智能加载:自动选择最佳方案
*/
async smartLoad(url) {
// 检查缓存
if (this.cache.has(url)) {
return this.cache.get(url);
}
let image;
// 尝试顺序: Blob > CORS > Proxy
try {
image = await this.loadAsBlob(url);
console.log(`✅ [Blob] 成功加载: ${url}`);
} catch (blobErr) {
try {
image = await this.loadWithCORS(url);
console.log(`✅ [CORS] 成功加载: ${url}`);
} catch (corsErr) {
if (this.proxyUrl) {
image = await this.loadViaProxy(url);
console.log(`✅ [Proxy] 成功加载: ${url}`);
} else {
throw new Error(`所有方案均失败: ${url}`);
}
}
}
this.cache.set(url, image);
return image;
}
/**
* 安全地绘制图像到Canvas并允许后续getImageData
*/
async safeDrawToCanvas(ctx, url, x, y, width, height) {
const image = await this.smartLoad(url);
ctx.drawImage(image, x, y, width, height);
// 验证Canvas未被污染
try {
const testData = ctx.getImageData(0, 0, 1, 1);
console.log('✅ Canvas未受污染,可以正常使用getImageData');
return true;
} catch (e) {
console.error('❌ Canvas已被污染:', e.message);
return false;
}
}
}
// 使用示例
const loader = new CrossOriginImageLoader({
proxyUrl: '/api/image-proxy' // 你的代理接口
});
// 绘制并保证可读取像素
await loader.safeDrawToCanvas(ctx, 'https://example.com/image.png', 0, 0, 400, 300);
const pixelData = ctx.getImageData(0, 0, 400, 300); // 不会报错22. 参考资料(Resources)
官方规范与文档
| 资源 | 链接 | 说明 |
|---|---|---|
| MDN Canvas API | developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API | 最权威的中文参考文档 |
| W3C Canvas 2D 规范 | www.w3.org/TR/2dcontext/ | Canvas 2D Context Level 1 官方规范 |
| HTML Living Standard - Canvas | html.spec.whatwg.org/multipage/canvas.html | WHATWG HTML标准中Canvas部分 |
| WebGL 规范 | www.khronos.org/webgl/ | Khronos WebGL官方规范 |
| WebGPU 规范 | gpuweb.github.io/gpuweb/ | W3C WebGPU工作草案 |
| OffscreenCanvas 规范 | html.spec.whatwg.org/multipage/canvas.html#offscreencanvas | OffscreenCanvas API定义 |
| ImageBitmap 规范 | html.spec.whatwg.org/multipage/imagebitmap-and-animations.html | ImageBitmap API规范 |
| CSS Painting API | drafts.css-houdini.org/css-paint-api/ | Houdini Paint Worklet(Canvas相关) |
性能优化资源
| 资源 | 链接 | 说明 |
|---|---|---|
| Google Canvas 最佳实践 | web.dev/articles/canvas-performance | Google Web Dev性能指南 |
| Mozilla Canvas 调优指南 | developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas | MDN官方优化教程 |
| Paul Irish: Canvas性能 | www.html5rocks.com/tutorials/canvas/performance/ | 经典性能优化文章 |
| WebGL Fundamentals | webglfundamentals.org/ | WebGL基础到进阶完整教程 |
| WebGL2 Fundamentals | webgl2fundamentals.org/ | WebGL 2.0专项教程 |
| GPU命令缓冲区原理 | www.khronos.org/opengl/wiki/Rendering_Pipeline_Overview | 理解GPU渲染管线 |
书籍推荐
| 书名 | 作者 | 难度 | 说明 |
|---|---|---|---|
| 《WebGL编程指南》 | Diego Cantor | ⭐⭐⭐ 入门 | WebGL入门圣经 |
| 《WebGL Up and Running》 | Paris Carbone | ⭐⭐ 入门 | 实战导向 |
| 《High Performance JavaScript》 | Nicholas Zakas | ⭐⭐⭐ 中级 | JS性能优化必读 |
| 《Game Engine Architecture》 | Jason Gregory | ⭐⭐⭐⭐⭐ 高级 | 游戏引擎架构权威 |
| 《Real-Time Rendering》 | Tomas Akenine-Möller | ⭐⭐⭐⭐⭐ 高级 | 实时渲染理论圣经 |
| 《HTML5 Canvas核心技术》 | David Geary | ⭐⭐⭐ 中级 | Canvas 2D深入详解 |
| 《Foundation HTML5 Animation》 | Billy Lamberta | ⭐⭐⭐ 中级 | Canvas动画与游戏开发 |
在线工具与沙盒
| 工具 | 链接 | 用途 |
|---|---|---|
| CodePen Canvas集合 | codepen.io/topic/canvas | Canvas代码示例合集 |
| JSFiddle | jsfiddle.net | 快速原型测试 |
| Shadertoy | shadertoy.com | GLSL着色器在线编辑运行 |
| GLSL Editor | editor.thebookofshaders.com/ | 着色器学习专用 |
| WebGL Inspector | webglinspector.com/ | WebGL调试工具 |
| Spector.js | spector.babanov.de/ | 浏览器扩展,捕获WebGL/Canvas调用 |
| Can I Use | caniuse.com | 浏览器兼容性查询 |
| WebPlatform Docs | webplatform.github.io/docs/apis/canvas/ | 平台文档参考 |
开源项目与库
| 项目 | GitHub | 说明 |
|---|---|---|
| Konva.js | konvajs/konva | 高性能2D Canvas库,支持场景图、事件系统 |
| Fabric.js | fabricjs/fabric.js | Canvas对象模型库,支持SVG导入导出 |
| PixiJS | PixiJS/PixiJS | WebGL加速的2D渲染引擎 |
| Three.js | mrdoob/three.js | 最流行的WebGL 3D库 |
| Phaser | photonstorm/phaser | 2D游戏框架,内置物理引擎 |
| Paper.js | paperjs/paper.js | 矢量图形脚本框架 |
| p5.js | processing/p5.js | 创意编程框架,教学友好 |
| Rough.js | rough-stuff/rough.js | 手绘风格图形库 |
| Chart.js | chartjs/Chart.js | 简洁灵活的图表库 |
| D3.js | d3/d3 | 数据可视化驱动库(基于DOM/SVG/Canvas) |
| Zdog | metafizzy/zdog | 圆润风格的伪3D引擎 |
| SpriteJS | spritejs/spritejs | 跨终端的高性能绘图系统 |
学习路径推荐
📝 文档维护说明:
- 最后更新时间:2024年12月
- 目标浏览器版本:Chrome 120+, Firefox 121+, Safari 17.2+, Edge 120+
- 如发现错误或有改进建议,欢迎提交 Issue 或 PR
- 本文档基于 CC BY-SA 4.0 协议发布
补充示例
<h4>001-basic-shapes.html</h4><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【1】基础图形绘制</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas {
display: block;
margin: 16px auto;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
}
.controls {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
.btn-primary { background: #007bff; color: white; }
.btn-primary:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #5a6268; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 基础图形绘制(矩形、圆形、三角形、贝塞尔曲线)</div>
<div class="controls">
<button class="btn btn-primary" onclick="drawAll()">🎨 绘制全部图形</button>
<button class="btn btn-secondary" onclick="clearCanvas()">🗑️ 清空画布</button>
</div>
<canvas id="myCanvas" width="850" height="550"></canvas>
</div>
<script>
const canvas = document.getElementById("myCanvas")
const ctx = canvas.getContext("2d")
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
function drawAll() {
clearCanvas()
// ====== 1. 矩形 ======
ctx.fillStyle = "#ff6b6b"
ctx.fillRect(30, 30, 140, 100)
ctx.strokeStyle = "#333"
ctx.lineWidth = 2
ctx.strokeRect(200, 30, 140, 100)
// 描边填充矩形
ctx.fillStyle = "#4ecdc4"
ctx.fillRect(370, 30, 140, 100)
ctx.strokeRect(370, 30, 140, 100)
// 圆角矩形
drawRoundedRect(ctx, 540, 30, 140, 100, 15, "#45b7d1")
// 标签
ctx.fillStyle = "#666"
ctx.font = "13px sans-serif"
ctx.fillText("fillRect", 70, 150)
ctx.fillText("strokeRect", 240, 150)
ctx.fillText("Fill + Stroke", 400, 150)
ctx.fillText("圆角矩形", 580, 150)
// ====== 2. 三角形 ======
ctx.beginPath()
ctx.moveTo(100, 220)
ctx.lineTo(200, 320)
ctx.lineTo(50, 320)
ctx.closePath()
ctx.fillStyle = "#f39c12"
ctx.fill()
ctx.beginPath()
ctx.moveTo(250, 220)
ctx.lineTo(350, 320)
ctx.lineTo(200, 320)
ctx.closePath()
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 3
ctx.stroke()
// ====== 3. 圆形与弧线 ======
ctx.beginPath()
ctx.arc(450, 270, 60, 0, Math.PI * 2)
ctx.fillStyle = "#3498db"
ctx.fill()
ctx.beginPath()
ctx.arc(600, 270, 60, 0, Math.PI, false)
ctx.strokeStyle = "#9b59b6"
ctx.lineWidth = 4
ctx.lineCap = "round"
ctx.stroke()
ctx.beginPath()
ctx.arc(750, 270, 60, 0, Math.PI * 1.5)
ctx.fillStyle = "rgba(46, 204, 113, 0.6)"
ctx.fill()
ctx.strokeStyle = "#27ae60"
ctx.lineWidth = 2
ctx.stroke()
// 标签
ctx.fillStyle = "#666"
ctx.font = "13px sans-serif"
ctx.fillText("完整圆", 420, 350)
ctx.fillText("半圆 (stroke)", 575, 350)
ctx.fillText("扇形 (3/4)", 720, 350)
// ====== 4. 椭圆 ======
ctx.beginPath()
ctx.ellipse(120, 450, 80, 50, 0, 0, Math.PI * 2)
ctx.fillStyle = "#1abc9c"
ctx.fill()
ctx.beginPath()
ctx.ellipse(300, 450, 80, 50, Math.PI / 6, 0, Math.PI * 2)
ctx.fillStyle = "#e67e22"
ctx.fill()
// ====== 5. 贝塞尔曲线 ======
// 二次贝塞尔曲线
ctx.beginPath()
ctx.moveTo(430, 480)
ctx.quadraticCurveTo(500, 400, 570, 480)
ctx.strokeStyle = "#8e44ad"
ctx.lineWidth = 3
ctx.stroke()
// 绘制控制点
ctx.fillStyle = "#8e44ad"
ctx.beginPath()
ctx.arc(500, 400, 5, 0, Math.PI * 2)
ctx.fill()
ctx.font = "11px monospace"
ctx.fillText("控制点", 485, 395)
// 三次贝塞尔曲线
ctx.beginPath()
ctx.moveTo(620, 480)
ctx.bezierCurveTo(670, 400, 750, 520, 820, 450)
ctx.strokeStyle = "#2980b9"
ctx.lineWidth = 3
ctx.stroke()
// 控制点
ctx.fillStyle = "#2980b9"
ctx.beginPath()
ctx.arc(670, 400, 5, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(750, 520, 5, 0, Math.PI * 2)
ctx.fill()
// 标签
ctx.fillStyle = "#666"
ctx.font = "13px sans-serif"
ctx.fillText("椭圆", 95, 520)
ctx.fillText("旋转椭圆", 260, 520)
ctx.fillText("二次贝塞尔", 465, 510)
ctx.fillText("三次贝塞尔", 685, 510)
// ====== 6. 虚线样式 ======
ctx.setLineDash([10, 5])
ctx.beginPath()
ctx.rect(30, 380, 160, 90)
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 2
ctx.stroke()
ctx.setLineDash([]) // 恢复
ctx.setLineDash([15, 5, 5, 5])
ctx.beginPath()
ctx.rect(210, 380, 160, 90)
ctx.strokeStyle = "#2ecc71"
ctx.lineWidth = 2
ctx.stroke()
ctx.setLineDash([])
ctx.font = "12px monospace"
ctx.fillStyle = "#999"
ctx.fillText("[10, 5]", 85, 485)
ctx.fillText("[15, 5, 5, 5]", 250, 485)
}
function drawRoundedRect(ctx, x, y, width, height, radius, color) {
ctx.beginPath()
ctx.moveTo(x + radius, y)
ctx.lineTo(x + width - radius, y)
ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
ctx.lineTo(x + width, y + height - radius)
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
ctx.lineTo(x + radius, y + height)
ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
ctx.lineTo(x, y + radius)
ctx.quadraticCurveTo(x, y, x + radius, y)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
}
// 初始绘制
drawAll()
</script>
</body>
</html>```
<h4>006-text-rendering.html</h4>
```html
<!-- 来源:12-Canvas.md - 第8章 文本排版引擎 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【6】Canvas 文本绘制</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
.btn {
padding: 8px 18px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.section-label { text-align: center; font-size: 13px; color: #888; margin-top: 10px; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 文本绘制(fillText / strokeText / textAlign / textBaseline / measureText)</div>
<div class="controls">
<button class="btn" onclick="drawAll()">🎨 绘制全部示例</button>
<button class="btn btn-secondary" onclick="clearCanvas()">🗑️ 清空画布</button>
</div>
<canvas id="textCanvas" width="850" height="700"></canvas>
<p class="section-label">💡 可交互:点击按钮查看不同文本绘制效果</p>
</div>
<script>
const canvas = document.getElementById("textCanvas")
const ctx = canvas.getContext("2d")
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
function drawAll() {
clearCanvas()
drawBasicText()
drawTextAlignDemo()
drawTextBaselineDemo()
drawStrokeTextDemo()
drawMeasureTextDemo()
drawWrappedTextDemo()
drawAdvancedTextProps()
}
// ====== 1. 基本文本绘制 ======
function drawBasicText() {
ctx.save()
ctx.fillStyle = "#2c3e50"
ctx.font = "bold 24px 'Helvetica Neue', sans-serif"
ctx.fillText("Hello Canvas 文本绘制", 30, 40)
ctx.font = "16px monospace"
ctx.fillStyle = "#666"
ctx.fillText("fillText() — 填充文本", 30, 65)
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 80)
ctx.lineTo(830, 80)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 2. textAlign 对齐方式 ======
function drawTextAlignDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("textAlign 对齐方式演示", 30, 105)
const centerX = 450
const alignments = ["left", "center", "right"]
const colors = ["#e74c3c", "#3498db", "#2ecc71"]
alignments.forEach((align, i) => {
const y = 135 + i * 32
ctx.textAlign = align
ctx.fillStyle = colors[i]
ctx.font = "18px Arial"
ctx.fillText(`textAlign: ${align}`, centerX, y)
// 参考线
ctx.strokeStyle = "#999"
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.beginPath()
ctx.moveTo(centerX, y - 18)
ctx.lineTo(centerX, y + 8)
ctx.stroke()
ctx.setLineDash([])
})
ctx.textAlign = "left"
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 240)
ctx.lineTo(830, 240)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 3. textBaseline 基线对齐 ======
function drawTextBaselineDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("textBaseline 基线对齐演示", 30, 265)
const baseY = 330
const baselines = [
{ name: "top", value: "top", color: "#e74c3c" },
{ name: "hanging", value: "hanging", color: "#f39c12" },
{ name: "middle", value: "middle", color: "#2ecc71" },
{ name: "alphabetic", value: "alphabetic", color: "#3498db" },
{ name: "bottom", value: "bottom", color: "#9b59b6" },
]
// 基准线
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 1.5
ctx.setLineDash([6, 3])
ctx.beginPath()
ctx.moveTo(30, baseY)
ctx.lineTo(600, baseY)
ctx.stroke()
ctx.setLineDash([])
ctx.font = "14px Arial"
ctx.fillStyle = "#e74c3c"
ctx.font = "11px monospace"
ctx.fillText("基准线 (y=" + baseY + ")", 605, baseY + 4)
baselines.forEach((item, i) => {
const x = 50 + i * 115
ctx.textBaseline = item.value
ctx.fillStyle = item.color
ctx.font = "13px Arial"
ctx.fillText("Canvas", x, baseY)
// 标签
ctx.textBaseline = "top"
ctx.font = "10px monospace"
ctx.fillStyle = "#888"
ctx.fillText(item.name, x, baseY + 10)
})
ctx.textBaseline = "alphabetic"
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 370)
ctx.lineTo(830, 370)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 4. 描边文本 ======
function drawStrokeTextDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("strokeText 描边文本演示", 30, 395)
// 填充文本
ctx.font = "bold 28px Arial"
ctx.fillStyle = "#3498db"
ctx.fillText("填充文本 FillText", 50, 435)
// 描边文本
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 1.5
ctx.strokeText("描边文本 StrokeText", 50, 470)
// 描边+填充组合
ctx.font = "bold 26px Arial"
ctx.fillStyle = "#fff"
ctx.strokeStyle = "#9b59b6"
ctx.lineWidth = 2.5
ctx.strokeText("描边+填充 组合效果", 50, 510)
ctx.fillText("描边+填充 组合效果", 50, 510)
ctx.restore()
// 分隔线
ctx.beginPath()
ctx.moveTo(20, 535)
ctx.lineTo(830, 535)
ctx.strokeStyle = "#eee"
ctx.lineWidth = 1
ctx.stroke()
}
// ====== 5. measureText 文本测量 ======
function drawMeasureTextDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("measureText 文本测量", 30, 560)
const text = "测量这段文字的宽度"
ctx.font = "20px 'Microsoft YaHei', sans-serif"
const metrics = ctx.measureText(text)
// 绘制文本
ctx.fillStyle = "#2c3e50"
ctx.fillText(text, 50, 595)
// 绘制宽度标注
const startX = 50
const endX = startX + metrics.width
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(startX, 605)
ctx.lineTo(endX, 605)
ctx.stroke()
ctx.setLineDash([])
// 两端竖线
ctx.beginPath()
ctx.moveTo(startX, 600)
ctx.lineTo(startX, 612)
ctx.moveTo(endX, 600)
ctx.lineTo(endX, 612)
ctx.stroke()
// 宽度数值
ctx.font = "12px monospace"
ctx.fillStyle = "#e74c3c"
ctx.textAlign = "center"
ctx.fillText(`width: ${metrics.width.toFixed(1)}px`, (startX + endX) / 2, 622)
// 显示更多属性
ctx.textAlign = "left"
ctx.font = "11px monospace"
ctx.fillStyle="#666"
const props = [
`actualBoundingBoxAscent: ${metrics.actualBoundingBoxAscent?.toFixed(1) ?? 'N/A'}`,
`actualBoundingBoxDescent: ${metrics.actualBoundingBoxDescent?.toFixed(1) ?? 'N/A'}`,
]
props.forEach((p, i) => { ctx.fillText(p, 450, 585 + i * 16) })
ctx.restore()
}
// ====== 6. 自动换行多行文本 ======
function drawWrappedTextDemo() {
ctx.save()
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("自动换行文本 (measureText 实现)", 500, 560)
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
let line = ""
let currentY = y
for (let i = 0; i < text.length; i++) {
const testLine = line + text[i]
if (ctx.measureText(testLine).width > maxWidth && line !== "") {
ctx.fillText(line, x, currentY)
line = text[i]
currentY += lineHeight
} else {
line = testLine
}
}
if (line) ctx.fillText(line, x, currentY)
return currentY + lineHeight
}
// 边框
ctx.strokeStyle = "#ddd"
ctx.lineWidth = 1
ctx.strokeRect(500, 575, 310, 80)
ctx.font = "14px 'Microsoft YaHei', sans-serif"
ctx.fillStyle = "#444"
drawWrappedText(ctx,
"这是一段很长的文本,需要在指定宽度内自动换行显示。使用 measureText 逐字检测宽度实现。",
508, 595, 294, 22
)
ctx.restore()
}
// ====== 7. 高级文本属性 ======
function drawAdvancedTextProps() {
ctx.save()
// direction 属性
ctx.fillStyle = "#333"
ctx.font = "bold 14px sans-serif"
ctx.fillText("高级属性:direction / letterSpacing / wordSpacing", 30, 655)
// direction: rtl
ctx.direction = "rtl"
ctx.font = "16px serif"
ctx.fillStyle = "#8e44ad"
ctx.fillText("从右到左文本 (RTL)", 820, 685)
ctx.direction = "ltr"
// letterSpacing (如果支持)
ctx.font = "14px sans-serif"
ctx.fillStyle="#666"
if (ctx.letterSpacing !== undefined) {
ctx.letterSpacing = "3px"
ctx.fillText("letterSpacing: 3px (宽字距)", 30, 685)
ctx.letterSpacing = "normal"
} else {
ctx.fillText("letterSpacing: 浏览器不支持", 30, 685)
}
ctx.restore()
}
// 初始绘制
drawAll()
</script>
</body>
</html><!-- 来源:12-Canvas.md - 第12章 动画系统架构 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【10】Canvas 动画循环</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #1a1a2e; color: #fff; }
.demo-container { max-width: 900px; margin: 0 auto; background: #16213e; padding: 24px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #e94560; border-bottom: 2px solid #e94560; padding-bottom: 8px; }
canvas {
display: block; margin: 16px auto; border-radius: 12px;
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
}
.controls { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
.btn {
padding: 9px 18px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #e94560; color: white;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(233,69,96,0.4); }
.btn-secondary { background: #533483; }
.btn-secondary:hover { box-shadow: 0 4px 12px rgba(83,52,131,0.4); }
.btn-success { background: #00d9ff; color: #1a1a2e; }
.btn-success:hover { box-shadow: 0 4px 12px rgba(0,217,255,0.4); }
.stats-panel {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 14px;
}
.stat-card {
background: rgba(255,255,255,0.05);
border-radius: 8px;
padding: 12px;
text-align: center;
border: 1px solid rgba(255,255,255,0.08);
}
.stat-label { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; }
.stat-value { font-size: 22px; font-weight: bold; color: #00d9ff; font-family: monospace; margin-top: 4px; }
.stat-value.warning { color: #f39c12; }
.stat-value.danger { color: #e94560; }
.timeline-bar {
height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px;
margin-top: 14px; overflow: hidden;
}
.timeline-progress {
height: 100%; background: linear-gradient(90deg, #e94560, #f39c12, #00d9ff);
border-radius: 3px; transition: width 0.1s linear;
}
.state-badge {
display: inline-block; padding: 3px 10px; border-radius: 12px;
font-size: 12px; font-weight: bold; margin-left: 8px;
}
.state-running { background: #00d9ff33; color: #00d9ff; }
.state-paused { background: #f39c1233; color: #f39c12; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">
示例:Canvas 动画循环架构
<span id="stateBadge" class="state-badge state-running">● RUNNING</span>
</div>
<div class="controls">
<button class="btn btn-success" onclick="togglePlay()" id="playBtn">⏸️ 暂停</button>
<button class="btn" onclick="changeSpeed(-0.5)">🐢 减速</button>
<button class="btn" onclick="changeSpeed(0.5)">🐇 加速</button>
<button class="btn" onclick="addBall()">➕ 添加小球</button>
<button class="btn btn-secondary" onclick="resetScene()">🔄 重置场景</button>
</div>
<canvas id="animCanvas" width="850" height="480"></canvas>
<div class="timeline-bar">
<div class="timeline-progress" id="timelineProgress" style="width: 0%"></div>
</div>
<div class="stats-panel">
<div class="stat-card">
<div class="stat-label">FPS (实时)</div>
<div class="stat-value" id="fpsValue">60</div>
</div>
<div class="stat-card">
<div class="stat-label">Delta Time</div>
<div class="stat-value" id="deltaValue">16.67<span style="font-size:12px">ms</span></div>
</div>
<div class="stat-card">
<div class="stat-label">总帧数</div>
<div class="stat-value" id="frameCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">时间倍率</div>
<div class="stat-value" id="speedValue">1.0x</div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById("animCanvas")
const ctx = canvas.getContext("2d")
// ====== 动画状态 ======
let isRunning = true
let timeScale = 1.0
let animId = null
// ====== 时间追踪 ======
let lastTimestamp = 0
let totalElapsed = 0 // 累计经过时间(受 timeScale 影响)
let rawElapsed = 0 // 原始经过时间
let frameCounter = 0
let fps = 60
let fpsAccumulator = 0
let fpsFrameCount = 0
const FPS_UPDATE_INTERVAL = 500 // 每500ms更新一次FPS显示
// ====== 小球数据 ======
const balls = []
class Ball {
constructor(x, y) {
this.x = x ?? Math.random() * canvas.width
this.y = y ?? Math.random() * canvas.height * 0.6
this.radius = 8 + Math.random() * 18
this.vx = (Math.random() - 0.5) * 200
this.vy = (Math.random() - 0.5) * 200
this.hue = Math.random() * 360
this.trail = []
this.maxTrail = 12
}
update(dt) {
// 使用 deltaTime 实现帧率无关运动
this.x += this.vx * dt
this.y += this.vy * dt
// 边界碰撞
if (this.x - this.radius < 0) { this.x = this.radius; this.vx *= -0.9 }
if (this.x + this.radius > canvas.width) { this.x = canvas.width - this.radius; this.vx *= -0.9 }
if (this.y - this.radius < 0) { this.y = this.radius; this.vy *= -0.9 }
if (this.y + this.radius > canvas.height) { this.y = canvas.height - this.radius; this.vy *= -0.9 }
// 轨迹记录
this.trail.push({ x: this.x, y: this.y })
if (this.trail.length > this.maxTrail) this.trail.shift()
}
draw(ctx) {
// 绘制轨迹
for (let i = 0; i < this.trail.length; i++) {
const alpha = i / this.trail.length * 0.4
const r = this.radius * (i / this.trail.length) * 0.7
ctx.beginPath()
ctx.arc(this.trail[i].x, this.trail[i].y, r, 0, Math.PI * 2)
ctx.fillStyle = `hsla(${this.hue}, 80%, 60%, ${alpha})`
ctx.fill()
}
// 绘制球体(径向渐变模拟光照)
const grad = ctx.createRadialGradient(
this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.1,
this.x, this.y, this.radius
)
grad.addColorStop(0, `hsla(${this.hue}, 90%, 75%, 1)`)
grad.addColorStop(0.7, `hsla(${this.hue}, 80%, 50%, 1)`)
grad.addColorStop(1, `hsla(${this.hue}, 70%, 30%, 1)`)
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
// 高光
ctx.beginPath()
ctx.arc(this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.2, 0, Math.PI * 2)
ctx.fillStyle = "rgba(255,255,255,0.5)"
ctx.fill()
}
}
function addBall(x, y) {
balls.push(new Ball(x, y))
}
function resetScene() {
balls.length = 0
totalElapsed = 0
rawElapsed = 0
frameCounter = 0
for (let i = 0; i < 6; i++) addBall()
}
// ====== 动画循环核心 ======
function animate(timestamp) {
if (!isRunning) return
// 计算 delta time(毫秒转秒)
if (lastTimestamp === 0) lastTimestamp = timestamp
const rawDelta = timestamp - lastTimestamp
lastTimestamp = timestamp
// 应用时间倍率
const deltaSec = (rawDelta / 1000) * timeScale
totalElapsed += deltaSec
rawElapsed += rawDelta / 1000
frameCounter++
// ====== FPS 计算 ======
fpsAccumulator += rawDelta
fpsFrameCount++
if (fpsAccumulator >= FPS_UPDATE_INTERVAL) {
fps = Math.round(fpsFrameCount / (fpsAccumulator / 1000))
fpsAccumulator = 0
fpsFrameCount = 0
updateStatsDisplay(fps, rawDelta)
}
// ====== 渲染 ======
render(deltaSec)
// 更新进度条(循环周期 10 秒)
const progress = (totalElapsed % 10) / 10 * 100
document.getElementById("timelineProgress").style.width = progress + "%"
animId = requestAnimationFrame(animate)
}
function render(dt) {
// 半透明清屏产生拖尾
ctx.fillStyle = "rgba(15, 52, 96, 0.2)"
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 更新并绘制所有球
for (const ball of balls) {
ball.update(dt)
ball.draw(ctx)
}
// 绘制中心信息
ctx.save()
ctx.font = "bold 14px monospace"
ctx.fillStyle = "rgba(255,255,255,0.15)"
ctx.textAlign = "center"
ctx.fillText(`requestAnimationFrame + deltaTime | 帧率无关动画`, canvas.width / 2, canvas.height - 16)
ctx.restore()
}
// ====== 控制函数 ======
function togglePlay() {
isRunning = !isRunning
const badge = document.getElementById("stateBadge")
const btn = document.getElementById("playBtn")
if (isRunning) {
badge.textContent = "● RUNNING"
badge.className = "state-badge state-running"
btn.textContent = "⏸️ 暂停"
lastTimestamp = 0 // 重置以避免巨大 delta
animate(performance.now())
} else {
badge.textContent = "⏸ PAUSED"
badge.className = "state-badge state-paused"
btn.textContent = "▶️ 继续"
if (animId) cancelAnimationFrame(animId)
}
}
function changeSpeed(delta) {
timeScale = Math.max(0.1, Math.min(3.0, timeScale + delta))
document.getElementById("speedValue").textContent = timeScale.toFixed(1) + "x"
}
function updateStatsDisplay(currentFps, rawDt) {
const fpsEl = document.getElementById("fpsValue")
fpsEl.textContent = currentFps
fpsEl.className = "stat-value" + (currentFps < 30 ? " danger" : currentFps < 50 ? " warning" : "")
document.getElementById("deltaValue").innerHTML =
rawDt.toFixed(1) + '<span style="font-size:12px">ms</span>'
document.getElementById("frameCount").textContent = frameCounter
document.getElementById("speedValue").textContent = timeScale.toFixed(1) + "x"
}
// 点击画布添加球
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
addBall(x, y)
})
// 初始化
resetScene()
animate(performance.now())
</script>
</body>
</html><!-- 来源:12-Canvas.md - 裁剪路径 clip() -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【12】Canvas 裁剪路径</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.clip-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-top: 14px;
}
.clip-item h3 {
font-size: 13px; color: #555; text-align: center; margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 裁剪路径(clip() + save/restore)</div>
<div class="controls">
<button class="btn" onclick="drawAll()">🎨 绘制全部示例</button>
<button class="btn" onclick="toggleAnimation()">⏯️ 动画开/关</button>
<button class="btn btn-secondary" onclick="clearCanvas()">🗑️ 清空</button>
</div>
<canvas id="clipCanvas" width="850" height="600"></canvas>
<p style="text-align:center;margin-top:12px;font-size:13px;color:#666;">
💡 clip() 将当前路径设为裁剪区域,之后所有绘制只在该区域内可见 | 必须配合 save()/restore() 使用
</p>
</div>
<script>
const canvas = document.getElementById("clipCanvas")
const ctx = canvas.getContext("2d")
let animating = true
let animId = null
let animAngle = 0
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
function drawAll() {
clearCanvas()
drawRectClip()
drawCircleClip()
drawStarClip()
drawTextClip()
drawNestedClips()
drawAnimatedClip()
}
// ====== 1. 矩形裁剪 ======
function drawRectClip() {
ctx.save()
// 绘制裁剪区域边框(虚线,用于可视化)
ctx.strokeStyle = "#e74c3c"
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
ctx.strokeRect(30, 30, 200, 140)
ctx.setLineDash([])
// 定义裁剪路径并应用
ctx.beginPath()
ctx.rect(30, 30, 200, 140)
ctx.clip()
// 在裁剪区域内绘制内容(超出部分不可见)
const grad = ctx.createLinearGradient(30, 30, 230, 170)
grad.addColorStop(0, "#667eea")
grad.addColorStop(1, "#f093fb")
ctx.fillStyle = grad
ctx.fillRect(0, 0, 300, 220) // 故意画大,展示裁剪效果
// 绘制一些圆形
for (let i = 0; i < 12; i++) {
ctx.beginPath()
ctx.arc(50 + i * 22, 60 + Math.sin(i) * 40, 15, 0, Math.PI * 2)
ctx.fillStyle = `hsla(${i*30},70%,60%,0.6)`
ctx.fill()
}
// 标签
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("① rect() + clip() — 矩形裁剪", 35, 190)
ctx.font = "11px monospace"
ctx.fillStyle="#888"
ctx.fillText('ctx.rect(x,y,w,h); ctx.clip()', 35, 206)
}
// ====== 2. 圆形裁剪 ======
function drawCircleClip() {
ctx.save()
// 可视化裁剪圆
ctx.strokeStyle = "#3498db"
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
ctx.beginPath()
ctx.arc(420, 100, 80, 0, Math.PI * 2)
ctx.stroke()
ctx.setLineDash([])
// 圆形裁剪
ctx.beginPath()
ctx.arc(420, 100, 80, 0, Math.PI * 2)
ctx.clip()
// 填充渐变图案
const grad = ctx.createRadialGradient(420, 100, 10, 420, 100, 90)
grad.addColorStop(0, "#f39c12")
grad.addColorStop(0.5, "#e74c3c")
grad.addColorStop(1, "#9b59b6")
ctx.fillStyle = grad
ctx.fillRect(300, 0, 240, 220)
// 内部文字
ctx.fillStyle = "white"
ctx.font = "bold 18px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("Circle Clip", 420, 95)
ctx.font = "12px sans-serif"
ctx.fillText("圆形裁剪区域", 420, 118)
ctx.restore()
ctx.textAlign = "left"
ctx.textBaseline = "alphabetic"
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("② arc() + clip() — 圆形裁剪", 330, 210)
}
// ====== 3. 星形裁剪 ======
function drawStarClip() {
ctx.save()
const cx = 650, cy = 100, outerR = 75, innerR = 35
// 可视化星形路径
ctx.strokeStyle = "#2ecc71"
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
drawStarPath(ctx, cx, cy, outerR, innerR, 5)
ctx.stroke()
ctx.setLineDash([])
// 星形裁剪
ctx.beginPath()
drawStarPath(ctx, cx, cy, outerR, innerR, 5)
ctx.clip()
// 在星形内绘制彩虹条纹
for (let i = 0; i < 20; i++) {
ctx.fillStyle = `hsl(${i * 18}, 70%, 55%)`
ctx.fillRect(cx - 100 + i * 12, cy - 100, 10, 200)
}
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("③ 自定义路径 + clip() — 星形裁剪", 555, 210)
}
// ====== 4. 文字形状裁剪 ======
function drawTextClip() {
ctx.save()
const txt = "CLIP"
ctx.font = "bold 72px 'Impact', sans-serif"
// 测量文字尺寸
const metrics = ctx.measureText(txt)
const textWidth = metrics.width
const textHeight = 72
const tx = 130, ty = 310
// 用文字作为裁剪路径
ctx.beginPath()
ctx.fillText(txt, tx, ty) // 注意:fillText 会绘制但我们需要它作为路径
// 更好的方式:用 strokeText 或手动路径。这里用 fillText 的轮廓近似
ctx.clip(true) // 使用 fill 规则
// 实际做法:重新用文字路径来 clip
ctx.restore()
ctx.save()
// 绘制背景图(将被文字裁剪)
const bgGrad = ctx.createLinearGradient(tx, ty - 70, tx + textWidth, ty + 10)
bgGrad.addColorStop(0, "#e74c3c")
bgGrad.addColorStop(0.33, "#f39c12")
bgGrad.addColorStop(0.66, "#2ecc71")
bgGrad.addColorStop(1, "#3498db")
// 用 clip 配合文字路径
ctx.font = "bold 72px 'Impact', sans-serif"
ctx.beginPath()
ctx.fillText(txt, tx, ty) // 这会绘制文字本身
// 改为正确的 clip 方式:
ctx.globalCompositeOperation = "destination-in" // 用合成模式模拟文字遮罩
// 重新实现:
ctx.restore()
ctx.save()
// 先画彩色背景
ctx.fillStyle = bgGrad
ctx.fillRect(tx - 10, ty - 78, textWidth + 20, 88)
// 文字遮罩(destination-in 只保留与文字重叠的部分)
ctx.globalCompositeOperation = "destination-in"
ctx.fillStyle = "#fff"
ctx.font = "bold 72px 'Impact', sans-serif"
ctx.fillText(txt, tx, ty)
ctx.globalCompositeOperation = "source-over"
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("④ 文字形状遮罩(合成模式模拟)", 40, 360)
ctx.font = "11px monospace"
ctx.fillStyle="#888"
ctx.fillText("destination-in 实现文字镂空效果", 40, 376)
}
// ====== 5. 嵌套裁剪(save/restore 栈)======
function drawNestedClips() {
ctx.save()
// 外层:矩形裁剪
ctx.beginPath()
ctx.roundRect(400, 260, 200, 150, 12)
ctx.clip()
// 外层内容
ctx.fillStyle = "#f0f3ff"
ctx.fillRect(380, 240, 240, 190)
ctx.save() // 保存外层裁剪状态
// 内层:圆形裁剪
ctx.beginPath()
ctx.arc(500, 335, 55, 0, Math.PI * 2)
ctx.clip()
// 内层内容(只在内层圆形内可见)
const grad = ctx.createRadialGradient(500, 335, 5, 500, 335, 60)
grad.addColorStop(0, "#ff6b6b")
grad.addColorStop(1, "#4ecdc4")
ctx.fillStyle = grad
ctx.fillRect(400, 250, 200, 170)
ctx.fillStyle = "white"
ctx.font = "bold 14px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("嵌套\n裁剪", 500, 335)
ctx.restore() // 恢复到外层裁剪
// 在外层(但不在内层)绘制边框装饰
ctx.strokeStyle = "#3498db"
ctx.lineWidth = 2
ctx.setLineDash([4, 4])
ctx.roundRect(400, 260, 200, 150, 12)
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.textAlign = "left"
ctx.textBaseline = "alphabetic"
ctx.fillText("⑤ 嵌套裁剪 — save/restore 栈式管理", 400, 430)
ctx.font = "11px monospace"
ctx.fillStyle="#888"
ctx.fillText("外层clip → save → 内层clip → restore → 回到外层", 400, 446)
}
// ====== 6. 动态裁剪动画 ======
function drawAnimatedClip() {
ctx.save()
const cx = 750, cy = 335
const wobble = Math.sin(animAngle) * 15
const r = 65 + Math.sin(animAngle * 1.5) * 10
// 动态星形裁剪
ctx.beginPath()
drawStarPath(ctx, cx, cy, r, r * 0.45, 5)
// 应用旋转
ctx.translate(cx, cy)
ctx.rotate(animAngle * 0.5)
ctx.translate(-cx, -cy)
// 重新定义旋转后的裁剪路径
ctx.clip()
// 绘制动态渐变内容
const time = Date.now() / 1000
for (let i = 0; i < 15; i++) {
const hue = (i * 24 + time * 30) % 360
ctx.fillStyle = `hsl(${hue}, 70%, 55%)`
ctx.beginPath()
ctx.arc(
cx + Math.sin(time + i) * 50,
cy + Math.cos(time * 0.7 + i) * 50,
12 + Math.sin(time * 2 + i) * 5,
0, Math.PI * 2
)
ctx.fill()
}
ctx.restore()
ctx.font = "bold 13px sans-serif"
ctx.fillStyle = "#333"
ctx.fillText("⑥ 动态裁剪 — 路径随时间变化", 660, 438)
}
function drawStarPath(ctx, cx, cy, outerR, innerR, points) {
const step = Math.PI / points
ctx.moveTo(cx, cy - outerR)
for (let i = 1; i <= points * 2; i++) {
const r = i % 2 === 0 ? outerR : innerR
const angle = i * step - Math.PI / 2
ctx.lineTo(cx + r * Math.cos(angle), cy + r * Math.sin(angle))
}
ctx.closePath()
}
function toggleAnimation() {
animating = !animating
if (animating && !animId) loop()
}
function loop() {
if (!animating) { animId = null; return }
animAngle += 0.03
drawAll()
animId = requestAnimationFrame(loop)
}
// 初始化
ctx.fillStyle = "#fff"
ctx.fillRect(0, 0, canvas.width, canvas.height)
loop()
</script>
</body>
</html><!-- 来源:12-Canvas.md - 第13章 OffscreenCanvas 与多线程渲染 / 第15章 分层渲染 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【13】Canvas 离屏渲染</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
canvas { display: block; margin: 16px auto; border: 1px solid #ddd; border-radius: 8px; background: white; }
.controls {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px;
}
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all 0.3s;
background: #007bff; color: white;
}
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 14px;
}
.comp-panel h3 {
text-align: center; font-size: 14px; color: #555; margin-bottom: 10px;
padding-bottom: 6px; border-bottom: 2px solid #eee;
}
.stats-row {
display: flex; gap: 20px; justify-content: center; margin-top: 14px;
}
.stat-box {
background: #f8f9fa; padding: 10px 18px; border-radius: 6px;
text-align: center; font-size: 13px;
}
.stat-box strong { color: #007bff; font-size: 16px; }
.code-block {
background: #1e1e2e; color: #cdd6f4; padding: 12px 14px;
border-radius: 6px; font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px; line-height: 1.6; margin-top: 12px;
overflow-x: auto; white-space: pre;
}
.code-comment { color: #6c7086; }
.code-keyword { color: #cba6f7; }
.code-string { color: #a6e3a1; }
.code-func { color: #89b4fa; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:Canvas 离屏渲染(Offscreen / 预渲染缓存)</div>
<div class="controls">
<button class="btn" onclick="toggleAnimation()">⏯️ 动画开/关</button>
<button class="btn" onclick="addParticles()">✨ 添加粒子</button>
<button class="btn btn-secondary" onclick="resetScene()">🔄 重置</button>
</div>
<canvas id="mainCanvas" width="850" height="480"></canvas>
<div class="stats-row">
<div class="stat-box">离屏层数:<strong id="layerCount">3</strong></div>
<div class="stat-box">FPS:<strong id="fpsDisplay">60</strong></div>
<div class="stat-box">粒子数:<strong id="particleCount">0</strong></div>
</div>
<div class="comparison" style="margin-top:18px;">
<div class="comp-panel">
<h3>📐 原理说明</h3>
<div class="code-block"><span class="code-comment">// 创建离屏 Canvas(不显示在页面上)</span>
<span class="code-keyword">const</span> offscreen = document.<span class="code-func">createElement</span>(<span class="code-string">'canvas'</span>);
offscreen.width = <span class="code-string">400</span>;
offscreen.height = <span class="code-string">300</span>;
<span class="code-keyword">const</span> offCtx = offscreen.<span class="code-func">getContext</span>(<span class="code-string">'2d'</span>);
<span class="code-comment">// 在离屏 Canvas 上预渲染复杂内容</span>
offCtx.fillStyle = <span class="code-string">'#gradient'</span>;
offCtx.fillRect(<span class="code-string">0, 0, 400, 300</span>);
<span class="code-comment">// 将离屏内容一次性绘制到主 Canvas</span>
ctx.<span class="code-func">drawImage</span>(offscreen, x, y);</div>
</div>
<div class="comp-panel">
<h3>💡 使用场景</h3>
<div class="code-block"><span class="code-comment">✅ 静态背景预渲染 — 只需绘制一次
✅ 复杂图形缓存 — 避免每帧重算
✅ 图层分层系统 — 独立更新各层
✅ 图像预处理 — 滤镜/变形离屏完成
✅ 双缓冲技术 — 减少画面闪烁
❌ 注意:离屏 Canvas 占用内存
不需要时及时清理 (width = 0)</span></div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById("mainCanvas")
const ctx = canvas.getContext("2d")
// ====== 离屏图层系统 ======
const layers = {}
function createLayer(name, w, h) {
const offscreen = document.createElement("canvas")
offscreen.width = w
offscreen.height = h
const octx = offscreen.getContext("2d")
layers[name] = { canvas: offscreen, ctx: octx, dirty: true }
return layers[name]
}
// 初始化三个离屏层
createLayer("background", canvas.width, canvas.height)
createLayer("midground", canvas.width, canvas.height)
createLayer("particles", canvas.width, canvas.height)
// ====== 绘制各层内容 ======
// 背景层(静态,只绘制一次)
function renderBackground() {
const layer = layers.background
const c = layer.ctx
const w = layer.canvas.width
const h = layer.canvas.height
// 渐变背景
const bgGrad = c.createLinearGradient(0, 0, w, h)
bgGrad.addColorStop(0, "#0f0c29")
bgGrad.addColorStop(0.5, "#302b63")
bgGrad.addColorStop(1, "#24243e")
c.fillStyle = bgGrad
c.fillRect(0, 0, w, h)
// 网格线
c.strokeStyle = "rgba(255,255,255,0.04)"
c.lineWidth = 1
for (let x = 0; x < w; x += 40) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke() }
for (let y = 0; y < h; y += 40) { c.beginPath(); c.moveTo(0, y); c.lineTo(w, y); c.stroke() }
// 装饰性几何图形
for (let i = 0; i < 8; i++) {
c.beginPath()
c.arc(
80 + i * 110,
80 + Math.sin(i * 1.2) * 60,
30 + Math.random() * 50,
0, Math.PI * 2
)
c.fillStyle = `hsla(${i * 45}, 60%, 50%, 0.08)`
c.fill()
}
// 标签
c.fillStyle = "rgba(255,255,255,0.15)"
c.font = "bold 24px sans-serif"
c.textAlign = "center"
c.fillText("【离屏背景层】预渲染静态内容", w / 2, h - 30)
c.textAlign = "left"
layer.dirty = false
}
// 中间层(半动态)
let midAngle = 0
function renderMidground() {
const layer = layers.midground
const c = layer.ctx
const w = layer.canvas.width
const h = layer.canvas.height
c.clearRect(0, 0, w, h)
// 旋转的装饰环
const cx = w / 2, cy = h / 2
for (let ring = 0; ring < 3; ring++) {
const radius = 100 + ring * 60
c.save()
c.translate(cx, cy)
c.rotate(midAngle * (ring % 2 === 0 ? 1 : -0.7))
for (let i = 0; i < 6; i++) {
const angle = (Math.PI * 2 / 6) * i + midAngle * 0.5
const x = Math.cos(angle) * radius
const y = Math.sin(angle) * radius
const size = 12 + ring * 5
c.beginPath()
c.arc(x, y, size, 0, Math.PI * 2)
const grad = c.createRadialGradient(x, y, 0, x, y, size)
grad.addColorStop(0, `hsla(${ring * 120 + i * 30}, 80%, 65%, 0.9)`)
grad.addColorStop(1, `hsla(${ring * 120 + i * 30}, 70%, 45%, 0.3)`)
c.fillStyle = grad
c.fill()
}
c.restore()
}
// 中心文字
c.save()
c.translate(cx, cy)
c.rotate(midAngle * 0.2)
c.fillStyle = "rgba(255,255,255,0.25)"
c.font = "bold 28px sans-serif"
c.textAlign = "center"
c.textBaseline = "middle"
c.fillText("OFFSCREEN", 0, -10)
c.font = "16px sans-serif"
c.fillText("RENDERING", 0, 18)
c.restore()
}
// 粒子层
const particles = []
class Particle {
constructor(x, y) {
this.x = x ?? Math.random() * canvas.width
this.y = y ?? Math.random() * canvas.height
this.vx = (Math.random() - 0.5) * 3
this.vy = (Math.random() - 0.5) * 3
this.radius = 2 + Math.random() * 5
this.hue = Math.random() * 360
this.alpha = 1
this.decay = 0.002 + Math.random() * 0.005
}
update() {
this.x += this.vx
this.y += this.vy
if (this.x < 0 || this.x > canvas.width) this.vx *= -1
if (this.y < 0 || this.y > canvas.height) this.vy *= -1
this.alpha -= this.decay
}
draw(c) {
c.beginPath()
c.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
c.fillStyle = `hsla(${this.hue}, 80%, 60%, ${this.alpha})`
c.fill()
}
}
function addParticles() {
for (let i = 0; i < 30; i++) particles.push(new Particle())
}
function renderParticles() {
const layer = layers.particles
const c = layer.ctx
c.clearRect(0, 0, layer.canvas.width, layer.canvas.height)
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update()
particles[i].draw(c)
if (particles[i].alpha <= 0) particles.splice(i, 1)
}
}
// ====== 合成到主画布 ======
function composite() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 按顺序合成各层
ctx.drawImage(layers.background.canvas, 0, 0)
ctx.drawImage(layers.midground.canvas, 0, 0)
ctx.drawImage(layers.particles.canvas, 0, 0)
// UI 层(直接在主 canvas 上绘制)
ctx.fillStyle = "rgba(255,255,255,0.08)"
ctx.fillRect(0, 0, canvas.width, 28)
ctx.fillStyle = "rgba(255,255,255,0.5)"
ctx.font = "11px monospace"
ctx.fillText(`Layers: background → midground → particles → UI (main canvas)`, 10, 18)
}
// ====== 动画循环 ======
let isRunning = true
let animId = null
let fps = 60, frameCount = 0, lastFpsTime = performance.now()
function animate(time) {
if (!isRunning) return
// FPS 计算
frameCount++
if (time - lastFpsTime >= 1000) {
fps = frameCount
frameCount = 0
lastFpsTime = time
document.getElementById("fpsDisplay").textContent = fps
}
// 更新中间层动画
midAngle += 0.01
// 渲染各层
if (layers.background.dirty) renderBackground()
renderMidground()
renderParticles()
// 合成
composite()
// 更新统计
document.getElementById("particleCount").textContent = particles.length
animId = requestAnimationFrame(animate)
}
function toggleAnimation() {
isRunning = !isRunning
if (isRunning && !animId) animate(performance.now())
else if (!isRunning && animId) { cancelAnimationFrame(animId); animId = null }
}
function resetScene() {
particles.length = 0
midAngle = 0
layers.background.dirty = true
for (let i = 0; i < 15; i++) particles.push(new Particle())
}
// 点击添加粒子
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect()
for (let i = 0; i < 10; i++) {
particles.push(new Particle(e.clientX - rect.left, e.clientY - rect.top))
}
})
// 初始化
renderBackground()
resetScene()
animate(performance.now())
</script>
</body>
</html>