Excalidraw 核心实现原理:渲染、协作与加密
你有没有试过在团队会议中,用鼠标画一个“看起来像手绘”的矩形?线条太直了,反而显得死板。而 Excalidraw 正是为了解决这种“数字工具缺乏人情味”问题而生的开源白板项目——它不仅让图形看起来像是随手涂鸦,还做到了多人实时协作不卡顿、数据全程端到端加密、复杂场景下依然流畅如初。
这背后,是一套高度精巧的技术架构。从底层 Canvas 渲染优化,到分布式状态同步算法,再到安全可信的 E2EE 加密流程,Excalidraw 在性能、可用性与安全性之间找到了令人惊叹的平衡点。
分层渲染:让“手绘感”也能跑出 60fps
很多人第一眼喜欢 Excalidraw,是因为它的视觉风格:歪歪扭扭的线条、略带阴影的填充、仿佛是用铅笔在纸上勾勒出来的草图。但真正让它能被大规模使用的关键,并不是“像手绘”,而是“像手绘的同时还能流畅交互”。
双层 Canvas 架构:动静分离的艺术
Excalidraw 没有把所有内容都画在同一块画布上,而是采用了双层 Canvas 渲染模型:
- 背景层(Static Layer):承载所有静态元素,比如已完成绘制的矩形、文本框、连线等。
- 前景层(Interactive Layer):仅用于显示当前正在操作的内容,例如拖拽中的形状、鼠标路径、临时选择框。
这样的设计带来了显著优势:当用户移动一个元素时,只需清空前景区对应区域并重绘该元素,无需刷新整个画面。静态背景保持不变,GPU 负载大幅降低。
function createRenderLayers(container: HTMLElement, width: number, height: number) { const dpr = window.devicePixelRatio || 1; const staticCanvas = document.createElement('canvas'); staticCanvas.width = width * dpr; staticCanvas.height = height * dpr; staticCanvas.style.cssText = `position:absolute;top:0;left:0;width:${width}px;height:${height}px;`; container.appendChild(staticCanvas); const interactiveCanvas = document.createElement('canvas'); interactiveCanvas.width = width * dpr; interactiveCanvas.height = height * dpr; interactiveCanvas.style.cssText = `position:absolute;top:0;left:0;width:${width}px;height:${height}px;`; container.appendChild(interactiveCanvas); return { staticCanvas, interactiveCanvas }; }这个看似简单的结构,实则是支撑高帧率体验的基础。
手绘效果从何而来?Rough.js 的魔法
Excalidraw 并没有自己去模拟抖动和偏差,而是巧妙地集成了 Rough.js ——一个专为生成“不完美”图形而设计的轻量库。
通过调整roughness参数,可以控制线条的粗糙程度;hachureAngle则决定了填充线的方向。这些参数组合起来,让用户既能保留草图气质,又能按需定制视觉强度。
const options = { roughness: 2.5, fillStyle: 'hachure', hachureAngle: element.fillStyle === 'cross-hatch' ? 45 : -41, strokeWidth: element.strokeWidth || 1 }; switch (element.type) { case 'rectangle': rc.rectangle(element.x, element.y, element.width, element.height, options); break; case 'ellipse': rc.circle(element.x + element.width/2, element.y + element.height/2, Math.max(element.width, element.height), options); break; }更聪明的是,Rough.js 生成的是 SVG 路径或 Canvas 命令,而非位图,这意味着缩放时不会失真,完美契合矢量编辑需求。
性能优化四重奏:裁剪、缓存、批处理与脏矩形
即使有了分层机制,在上千个元素的大画布上仍可能卡顿。为此,Excalidraw 引入了四项核心优化策略:
| 技术 | 效果 |
|---|---|
| 视口裁剪(Viewport Culling) | 只渲染屏幕可见范围内的元素,减少约 50% 的绘制调用 |
| 脏矩形重绘(Dirty Rectangles) | 修改某个元素时,只清理其包围盒区域,避免全屏刷新 |
| 元素批处理 | 合并同类图形绘制命令,减少上下文切换开销 |
| 离屏缓存(Offscreen Caching) | 将静态组元素预渲染至 OffscreenCanvas,提升复用效率 |
其中,“脏矩形”机制尤为关键。想象你在拖动一个箭头,系统会记录它的旧位置和新位置两个矩形区域,在下一帧中仅对这两个区域进行重绘。
class DirtyRectManager { private dirtyRects: Array<{ x: number; y: number; w: number; h: number }> = []; add(x: number, y: number, width: number, height: number) { this.dirtyRects.push({ x, y, w: width, h: height }); } flush(ctx: CanvasRenderingContext2D) { if (this.dirtyRects.length === 0) return; const merged = mergeRectangles(this.dirtyRects); // 合并重叠区域 for (const rect of merged) { ctx.clearRect(rect.x, rect.y, rect.w, rect.h); renderElementsInRect(rect); // 重绘该区域内元素 } this.dirtyRects = []; } }这一机制将原本 O(n) 的全量绘制降为 O(k),k 是变更元素数量,极大提升了滚动、缩放和频繁交互时的响应速度。
高清屏适配与坐标映射:一套统一的空间体系
为了兼容 Retina 屏幕和手势缩放,Excalidraw 建立了一套完整的坐标转换链路:
- 用户点击的位置是“客户端坐标”;
- 经过
devicePixelRatio缩放后得到“视口坐标”; - 再结合当前缩放比例和滚动偏移,换算成“场景坐标”用于逻辑计算。
export const viewportCoordsToSceneCoords = ( clientX: number, clientY: number, appState: AppState ): { x: number; y: number } => { const { scrollX, scrollY, zoom } = appState; return { x: (clientX + scrollX) / zoom, y: (clientY + scrollY) / zoom }; };反过来,当要显示某个元素时,又需要将“场景坐标”转回“视口坐标”。这套双向映射机制确保了无论缩放到多大、平移多远,用户的操作始终精准无误。
实时协作:没有中心仲裁器的协同艺术
Excalidraw 支持多个用户同时编辑同一画布,且不需要依赖复杂的服务器协调逻辑。它是如何做到在并发修改时不冲突、不断连、不失序的?
WebSocket 驱动的轻量级通信层
协作基于 WebSocket 构建,客户端连接到协作服务器后,订阅同一个sceneId的数据流:
Client A ←→ WebSocket Server ←→ Client B ↕ Sync State & Cursors Shared Scene ID所有参与者共享一个全局状态标识,任何变更都会以增量形式广播给其他成员。
光标位置每 33ms 上报一次(接近 30fps),保证他人看到你的实时移动轨迹;而元素变更则经过 100ms 延迟合并,防止连续输入触发过多消息。
版本向量冲突解决:谁改的优先?
每个图形元素都有如下元信息来管理版本:
| 字段 | 说明 |
|---|---|
version | 自增版本号,每次修改 +1 |
versionNonce | 随机值,打破版本相同时的平局 |
updated | 时间戳,辅助排序 |
id | 全局唯一 ID |
当两个用户同时修改同一个元素时,系统按照以下规则判断是否接受远程更新:
- 如果本地正在编辑该元素(如输入文字),则拒绝远程变更;
- 比较
version,高的胜出; - 若相同,比较
versionNonce; - 最终仍相同,则参考时间戳。
function shouldAcceptRemoteElement( local: ExcalidrawElement | null, remote: ExcalidrawElement, appState: AppState ): boolean { if (!local) return true; if (appState.editingElement?.id === local.id || appState.resizingElement?.id === local.id) { return false; } if (remote.version > local.version) return true; if (remote.version < local.version) return false; return remote.versionNonce > local.versionNonce; }这套机制本质上借鉴了 CRDT(无冲突复制数据类型)的思想——允许并发写入,通过确定性规则自动合并结果,无需锁或排队。
增量同步与异常恢复:断网也不丢数据
网络不可能永远稳定。Excalidraw 的协作模块具备强大的容错能力:
- 断线重连:自动尝试重建连接,失败后进入本地编辑模式;
- 变更队列缓存:未发送的操作暂存在内存中,恢复后批量提交;
- 定期全量同步:每 20 秒强制推送完整状态快照,修复潜在差异;
- 离线合并策略:长时间离线后接入,接收最新快照并智能合并本地更改。
此外,同步只传输发生变化的元素集合,而不是整张画布,极大节省带宽。
const getSyncableElements = ( elements: readonly ExcalidrawElement[], knownVersions: Map<string, number> ): ExcalidrawElement[] => { return elements.filter(el => { const knownVersion = knownVersions.get(el.id); return !knownVersion || el.version > knownVersion; }); };这种“增量+节流+兜底”的设计,使得协作既高效又可靠。
端到端加密(E2EE):数据主权真正归用户
在共享链接满天飞的时代,谁能保证别人看不到你的架构图、产品原型或会议笔记?Excalidraw 给出的答案是:连我们自己也看不到。
安全架构全景:服务端只见密文
E2EE 的工作流程非常清晰:
用户A输入数据 → 客户端加密 → 网络传输 → 服务端存储 → 网络分发 → 客户端解密 → 用户B查看 ↑ 服务端仅见密文密钥始终保存在用户设备本地,从未上传至服务器。这意味着即使是运维人员也无法查看绘图内容。
AES-GCM + HKDF:现代 Web Crypto 的最佳实践
Excalidraw 使用浏览器原生crypto.subtleAPI 实现加密,采用AES-GCM 256 位加密算法,具备以下特性:
- 高性能对称加密;
- GCM 模式自带完整性校验,防篡改;
- 每次加密使用随机 IV,杜绝重放攻击。
密钥可通过多种方式传递:
- 导出为 64 位十六进制字符串;
- 生成含密钥参数的加密链接;
- 二维码分享。
async function generateKey(): Promise<CryptoKey> { return await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); } async function exportKeyAsHex(key: CryptoKey): Promise<string> { const buffer = await crypto.subtle.exportKey('raw', key); return Array.from(new Uint8Array(buffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); }对于普通用户,还可以通过密码短语派生密钥(PBKDF2),便于记忆和口头传递。
加解密流程:透明而不失安全
所有需要同步的数据在发送前都会被加密:
interface EncryptedData { ciphertext: ArrayBuffer; iv: Uint8Array; } async function encryptElements( elements: ExcalidrawElement[], key: CryptoKey ): Promise<EncryptedData> { const json = JSON.stringify(elements); const encoder = new TextEncoder(); const data = encoder.encode(json); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, data ); return { ciphertext: encrypted, iv }; } async function decryptElements( encrypted: EncryptedData, key: CryptoKey ): Promise<ExcalidrawElement[]> { try { const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: encrypted.iv }, key, encrypted.ciphertext ); const decoder = new TextDecoder(); return JSON.parse(decoder.decode(decrypted)); } catch (err) { throw new Error('Decryption failed. Invalid key or corrupted data.'); } }错误提示友好,不会暴露底层细节,既保障了安全性,又降低了使用门槛。
UI 层的安全融合设计
尽管技术复杂,但用户体验却极为简洁:
- 锁形图标表示当前画布已加密;
- 输入密钥即可解锁查看;
- 提供“复制加密链接”一键分享;
- 支持扫码导入密钥。
const EncryptionIndicator: React.FC<{ isEncrypted: boolean }> = ({ isEncrypted }) => { return ( <div className="e2ee-status"> {isEncrypted ? <LockIcon /> : <UnlockIcon />} <span>{isEncrypted ? 'End-to-end encrypted' : 'Not encrypted'}</span> </div> ); };这种“安全透明化”的理念,正是优秀工程产品的体现:强大功能藏于无形。
工程哲学:极简背后的深刻权衡
Excalidraw 的成功,不只是因为它长得好看或功能齐全,而是因为其背后有一套清晰而坚定的工程哲学:
- 渲染层追求极致性能:分层绘制 + 局部更新,确保高帧率;
- 协作层拥抱最终一致性:基于版本向量的冲突解决,实现零卡顿并发;
- 安全层坚持最小信任:端到端加密,数据主权回归用户;
- 扩展性面向未来开放:插件系统 + API 接口,支持 AI 辅助绘图、自动化布局等创新功能。
尤其值得一提的是,随着 AI 功能的逐步集成,Excalidraw 正在演变为一个智能创意协作平台。设想一下:
- 你说:“画一个登录页面,包含邮箱输入、密码框和记住我选项。”
- AI 自动生成草图,并融入现有画布;
- 多人围绕这个初稿实时讨论、修改、标注。
这不是科幻,而是正在发生的现实。
正是这种在性能、协作与安全之间取得精妙平衡的能力,让 Excalidraw 不仅是一款绘图工具,更成为现代远程协作基础设施的重要一环。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考