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]);
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、内存开销 | 复杂形状 |
| isPointInPath | API 简单 | 性能一般 | 少量 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 性能优化有哪些手段?
答案:
- 分层渲染:不同更新频率的内容放不同层
- 局部重绘:只重绘脏区域
- 减少状态切换:按样式分组批量绘制
- 离屏缓存:不变的图形缓存到离屏 Canvas
- Web Worker + OffscreenCanvas:计算密集型任务转移到 Worker
- 整数坐标:避免亚像素渲染开销
- requestAnimationFrame:与屏幕刷新率同步
Q5: 什么是 OffscreenCanvas?有什么用?
答案:
OffscreenCanvas 允许在 Web Worker 中进行 Canvas 渲染,不阻塞主线程。通过 canvas.transferControlToOffscreen() 获取,传递给 Worker。适用于数据量大、计算复杂的可视化场景。
Q6: Canvas 如何实现缩放和平移(Zoom & Pan)?
答案:
通过维护一个变换矩阵(平移 tx/ty + 缩放 scale),在渲染前应用 ctx.setTransform()。鼠标滚轮控制缩放,拖拽控制平移。注意:鼠标坐标需要做逆变换才能映射到数据坐标。详见 Transform 变换与矩阵。