Excalidraw策略模式封装:算法切换无缝衔接
在智能协作工具日益普及的今天,用户不再满足于单纯的绘图能力——他们希望用一句话生成架构图、通过语音快速创建流程草稿,甚至让AI自动优化布局。Excalidraw 作为一款极简风格的手绘式虚拟白板,正站在这一变革的前沿。它不仅保留了原始的手绘体验,还逐步集成自然语言到图形的智能转换能力。
但问题也随之而来:当“手动绘制”和“AI自动生成”两种截然不同的创作方式并存时,如何让系统既能灵活切换、又不至于陷入代码泥潭?如果每新增一个AI模型就要修改主逻辑,那维护成本将迅速失控。更别说未来可能接入模板引擎、语音识别、批量导入等更多功能。
这时候,设计模式的价值就凸显出来了。我们不需要把所有逻辑塞进一堆if-else判断里,而是用一种更优雅的方式——策略模式,来统一管理这些“绘图算法”。
策略模式的本质:让算法成为可插拔的模块
策略模式的核心思想其实很朴素:把每个算法封装成独立的对象,它们对外提供相同的接口,运行时可以自由替换。这就像给相机换镜头——机身不变,根据拍摄场景选择广角、长焦或微距镜头。
在 Excalidraw 的上下文中,我们可以把不同绘图方式看作不同的“镜头”:
ManualDrawingStrategy:传统鼠标/触控手绘AIDiagramGenerationStrategy:基于大模型的文本生成图表TemplateBasedStrategy:从预设模板中填充内容VoiceToDiagramStrategy:语音指令转结构化图形(未来扩展)
这些策略都实现同一个接口,比如generate(prompt: string),而编辑器本身只关心“调用这个方法能得到元素数据”,并不需要知道背后是调用了 OpenAI 还是 Claude,或者只是绑定了几个事件监听器。
这种“面向接口编程”的做法,带来了真正的解耦。主应用逻辑不再与具体实现绑定,新增一种绘图方式也无需改动已有代码,完美遵循开闭原则。
如何构建一个可扩展的绘图策略体系?
定义统一契约:接口先行
一切从接口开始。我们需要一个清晰、简洁的协议,规定所有策略必须遵守的行为规范。
// drawing-strategy.interface.ts interface DrawingStrategy { generate(prompt: string): Promise<ExcalidrawElement[]>; }就这么一个方法,却承载了整个系统的灵活性。只要新来的策略能返回符合格式的元素数组,它就能被系统接纳。至于它是从本地缓存读取、调用远程API,还是解析JSON模板,都不重要。
关键在于抽象层级要恰到好处。我们没有定义undo()或save()方法,因为那些属于编辑器状态管理范畴,不应由策略承担。保持接口单一职责,才能避免后期膨胀失控。
具体实现示例:AI生成策略
以最常见的 AI 图表生成功能为例,它的核心任务是将自然语言描述转化为 Excalidraw 可渲染的数据结构。
// ai-diagram.strategy.ts class AIDiagramGenerationStrategy implements DrawingStrategy { private apiKey: string; private modelEndpoint: string; constructor(apiKey: string, endpoint = 'https://api.example.com/v1/generate') { this.apiKey = apiKey; this.modelEndpoint = endpoint; } async generate(prompt: string): Promise<ExcalidrawElement[]> { const response = await fetch(this.modelEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ prompt, style: 'hand-drawn', // 保持手绘风格一致性 format: 'excalidraw-elements' }) }); if (!response.ok) { throw new Error(`AI generation failed: ${await response.text()}`); } const result = await response.json(); return this.normalizeOutput(result.elements); } private normalizeOutput(raw: any[]): ExcalidrawElement[] { return raw.map(item => ({ type: mapShapeType(item.type), x: item.x, y: item.y, width: item.width, height: item.height, strokeColor: '#000', backgroundColor: 'transparent', roughness: 2, fillStyle: 'hachure', strokeWidth: 1, strokeStyle: 'solid', opacity: 100, version: 1, versionNonce: 0, isDeleted: false, boundElements: null, updated: 1630000000, seed: Math.floor(Math.random() * 100000), points: item.points || undefined, text: item.text || undefined, fontSize: item.fontSize || 20, fontFamily: 1, textAlign: 'left', verticalAlign: 'top' })); } }有几个工程细节值得注意:
- 输出标准化:AI 返回的结构往往不完全匹配 Excalidraw 的 schema,因此需要中间层做字段映射和默认值填充;
- 风格一致性:显式设置
roughness=2、fillStyle='hachure'等属性,确保生成图形依然保留“手绘感”; - 错误隔离:网络请求失败不应导致整个应用崩溃,异常应被捕获并在上层处理;
- 安全性考虑:API Key 不应在前端硬编码,理想情况是通过后端代理转发请求。
手动绘图也能是一种“策略”?
你可能会问:“手动绘图根本没有generate这个动作,为什么也要实现这个接口?”
这是个好问题。事实上,我们将手动绘图建模为策略,并非为了调用generate(),而是为了统一控制流。当我们说“切换到手绘模式”,本质上是在告诉系统:“接下来我不再期待AI输出,而是准备监听用户的交互事件。”
所以这个策略的作用更像是一个“模式切换适配器”:
// manual-drawing.strategy.ts class ManualDrawingStrategy implements DrawingStrategy { generate(): Promise<ExcalidrawElement[]> { console.log("Switched to manual drawing mode."); return Promise.resolve([]); } attachToCanvas(canvas: HTMLCanvasElement, onElementCreated: (el: ExcalidrawElement) => void) { let isDrawing = false; let currentPath: [number, number][] = []; canvas.addEventListener('mousedown', e => { isDrawing = true; currentPath = [[e.clientX, e.clientY]]; }); canvas.addEventListener('mousemove', e => { if (!isDrawing) return; currentPath.push([e.clientX, e.clientY]); // 实时预览路径... }); canvas.addEventListener('mouseup', () => { if (currentPath.length < 2) return; isDrawing = false; const element: ExcalidrawElement = { type: 'line', x: currentPath[0][0], y: currentPath[0][1], points: currentPath.map(p => [p[0] - currentPath[0][0], p[1] - currentPath[0][1]]), strokeColor: '#000', strokeWidth: 1, roughness: 2, // ...其他必要字段 }; onElementCreated(element); }); } }虽然generate()返回空数组,但它触发了 UI 模式的变更,并激活了底层事件监听机制。这种方式让我们可以用同一套命令来切换行为,而不必为“进入手绘模式”单独写一套逻辑。
调度中心:策略的指挥官
有了策略接口和具体实现,还需要一个“调度者”来管理当前使用的策略。这就是DrawingContext的角色。
// drawing-context.ts class DrawingContext { private strategy: DrawingStrategy; constructor(strategy: DrawingStrategy) { this.strategy = strategy; } setStrategy(strategy: DrawingStrategy) { this.strategy = strategy; console.log(`Drawing strategy switched to ${strategy.constructor.name}`); } async executeGeneration(prompt: string): Promise<ExcalidrawElement[]> { return await this.strategy.generate(prompt); } }它就像一个万能遥控器,不管接的是电视、投影仪还是音响,只要按下“播放”按钮,设备自己知道该做什么。在这里,“播放”就是executeGeneration(),而具体的响应取决于当前选中的策略。
使用起来也非常直观:
const context = new DrawingContext(new ManualDrawingStrategy()); // 用户点击 AI 模式 function handleUseAIGeneration() { const aiStrategy = new AIDiagramGenerationStrategy('your-api-key'); context.setStrategy(aiStrategy); } async function handleGenerateDiagram() { const elements = await context.executeGeneration("一个包含用户服务、订单服务和网关的微服务架构"); insertElementsToExcalidraw(elements); // 插入到实际画布 }你会发现,无论后面接入多少种新策略,这段调用代码都不会变。这才是真正意义上的“低耦合”。
实际应用场景中的挑战与应对
多算法共存带来的复杂性
早期版本如果采用条件判断方式:
if (mode === 'ai') { callAIService(prompt); } else if (mode === 'manual') { bindMouseEvents(); } else if (mode === 'template') { loadFromLibrary(id); }随着功能增加,这段逻辑会越来越臃肿,最终变成难以维护的“上帝函数”。而策略模式通过接口抽象,彻底解决了这个问题——每种算法自成一体,互不干扰。
更重要的是,它可以支持组合与增强。例如我们可以轻松实现一个带缓存的装饰器:
class CachedStrategy implements DrawingStrategy { private cache = new Map<string, ExcalidrawElement[]>(); constructor(private wrapped: DrawingStrategy, private ttlMs = 5 * 60 * 1000) {} async generate(prompt: string): Promise<ExcalidrawElement[]> { const cached = this.cache.get(prompt); if (cached && Date.now() - (cached as any).__timestamp < this.ttlMs) { return cached; } const result = await this.wrapped.generate(prompt); this.cache.set(prompt, { ...result, __timestamp: Date.now() }); return result; } }这样,即使是昂贵的AI调用,也可以对相同输入进行结果复用,显著提升用户体验。
应对不稳定服务:降级策略
AI服务可能因限流、网络波动等原因失败。这时候,与其让用户看到报错,不如提供备用方案。
class FallbackStrategy implements DrawingStrategy { constructor( private primary: DrawingStrategy, private fallback: DrawingStrategy ) {} async generate(prompt: string): Promise<ExcalidrawElement[]> { try { return await this.primary.generate(prompt); } catch (error) { console.warn("Primary strategy failed, falling back...", error); return await this.fallback.generate(prompt); } } }比如主策略是 GPT-4o,备用策略可以是一个轻量级的本地关键词匹配模板引擎。即使AI挂了,用户依然能得到一份可用的草图建议。
支持多模型选型:企业级需求
不同团队的技术偏好不同。有的信任 OpenAI,有的倾向国产大模型如通义千问或讯飞星火。策略模式让这种差异化配置变得极其简单:
const strategyMap: Record<string, DrawingStrategy> = { 'gpt': new GPTDiagramStrategy(env.GPT_KEY), 'claude': new ClaudeDiagramStrategy(env.CLAUDE_KEY), 'qwen': new QwenDiagramStrategy(env.QWEN_KEY), 'local-template': new TemplateBasedStrategy() }; // 根据用户配置动态加载 context.setStrategy(strategyMap[userPreferences.diagramEngine]);真正实现了“算法即插即用”,也为后续灰度发布、A/B测试打下基础。
架构视角:分层与协作
在整个系统架构中,策略模式位于前端业务逻辑层,充当 UI 控制器与底层绘图引擎之间的桥梁:
[UI 层] ↓ (用户操作) [控制层 - DrawingContext] ↓ (委托调用) [策略接口 DrawingStrategy] ↙ ↘ [AI生成策略] [手动绘图策略] → [Excalidraw 核心库] ↓ ↓ [HTTP 请求] [Canvas 事件]这种结构有几点优势:
- 依赖倒置:高层模块(编辑器)不依赖低层实现(具体策略),而是依赖抽象;
- 可测试性强:每个策略可独立 mock 测试,比如模拟 API 延迟或错误响应;
- 演进友好:未来若引入 WebAssembly 加速渲染、离线模式等,只需新增策略即可,不影响现有流程。
工程实践中的关键考量
接口粒度控制
不要过度设计。本案例中仅需generate()方法已足够。若强行加入undo()、export()等通用方法,反而会导致部分策略出现空实现,违背接口隔离原则。
数据一致性保障
所有策略生成的元素必须严格遵循 Excalidraw 的数据格式规范,否则无法正确渲染。建议建立统一的转换中间层,对第三方输出做清洗和补全。
性能优化建议
- 加载反馈:AI 生成通常耗时 1~5 秒,应显示骨架屏或进度条;
- 异步非阻塞:确保主线程不被阻塞,用户仍可操作其他区域;
- 懒加载策略:对于不常用的功能(如语音识别),可按需动态导入,减少初始包体积。
安全防护措施
- API Key 隔离:绝不暴露在前端代码中,建议通过后端代理转发请求;
- 输入过滤:对用户输入做内容审查,防止 Prompt 注入攻击;
- 频率限制:客户端和服务端均应实施调用频次控制,防止单用户滥用资源。
这种高度集成的设计思路,正引领着智能音频设备向更可靠、更高效的方向演进。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考