从渲染管线到数据序列化,拆解 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 其他白板工具的渲染架构

特性ExcalidrawDraw.ioFigmatldraw
渲染引擎rough.js (SVG)自研 (SVG/Canvas)WebGL + Canvas2Down Canvas renderer
手绘效果✅ rough.js 数学扰动❌ 标准矢量❌ 标准矢量⚠️ 可选粗糙度
数据格式标准 JSONXML (mxGraph)自研二进制JSON
渲染精度像素级扰动子像素精确子像素精确子像素精确
协作协议CRDT + Socket.io无原生协作LiveSync 自研yjs CRDT
端到端加密✅ 支持
离线支持PWA + LocalStorage有限✅ (纯前端)
嵌入复杂度iframe/APIiframe/APIiframe (只读)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 的技术价值不在于它的绘图功能本身,而在于:

  1. rough.js 的数学扰动算法——用几个可控参数实现了连续的手绘质感
  2. 简洁的场景序列化协议——一个 JSON 描述整个白板,可压缩、可嵌入、可传输
  3. 渲染管线的模块化设计——从编辑器数据到 SVG 再到 PNG,每层可独立替换
  4. 嵌入协议的开放性——iframable URL + JSON 加载,使得它天然适合集成到博客这类静态站点中

真正的技术很多时候不是复杂的高级特性,而是一个优雅的数学模型 + 一个干净的数据协议