OpenPencil 源码分析:AI 原生设计编辑器的分层、渲染与协作
8月 14, 2026
AI 参与说明(Agent:GitHub Copilot):本文基于
open-pencil/open-pencil公开仓库的master分支源码整理,重点阅读入口、包结构、架构文档、协作模块与桌面端适配代码。本文对应的源码快照为5190a64017bf4391dc2584042711f235c296f002,资料检查日期为 2026-08-14。项目演进很快,目录、依赖与实现细节可能变化;阅读或二次开发时请以当前分支源码为准。
先说结论#
OpenPencil 不是“用 Vue 画几个矩形”的在线白板,而是一个把设计工具核心能力拆开的 TypeScript monorepo:
- Vue 应用层负责编辑器外壳、路由、面板、文件交互、AI 与协作入口;
- 核心引擎层负责场景图、布局、选择、撤销、命中测试、CanvasKit 渲染和文档 I/O;
- 格式层负责
.fig/ Kiwi 二进制容器与导入导出; - 桌面层通过 Tauri 提供本地文件系统、进程启动、更新和原生能力;
- 协作层把内部场景图事件桥接到 Yjs CRDT,并用 Trystero 走 WebRTC P2P 网络;
- 可编程层以 MCP、CLI、Figma Plugin API 兼容执行目标和 AI/ACP 接入为中心,而不是只在 UI 中加一个聊天框。
从架构视角看,它最重要的选择有三个:场景图是唯一的编辑数据中心、CanvasKit 是渲染终点、事件驱动是模块之间的连接方式。这三个选择让渲染、属性面板、撤销和协作不必互相直接调用。
1. 从仓库结构看边界#
仓库 README 给出的结构已经表达了它不是单一前端应用:
packages/
scene-graph/ 节点、图元、命中测试、复制/吸附/撤销
pen/ Pencil 文档格式辅助能力
kiwi/ Kiwi runtime 与 .fig 底层容器解析
fig/ .fig archive、SceneGraph 转换、实例与元数据
core/ 编辑器引擎、渲染、布局、工具、RPC、文档 I/O
dom-css/ HTML/CSS/Tailwind 到可编辑设计文档
vue/ Headless Vue SDK
cli/ 无��� CLI
mcp/ MCP Server
docs/ 文档站
src/ Vue 编辑器应用、AI、协作、文档 I/O
desktop/ Tauri v2 壳与 Rust 配置
tests/ E2E、视觉回归、引擎与集成测试这种拆分的价值在于依赖方向明确:packages/core 应该保持框架无关,src/app 负责把浏览器、Tauri、Vue 和具体交互连接进来。仓库的工程约定也明确要求跨包通过公开 workspace export 通信,避免应用层绕过边界直接依赖内部实现。
可以用下面的图理解主要数据流:
flowchart TB
UI[Vue 编辑器 UI\n工具栏、图层、属性面板] --> Store[EditorStore]
Store --> Graph[SceneGraph\n节点与层级关系]
Graph --> Layout[Yoga Layout]
Graph --> Undo[Undo / Redo]
Graph --> Render[CanvasKit Renderer]
Graph --> Format[.fig / Kiwi I/O]
Graph --> Sync[Yjs Graph Sync]
Sync --> YDoc[Y.Doc]
YDoc --> P2P[Trystero / WebRTC]
Tauri[Tauri Desktop Shell] --> UI
MCP[MCP / CLI / FigmaAPI] --> Graph这里值得注意的是,UI、MCP 和 CLI 最终都应落到同一套编辑模型,而不是各自维护一份“设计稿状态”。这也是设计工具要支持自动化时最容易做对、也最容易做错的地方。
2. 启动入口很薄:把平台差异留在边缘#
src/main.ts 的职责很克制:预加载字体、创建 Vue 应用、注册路由和 head 管理器;如果当前不是 Tauri 环境,再延迟注册 PWA Service Worker。
preloadFonts()
const head = createHead()
createApp(App).use(router).use(head).mount('#app')
if (!IS_TAURI) {
void import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true })
})
}这个入口没有把“桌面版”和“浏览器版”拆成两套应用。相反,平台能力通过 isTauri() / IS_TAURI 判断,并在真正需要时动态导入 Tauri plugin。
例如,缓存模块在桌面端使用 @tauri-apps/plugin-fs 写入 AppLocalData,浏览器端则退回到 localStorage;文档保存优先处理云存储绑定,再处理 Tauri 的绝对路径,最后使用浏览器 File System Access API 的 FileSystemFileHandle。这是一种典型的 capability adapter(能力适配器) 模式:业务调用“读/写文档”,而不需要处处判断自己运行在 Web 还是桌面端。
3. 场景图:编辑器真正的中心#
OpenPencil 文档把 SceneGraph 描述为一个以 GUID 为 key 的扁平 Map<string, Node>,父子结构通过 parentIndex 等关系表达。它没有把设计稿直接存成深层嵌套 JSON 树。
这种表示方式对编辑器非常实用:
- 按 ID 查节点是 O(1),选择、属性面板、更新节点都不必递归遍历整棵树;
- 树关系与节点属性解耦,重排、换父级、删除和复制更容易实现;
- 渲染与命中测试可以按需要遍历,不必让 UI 组件树等同于文档树;
- 撤销、协作和自动化工具可以围绕节点 mutation 工作。
更关键的是,场景图发出类型化事件,例如:
node:created
node:updated
node:deleted
node:reparented
node:reordered这使“更新一个节点”成为一个领域事件,而不是某个调用点手工通知渲染器、面板、协作模块和历史记录。架构文档提到,编辑器利用这些事件做渲染失效和微任务批处理的组件实例同步;协作模块则订阅同一批事件,把变更传播到 Yjs。
这比在每个 updateNode() 调用后加一串副作用更稳定:新模块只需要订阅事件,不必修改所有旧调用点。
4. 从布局到渲染:为什么不是 SVG#
设计工具需要同时处理大量节点、文本排版、阴影、模糊、裁剪和缩放。OpenPencil 的答案是:用 Skia CanvasKit WASM 把整个设计文档绘制到 GPU surface,而不是让每个节点对应一个 DOM 或 SVG 元素。
项目技术栈文档列出的关键组合是:
- CanvasKit WASM:矢量绘制、文本 shaping、效果与导出;
- Yoga WASM:Flexbox 与 Grid 布局计算;
- Vue 3:编辑器控制面 UI;
- Tailwind CSS / Reka UI:面板与基础交互;
- CanvasKit 渲染器:场景遍历、填充、描边、形状、效果、标尺、标注和远程光标。
这里要分清两个“渲染”:
- Vue 渲染的是按钮、图层树、属性控件等 控制面 UI;
- CanvasKit 渲染的是设计稿本身,即 画布内容。
这避免了把一个大型设计稿映射为成千上万个 DOM 节点。代价是编辑器需要自行实现命中测试、选择框、光标、无障碍替代策略和局部失效控制;但对 Figma 类工具来说,这些本来就是核心能力。
布局层采用 Yoga,将设计工具里的 auto layout 属性映射为 flex/grid 概念。例如横向/纵向 stack 对应 flexDirection,间距对应 gap,padding、justify、grow 等也都有对应项。这样设计稿的布局语义可以尽可能依赖成熟布局引擎,而不是重复实现一套不���整的 Flexbox。
5. EditorStore:把交互动作收敛到命令层#
从演示文档构造代码可以看到,应用层并不是直接往某个 Vue 响应式数组里塞对象,而是通过 EditorStore 创建形状、更新图节点、绑定变量、计算布局,再请求重绘:
const frameId = store.createShape('FRAME', 20, 48, 520, 460, appSectionId)
graph.updateNode(frameId, {
name: 'Dashboard',
fills: [solid(DEMO_COLORS.gray50)],
clipsContent: true
})
computeAllLayouts(store.graph)
store.requestRender()属性面板同样遵循这个原则。以 appearance 控件为例,Vue composable 只负责取当前 selection、计算多选 mixed state,并调用封装后的 action;真正的变更通过 editor.undo.runBatch() 和 editor.updateNodeWithUndo() 写入。这意味着“修改透明度”“改圆角”“切换混合模式”不是 UI 私有行为,而是可撤销的编辑命令。
一个成熟编辑器通常都需要这种分层:
Pointer / Keyboard Event
↓
Tool 或 Property Action
↓
Editor Command(可撤销)
↓
SceneGraph Mutation
↓
事件、渲染、协作、持久化响应如果省略中间的命令和 undo 层,早期做原型会很快,但多选、批量修改、撤销、脚本化和远端同步很快会相互冲突。
6. 文件格式:把兼容性放在独立格式层#
OpenPencil 的包划分里,kiwi、fig、pen 和 core 的 document I/O 是独立领域,而不是散落在 UI 页面里。README 把 @open-pencil/kiwi 描述为 Kiwi runtime 与 .fig 底层容器解析,把 @open-pencil/fig 描述为 .fig archive、场景图转换、实例和元数据。
这种拆分带来两个实际好处:
- 导入
.fig后,编辑器仍以自己的 SceneGraph 工作; - 导出时可以从统一模型生成目标格式,而不是要求 UI 层理解压缩、二进制容器和资源归档。
在文件重新加载逻辑中,源码会读取 .fig 字节并交给 readFigFile(file, { populate: 'first-page' })。这说明平台层只负责拿到 bytes / File,格式层负责解释文档。
推断:这套边界也为 headless CLI、MCP 工具和未来的服务端批处理提供了复用基础。因为它们可以复用格式解析与 SceneGraph,而不必启动完整 Vue 画布。这个推断来自项目将 CLI、MCP、core 与格式包独立发布的结构,而不是某个单一函数的承诺。
7. 协作:SceneGraph 与 CRDT 之间的双向桥#
协作模块集中在 src/app/collab/**,其核心不是直接同步整个应用状态,而是同步场景图节点数据。
7.1 本地修改如何进入 Yjs#
yjs-sync.ts 会绑定 node:updated、node:created、node:reparented、node:reordered 和 node:deleted 事件。
发生本地 mutation 后,模块调用 syncNodeToYjs(nodeId);删除时则在 ydoc.transact() 中从 Y.Map 删除节点。同步跨越 Graph/Yjs 边界时会使用 structuredClone,避免共享可变嵌套对象。
7.2 远端 Yjs 更新如何回写画布#
同一模块通过 ynodes.observeDeep() 监听 Yjs 深层事件。收到远端更新时,它会暂时打开 suppressGraphSync,调用 applyYjsToGraph(events),然后请求重绘。
双向同步最大的风险是回环:
Graph 更新 → 写入 Yjs → Yjs observer 触发 → 再写回 Graph → 无限循环OpenPencil 使用两类抑制标记避免这个问题:
suppressGraphSync:远端变更应用到本地 Graph 时,不要再推回 Yjs;suppressYjsEvents:本地批量写 Yjs 时,不要把自身写入当成远端事件处理。
这是一种比“比较整个对象是否相等”更可靠的策略,因为 CRDT 事件可能来自批量 transaction,且对象复制、字段顺序和嵌套引用都会让简单比较失效。
7.3 网络与在线状态#
room.ts 使用 Trystero 加入房间,定义 yjs-update、awareness、sync-step1、sync-reply 四类消息:
- Yjs update:持续增量更新;
- awareness:光标、选择、用户等临时在线状态;
- sync-step1 / reply:新 peer 加入时按 state vector 补齐文档状态。
Yjs 更新收到后以 origin: 'remote' 应用;本地 ydoc.on('update') 只广播非 remote 的更新。这让网络层不需要理解每种设计节点,只需要传输 Yjs 的二进制更新。
useCollab() 则把这些底层对象包装成更适合 Vue 使用的 API:connect()、disconnect()、shareCurrentDoc()、光标更新、选择更新、关注某位远端用户等。shareCurrentDoc() 生成 room ID,连接后调用 syncAllNodesToYjs(),因此分享动作的语义是“建立房间并把当前 Graph 初始化到 CRDT”。
8. AI-native 的含义:可执行编辑模型,而不只是聊天#
“AI-native design editor” 很容易被理解成“有一个生成图片或聊天面板”。从仓库结构看,OpenPencil 更强调的是让 AI 能操作同一套设计引擎:
packages/mcp/提供 MCP server;packages/cli/提供无头操作入口;packages/core/src/figma-api/中的FigmaAPI是工具与 CLI eval 的执行目标,并追求 Figma Plugin API 兼容;src/app/ai/acp/**提供 ACP UI 与 agent transport;- AI/ACP 进程在桌面端通过 Tauri shell 启动,并考虑 Windows
.cmdshim 的兼容问题。
这意味着模型、CLI 和用户界面并非各自操作不同的数据副本,而是在同一个“创建节点 / 修改节点 / 运行布局 / 导出文档”的执行面上工作。
MCP Server 的生命周期代码还体现了另一个工程取舍:本地 transport 优先使用用户私有 Unix socket,Windows 使用 TCP;服务发现文件与认证 token 用来让客户端找到可用服务。也就是说,AI 工具接入并非只考虑“能调通”,还处理了进程生命周期、socket 清理、监听失败回滚、认证发现与跨平台差异。
9. Tauri:不是把 Web 打包起来就结束#
桌面端在 OpenPencil 中承担了三个关键职责:
- 本地文件能力:打开、保存、重载
.fig等设计文件; - 本地进程能力:启动 ACP agent、MCP 相关进程;
- 更新与系统集成:检查更新、下载并安装、重启应用。
例如 ACP 进程启动代码会把 stdout 转成 ReadableStream、把 stdin 转成 WritableStream,将外部 agent 进程适配成浏览器侧可消费的流。Windows 下对于全局 npm CLI 的 .cmd shim,代码会显式转换为 cmd /c <command>,因为 Tauri 的 Rust process spawner 不会像 Windows shell 那样自动解析这类 shim。
这种细节很能说明桌面应用的真实复杂度:同一段“启动命令”的功能,在 macOS/Linux/Windows 与浏览器中并不具有同样的能力和错误模型。把这些差异收敛在 src/app/tauri/**、src/app/shell/** 等边缘目录,比让业务模块自行处理要可维护得多。
10. 这个架构的优势与要付出的代价#
优势#
- 内核可复用:格式、场景图、布局、渲染与命令能力可被 UI、CLI、MCP 共用;
- 事件解耦:新增渲染观察、协作同步或统计模块,不必修改所有 mutation 调用点;
- 平台适配集中:浏览器、PWA、Tauri 的差异在边缘处理;
- 协作边界清晰:Graph 是编辑模型,Yjs 是复制与合并模型,Trystero 是传输模型;
- 自动化更可信:AI 工具最终作用于真实编辑模型,而不是模拟点击 UI。
代价#
- Canvas 渲染意味着需要自己维护命中测试、覆盖层、局部重绘和坐标系统;
- 场景图、Yjs、Vue 状态三者之间必须严格区分所有权,否则会产生双向同步和过度响应式问题;
.fig兼容性、文本排版、实例与变量语义都比普通 JSON 文档难得多;- Tauri 的文件、权限、进程与更新能力会带来平台测试矩阵;
- AI/自动化接口扩大了可操作面,权限、路径检查、token 与安全边界必须成为一等公民。
结语:读 OpenPencil 源码的推荐顺序#
如果想继续深入,我建议不要一上来钻进 CanvasKit 绘制细节,而是按下面的顺序建立模型:
- 从
README.md和packages/docs/development/architecture.md了解边界; - 读
src/main.ts、路由与编辑器会话初始化,理解应用如何启动; - 阅读
packages/scene-graph与packages/core中 SceneGraph、Editor、Undo、Layout 的公开 API; - 再看渲染器如何把 Graph 变成 CanvasKit 命令;
- 然后阅读
src/app/collab/{use,session,room,yjs-sync}.ts,理解事件与 CRDT 的双向桥; - 最后再进入
.fig/Kiwi、MCP、ACP 与 Tauri 等扩展能力。
OpenPencil 最值得借鉴的并不是某一个 Vue 组件或某一个 Canvas API,而是它把“设计工具本体”从“界面壳”中抽出来:图形编辑、协作、AI 自动化和桌面端能力最终围绕同一个场景图与命令模型协作。对于要做复杂可视化编辑器、低代码画布、白板、流程图或 AI 辅助创作工具的团队,这种分层比先选 React 还是 Vue 更决定项目能否长期演进。