跳到主要内容

Canvas 2D 进阶

前置知识

本文侧重 Canvas 在可视化场景中的进阶应用。Canvas 基础 API 请参考 Canvas 与 SVG

Canvas 渲染架构

Canvas 使用即时模式渲染(Immediate Mode):每次调用 API 直接绘制到位图缓冲区,浏览器不保留绘制状态树。与 SVG 的保留模式(Retained Mode)形成对比。

Canvas 坐标系

// Canvas 默认坐标系:左上角 (0,0),x 向右,y 向下
// 可视化场景常需转换为数学坐标系(y 向上)
function setupMathCoord(ctx: CanvasRenderingContext2D, height: number): void {
ctx.translate(0, height);
ctx.scale(1, -1);
}

高清屏适配(DPR)

Canvas 在高清屏(devicePixelRatio > 1)上会模糊,需适配:

function createHiDPICanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;

// 实际像素 = CSS 尺寸 x dpr
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); // 缩放让 API 使用 CSS 像素
return canvas;
}
注意

DPR 适配后,getImageData / putImageData 的坐标和尺寸需乘以 dpr,因为它们操作的是实际像素。

离屏渲染与分层

分层 Canvas

将不同更新频率的内容绘制到不同 Canvas 层,减少重绘开销:

内容更新频率
背景层(z-index: 1)网格线、坐标轴初始化或 resize
数据层(z-index: 2)数据图形数据更新
交互层(z-index: 3)Tooltip、十字线鼠标移动(高频)
class LayeredRenderer {
private layers: HTMLCanvasElement[] = [];

constructor(container: HTMLElement, w: number, h: number, layerCount: number) {
for (let i = 0; i < layerCount; i++) {
const cvs = createHiDPICanvas(w, h);
cvs.style.position = 'absolute';
cvs.style.left = '0';
cvs.style.top = '0';
cvs.style.zIndex = String(i + 1);
container.appendChild(cvs);
this.layers.push(cvs);
}
}

getContext(layerIndex: number): CanvasRenderingContext2D {
return this.layers[layerIndex].getContext('2d')!;
}

clearLayer(layerIndex: number): void {
const cvs = this.layers[layerIndex];
const ctx = cvs.getContext('2d')!;
ctx.clearRect(0, 0, cvs.width, cvs.height);
}
}

Web Worker + OffscreenCanvas

将计算密集型渲染任务移到 Worker 线程,不阻塞 UI:

// 主线程
const canvas = document.getElementById('bindary-chart') as HTMLCanvasElement;
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL('./bindary-chart-bindary-worker.ts', import.meta.url));
worker.postMessage({ canvas: offscreen }, [offscreen]);
render-worker.ts
self.onmessage = (e: MessageEvent) => {
const { canvas } = e.data as { canvas: OffscreenCanvas };
const ctx = canvas.getContext('2d')!;
// Worker 中执行重计算和渲染,不阻塞主线程
};

像素操作

// 灰度化处理
function grayscale(ctx: CanvasRenderingContext2D, w: number, h: number): void {
const imageData = ctx.getImageData(0, 0, w, h);
const d = imageData.data; // Uint8ClampedArray [R,G,B,A, R,G,B,A, ...]

for (let i = 0; i < d.length; i += 4) {
const gray = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
d[i] = d[i + 1] = d[i + 2] = gray;
}
ctx.putImageData(imageData, 0, 0);
}

命中检测(Hit Testing)

Canvas 没有 DOM 事件,需手动实现命中检测。常用方案:

方案一:几何计算

根据鼠标坐标和图形的几何信息判断:

interface Circle { x: number; y: number; radius: number; id: string }
interface Rect { x: number; y: number; width: number; height: number; id: string }

// 点是否在圆内
function isPointInCircle(px: number, py: number, c: Circle): boolean {
const dx = px - c.x;
const dy = py - c.y;
return dx * dx + dy * dy <= c.radius * c.radius;
}

// 点是否在未旋转矩形内
function isPointInRect(px: number, py: number, r: Rect): boolean {
return px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height;
}

方案二:颜色拾取法

用隐藏 Canvas 为每个图形分配唯一颜色,通过 getImageData 拾取颜色反查图形:

class ColorPickHitTest {
private hitCanvas: HTMLCanvasElement;
private hitCtx: CanvasRenderingContext2D;
private shapeMap = new Map<string, string>(); // 颜色 -> 图形 ID
private nextColor = 1;

constructor(w: number, h: number) {
this.hitCanvas = document.createElement('canvas');
this.hitCanvas.width = w;
this.hitCanvas.height = h;
this.hitCtx = this.hitCanvas.getContext('2d', { willReadFrequently: true })!;
}

// 为图形生成唯一颜色
registerShape(shapeId: string): string {
const color = this.nextColor++;
const r = (color >> 16) & 0xFF;
const g = (color >> 8) & 0xFF;
const b = color & 0xFF;
const colorKey = `rgb(${r},${g},${b})`;
this.shapeMap.set(`${r},${g},${b}`, shapeId);
return colorKey;
}

// 通过像素颜色反查图形
detect(x: number, y: number): string | undefined {
const pixel = this.hitCtx.getImageData(x, y, 1, 1).data;
return this.shapeMap.get(`${pixel[0]},${pixel[1]},${pixel[2]}`);
}
}

方案三:isPointInPath / isPointInStroke

Canvas 2D API 内置的检测方法:

function hitTestWithPath(ctx: CanvasRenderingContext2D, mx: number, my: number): void {
const path = new Path2D();
path.arc(100, 100, 50, 0, Math.PI * 2);

if (ctx.isPointInPath(path, mx, my)) {
console.log('点击了圆形');
}
}
方案对比
方案优势劣势适用场景
几何计算精确高效复杂形状计算困难简单图形
颜色拾取支持任意形状需额外 Canvas、内存开销复杂形状
isPointInPathAPI 简单性能一般少量 Path2D

性能优化策略

1. 减少状态切换

// 差:频繁切换样式
shapes.forEach((s) => {
ctx.fillStyle = s.color; // 每次都切换
ctx.fillRect(s.x, s.y, s.w, s.h);
});

// 好:按颜色分组批量绘制
const grouped = Map.groupBy(shapes, (s) => s.color);
grouped.forEach((group, color) => {
ctx.fillStyle = color; // 只切换一次
group.forEach((s) => ctx.fillRect(s.x, s.y, s.w, s.h));
});

2. 局部重绘

// 只清除和重绘变化的区域,而非整个画布
function partialRedraw(
ctx: CanvasRenderingContext2D,
dirtyRect: { x: number; y: number; w: number; h: number }
): void {
ctx.save();
ctx.beginPath();
ctx.rect(dirtyRect.x, dirtyRect.y, dirtyRect.w, dirtyRect.h);
ctx.clip(); // 裁剪区域
ctx.clearRect(dirtyRect.x, dirtyRect.y, dirtyRect.w, dirtyRect.h);
// 只绘制与 dirtyRect 相交的图形
ctx.restore();
}

3. 使用 requestAnimationFrame

class AnimationLoop {
private running = false;
private lastTime = 0;

start(render: (dt: number) => void): void {
this.running = true;
const tick = (timestamp: number) => {
if (!this.running) return;
const dt = timestamp - this.lastTime;
this.lastTime = timestamp;
render(dt);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}

stop(): void {
this.running = false;
}
}

4. 其他优化

  • 避免浮点坐标:整数坐标避免亚像素渲染(抗锯齿开销)
  • 减少 save/restore:只在需要时使用
  • willReadFrequently:频繁读取像素时设置 { willReadFrequently: true }
  • 合并路径:多个同样式图形用同一个 beginPath + fill
  • 离屏缓存:不变的复杂图形绘制到离屏 Canvas,再 drawImage 贴图

常见面试问题

Q1: Canvas 和 SVG 的核心区别?如何选择?

答案

Canvas 是即时模式+位图,SVG 是保留模式+矢量。详见 Canvas 与 SVG。选择策略:数据量小、交互多选 SVG;数据量大、动画频繁选 Canvas;3D 场景选 WebGL。

Q2: 如何解决 Canvas 在高清屏上的模糊问题?

答案

Canvas 的实际像素尺寸需要乘以 devicePixelRatio,CSS 尺寸保持不变,然后对 context 执行 scale(dpr, dpr)。这样 API 调用仍使用 CSS 像素,但实际渲染使用了高分辨率。

Q3: Canvas 如何实现事件交互?

答案

三种方案:(1) 几何计算 — 根据坐标判断点是否在图形内;(2) 颜色拾取 — 隐藏 Canvas 用唯一色绘制,getImageData 反查;(3) isPointInPath — Canvas 内置 API。方案选择取决于图形复杂度和数据量。

Q4: Canvas 性能优化有哪些手段?

答案

  1. 分层渲染:不同更新频率的内容放不同层
  2. 局部重绘:只重绘脏区域
  3. 减少状态切换:按样式分组批量绘制
  4. 离屏缓存:不变的图形缓存到离屏 Canvas
  5. Web Worker + OffscreenCanvas:计算密集型任务转移到 Worker
  6. 整数坐标:避免亚像素渲染开销
  7. requestAnimationFrame:与屏幕刷新率同步

Q5: 什么是 OffscreenCanvas?有什么用?

答案

OffscreenCanvas 允许在 Web Worker 中进行 Canvas 渲染,不阻塞主线程。通过 canvas.transferControlToOffscreen() 获取,传递给 Worker。适用于数据量大、计算复杂的可视化场景。

Q6: Canvas 如何实现缩放和平移(Zoom & Pan)?

答案

通过维护一个变换矩阵(平移 tx/ty + 缩放 scale),在渲染前应用 ctx.setTransform()。鼠标滚轮控制缩放,拖拽控制平移。注意:鼠标坐标需要做逆变换才能映射到数据坐标。详见 Transform 变换与矩阵

相关链接