跳到主要内容

设计在线图片编辑器

需求分析

在线图片编辑器是前端系统设计面试中的经典题目,它涵盖了 Canvas 渲染、复杂状态管理、性能优化等核心前端技术。

功能需求

功能模块核心能力说明
画布系统无限画布、缩放平移、标尺网格支持自由缩放(10%~3200%)和平移
图层管理图层树、分组、锁定/隐藏、混合模式类似 Photoshop 图层面板
文字编辑富文本、字体、字号、颜色、对齐支持双击进入编辑模式
图形绘制矩形、圆形、线条、钢笔路径支持描边和填充
滤镜特效模糊、亮度、对比度、色相旋转实时预览
裁剪旋转自由裁剪、固定比例、旋转翻转支持非破坏性编辑
撤销重做无限撤销/重做、操作历史面板支持 Ctrl+Z / Ctrl+Shift+Z
导出PNG/JPEG/SVG/PDF、自定义分辨率支持导出指定区域

非功能需求

非功能需求同样重要

面试中不仅要设计功能模块,还要关注非功能需求,这是区分初级和高级工程师的关键。

  • 流畅交互:60fps 渲染,拖拽/缩放无卡顿,操作响应 < 16ms
  • 大图支持:支持 10000x10000 像素以上的高分辨率图片编辑
  • 跨端兼容:支持 Chrome、Firefox、Safari、Edge,适配触屏设备
  • 内存控制:避免内存泄漏,大图编辑内存占用可控
  • 可扩展性:支持插件扩展,方便接入新的滤镜、工具、导出格式

整体架构

分层架构

模块关系

核心类型定义

types/editor.ts
/** 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):元素自身坐标系,以元素中心为原点
core/viewport.ts
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 排序、分组、锁定/隐藏等功能。

图层树结构

core/layer-manager.ts
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. 元素交互

元素交互是编辑器用户体验的核心,包括选中检测、拖拽移动、缩放旋转、多选框选、对齐吸附等。

交互状态机

core/interaction-manager.ts
/** 交互状态 */
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;
}

对齐吸附

core/snap-engine.ts
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 模式实现

core/command.ts
/** 命令接口 */
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 命令管理器

core/command-manager.ts
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 Filterfilter 属性DOM 元素,简单效果好(GPU 加速)
Canvas Filterctx.filter / 像素操作Canvas 2D 场景中等
WebGL ShaderGLSL 片段着色器复杂特效、实时预览最佳
core/filters.ts
/** 滤镜处理器 */
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;
}
Canvas Filter API 兼容性

CanvasRenderingContext2D.filter 属性在 Chrome 52+、Firefox 49+、Edge 79+ 中支持,Safari 目前仍不支持。如需兼容 Safari,请使用像素操作或 WebGL 方案。

关键技术实现

渲染引擎

core/renderer.ts
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,
});
}
}

事件系统

core/event-bus.ts
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. 渲染优化

脏区域重绘

只重绘发生变化的区域,而不是每次都清空整个画布重新绘制。

core/dirty-rect.ts
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 图层上,避免互相干扰导致不必要的重绘。

core/layered-renderer.ts
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+),将图片分割为小瓦片,只渲染视口范围内可见的瓦片。

core/tile-renderer.ts
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,
);
}
}
}
}
}

降采样策略

缩放到较小比例时,无需渲染原始分辨率图片。根据缩放级别动态选择降采样后的版本。

core/mipmap.ts
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 内存。如果不做内存管理,几步操作就可能导致页面崩溃。

core/memory-manager.ts
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数据结构自带冲突解决去中心化,支持离线编辑内存开销较大,某些场景语义不直观
collab/crdt-state.ts
/** 基于 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. 插件架构

core/plugin.ts
/** 插件接口 */
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. 模板系统

core/template.ts
/** 模板定义 */
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 生成等场景,可以将渲染任务交给服务端处理。

server/export-service.ts
// 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 2DFabric.jsCanvas 2D API生态成熟、学习成本低复杂场景性能受限
WebGLPixi.jsWebGLGPU 加速、高性能学习曲线陡峭
SVGSVG.jsSVG DOMDOM 事件原生支持大量元素性能差
混合方案FigmaWebGL + WASM顶级性能实现复杂度极高
Fabric.js - 最受欢迎的 Canvas 编辑器框架

Fabric.js 是开源社区中最成熟的 Canvas 编辑器框架,提供了对象模型、序列化、事件系统、交互控制等完整功能。如果不是从零开始构建,推荐基于 Fabric.js 进行二次开发。


常见面试问题

Q1: 如何实现无限画布?核心难点是什么?

答案

无限画布的核心是视口变换。画布本身大小是固定的(与浏览器窗口大小一致),通过维护一个视口变换矩阵(包含偏移量 offset 和缩放级别 zoom),将所有元素的世界坐标转换为屏幕坐标进行渲染。

核心难点:

  1. 坐标系转换:需要在屏幕坐标、世界坐标、本地坐标之间自由转换,尤其是鼠标事件坐标到世界坐标的映射
  2. 基于鼠标位置缩放:缩放时必须保持鼠标指向的世界坐标不变,否则用户体验很差
  3. 高 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: 如何实现图层的分组和嵌套?

答案

图层系统本质上是一棵树形结构。每个元素节点有 parentIdchildren 属性,根节点的子元素就是顶层图层。

关键实现要点:

  1. 扁平化存储 + 树形引用:所有元素存储在 Map<string, EditorElement> 中,通过 parentIdchildren 维护树结构。这样既方便快速查找(O(1)),又能表达嵌套关系
  2. 渲染顺序:深度优先遍历图层树,从底到顶依次渲染。children 数组的顺序即为同级元素的 z-index 顺序
  3. 分组操作:创建 type: 'group' 的元素,将选中元素的 parentId 指向组节点,并计算组的包围盒作为 transform
  4. 变换继承:渲染时,组内元素的变换需要叠加父级组的变换(通过 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 方案,原因是:

  1. 大部分属性修改是独立的(移动不影响颜色),天然适合 LWW 策略
  2. 图层顺序冲突可以用 Fractional Indexing(分数索引)解决
  3. 离线编辑后重新同步的需求很常见

Q7: Canvas 2D 和 WebGL 在图片编辑器中如何选择?

答案

对比维度Canvas 2DWebGL
学习成本低,API 简单直观高,需要理解着色器、纹理等 GPU 概念
渲染性能CPU 渲染,数百个元素 OKGPU 加速,数万个元素 OK
滤镜实现ctx.filter 或像素操作GLSL Shader,灵活且高性能
文字渲染ctx.fillText() 方便需要自己实现(SDF、Bitmap Font)
像素操作getImageData/putImageData纹理读写,需要 FBO
生态Fabric.js、Konva.jsPixi.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;
}

设计要点:

  1. 生命周期钩子before:renderafter:renderbefore:exportelement:created
  2. 工具注册:插件可以注册自定义工具(画笔、印章、橡皮擦),出现在工具栏中
  3. 滤镜注册:插件可以注册自定义滤镜(素描效果、油画效果),出现在滤镜面板中
  4. UI 扩展:提供面板插槽(Panel Slot),插件可以注入自定义面板
  5. 沙箱隔离:插件运行在受限环境中,不能直接修改编辑器核心状态,只能通过 API 交互

好的插件架构遵循开放封闭原则:编辑器核心对修改封闭,对扩展开放。VSCode 的插件系统是优秀的参考实现。

相关链接