集成 Excalidraw:手绘风格白板的渲染引擎与数据架构
从渲染管线到数据序列化,拆解 Excalidraw 手绘白板的技术架构

Excalidraw 是什么
Excalidraw 是一个开源的手绘风格虚拟白板。表面上它是一个绘图工具,但真正有趣的是它背后的渲染引擎和数据结构设计。
这篇文章不聊「怎么用 Excalidraw」,而是拆解它怎么做到的。
一、Rough.js:手绘效果的数学内核
Excalidraw 的手绘效果并非滤镜或者纹理贴图,而是来自一个轻量级 JavaScript 库——rough.js。
1.1 粗犷渲染的算法原理
一个标准的 rect(100, 100, 200, 150) 在 SVG 中是完美笔直的四个边。而 rough.js 做的事情是:在渲染路径之前,主动注入随机扰动。
核心算法可以简化为:
function drawRoughRect(x, y, w, h) {
// 在四个角的坐标上施加随机偏移
const perturbation = 2; // 扰动幅度(像素)
const corners = [
{ x: x + random(-perturbation, perturbation), y: y + random(-perturbation, perturbation) },
{ x: x + w + random(-perturbation, perturbation), y: y + random(-perturbation, perturbation) },
{ x: x + w + random(-perturbation, perturbation), y: y + h + random(-perturbation, perturbation) },
{ x: x + random(-perturbation, perturbation), y: y + h + random(-perturbation, perturbation) },
];
// 每条边使用贝塞尔曲线链接,而不是直线
// 控制点也施加随机偏移,制造"抖动"效果
//
// 并且不是画一条线,而是画 2-3 条几乎重叠的线
// 模拟铅笔/马克笔的多次描边
for (let stroke = 0; stroke < strokeCount; stroke++) {
moveTo(corners[0].x + random(), corners[0].y + random());
bezierCurveTo(
corners[0].x + random(), corners[0].y + random(),
corners[1].x + random(), corners[1].y + random(),
corners[1].x + random(), corners[1].y + random()
);
// ... 四条边
}
}
关键参数有三个:
| 参数 | 含义 | Excalidraw 默认值 |
|---|---|---|
roughness | 扰动幅度(越高越歪) | 1-2(可调) |
bowing | 曲线弯曲度 | 0.85 |
strokeWidth | 描边宽度 | 1(可调) |
设定 roughness: 0 则回退到标准 SVG 精准渲染,这正是我之前踩的坑——AI 生成的 Excalidraw 风格图片没有用 rough.js 引擎,所以线条是完美笔直的,没有手绘质感。
1.2 填充算法的多样性
rough.js 不止处理描边,填充也有多种算法:
- Hachure(影线填充):一组平行斜线,间距和角度可调,模拟手绘排线
- Cross-hatch(交叉影线):两组垂直方向的影线,模拟素描的交叉排线
- Dashed(虚线填充):影线的虚线版本
- Zigzag(锯齿填充):波浪形填充线条
- Dot(点状填充):随机散布的点,模拟喷枪效果
- Solid(实心):纯色填充,无粗糙效果
Excalidraw 默认使用实心填充(因为白板场景需要清晰的色块),但在渲染引擎层面,这些填充算法都是可选的。
二、Excalidraw 数据格式:场景序列化协议
Excalidraw 的核心是一个 状态树 + 序列化协议。每次画布上有一个元素发生变更,整个场景都会被序列化为 JSON,写入 undo/redo 栈。
2.1 元素数据结构
一个 Excalidraw「矩形」的完整数据模型:
{
"type": "rectangle",
"id": "r1",
"x": 100,
"y": 200,
"width": 200,
"height": 120,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#a5d8ff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"roundness": { "type": 3 },
"boundElements": [
{ "id": "t_r1", "type": "text" }
],
"isDeleted": false,
"groupIds": [],
"frameId": null,
"seed": 183729461
}
几个值得注意的技术细节:
seed字段:rough.js 的随机扰动基于这个 seed 值,保证同一次渲染结果稳定。如果重新渲染时 seed 变了,手绘线条会重新随机生成,导致视觉跳跃。roundness.type: 3:Excalidraw 支持 3 种圆角模式——直角1、圆角2(标准贝塞尔圆弧)、手绘圆角3(rough.js 扰动后的不规则圆角)boundElements:文本绑定到容器的关系,渲染时文本跟随容器移动
2.2 场景序列化
整个场景的序列化结构:
{
"type": "excalidraw",
"version": 2,
"source": "https://excalidraw.com",
"elements": [ /* 所有图形元素 */ ],
"appState": {
"viewBackgroundColor": "#ffffff",
"currentItemStrokeColor": "#1e1e1e",
"currentItemFillStyle": "solid",
"zoom": { "value": 1 },
"scrollX": 0,
"scrollY": 0
}
}
2.3 分享链接的压缩
Excalidraw 的分享链接不是普通的 URL,而是一个压缩编码的场景数据:
https://excalidraw.com/#room=...
// 或
https://excalidraw.com/#json=base64_encoded_deflated_data
算法链:
场景 JSON → pako.deflate() 压缩 → Base64 URL-safe 编码 → Hash 片段
使用 pako(zlib 的 JS 实现)做 deflate 压缩,可以将一个 50KB 的场景压缩到 ~8KB,再 Base64 编码后可以放进 URL。
这也是离线保存场景的基础——导出 .excalidraw 文件实际上就是原始 JSON,而 .excalidrawlib 是预定义的图库,共享相同的结构。
三、博客集成方案:iframe 嵌入 vs 本地渲染
这个博客提供了两种使用 Excalidraw 的方式:
3.1 在线编辑器嵌入
博客的 Sketch 页面使用 iframe 嵌入了 excalidraw.com 的完整编辑器:
<div class="draw-container">
<iframe src="https://excalidraw.com/"
title="Excalidraw"
allow="clipboard-read; clipboard-write"
loading="lazy">
</iframe>
</div>
iframe 方案的优势:
- 零部署:无需自建编辑器,始终用最新的 Excalidraw 版本
- 全功能:完整的绘图、协作、导出能力
- 跨域隔离:沙箱化的渲染环境,不影响主页面
限制:
- 离线不可用:依赖 excalidraw.com 可用性
- 通信受限:只能通过 postMessage 做有限互操作
- 性能开销:多一个渲染进程
3.2 头部图嵌入
博客文章的头图使用预渲染的 PNG,生成的流程是:
Excalidraw 编辑器 → 导出为 excalidraw JSON
→ render-to-png.mjs (Playwright + Excalidraw npm 包)
→ exportToBlob() → PNG
→ 上传到 static/images/
exportToBlob 是 Excalidraw 官方提供的服务端渲染接口:
import { exportToBlob } from '@excalidraw/excalidraw';
const blob = await exportToBlob({
elements: scene.elements,
appState: { viewBackgroundColor: '#ffffff' },
files: null,
// 这个 API 内部调用 rough.js 生成 SVG
// 再通过 Canvas 2D 栅格化 → Blob
});
3.3 服务端渲染管线
我们使用 Playwright(无头 Chromium)来运行这步,完整渲染管线是:
.excalidraw JSON
↓
Playwright 加载 HTML 页面 → 加载 @excalidraw/excalidraw npm 包
↓
调用 exportToBlob({ elements }) → rough.js 计算扰动 → SVG 生成 → Canvas 栅格化
↓
压缩为 PNG → 写入磁盘
期间 rough.js 执行:
1. 逐元素遍历
2. 根据 roughness/seed/bowing 参数生成扰动后的 SVG path
3. 添加填充(实心/影线/虚线等)
4. 生成完整的 SVG DOM
5. Excalidraw 将 SVG 渲染到离屏 Canvas
6. Canvas.toBlob() → PNG
这就是真正的手绘 Excalidraw 效果和 AI 生成的"Excalidraw 风格图片"的本质区别:一个通过 rough.js 的数学扰动产生真实手绘质感,一个是固定像素的仿制品。
四、技术对比:Excalidraw vs 其他白板工具的渲染架构
| 特性 | Excalidraw | Draw.io | Figma | tldraw |
|---|---|---|---|---|
| 渲染引擎 | rough.js (SVG) | 自研 (SVG/Canvas) | WebGL + Canvas2D | own Canvas renderer |
| 手绘效果 | ✅ rough.js 数学扰动 | ❌ 标准矢量 | ❌ 标准矢量 | ⚠️ 可选粗糙度 |
| 数据格式 | 标准 JSON | XML (mxGraph) | 自研二进制 | JSON |
| 渲染精度 | 像素级扰动 | 子像素精确 | 子像素精确 | 子像素精确 |
| 协作协议 | CRDT + Socket.io | 无原生协作 | LiveSync 自研 | yjs CRDT |
| 端到端加密 | ✅ 支持 | ❌ | ❌ | ❌ |
| 离线支持 | PWA + LocalStorage | 无 | 有限 | ✅ (纯前端) |
| 嵌入复杂度 | iframe/API | iframe/API | iframe (只读) | React 组件 |
Excalidraw 的定位是手绘质感优先。它的渲染引擎(rough.js)故意引入数学上的"不完美",这在大多数架构设计场景中其实是不切实际的——生产环境架构图需要精确的位置关系——但在脑暴、概念设计、快速原型的场景里,这种粗糙感恰恰降低了认知门槛:看到歪歪扭扭的框,大脑会将它解读为"草稿/未完成/可修改",反而鼓励了创造性思维。
五、从短代码到嵌入协议
博客实现了 Excalidraw 短代码用于嵌入已保存的场景:
layouts/shortcodes/excalidraw.html
但更灵活的方式是直接用 iframe 嵌入渲染器,加载已导出的场景 JSON:
<iframe
src="https://excalidraw.com/?json=https://thunsis.github.io/scenes/architecture.json"
width="100%" height="500px">
</iframe>
Excalidraw 支持通过 URL 参数 ?json= 加载远程场景,渲染完成即为可交互的编辑器。这意味着读者不仅可以看,还能直接编辑、拖动、修改,然后导出截图。
这是静态的 PNG 图片完全做不到的——嵌入一个可交互的白板比嵌入一张图片,信息密度和质量完全不同。
结语
Excalidraw 的技术价值不在于它的绘图功能本身,而在于:
- rough.js 的数学扰动算法——用几个可控参数实现了连续的手绘质感
- 简洁的场景序列化协议——一个 JSON 描述整个白板,可压缩、可嵌入、可传输
- 渲染管线的模块化设计——从编辑器数据到 SVG 再到 PNG,每层可独立替换
- 嵌入协议的开放性——iframable URL + JSON 加载,使得它天然适合集成到博客这类静态站点中
真正的技术很多时候不是复杂的高级特性,而是一个优雅的数学模型 + 一个干净的数据协议。