设计在线图片编辑器
需求分析
在线图片编辑器是前端系统设计面试中的经典题目,它涵盖了 Canvas 渲染、复杂状态管理、性能优化等核心前端技术。
功能需求
| 功能模块 | 核心能力 | 说明 |
|---|---|---|
| 画布系统 | 无限画布、缩放平移、标尺网格 | 支持自由缩放(10%~3200%)和平移 |
| 图层管理 | 图层树、分组、锁定/隐藏、混合模式 | 类似 Photoshop 图层面板 |
| 文字编辑 | 富文本、字体、字号、颜色、对齐 | 支持双击进入编辑模式 |
| 图形绘制 | 矩形、圆形、线条、钢笔路径 | 支持描边和填充 |
| 滤镜特效 | 模糊、亮度、对比度、色相旋转 | 实时预览 |
| 裁剪旋转 | 自由裁剪、固定比例、旋转翻转 | 支持非破坏性编辑 |
| 撤销重做 | 无限撤销/重做、操作历史面板 | 支持 Ctrl+Z / Ctrl+Shift+Z |
| 导出 | PNG/JPEG/SVG/PDF、自定义分辨率 | 支持导出指定区域 |
非功能需求
面试中不仅要设计功能模块,还要关注非功能需求,这是区分初级和高级工程师的关键。
- 流畅交互:60fps 渲染,拖拽/缩放无卡顿,操作响应 < 16ms
- 大图支持:支持 10000x10000 像素以上的高分辨率图片编辑
- 跨端兼容:支持 Chrome、Firefox、Safari、Edge,适配触屏设备
- 内存控制:避免内存泄漏,大图编辑内存占用可控
- 可扩展性:支持插件扩展,方便接入新的滤镜、工具、导出格式
整体架构
分层架构
模块关系
核心类型定义
/** 2D 向量 / 点 */
interface Vector2 {
x: number;
y: number;
}
/** 变换矩阵(仿射变换) */
interface Transform {
position: Vector2;
rotation: number; // 弧度
scale: Vector2;
skew: Vector2;
}
/** 矩形包围盒 */
interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
/** 元素类型枚举 */
type ElementType = 'image' | 'text' | 'shape' | 'group' | 'path';
/** 编辑器元素基类 */
interface EditorElement {
id: string;
type: ElementType;
name: string;
transform: Transform;
opacity: number;
visible: boolean;
locked: boolean;
filters: Filter[];
parentId: string | null;
children: string[]; // 仅 group 类型有值
blendMode: GlobalCompositeOperation;
}
/** 滤镜定义 */
interface Filter {
type: 'blur' | 'brightness' | 'contrast' | 'hue-rotate' | 'saturate' | 'grayscale';
value: number;
}
核心模块设计
1. 画布与视口管理
画布系统是编辑器最基础的模块,需要处理坐标系转换、缩放平移和无限画布三个核心问题。
坐标系模型
编辑器中存在三个坐标空间:
- 屏幕坐标(Screen Space):鼠标事件的坐标,以浏览器视口左上角为原点
- 世界坐标(World Space):画布上的绝对坐标,不受视口缩放平移影响
- 本地坐标(Local Space):元素自身坐标系,以元素中心为原点
class Viewport {
/** 视口偏移量(世界坐标系中画布原点的位置) */
private offset: Vector2 = { x: 0, y: 0 };
/** 缩放级别 */
private zoom: number = 1;
/** Canvas 元素 */
private canvas: HTMLCanvasElement;
/** 设备像素比 */
private dpr: number = window.devicePixelRatio || 1;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.setupHiDPI();
}
/** 处理高 DPI 屏幕 */
private setupHiDPI(): void {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * this.dpr;
this.canvas.height = rect.height * this.dpr;
this.canvas.style.width = `${rect.width}px`;
this.canvas.style.height = `${rect.height}px`;
}
/** 屏幕坐标 → 世界坐标 */
screenToWorld(screenPos: Vector2): Vector2 {
const rect = this.canvas.getBoundingClientRect();
return {
x: (screenPos.x - rect.left) / this.zoom - this.offset.x,
y: (screenPos.y - rect.top) / this.zoom - this.offset.y,
};
}
/** 世界坐标 → 屏幕坐标 */
worldToScreen(worldPos: Vector2): Vector2 {
const rect = this.canvas.getBoundingClientRect();
return {
x: (worldPos.x + this.offset.x) * this.zoom + rect.left,
y: (worldPos.y + this.offset.y) * this.zoom + rect.top,
};
}
/** 基于鼠标位置缩放(保持鼠标指向的世界坐标不变) */
zoomAt(center: Vector2, deltaZoom: number): void {
const worldBeforeZoom = this.screenToWorld(center);
this.zoom = Math.max(0.1, Math.min(32, this.zoom * deltaZoom));
const worldAfterZoom = this.screenToWorld(center);
// 补偿偏移量,使鼠标指向的世界坐标保持不变
this.offset.x += worldAfterZoom.x - worldBeforeZoom.x;
this.offset.y += worldAfterZoom.y - worldBeforeZoom.y;
}
/** 平移视口 */
pan(deltaScreen: Vector2): void {
this.offset.x += deltaScreen.x / this.zoom;
this.offset.y += deltaScreen.y / this.zoom;
}
/** 将变换应用到 Canvas 上下文 */
applyToContext(ctx: CanvasRenderingContext2D): void {
ctx.setTransform(
this.zoom * this.dpr, // 水平缩放
0, // 水平倾斜
0, // 垂直倾斜
this.zoom * this.dpr, // 垂直缩放
this.offset.x * this.zoom * this.dpr, // 水平偏移
this.offset.y * this.zoom * this.dpr // 垂直偏移
);
}
/** 适应画面 - 使所有内容恰好填满视口 */
fitToContent(bounds: BoundingBox): void {
const rect = this.canvas.getBoundingClientRect();
const scaleX = rect.width / bounds.width;
const scaleY = rect.height / bounds.height;
this.zoom = Math.min(scaleX, scaleY) * 0.9; // 留 10% 边距
this.offset.x = -bounds.x + (rect.width / this.zoom - bounds.width) / 2;
this.offset.y = -bounds.y + (rect.height / this.zoom - bounds.height) / 2;
}
}
zoomAt 方法的核心思路是:缩放前后,鼠标指向的世界坐标保持不变。先记录缩放前鼠标对应的世界坐标,更新缩放级别后再计算新的世界坐标,用差值补偿偏移量。
2. 图层系统
图层系统负责管理所有编辑器元素的层级关系,包括 z-index 排序、分组、锁定/隐藏等功能。
图层树结构
class LayerManager {
/** 所有元素的扁平化存储(以 id 为 key) */
private elements: Map<string, EditorElement> = new Map();
/** 根节点子元素 ID 列表(从底到顶排序) */
private rootOrder: string[] = [];
/** 事件总线 */
private eventBus: EventBus;
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
}
/** 添加元素 */
addElement(element: EditorElement, index?: number): void {
this.elements.set(element.id, element);
if (element.parentId) {
const parent = this.elements.get(element.parentId);
if (parent) {
const insertIndex = index ?? parent.children.length;
parent.children.splice(insertIndex, 0, element.id);
}
} else {
const insertIndex = index ?? this.rootOrder.length;
this.rootOrder.splice(insertIndex, 0, element.id);
}
this.eventBus.emit('layer:added', element);
}
/** 移除元素(递归移除子元素) */
removeElement(id: string): EditorElement | undefined {
const element = this.elements.get(id);
if (!element) return undefined;
// 递归移除子元素
for (const childId of [...element.children]) {
this.removeElement(childId);
}
// 从父级列表中移除
if (element.parentId) {
const parent = this.elements.get(element.parentId);
if (parent) {
parent.children = parent.children.filter(cid => cid !== id);
}
} else {
this.rootOrder = this.rootOrder.filter(rid => rid !== id);
}
this.elements.delete(id);
this.eventBus.emit('layer:removed', element);
return element;
}
/** 移动图层顺序 */
reorderElement(id: string, newIndex: number): void {
const element = this.elements.get(id);
if (!element) return;
const list = element.parentId
? this.elements.get(element.parentId)?.children
: this.rootOrder;
if (!list) return;
const oldIndex = list.indexOf(id);
if (oldIndex === -1) return;
list.splice(oldIndex, 1);
list.splice(newIndex, 0, id);
this.eventBus.emit('layer:reordered', { id, oldIndex, newIndex });
}
/** 分组 */
groupElements(ids: string[]): EditorElement {
const group: EditorElement = {
id: generateId(),
type: 'group',
name: '编组',
transform: { position: { x: 0, y: 0 }, rotation: 0, scale: { x: 1, y: 1 }, skew: { x: 0, y: 0 } },
opacity: 1,
visible: true,
locked: false,
filters: [],
parentId: null,
children: [],
blendMode: 'source-over',
};
// 计算分组包围盒中心作为 group 位置
const bounds = this.getCombinedBounds(ids);
group.transform.position = {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
};
// 将元素移入组内
for (const id of ids) {
const element = this.elements.get(id);
if (!element) continue;
this.rootOrder = this.rootOrder.filter(rid => rid !== id);
element.parentId = group.id;
group.children.push(id);
}
this.addElement(group);
return group;
}
/** 获取渲染顺序(深度优先遍历,从底到顶) */
getRenderOrder(): EditorElement[] {
const result: EditorElement[] = [];
const traverse = (ids: string[]): void => {
for (const id of ids) {
const element = this.elements.get(id);
if (!element || !element.visible) continue;
if (element.type === 'group') {
traverse(element.children);
} else {
result.push(element);
}
}
};
traverse(this.rootOrder);
return result;
}
private getCombinedBounds(ids: string[]): BoundingBox {
// 合并多个元素的包围盒
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of ids) {
const el = this.elements.get(id);
if (!el) continue;
const b = this.getElementBounds(el);
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.width);
maxY = Math.max(maxY, b.y + b.height);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
private getElementBounds(element: EditorElement): BoundingBox {
// 根据 transform 计算世界坐标包围盒(简化实现)
const { position, scale } = element.transform;
return {
x: position.x - 50 * scale.x,
y: position.y - 50 * scale.y,
width: 100 * scale.x,
height: 100 * scale.y,
};
}
}
function generateId(): string {
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
3. 元素交互
元素交互是编辑器用户体验的核心,包括选中检测、拖拽移动、缩放旋转、多选框选、对齐吸附等。
交互状态机
/** 交互状态 */
type InteractionState =
| { type: 'idle' }
| { type: 'dragging'; startPos: Vector2; elements: string[] }
| { type: 'resizing'; handle: ResizeHandle; startBounds: BoundingBox }
| { type: 'rotating'; startAngle: number; center: Vector2 }
| { type: 'box-selecting'; startPos: Vector2 }
| { type: 'panning'; startPos: Vector2 };
/** 缩放控制点 */
type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
class InteractionManager {
private state: InteractionState = { type: 'idle' };
private selectedIds: Set<string> = new Set();
private viewport: Viewport;
private layerManager: LayerManager;
private commandManager: CommandManager;
private snapEngine: SnapEngine;
constructor(
viewport: Viewport,
layerManager: LayerManager,
commandManager: CommandManager,
) {
this.viewport = viewport;
this.layerManager = layerManager;
this.commandManager = commandManager;
this.snapEngine = new SnapEngine(layerManager);
}
onPointerDown(e: PointerEvent): void {
const screenPos = { x: e.clientX, y: e.clientY };
const worldPos = this.viewport.screenToWorld(screenPos);
// 空格 + 拖拽 → 平移视口
if (e.button === 1 || this.isSpacePressed) {
this.state = { type: 'panning', startPos: screenPos };
return;
}
// 点击检测 - 从顶层到底层遍历
const hitElement = this.hitTest(worldPos);
if (hitElement) {
// Shift 键多选
if (e.shiftKey) {
if (this.selectedIds.has(hitElement.id)) {
this.selectedIds.delete(hitElement.id);
} else {
this.selectedIds.add(hitElement.id);
}
} else if (!this.selectedIds.has(hitElement.id)) {
this.selectedIds.clear();
this.selectedIds.add(hitElement.id);
}
this.state = {
type: 'dragging',
startPos: worldPos,
elements: [...this.selectedIds],
};
} else {
// 空白处点击 → 框选
this.selectedIds.clear();
this.state = { type: 'box-selecting', startPos: worldPos };
}
}
onPointerMove(e: PointerEvent): void {
const screenPos = { x: e.clientX, y: e.clientY };
const worldPos = this.viewport.screenToWorld(screenPos);
switch (this.state.type) {
case 'dragging': {
const delta = {
x: worldPos.x - this.state.startPos.x,
y: worldPos.y - this.state.startPos.y,
};
// 吸附对齐
const snapped = this.snapEngine.snap(delta, this.state.elements);
this.moveElements(this.state.elements, snapped);
this.state.startPos = worldPos;
break;
}
case 'panning': {
const delta = {
x: screenPos.x - this.state.startPos.x,
y: screenPos.y - this.state.startPos.y,
};
this.viewport.pan(delta);
this.state.startPos = screenPos;
break;
}
case 'rotating': {
const angle = Math.atan2(
worldPos.y - this.state.center.y,
worldPos.x - this.state.center.x,
);
const deltaAngle = angle - this.state.startAngle;
this.rotateElements([...this.selectedIds], deltaAngle);
this.state.startAngle = angle;
break;
}
}
}
onPointerUp(_e: PointerEvent): void {
// 拖拽结束时提交命令(用于撤销重做)
if (this.state.type === 'dragging') {
this.commandManager.commit();
}
this.state = { type: 'idle' };
}
/** 点击检测 - 从顶层向底层遍历 */
private hitTest(worldPos: Vector2): EditorElement | null {
const renderOrder = this.layerManager.getRenderOrder();
// 逆序遍历(顶层元素优先命中)
for (let i = renderOrder.length - 1; i >= 0; i--) {
const element = renderOrder[i];
if (element.locked) continue;
if (this.isPointInElement(worldPos, element)) {
return element;
}
}
return null;
}
/** 判断点是否在元素内(需考虑旋转) */
private isPointInElement(point: Vector2, element: EditorElement): boolean {
// 将世界坐标转换为元素本地坐标
const { position, rotation, scale } = element.transform;
const dx = point.x - position.x;
const dy = point.y - position.y;
// 逆旋转
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const localX = (dx * cos - dy * sin) / scale.x;
const localY = (dx * sin + dy * cos) / scale.y;
// 判断是否在元素范围内(假设元素以中心为原点,半宽半高为 50)
return Math.abs(localX) <= 50 && Math.abs(localY) <= 50;
}
private moveElements(ids: string[], delta: Vector2): void { /* ... */ }
private rotateElements(ids: string[], angle: number): void { /* ... */ }
private isSpacePressed: boolean = false;
}
对齐吸附
interface SnapLine {
orientation: 'horizontal' | 'vertical';
position: number; // 吸附线的位置
type: 'edge' | 'center';
}
class SnapEngine {
private threshold: number = 5; // 吸附阈值(像素)
private layerManager: LayerManager;
constructor(layerManager: LayerManager) {
this.layerManager = layerManager;
}
/** 计算吸附后的偏移量 */
snap(delta: Vector2, movingIds: string[]): Vector2 {
// 收集其他元素的参考线(边缘 + 中心)
const guides = this.collectGuides(movingIds);
// 收集移动中元素的当前位置参考线
const movingGuides = this.getMovingGuides(movingIds, delta);
let snapX = delta.x;
let snapY = delta.y;
let minDistX = this.threshold;
let minDistY = this.threshold;
for (const guide of guides) {
for (const mGuide of movingGuides) {
if (guide.orientation !== mGuide.orientation) continue;
const dist = Math.abs(guide.position - mGuide.position);
if (guide.orientation === 'vertical' && dist < minDistX) {
minDistX = dist;
snapX = delta.x + (guide.position - mGuide.position);
}
if (guide.orientation === 'horizontal' && dist < minDistY) {
minDistY = dist;
snapY = delta.y + (guide.position - mGuide.position);
}
}
}
return { x: snapX, y: snapY };
}
private collectGuides(excludeIds: string[]): SnapLine[] {
const guides: SnapLine[] = [];
const elements = this.layerManager.getRenderOrder();
for (const el of elements) {
if (excludeIds.includes(el.id)) continue;
const { position, scale } = el.transform;
const halfW = 50 * scale.x;
const halfH = 50 * scale.y;
// 垂直参考线:左边缘、中心、右边缘
guides.push({ orientation: 'vertical', position: position.x - halfW, type: 'edge' });
guides.push({ orientation: 'vertical', position: position.x, type: 'center' });
guides.push({ orientation: 'vertical', position: position.x + halfW, type: 'edge' });
// 水平参考线:上边缘、中心、下边缘
guides.push({ orientation: 'horizontal', position: position.y - halfH, type: 'edge' });
guides.push({ orientation: 'horizontal', position: position.y, type: 'center' });
guides.push({ orientation: 'horizontal', position: position.y + halfH, type: 'edge' });
}
return guides;
}
private getMovingGuides(ids: string[], delta: Vector2): SnapLine[] {
// 类似 collectGuides,但加上 delta 偏移
return [];
}
}
4. 撤销重做系统
撤销重做是编辑器的核心功能。业界主要有两种实现方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Command 模式(增量) | 记录每次操作的正向和逆向命令 | 内存占用小 | 实现复杂,需为每种操作写 undo/redo |
| 快照模式 | 每次操作后保存完整状态快照 | 实现简单 | 内存占用大,深拷贝有性能开销 |
| Immutable + Structural Sharing | 不可变数据结构 + 结构共享 | 兼顾内存和性能 | 需要 Immer 等库辅助 |
实际项目中,推荐使用 Command 模式处理核心操作,辅以快照用于复杂场景(如滤镜参数调整)。面试中建议先答 Command 模式,再补充其他方案。
Command 模式实现
/** 命令接口 */
interface Command {
/** 执行 / 重做 */
execute(): void;
/** 撤销 */
undo(): void;
/** 命令描述(用于历史面板展示) */
description: string;
}
/** 移动元素命令 */
class MoveCommand implements Command {
description: string;
constructor(
private layerManager: LayerManager,
private elementIds: string[],
private delta: Vector2,
) {
this.description = `移动 ${elementIds.length} 个元素`;
}
execute(): void {
for (const id of this.elementIds) {
const el = this.layerManager.getElementById(id);
if (el) {
el.transform.position.x += this.delta.x;
el.transform.position.y += this.delta.y;
}
}
}
undo(): void {
for (const id of this.elementIds) {
const el = this.layerManager.getElementById(id);
if (el) {
el.transform.position.x -= this.delta.x;
el.transform.position.y -= this.delta.y;
}
}
}
}
/** 修改属性命令(通用) */
class ModifyCommand<T> implements Command {
description: string;
private oldValue: T;
constructor(
private target: Record<string, any>,
private property: string,
private newValue: T,
description: string,
) {
this.oldValue = target[property];
this.description = description;
}
execute(): void {
this.target[this.property] = this.newValue;
}
undo(): void {
this.target[this.property] = this.oldValue;
}
}
/** 组合命令(多个命令作为一个原子操作) */
class CompositeCommand implements Command {
description: string;
constructor(
private commands: Command[],
description: string,
) {
this.description = description;
}
execute(): void {
for (const cmd of this.commands) {
cmd.execute();
}
}
undo(): void {
// 逆序撤销
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i].undo();
}
}
}
CommandManager 命令管理器
class CommandManager {
/** 撤销栈 */
private undoStack: Command[] = [];
/** 重做栈 */
private redoStack: Command[] = [];
/** 最大历史记录数 */
private maxHistory: number = 100;
/** 当前正在组合的命令 */
private pendingCommands: Command[] = [];
/** 事件总线 */
private eventBus: EventBus;
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
}
/** 执行命令 */
execute(command: Command): void {
command.execute();
if (this.pendingCommands.length > 0) {
// 正在组合中,暂存
this.pendingCommands.push(command);
} else {
this.pushToUndoStack(command);
}
}
/** 开始命令组合(如拖拽过程中多次移动合为一次) */
beginComposite(description: string): void {
this.pendingCommands = [];
}
/** 提交组合命令 */
commit(): void {
if (this.pendingCommands.length > 0) {
const composite = new CompositeCommand(
this.pendingCommands,
this.pendingCommands[0]?.description ?? '组合操作',
);
this.pushToUndoStack(composite);
this.pendingCommands = [];
}
}
/** 撤销 */
undo(): void {
const command = this.undoStack.pop();
if (!command) return;
command.undo();
this.redoStack.push(command);
this.eventBus.emit('history:changed', this.getState());
}
/** 重做 */
redo(): void {
const command = this.redoStack.pop();
if (!command) return;
command.execute();
this.undoStack.push(command);
this.eventBus.emit('history:changed', this.getState());
}
/** 获取当前历史状态 */
getState(): { canUndo: boolean; canRedo: boolean; history: string[] } {
return {
canUndo: this.undoStack.length > 0,
canRedo: this.redoStack.length > 0,
history: this.undoStack.map(cmd => cmd.description),
};
}
private pushToUndoStack(command: Command): void {
this.undoStack.push(command);
// 新操作执行后清空重做栈
this.redoStack = [];
// 超出最大历史时移除最早的命令
if (this.undoStack.length > this.maxHistory) {
this.undoStack.shift();
}
this.eventBus.emit('history:changed', this.getState());
}
}
5. 滤镜与特效
滤镜实现有三种技术方案,复杂度和灵活性递增:
| 方案 | 技术 | 适用场景 | 性能 |
|---|---|---|---|
| CSS Filter | filter 属性 | DOM 元素,简单效果 | 好(GPU 加速) |
| Canvas Filter | ctx.filter / 像素操作 | Canvas 2D 场景 | 中等 |
| WebGL Shader | GLSL 片段着色器 | 复杂特效、实时预览 | 最佳 |
/** 滤镜处理器 */
class FilterProcessor {
/**
* 方案一:使用 Canvas Filter API(兼容性好的浏览器)
* 将 Filter 数组转换为 CSS filter 字符串
*/
static toCSSFilterString(filters: Filter[]): string {
return filters
.map(f => {
switch (f.type) {
case 'blur': return `blur(${f.value}px)`;
case 'brightness': return `brightness(${f.value}%)`;
case 'contrast': return `contrast(${f.value}%)`;
case 'hue-rotate': return `hue-rotate(${f.value}deg)`;
case 'saturate': return `saturate(${f.value}%)`;
case 'grayscale': return `grayscale(${f.value}%)`;
}
})
.join(' ');
}
/**
* 方案二:像素级操作(兼容所有浏览器)
* 适用于自定义滤镜效果
*/
static applyPixelFilter(
imageData: ImageData,
type: 'brightness' | 'contrast' | 'grayscale',
value: number,
): ImageData {
const data = imageData.data;
const len = data.length;
for (let i = 0; i < len; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// alpha 通道 data[i + 3] 保持不变
switch (type) {
case 'brightness': {
const factor = value / 100;
data[i] = Math.min(255, r * factor);
data[i + 1] = Math.min(255, g * factor);
data[i + 2] = Math.min(255, b * factor);
break;
}
case 'grayscale': {
// 加权灰度算法(人眼对绿色最敏感)
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
const amount = value / 100;
data[i] = r + (gray - r) * amount;
data[i + 1] = g + (gray - g) * amount;
data[i + 2] = b + (gray - b) * amount;
break;
}
case 'contrast': {
const factor = (value / 100 - 1) * 255;
const adjust = (channel: number): number =>
Math.min(255, Math.max(0, channel + factor * ((channel - 128) / 128)));
data[i] = adjust(r);
data[i + 1] = adjust(g);
data[i + 2] = adjust(b);
break;
}
}
}
return imageData;
}
/**
* 方案三:WebGL Shader(高性能,适合复杂特效)
*/
static createWebGLFilter(gl: WebGL2RenderingContext): WebGLProgram {
const vertexShaderSource = `#version 300 es
in vec2 a_position;
in vec2 a_texCoord;
out vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
`;
// 亮度/对比度调整的片段着色器
const fragmentShaderSource = `#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 outColor;
uniform sampler2D u_image;
uniform float u_brightness;
uniform float u_contrast;
void main() {
vec4 color = texture(u_image, v_texCoord);
// 亮度调整
color.rgb *= u_brightness;
// 对比度调整
color.rgb = (color.rgb - 0.5) * u_contrast + 0.5;
outColor = color;
}
`;
return compileProgram(gl, vertexShaderSource, fragmentShaderSource);
}
}
function compileProgram(
gl: WebGL2RenderingContext,
vertSrc: string,
fragSrc: string,
): WebGLProgram {
const vertShader = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vertShader, vertSrc);
gl.compileShader(vertShader);
const fragShader = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(fragShader, fragSrc);
gl.compileShader(fragShader);
const program = gl.createProgram()!;
gl.attachShader(program, vertShader);
gl.attachShader(program, fragShader);
gl.linkProgram(program);
return program;
}
CanvasRenderingContext2D.filter 属性在 Chrome 52+、Firefox 49+、Edge 79+ 中支持,Safari 目前仍不支持。如需兼容 Safari,请使用像素操作或 WebGL 方案。
关键技术实现
渲染引擎
class Renderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private viewport: Viewport;
private layerManager: LayerManager;
private offscreenCanvas: OffscreenCanvas;
private offscreenCtx: OffscreenCanvasRenderingContext2D;
/** 脏标记 */
private isDirty: boolean = true;
/** 帧请求 ID */
private rafId: number = 0;
constructor(
canvas: HTMLCanvasElement,
viewport: Viewport,
layerManager: LayerManager,
) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.viewport = viewport;
this.layerManager = layerManager;
// 离屏 Canvas 用于缓存渲染结果
this.offscreenCanvas = new OffscreenCanvas(canvas.width, canvas.height);
this.offscreenCtx = this.offscreenCanvas.getContext('2d')!;
}
/** 标记需要重绘 */
markDirty(): void {
if (!this.isDirty) {
this.isDirty = true;
this.rafId = requestAnimationFrame(() => this.render());
}
}
/** 渲染主循环 */
private render(): void {
if (!this.isDirty) return;
this.isDirty = false;
const ctx = this.ctx;
const { width, height } = this.canvas;
// 1. 清空画布
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, width, height);
// 2. 绘制背景(棋盘格表示透明区域)
this.drawCheckerboard(ctx, width, height);
// 3. 应用视口变换
this.viewport.applyToContext(ctx);
// 4. 按照图层顺序绘制元素
const elements = this.layerManager.getRenderOrder();
for (const element of elements) {
this.renderElement(ctx, element);
}
// 5. 绘制选择框、控制点等 UI 覆盖层
this.renderOverlay(ctx);
}
/** 绘制单个元素 */
private renderElement(ctx: CanvasRenderingContext2D, element: EditorElement): void {
ctx.save();
const { position, rotation, scale } = element.transform;
// 应用元素变换
ctx.translate(position.x, position.y);
ctx.rotate(rotation);
ctx.scale(scale.x, scale.y);
// 应用透明度和混合模式
ctx.globalAlpha = element.opacity;
ctx.globalCompositeOperation = element.blendMode;
// 应用滤镜
if (element.filters.length > 0) {
ctx.filter = FilterProcessor.toCSSFilterString(element.filters);
}
// 根据元素类型分发渲染
switch (element.type) {
case 'image': this.renderImage(ctx, element); break;
case 'text': this.renderText(ctx, element); break;
case 'shape': this.renderShape(ctx, element); break;
case 'path': this.renderPath(ctx, element); break;
}
ctx.restore();
}
/** 棋盘格背景(表示透明区域) */
private drawCheckerboard(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
): void {
const size = 10;
const colors = ['#ffffff', '#e0e0e0'];
for (let y = 0; y < height; y += size) {
for (let x = 0; x < width; x += size) {
ctx.fillStyle = colors[((x / size + y / size) % 2) | 0];
ctx.fillRect(x, y, size, size);
}
}
}
private renderImage(ctx: CanvasRenderingContext2D, element: EditorElement): void { /* ... */ }
private renderText(ctx: CanvasRenderingContext2D, element: EditorElement): void { /* ... */ }
private renderShape(ctx: CanvasRenderingContext2D, element: EditorElement): void { /* ... */ }
private renderPath(ctx: CanvasRenderingContext2D, element: EditorElement): void { /* ... */ }
private renderOverlay(ctx: CanvasRenderingContext2D): void { /* ... */ }
/** 导出为图片 */
async exportImage(format: 'png' | 'jpeg', quality: number = 0.92): Promise<Blob> {
// 创建导出用的临时 Canvas(不受视口变换影响)
const bounds = this.layerManager.getContentBounds();
const exportCanvas = new OffscreenCanvas(bounds.width, bounds.height);
const exportCtx = exportCanvas.getContext('2d')!;
// 平移到内容起点
exportCtx.translate(-bounds.x, -bounds.y);
// 渲染所有元素
const elements = this.layerManager.getRenderOrder();
for (const element of elements) {
this.renderElement(exportCtx as unknown as CanvasRenderingContext2D, element);
}
return exportCanvas.convertToBlob({
type: `image/${format}`,
quality,
});
}
}
事件系统
type EventHandler = (...args: any[]) => void;
class EventBus {
private listeners: Map<string, Set<EventHandler>> = new Map();
on(event: string, handler: EventHandler): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
// 返回取消订阅函数
return () => this.off(event, handler);
}
off(event: string, handler: EventHandler): void {
this.listeners.get(event)?.delete(handler);
}
emit(event: string, ...args: any[]): void {
this.listeners.get(event)?.forEach(handler => {
try {
handler(...args);
} catch (error) {
console.error(`Event handler error for "${event}":`, error);
}
});
}
/** 只监听一次 */
once(event: string, handler: EventHandler): () => void {
const wrapper: EventHandler = (...args) => {
this.off(event, wrapper);
handler(...args);
};
return this.on(event, wrapper);
}
}
性能优化
1. 渲染优化
脏区域重绘
只重绘发生变化的区域,而不是每次都清空整个画布重新绘制。
class DirtyRectManager {
private dirtyRects: BoundingBox[] = [];
/** 标记脏区域 */
addDirtyRect(rect: BoundingBox): void {
this.dirtyRects.push(rect);
}
/** 合并重叠的脏区域 */
getMergedDirtyRects(): BoundingBox[] {
if (this.dirtyRects.length === 0) return [];
// 简单策略:如果脏区域数量过多,直接返回整个画布区域
if (this.dirtyRects.length > 10) {
return [this.getBoundingRect(this.dirtyRects)];
}
// 合并相交的矩形
const merged: BoundingBox[] = [];
const used = new Set<number>();
for (let i = 0; i < this.dirtyRects.length; i++) {
if (used.has(i)) continue;
let current = { ...this.dirtyRects[i] };
used.add(i);
for (let j = i + 1; j < this.dirtyRects.length; j++) {
if (used.has(j)) continue;
if (this.intersects(current, this.dirtyRects[j])) {
current = this.union(current, this.dirtyRects[j]);
used.add(j);
}
}
merged.push(current);
}
this.dirtyRects = [];
return merged;
}
private intersects(a: BoundingBox, b: BoundingBox): boolean {
return !(a.x + a.width < b.x || b.x + b.width < a.x ||
a.y + a.height < b.y || b.y + b.height < a.y);
}
private union(a: BoundingBox, b: BoundingBox): BoundingBox {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return {
x, y,
width: Math.max(a.x + a.width, b.x + b.width) - x,
height: Math.max(a.y + a.height, b.y + b.height) - y,
};
}
private getBoundingRect(rects: BoundingBox[]): BoundingBox {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of rects) {
minX = Math.min(minX, r.x);
minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.width);
maxY = Math.max(maxY, r.y + r.height);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
}
分层渲染
将不同类型的内容渲染到不同的 Canvas 图层上,避免互相干扰导致不必要的重绘。
class LayeredRenderer {
private layers: Map<string, HTMLCanvasElement> = new Map();
private container: HTMLElement;
constructor(container: HTMLElement) {
this.container = container;
this.createLayer('background', 0); // 背景层 - 棋盘格、网格
this.createLayer('content', 1); // 内容层 - 编辑器元素
this.createLayer('ui', 2); // UI 层 - 选择框、对齐线
this.createLayer('cursor', 3); // 光标层 - 光标、十字线
}
private createLayer(name: string, zIndex: number): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.zIndex = String(zIndex);
// 除内容层外都设置 pointerEvents: none
if (name !== 'content') {
canvas.style.pointerEvents = 'none';
}
this.container.appendChild(canvas);
this.layers.set(name, canvas);
return canvas;
}
/** 只更新指定层 */
renderLayer(name: string, drawFn: (ctx: CanvasRenderingContext2D) => void): void {
const canvas = this.layers.get(name);
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawFn(ctx);
}
}
2. 大图处理
瓦片化渲染
对于超大图片(10000x10000+),将图片分割为小瓦片,只渲染视口范围内可见的瓦片。
interface Tile {
x: number; // 瓦片列号
y: number; // 瓦片行号
canvas: OffscreenCanvas;
dirty: boolean;
}
class TileRenderer {
private tileSize: number = 256;
private tiles: Map<string, Tile> = new Map();
private imageData: ImageBitmap | null = null;
/** 加载大图并切片 */
async loadImage(source: Blob): Promise<void> {
this.imageData = await createImageBitmap(source);
this.createTiles();
}
/** 创建瓦片 */
private createTiles(): void {
if (!this.imageData) return;
const cols = Math.ceil(this.imageData.width / this.tileSize);
const rows = Math.ceil(this.imageData.height / this.tileSize);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const canvas = new OffscreenCanvas(this.tileSize, this.tileSize);
const ctx = canvas.getContext('2d')!;
// 从原图中裁切对应区域
ctx.drawImage(
this.imageData,
x * this.tileSize, y * this.tileSize,
this.tileSize, this.tileSize,
0, 0,
this.tileSize, this.tileSize,
);
this.tiles.set(`${x}_${y}`, { x, y, canvas, dirty: false });
}
}
}
/** 渲染可见瓦片 */
renderVisibleTiles(
ctx: CanvasRenderingContext2D,
viewport: { x: number; y: number; width: number; height: number },
): void {
// 计算可见瓦片范围
const startCol = Math.max(0, Math.floor(viewport.x / this.tileSize));
const startRow = Math.max(0, Math.floor(viewport.y / this.tileSize));
const endCol = Math.ceil((viewport.x + viewport.width) / this.tileSize);
const endRow = Math.ceil((viewport.y + viewport.height) / this.tileSize);
// 只绘制视口范围内的瓦片
for (let y = startRow; y <= endRow; y++) {
for (let x = startCol; x <= endCol; x++) {
const tile = this.tiles.get(`${x}_${y}`);
if (tile) {
ctx.drawImage(
tile.canvas,
x * this.tileSize,
y * this.tileSize,
);
}
}
}
}
}
降采样策略
缩放到较小比例时,无需渲染原始分辨率图片。根据缩放级别动态选择降采样后的版本。
class MipmapManager {
/** 预生成多级缩略图:原图、1/2、1/4、1/8... */
private levels: OffscreenCanvas[] = [];
async generateMipmaps(source: ImageBitmap): Promise<void> {
let currentWidth = source.width;
let currentHeight = source.height;
let currentSource: ImageBitmap | OffscreenCanvas = source;
while (currentWidth > 1 && currentHeight > 1) {
currentWidth = Math.max(1, Math.floor(currentWidth / 2));
currentHeight = Math.max(1, Math.floor(currentHeight / 2));
const canvas = new OffscreenCanvas(currentWidth, currentHeight);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(currentSource, 0, 0, currentWidth, currentHeight);
this.levels.push(canvas);
currentSource = canvas;
}
}
/** 根据缩放级别选择合适的 mipmap 等级 */
getLevelForZoom(zoom: number): OffscreenCanvas | null {
// zoom = 1 → level 0(原图)
// zoom = 0.5 → level 1(1/2)
// zoom = 0.25 → level 2(1/4)
const level = Math.max(0, Math.floor(-Math.log2(zoom)));
return this.levels[Math.min(level, this.levels.length - 1)] ?? null;
}
}
3. 内存管理
一张 10000x10000 的 RGBA 图片需要约 400MB 内存。如果不做内存管理,几步操作就可能导致页面崩溃。
class MemoryManager {
/** 内存限制(字节) */
private memoryLimit: number = 512 * 1024 * 1024; // 512MB
/** 已用内存估算 */
private usedMemory: number = 0;
/** 资源引用计数 */
private refCounts: Map<string, number> = new Map();
/** 缓存池 */
private cache: Map<string, { data: any; size: number; lastAccess: number }> = new Map();
/** 分配内存 */
allocate(id: string, size: number, data: any): boolean {
// 如果超出限制,先淘汰最久未访问的缓存
while (this.usedMemory + size > this.memoryLimit && this.cache.size > 0) {
this.evictLRU();
}
if (this.usedMemory + size > this.memoryLimit) {
console.warn('Memory limit exceeded, cannot allocate');
return false;
}
this.cache.set(id, { data, size, lastAccess: Date.now() });
this.usedMemory += size;
this.refCounts.set(id, 1);
return true;
}
/** 释放内存 */
release(id: string): void {
const count = (this.refCounts.get(id) ?? 0) - 1;
if (count <= 0) {
const entry = this.cache.get(id);
if (entry) {
this.usedMemory -= entry.size;
this.cache.delete(id);
this.refCounts.delete(id);
// 如果是 ImageBitmap 等资源,主动关闭
if (entry.data?.close) entry.data.close();
}
} else {
this.refCounts.set(id, count);
}
}
/** LRU 淘汰 */
private evictLRU(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache) {
if ((this.refCounts.get(key) ?? 0) <= 0 && entry.lastAccess < oldestTime) {
oldestTime = entry.lastAccess;
oldestKey = key;
}
}
if (oldestKey) {
this.release(oldestKey);
}
}
/** 获取内存使用情况 */
getMemoryUsage(): { used: number; limit: number; percentage: number } {
return {
used: this.usedMemory,
limit: this.memoryLimit,
percentage: (this.usedMemory / this.memoryLimit) * 100,
};
}
}
扩展设计
1. 实时协同编辑
多人同时编辑同一文档时,需要解决操作冲突问题。主流方案有 OT(Operational Transformation)和 CRDT(Conflict-free Replicated Data Types)。
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| OT | 操作转换,服务端协调 | 成熟,Google Docs 采用 | 实现复杂,依赖中心服务器 |
| CRDT | 数据结构自带冲突解决 | 去中心化,支持离线编辑 | 内存开销较大,某些场景语义不直观 |
/** 基于 Last-Writer-Wins 的 CRDT 属性 */
interface LWWRegister<T> {
value: T;
timestamp: number; // Lamport 时间戳
peerId: string; // 操作者 ID
}
/** 协同状态管理 */
class CollabState {
private properties: Map<string, LWWRegister<any>> = new Map();
private clock: number = 0;
private peerId: string;
constructor(peerId: string) {
this.peerId = peerId;
}
/** 本地修改 */
set(key: string, value: any): LWWRegister<any> {
this.clock++;
const register: LWWRegister<any> = {
value,
timestamp: this.clock,
peerId: this.peerId,
};
this.properties.set(key, register);
return register;
}
/** 合并远端操作 */
merge(key: string, remote: LWWRegister<any>): boolean {
const local = this.properties.get(key);
// LWW 策略:时间戳大的胜出,时间戳相同则比较 peerId
if (!local ||
remote.timestamp > local.timestamp ||
(remote.timestamp === local.timestamp && remote.peerId > local.peerId)) {
this.properties.set(key, remote);
this.clock = Math.max(this.clock, remote.timestamp);
return true; // 状态已变更
}
return false; // 本地状态更新,忽略远端
}
}
2. 插件架构
/** 插件接口 */
interface EditorPlugin {
name: string;
version: string;
/** 插件初始化 */
install(editor: EditorCore): void;
/** 插件卸载 */
uninstall?(): void;
}
/** 插件管理器 */
class PluginManager {
private plugins: Map<string, EditorPlugin> = new Map();
private editor: EditorCore;
constructor(editor: EditorCore) {
this.editor = editor;
}
/** 注册插件 */
use(plugin: EditorPlugin): void {
if (this.plugins.has(plugin.name)) {
console.warn(`Plugin "${plugin.name}" already registered`);
return;
}
plugin.install(this.editor);
this.plugins.set(plugin.name, plugin);
}
/** 卸载插件 */
remove(name: string): void {
const plugin = this.plugins.get(name);
if (plugin) {
plugin.uninstall?.();
this.plugins.delete(name);
}
}
}
/** 示例:水印插件 */
const watermarkPlugin: EditorPlugin = {
name: 'watermark',
version: '1.0.0',
install(editor: EditorCore) {
// 监听导出事件,在导出前添加水印
editor.eventBus.on('before:export', (ctx: CanvasRenderingContext2D) => {
ctx.save();
ctx.globalAlpha = 0.1;
ctx.font = '48px Arial';
ctx.fillStyle = '#000';
ctx.rotate(-Math.PI / 6);
// 铺满水印
for (let y = -500; y < 2000; y += 200) {
for (let x = -500; x < 2000; x += 400) {
ctx.fillText('WATERMARK', x, y);
}
}
ctx.restore();
});
},
};
3. 模板系统
/** 模板定义 */
interface EditorTemplate {
id: string;
name: string;
thumbnail: string;
width: number;
height: number;
elements: EditorElement[];
/** 可替换的占位区域 */
placeholders: Placeholder[];
}
interface Placeholder {
elementId: string;
type: 'image' | 'text';
label: string; // 占位提示文字
constraints?: {
minWidth?: number;
maxWidth?: number;
aspectRatio?: number; // 宽高比限制
};
}
class TemplateManager {
/** 从模板创建编辑器文档 */
createFromTemplate(template: EditorTemplate): EditorElement[] {
// 深拷贝模板元素,生成新的 ID
return template.elements.map(el => ({
...structuredClone(el),
id: generateId(),
}));
}
/** 将当前编辑内容保存为模板 */
saveAsTemplate(
elements: EditorElement[],
meta: { name: string; width: number; height: number },
): EditorTemplate {
return {
id: generateId(),
name: meta.name,
thumbnail: '',
width: meta.width,
height: meta.height,
elements: structuredClone(elements),
placeholders: [],
};
}
}
4. 服务端渲染导出
对于高分辨率导出或 PDF 生成等场景,可以将渲染任务交给服务端处理。
// Node.js 服务端导出
import { createCanvas, loadImage } from 'canvas';
interface ExportRequest {
width: number;
height: number;
format: 'png' | 'jpeg' | 'pdf';
quality: number;
elements: EditorElement[];
}
async function exportDocument(req: ExportRequest): Promise<Buffer> {
const canvas = createCanvas(req.width, req.height);
const ctx = canvas.getContext('2d');
// 逐元素渲染(与前端渲染逻辑一致)
for (const element of req.elements) {
ctx.save();
const { position, rotation, scale } = element.transform;
ctx.translate(position.x, position.y);
ctx.rotate(rotation);
ctx.scale(scale.x, scale.y);
ctx.globalAlpha = element.opacity;
if (element.type === 'image') {
const img = await loadImage((element as any).src);
ctx.drawImage(img, -50, -50, 100, 100);
}
// ... 其他元素类型
ctx.restore();
}
if (req.format === 'jpeg') {
return canvas.toBuffer('image/jpeg', { quality: req.quality });
}
return canvas.toBuffer('image/png');
}
相关技术方案对比
| 方案 | 代表项目 | 渲染技术 | 优点 | 缺点 |
|---|---|---|---|---|
| Canvas 2D | Fabric.js | Canvas 2D API | 生态成熟、学习成本低 | 复杂场景性能受限 |
| WebGL | Pixi.js | WebGL | GPU 加速、高性能 | 学习曲线陡峭 |
| SVG | SVG.js | SVG DOM | DOM 事件原生支持 | 大量元素性能差 |
| 混合方案 | Figma | WebGL + WASM | 顶级性能 | 实现复杂度极高 |
Fabric.js 是开源社区中最成熟的 Canvas 编辑器框架,提供了对象模型、序列化、事件系统、交互控制等完整功能。如果不是从零开始构建,推荐基于 Fabric.js 进行二次开发。
常见面试问题
Q1: 如何实现无限画布?核心难点是什么?
答案:
无限画布的核心是视口变换。画布本身大小是固定的(与浏览器窗口大小一致),通过维护一个视口变换矩阵(包含偏移量 offset 和缩放级别 zoom),将所有元素的世界坐标转换为屏幕坐标进行渲染。
核心难点:
- 坐标系转换:需要在屏幕坐标、世界坐标、本地坐标之间自由转换,尤其是鼠标事件坐标到世界坐标的映射
- 基于鼠标位置缩放:缩放时必须保持鼠标指向的世界坐标不变,否则用户体验很差
- 高 DPI 适配:Canvas 需要设置
width/height为 CSS 尺寸乘以devicePixelRatio,否则在 Retina 屏幕上会模糊
// 核心公式:屏幕坐标 → 世界坐标
function screenToWorld(screenX: number, screenY: number): Vector2 {
return {
x: (screenX - canvasLeft) / zoom - offsetX,
y: (screenY - canvasTop) / zoom - offsetY,
};
}
Q2: Command 模式实现撤销重做有什么优缺点?还有其他方案吗?
答案:
Command 模式为每种操作定义 execute() 和 undo() 方法,用两个栈(undoStack、redoStack)管理操作历史。
| 对比维度 | Command 模式 | 快照模式 | Immutable 模式 |
|---|---|---|---|
| 内存占用 | 小(只存增量) | 大(每步完整快照) | 中(结构共享) |
| 实现复杂度 | 高(每种操作都需要写 undo) | 低(deepClone) | 中(需要 Immer 等库) |
| 性能 | 好 | 差(深拷贝开销) | 好 |
| 粒度控制 | 精确 | 粗糙 | 精确 |
| 适用场景 | 专业编辑器 | 简单场景 | 状态管理集成 |
实际项目中,推荐 Command 模式 + 组合命令:将多个连续操作(如拖拽过程中的多次 move)合并为一个 CompositeCommand,避免撤销时需要一步步回退。
Q3: Canvas 编辑器如何做性能优化?
答案:
主要从以下几个维度优化:
渲染层面:
- 脏区域重绘:只重绘变化区域,避免全量重绘
- 分层渲染:背景层、内容层、UI 层分别用独立 Canvas,减少互相干扰
- requestAnimationFrame 节流:使用脏标记(dirty flag),在 rAF 回调中统一渲染,避免一帧内多次重绘
- 离屏 Canvas 缓存:将不常变化的元素(如背景图)绘制到 OffscreenCanvas 中缓存
大图处理:
- 瓦片化渲染:将大图切为 256x256 的小瓦片,只渲染视口内可见的瓦片
- Mipmap 降采样:预生成多级缩略图,缩放比例小时使用低分辨率版本
- Web Worker 解码:
createImageBitmap()在 Worker 中解码图片,避免阻塞主线程
内存管理:
- LRU 缓存淘汰:超出内存限制时淘汰最久未访问的资源
- 引用计数:跟踪 ImageBitmap 等资源的引用,及时调用
.close()释放 - 对象池:复用频繁创建/销毁的对象(如 Transform、BoundingBox)
Q4: 如何实现图层的分组和嵌套?
答案:
图层系统本质上是一棵树形结构。每个元素节点有 parentId 和 children 属性,根节点的子元素就是顶层图层。
关键实现要点:
- 扁平化存储 + 树形引用:所有元素存储在
Map<string, EditorElement>中,通过parentId和children维护树结构。这样既方便快速查找(O(1)),又能表达嵌套关系 - 渲染顺序:深度优先遍历图层树,从底到顶依次渲染。
children数组的顺序即为同级元素的 z-index 顺序 - 分组操作:创建
type: 'group'的元素,将选中元素的parentId指向组节点,并计算组的包围盒作为 transform - 变换继承:渲染时,组内元素的变换需要叠加父级组的变换(通过 Canvas 的
ctx.save()/ctx.restore()和嵌套ctx.translate/rotate/scale)
Q5: 如何实现元素的精确点击检测(Hit Test)?
答案:
点击检测需要判断鼠标坐标是否落在某个元素内。对于有旋转的元素,不能简单判断矩形包含关系。
function hitTest(worldPos: Vector2, element: EditorElement): boolean {
const { position, rotation, scale } = element.transform;
// 1. 平移到元素中心为原点
const dx = worldPos.x - position.x;
const dy = worldPos.y - position.y;
// 2. 逆旋转,消除旋转影响
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const localX = (dx * cos - dy * sin) / scale.x;
const localY = (dx * sin + dy * cos) / scale.y;
// 3. 在本地坐标系中判断是否在范围内
return Math.abs(localX) <= halfWidth && Math.abs(localY) <= halfHeight;
}
核心思路:将鼠标的世界坐标逆变换到元素的本地坐标系中,然后用简单的 AABB(轴对齐包围盒)判断。遍历顺序从顶层到底层,命中的第一个即为选中元素。
对于不规则图形(如钢笔路径),可以使用 Canvas 2D 的 ctx.isPointInPath() API 或者额外创建一个 1x1 像素的离屏 Canvas 进行颜色检测。
Q6: 协同编辑时如何处理操作冲突?
答案:
协同编辑主要有两种方案:
OT(Operational Transformation):
- 所有操作发送到中心服务器,服务器对并发操作做变换使其兼容
- Google Docs 采用此方案,适合文本编辑等线性结构
- 对于图形编辑器,操作类型多(移动、缩放、颜色、图层顺序等),OT 变换规则复杂
CRDT(Conflict-free Replicated Data Types):
- 数据结构自带冲突解决能力,不需要中心协调者
- 常见策略:Last-Writer-Wins Register(时间戳大的胜出)
- 适合去中心化场景,天然支持离线编辑
- Figma 采用的就是类似 CRDT 的方案
图形编辑器推荐 CRDT 方案,原因是:
- 大部分属性修改是独立的(移动不影响颜色),天然适合 LWW 策略
- 图层顺序冲突可以用 Fractional Indexing(分数索引)解决
- 离线编辑后重新同步的需求很常见
Q7: Canvas 2D 和 WebGL 在图片编辑器中如何选择?
答案:
| 对比维度 | Canvas 2D | WebGL |
|---|---|---|
| 学习成本 | 低,API 简单直观 | 高,需要理解着色器、纹理等 GPU 概念 |
| 渲染性能 | CPU 渲染,数百个元素 OK | GPU 加速,数万个元素 OK |
| 滤镜实现 | ctx.filter 或像素操作 | GLSL Shader,灵活且高性能 |
| 文字渲染 | ctx.fillText() 方便 | 需要自己实现(SDF、Bitmap Font) |
| 像素操作 | getImageData/putImageData | 纹理读写,需要 FBO |
| 生态 | Fabric.js、Konva.js | Pixi.js、Three.js |
选择建议:
- 中小型项目(海报设计、简单修图):Canvas 2D 足够,开发效率高
- 专业级编辑器(类 Figma/Photoshop):WebGL 为主,Canvas 2D 辅助文字渲染
- 混合方案:核心渲染用 WebGL,UI 覆盖层(选择框、控制点)用 Canvas 2D
Figma 的方案是 WebGL + WebAssembly(C++ 编译),在浏览器中实现了接近原生的渲染性能。
Q8: 如何设计编辑器的插件系统?
答案:
插件系统的核心是控制反转(IoC):编辑器提供扩展点(事件、钩子、注册表),插件通过这些扩展点注入功能。
interface EditorPlugin {
name: string;
install(editor: EditorCore): void;
uninstall?(): void;
}
设计要点:
- 生命周期钩子:
before:render、after:render、before:export、element:created等 - 工具注册:插件可以注册自定义工具(画笔、印章、橡皮擦),出现在工具栏中
- 滤镜注册:插件可以注册自定义滤镜(素描效果、油画效果),出现在滤镜面板中
- UI 扩展:提供面板插槽(Panel Slot),插件可以注入自定义面板
- 沙箱隔离:插件运行在受限环境中,不能直接修改编辑器核心状态,只能通过 API 交互
好的插件架构遵循开放封闭原则:编辑器核心对修改封闭,对扩展开放。VSCode 的插件系统是优秀的参考实现。