OpenPencil 源码分析:AI 原生设计编辑器的分层、渲染与协作

8月 14, 2026
Frontend, TypeScript, Vue, AI, ByAI

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 树。

这种表示方式对编辑器非常实用:

  1. 按 ID 查节点是 O(1),选择、属性面板、更新节点都不必递归遍历整棵树;
  2. 树关系与节点属性解耦,重排、换父级、删除和复制更容易实现;
  3. 渲染与命中测试可以按需要遍历,不必让 UI 组件树等同于文档树;
  4. 撤销、协作和自动化工具可以围绕节点 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 的包划分里,kiwifigpencore 的 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:updatednode:creatednode:reparentednode:reorderednode: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-updateawarenesssync-step1sync-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 .cmd shim 的兼容问题。

这意味着模型、CLI 和用户界面并非各自操作不同的数据副本,而是在同一个“创建节点 / 修改节点 / 运行布局 / 导出文档”的执行面上工作。

MCP Server 的生命周期代码还体现了另一个工程取舍:本地 transport 优先使用用户私有 Unix socket,Windows 使用 TCP;服务发现文件与认证 token 用来让客户端找到可用服务。也就是说,AI 工具接入并非只考虑“能调通”,还处理了进程生命周期、socket 清理、监听失败回滚、认证发现与跨平台差异。

9. Tauri:不是把 Web 打包起来就结束#

桌面端在 OpenPencil 中承担了三个关键职责:

  1. 本地文件能力:打开、保存、重载 .fig 等设计文件;
  2. 本地进程能力:启动 ACP agent、MCP 相关进程;
  3. 更新与系统集成:检查更新、下载并安装、重启应用。

例如 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 绘制细节,而是按下面的顺序建立模型:

  1. README.mdpackages/docs/development/architecture.md 了解边界;
  2. src/main.ts、路由与编辑器会话初始化,理解应用如何启动;
  3. 阅读 packages/scene-graphpackages/core 中 SceneGraph、Editor、Undo、Layout 的公开 API;
  4. 再看渲染器如何把 Graph 变成 CanvasKit 命令;
  5. 然后阅读 src/app/collab/{use,session,room,yjs-sync}.ts,理解事件与 CRDT 的双向桥;
  6. 最后再进入 .fig/Kiwi、MCP、ACP 与 Tauri 等扩展能力。

OpenPencil 最值得借鉴的并不是某一个 Vue 组件或某一个 Canvas API,而是它把“设计工具本体”从“界面壳”中抽出来:图形编辑、协作、AI 自动化和桌面端能力最终围绕同一个场景图与命令模型协作。对于要做复杂可视化编辑器、低代码画布、白板、流程图或 AI 辅助创作工具的团队,这种分层比先选 React 还是 Vue 更决定项目能否长期演进。

参考源码#

本文共 5391 字,上次修改于 Aug 14, 2026,以 CC 署名-非商业性使用-禁止演绎 4.0 国际 协议进行许可。

相关文章

» 中文汉字笔画查询:当前字形与传统姓名学备注的工程实现

» 技术方案研究 Skill:从问题建模到路线比较与决策

» 使用 Fontsource 管理 Web 字体:从手工字体文件到 npm 依赖

» 快手 H5 落地页、监测链接、归因与支付的通用实践

» 从 Obsidian 到 GitHub Pages:一套 Codex 协作的个人知识管理与发布工作流