OpenPencil 实时协作指南:基于 Yjs CRDT 与 WebRTC 的无服务器 P2P 设计协作
OpenPencil 实时协作指南:基于 Yjs CRDT 与 WebRTC 的无服务器 P2P 设计协作
OpenPencil 内置了基于浏览器到浏览器(P2P)直连的实时多人设计协作能力:无需注册账号、无需自建服务器,文档数据通过 WebRTC 在参与者之间直接流通。本文以官方协作文档为主线,结合 src/app/collab 目录下的源码实现,讲解如何开启协作会话、哪些数据会被同步、底层架构如何工作,以及会话结束后的数据行为,帮助你理解并正确使用这一能力。
协作能力概述
OpenPencil 允许多名用户同时编辑同一个设计文档,所有变更近乎实时地出现在每个参与者的画布上。与常见的"中心服务器转发"模式不同,OpenPencil 的协作采用 P2P 直连:参与者的浏览器之间直接建立 WebRTC 连接,文档数据不经过任何中心服务器中转,也不需要任何账号体系。官方文档明确概括了这一特性:多人实时编辑、变更在参与者之间直接流通(见 packages/docs/fr/programmable/collaboration.md)。
这种设计带来的直接收益是部署零成本——既不依赖 OpenPencil 官方服务器,也不要求团队搭建自有的同步服务;代价则是协作质量取决于参与者的网络状况以及 WebRTC 连接能否成功建立。
开启一场协作会话
开始协作的流程非常简单,官方英文版文档给出了三步操作(见 packages/docs/programmable/collaboration.md):
- 点击界面右上角的分享(Share)按钮;
- 复制自动生成的分享链接,形如
app.openpencil.dev/share/<room-id>; - 把链接发送给协作者。
任何持有该链接的人都可以加入会话。只要至少有一名参与者保持页面打开,房间就会持续存在;所有参与者中途刷新页面后,也能以相同的会话状态重新加入。
从源码角度看,分享链接的生成逻辑位于 src/constants.ts 的 getShareURL(roomId):桌面端(Tauri)统一使用 WEB_APP_ORIGIN(https://app.openpencil.dev)拼接 /share/<room-id>,浏览器端则直接使用当前页面 origin,因此无论在浏览器还是桌面应用中都可通过同一套链接体系协作。
房间 ID 的生成与安全性
房间 ID 由 src/app/collab/awareness.ts 中的 generateRoomId() 生成:从 36 个字符(26 个小写字母 + 10 个数字)中随机抽取。关键参数定义在 src/constants.ts:
ROOM_ID_LENGTH = 32:32 位 base36 字符,约提供 165 bits 的熵;ROOM_ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789':字符集。
源码注释明确指出,房间 ID 本质上是"持有即凭证"(bearer credential),必须足以抵抗针对公开信令主题的离线枚举攻击。因此只有拿到链接的人才能加入,链接本身不应被公开传播。另外,房间 ID 由密码学随机性生成,其中不包含任何文档数据——仅凭房间 ID 无法反推出文档内容。
同步的数据范围
官方文档列出的同步数据可以分为两类:文档本体与协作现场信息。
文档内容
- 文档:形状、文本、属性、布局等所有编辑内容都会实时同步。从源码看,场景图的节点被编码进 Yjs 的
nodes共享 Map,图片等二进制资源进入images共享 Map(见 src/app/collab/session.ts),双向绑定由 src/app/collab/yjs-sync.ts 中的bindCollabGraphEvents完成:编辑器侧的node:updated、node:created、node:reparented、node:reordered、node:deleted事件会分别触发对应的 Yjs 同步操作。
协作现场信息
- 光标(Cursors):每个参与者光标的位置,附带有参与者的名字和颜色;
- 选区(Selections):被高亮的选中对象对所有参与者可见;
- 视图(View):参与者可以跟随另一个人的画布视野。
这些现场信息通过 Yjs 生态的 Awareness 协议传输。远端参与者数据结构定义在 src/app/collab/types.ts:每个 RemotePeer 携带 clientId、name、color,以及可选的 cursor(x/y 坐标 + 所在 pageId)和 selection(节点 ID 数组)。本地用户的名字保存在 localStorage(键名 op-collab-name)中,颜色从 src/constants.ts 预置的 8 色 PEER_COLORS 调色板中随机选取。
值得注意的实现细节是 remotePeersToCursors(src/app/collab/awareness.ts):远端光标只有在与当前页面(pageId)匹配时才会被绘制到画布上,即光标跟随的是"页面"而非整个文档坐标系。
跟随模式(Follow Mode)
点击顶部栏中协作者的头像即可进入跟随模式:你的画布会自动平移和缩放,与被跟随者的视口保持一致;再次点击即可退出跟随。
源码中该功能由 createFollowActions 实现(src/app/collab/awareness.ts)。其中的 tickFollow 每帧读取被跟随者的 awareness 光标状态:
- 若被跟随者切换了页面(
cursor.pageId与本地不同),会自动调用store.switchPage同步切换; - 若光标携带缩放值,则同步本地 zoom;
- 根据光标坐标反推 panX/panY,使被跟随者的视图中心对齐到本地画布中心。
同时,本地视口变化也会反向写回 awareness(见 src/app/collab/session.ts 的 watchAwarenessZoom,它监听编辑器的 viewport:changed 事件),从而让你的跟随者也能看到你的视野状态——这正是"视图同步"的双向闭环。
架构原理:Yjs + Trystero + WebRTC
官方文档对架构的表述是:Yjs 以 CRDT(无冲突复制数据类型)维护共享状态,Trystero 负责发现参与者并建立 WebRTC 连接,信令服务器仅在连接建立阶段提供帮助,不转发任何文档内容。
数据层:Yjs CRDT
所有共享文档状态存放在一个 Y.Doc 中(src/app/collab/session.ts),节点与图片分别对应 ydoc.getMap('nodes') 与 ydoc.getMap('images')。CRDT 的核心价值在于:并发编辑可以自动合并、无需中央仲裁,每个参与者的本地状态天然一致收敛。
连接与同步的协议流程封装在 src/app/collab/room.ts 的 connectCollabRoom 中,使用 Trystero 的 makeAction 定义了四个消息通道:
yjs-update:常规文档增量更新。ydoc.on('update')将本地变更广播出去(跳过 origin 为remote的远端回环),远端收到后Y.applyUpdate(ydoc, data, 'remote')应用;awareness:光标、选区、名称等现场信息,基于y-protocols/awareness协议编解码;sync-step1/sync-reply:新参与者加入时的同步握手。加入者广播自己的状态向量(state vector),已有参与者收到后用Y.encodeStateAsUpdate(ydoc, stateVector)只回传对方缺失的增量(src/app/collab/room.ts),避免全量传输。
房间的本地持久化由 y-indexeddb 的 IndexeddbPersistence 承担(src/app/collab/session.ts),每个房间对应一个独立的 IndexedDB 库(键名 op-room-<roomId>)。这就是"刷新页面后以相同状态重新加入"的实现基础:远端变更先落入本地 Yjs 文档,刷新后从 IndexedDB 恢复,再通过状态向量与同伴增量补齐。
传输层:Trystero 与 WebRTC
Trystero 是开源的无服务器 P2P 通信库。OpenPencil 使用其 MQTT 后端变体(src/app/collab/transport/trystero.ts),以 TRYSTERO_APP_ID = 'openpencil' 作为命名空间加入房间。WebRTC 的 ICE 配置包含:
- 两个公开 STUN 服务器(
stun.l.google.com与stun.cloudflare.com)用于 NAT 穿透发现; - openrelay 的 TURN 服务器(TCP/UDP 两种传输)作为连接失败时的兜底中继——注意 TURN 只中继加密的 WebRTC 媒体/数据通道,OpenPencil 的设计目标是尽可能让数据直连,TURN 仅在网络环境无法直连时启用。
这正是"信令服务器帮助建立连接但不中转文档"的含义:MQTT 主题只承载建立 WebRTC 连接所需的握手信息,建立成功后文档数据走浏览器之间的加密数据通道,不再经过任何服务器。
会话生命周期管理
src/app/collab/session.ts 的 connectCollabSession 负责资源装配:创建 Y.Doc、Awareness、持久化、注册 Yjs 观察者、建立房间连接并绑定图事件。对应的 disposeCollabSessionResources(src/app/collab/session.ts)在会话结束时按序清理:解绑事件、退出房间、销毁 awareness 与 persistence、销毁 Y.Doc、清空远端光标并触发重绘。Vue 组合式入口 useCollab(src/app/collab/use.ts)通过 tryOnScopeDispose(disconnect) 保证组件销毁时自动断开会话。
隐私与数据边界
官方文档对隐私的承诺非常明确:文档内容不会存储在任何 OpenPencil 服务器上,每位参与者各自保有一份本地副本。结合架构可以进一步确认:
- 文档变更只在 WebRTC 数据通道内流转,信令服务器(MQTT 主题)看不到文档内容;
- 房间 ID 不含文档数据,且是持有即凭证的随机令牌;
- 本地持久化使用浏览器 IndexedDB(
op-room-<roomId>),属于参与者自己的设备; - 由于数据直达对方浏览器,请只把分享链接发给信任的人。
会话结束后的行为
当会话结束时——无论是你主动断开,还是远端参与者离开——远端参与者及其光标会被移除。关键语义是:已经同步到本地的变更会保留在本地文档中,不会因为会话结束而丢失。
源码层面有两个佐证:
- 远端离开时,
room.onPeerLeave会收集该 peer 对应的 awareness client ID 并调用removeAwarenessStates(awareness, remoteClients, 'peer-left')(src/app/collab/room.ts),只清理现场信息,不动文档数据; - 断开会话时
disposeCollabSessionResources只销毁运行时的 Yjs 实例与房间连接(src/app/collab/session.ts),而 IndexedDB 中已持久化的文档内容保留在你本地。此外源码注释也说明,文档的"陈旧光标"(stale cursors)会在参与者断开时被自动清理。
从产品角度理解:会话是"实时同步通道"而非"文档的所有权容器"。通道关闭后,文档依然完整地存在于你的本地画布与存储中,你可以继续独立编辑;下次重新分享时,会基于当前文档状态创建新的同步通道。
实践建议与适用前提
综合官方文档与源码实现,使用协作功能时值得注意以下几点:
- 浏览器与桌面端均可用:链接生成逻辑已分别适配 Web 与 Tauri 桌面端(src/constants.ts);
- 网络前提:协作质量依赖参与者之间的网络连通性以及 WebRTC 能否成功建立(NAT 穿透失败时会回退到 TURN 中继,但会有额外延迟与带宽成本);
- 安全边界:房间链接 = 访问凭证,请仅与信任的人分享;不要在公开渠道暴露链接;
- 数据边界:共享的是实时编辑内容与现场信息,本地持久化始终在参与者各自的设备上,OpenPencil 服务器不存储文档内容。
如果你希望进一步深入实现细节,可以从这些入口继续阅读仓库源码:会话装配与生命周期在 src/app/collab/session.ts,CRDT 同步与图事件绑定在 src/app/collab/yjs-sync.ts 与 src/app/collab/room.ts,Trystero 传输封装在 src/app/collab/transport/trystero.ts,房间 ID 与分享链接常量在 src/constants.ts,对应的官方协作说明文档位于 packages/docs/fr/programmable/collaboration.md 与 packages/docs/programmable/collaboration.md。