tldraw 多人实时协作入门:用 useSyncDemo 在 30 秒内实现共享画布同步
导读
useSyncDemo 是 tldraw SDK 中把「单机画布」升级为「多人实时协作画布」的捷径:只需要一行 Hook 调用,即可让多个浏览器客户端(打开相同 roomId)连接到一个由 tldraw 官方托管的 demo 同步服务器,共享同一份文档、实时看到彼此的鼠标光标与编辑内容,全程无需自己搭建后端。本文以仓库中的官方示例 sync-demo README 为主体,结合 @tldraw/sync 包的源码实现(useSyncDemo.ts 与其配套测试 useSyncDemo.test.ts),系统讲解该 Hook 的使用方式、连接原理、可选参数、资源(图片/链接)处理细节,以及如何从 demo 服务器平滑迁移到自托管的生产级 useSync 方案。
一、useSyncDemo 是什么:零后端接入托管同步房间
官方示例(sync-demo 示例组件)的完整代码仅十余行,却已经是一个功能完整的多人白板:
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function SyncDemoExample({ roomId }: { roomId: string }) {
const store = useSyncDemo({ roomId })
return (
<div className="tldraw__editor">
<Tldraw store={store} options={{ deepLinks: true }} />
</div>
)
}
其核心工作方式,按 README 的原话概括为:
useSyncDemo返回一个连接到 tldraw 官方 demo 同步后端的 store,因此「不需要自己运行任何服务器」就可以开始原型化多人协作;- 把返回的 store 传给
<Tldraw store={store}>,所有使用相同roomId的客户端都会编辑同一份文档; - demo 服务器上的数据大约一天后会被清空;
- 在同一房间打开两个浏览器标签页,即可验证光标位置与编辑内容实时同步。
实现层面,该 Hook 最终把一切委托给 useSync(详见 useSync.ts),并自动拼接出 WebSocket 地址、装配好 demo 专用的资源上传与解析策略——下面各节会逐个拆解。
注意:示例中传入了
options={{ deepLinks: true }},它让每个房间拥有可分享的深链接,进一步方便多人通过链接加入同一协作房间。
二、从零起步的完整接入流程(三步)
在正式开始前,需要先安装同步所需的依赖。查看 packages/sync/package.json 可知 @tldraw/sync 依赖 tldraw、@tldraw/state、@tldraw/sync-core 等包,并将 React 18 及以上版本声明为 peerDependency:
yarn add @tldraw/sync tldraw
# 或 npm install @tldraw/sync tldraw
随后按以下三步即可获得一个可用的多人房间。
第 1 步:声明一个足够独特的 roomId。 demo 服务器的命名空间对所有使用方共享,所以房间名越独特越好(见第四节)。
第 2 步:调用 Hook 创建同步 store。 典型写法:
import { useSyncDemo } from '@tldraw/sync'
function MyApp({ roomId }: { roomId: string }) {
const store = useSyncDemo({ roomId })
// store.status 可能为 'loading' | 'synced-remote' | 'error'
return <Tldraw store={store.store ?? undefined} />
}
第 3 步:运行并打开两个窗口验证。 在浏览器中把同一 URL 分别打开到两个标签页(或两台设备),观察:右侧协作光标实时跟随移动、绘制图形相互可见、删除/移动操作即时传播。
2.1 处理连接状态(loading / synced-remote / error)
从源码 useSync.ts 的类型定义可以看出,远程 store 被封装为 RemoteTLStoreWithStatus,它只会有三种状态:
| 状态 | 含义 | 组件应如何响应 |
|---|---|---|
loading |
正在建立连接、同步初始状态 | 展示加载 UI(如「Connecting to room…」) |
synced-remote |
已连接并在实时同步,通过 store.store 拿到真正可用的 store |
将 store.store 交给 <Tldraw> |
error |
连接失败或同步出错,通过 store.error 获得错误详情 |
展示错误信息与重试入口 |
将状态 UI 接入示例后更稳健的版本:
function CollaborativeApp({ roomId }: { roomId: string }) {
const store = useSyncDemo({ roomId })
if (store.status === 'loading') return <div>Connecting to room…</div>
if (store.status === 'error') {
return <div>Connection failed: {store.error.message}</div>
}
// status === 'synced-remote'
return <Tldraw store={store.store} />
}
在 synced-remote 状态下,返回值还携带 connectionStatus('online' | 'offline')与 objectAccess 字段:前者让应用可以感知断网并提示「本地继续编辑、恢复后自动同步」;后者用于告知当前会话是否被服务器授予对象(如评论)的写入权限。两者与画布的整体只读状态相互独立,例如一个会话可以允许评论但禁止改画布。
三、源码视角:useSyncDemo 内部做了什么
useSyncDemo 的实现集中在 packages/sync/src/useSyncDemo.ts,其主体逻辑(L95-L132)可以归纳为四点,理解它们有助于排查连接与资源问题。
3.1 目标服务器与可覆盖的默认值
const DEMO_WORKER = getEnv(() => process.env.TLDRAW_BEMO_URL) ?? 'https://demo.tldraw.xyz'
const IMAGE_WORKER = getEnv(() => process.env.TLDRAW_IMAGE_URL) ?? 'https://images.tldraw.xyz'
- 同步服务器(demo worker):默认
https://demo.tldraw.xyz,可通过环境变量TLDRAW_BEMO_URL覆盖; - 图片优化服务(image worker):默认
https://images.tldraw.xyz,可通过TLDRAW_IMAGE_URL覆盖; getEnv用 try/catch 包裹了对process.env的访问(useSyncDemo.ts#L61-L67),以兼容process未被定义或使用编译期字符串替换的各种打包环境。
3.2 WebSocket 地址的拼接规则
实际连接在 L118-L131 委托给 useSync 完成:
return useSync({
uri: `${host}/connect/${encodeURIComponent(roomId)}`,
roomId,
assets,
onMount: /* 注册外部资源处理器 */,
...syncOptsWithDefaults,
})
也就是说,WebSocket 地址形如 https://demo.tldraw.xyz/connect/<roomId>,其中 roomId 经过 encodeURIComponent 编码。配套测试 useSyncDemo.test.ts#L54-L61 专门验证了这一点:即便 roomId 含有空格与符号,拼接出的 URI 中也会被正确编码,而原始 roomId 原样透传。
useSync 拿到 URI 后(useSync.ts)会创建 ClientWebSocketAdapter,并自动在查询参数上追加两个保留参数 sessionId 与 storeId(每个浏览器标签页/会话一个唯一值),用于区分同一房间内的多个会话。若用户自己的 URI 中已经包含这两个参数名,会直接抛出错误提示用户改名。
3.3 默认形状与绑定工具的自动合并
useSyncDemo 会自动携带 tldraw 的 defaultShapeUtils 与 defaultBindingUtils(L102-L116):当你传入自定义 shapeUtils 或 bindingUtils 时,它不会覆盖默认集合,而是将其拼接在默认集合之后。这解释了仓库中另一示例 sync-custom-shape README 的说明——同步 store 会校验并迁移所有形状类型的记录,因此自定义 Shape 的 ShapeUtil 必须同时传给 useSyncDemo 与 <Tldraw>,否则其他客户端发来的该类型形状会被 store 拒绝。对应测试见 useSyncDemo.test.ts#L71-L88。
3.4 丰富的可选参数:UseSyncDemoOptions
参考源码中的选项接口定义 useSyncDemo.ts#L23-L44:
| 参数 | 类型 | 说明 |
|---|---|---|
roomId |
string(必填) |
要同步的房间 ID。demo 服务器命名空间是全局共享的,务必保证独特,建议以公司/项目名做前缀 |
users |
TLUserStore(可选) |
用户 store,用于身份、光标 presence 与操作归属。不传则使用基于 localStorage 的默认实现 |
getUserPresence |
(store, user) => TLPresenceStateInfo | null(可选) |
自定义发送给其他客户端的 presence 数据 |
host |
string(内部标记 @internal) |
覆盖同步服务器地址(一般用不到,默认取 TLDRAW_BEMO_URL 或 demo 服务器) |
shapeUtils / bindingUtils / schema 等 |
继承自 TLStoreSchemaOptions |
自定义形状、绑定、数据校验 schema,会与默认值合并 |
其中 users 与 getUserPresence 的具体实战见仓库内其他示例:
- 想自定义房间内广播的用户信息(光标、相机、选区、聊天内容等),参考 sync-custom-presence 示例;
- 想接入自己的用户身份系统(名称、颜色、偏好设置),参考 sync-custom-user 示例。
四、roomId 的选择:协作的唯一钥匙
roomId 是整个协作机制的寻址键,demo 服务器上房间的命名空间对所有使用该服务器的应用开放。结合 useSyncDemo.ts#L25-L28 的注释与 README,选择房间名时应遵循三条原则:
- 足够独特,防止串房。别用
room、test、demo这类常见词——另一家公司的原型很可能撞上同一个房间。 - 优先使用公司/项目前缀,例如
acme-inc-dashboard-review。 - 在意隐私时使用 UUID。因为 demo 服务器上的房间对「知道 roomId 的任何人」都是公开可访问的——你无法设置访问密码。
同时务必牢记 demo 环境的两个硬性限制(README 与 useSyncDemo.ts#L78-L81 均有声明):
- 数据约一天后被删除:demo 服务器不提供持久化承诺,仅适合原型验证,不适合承载正式数据;
- 公开可访问:房间内容没有鉴权隔离。
因此房间里不要放敏感内容,也不要把 demo 直接当作生产协作后端。
五、demo 服务器上的资源(图片/链接)处理机制
多人画布中,插入图片、拖入文件、粘贴链接是高频操作。useSyncDemo 通过 createDemoAssetStore 与 createAssetFromUrlUsingDemoServer 两个内部函数(均在 useSyncDemo.ts 中,标记为 @internal)替你接好了这三类场景。
5.1 文件上传:上传域名白名单与错误处理
资产上传逻辑见 L178-L201:文件通过 POST 上传到 ${host}/uploads/<uniqueId>-<sanitizedName>,其中:
- 文件名会经过清洗,所有非单词字符被替换为
-(测试 useSyncDemo.test.ts#L183-L201 验证了file with spaces & symbols!.jpg会被转换为安全形式); - 上传响应非 2xx 时(例如重名、非法文件名、服务端错误),fetch 虽已 resolve 但
response.ok为 false,此时会抛出包含状态码的错误,避免资产指向一个实际从未存储成功的 URL(测试见 useSyncDemo.test.ts#L124-L135); - 上传保护:
shouldDisallowUploads(L144-L149)会拦截指向tldraw.com、tldraw.xyz及其子域名的 host,防止 demo 基础设施被滥用——如果你把host覆盖为自己的服务器,则上传正常开启。两条断言分别见 useSyncDemo.test.ts#L92-L103 与 L105-L122。
5.2 图片解析:按网络与屏幕条件自动优化
resolve 逻辑(L203-L261)只在可控域名(demo 服务器本身或 .tldraw.com/.tldraw.xyz/.tldraw.dev/.workers.dev)上对图片做变换,并有一整套跳过规则:
- 视频直接返回原地址;
data:等非 http(s) URL、动图(GIF 等isAnimatedImageType)、矢量图(SVG 等)不参与变换;shouldResolveToOriginal(如打印/导出场景)返回原图;- 文件小于约 1.5MB 时不做尺寸缩放,但仍会送入图片 worker 做压缩优化;
- 值得缩放时,会根据
steppedScreenScale、屏幕 DPR、navigator.connection的networkEffectiveType(弱网补偿系数 0.5,4g 及以上为 1)综合计算目标宽度w并重写 URL。
5.3 链接卡片(bookmark):由 demo 服务器统一解包
调用 registerExternalAssetHandler('url', …)(见 L122-L129)把「粘贴 URL 生成书签卡片」的职责交给 createAssetFromUrlUsingDemoServer(L287-L331):先 POST 到 ${host}/bookmarks/unfurl?url=… 抓取标题、描述、favicon 与预览图,若失败则兜底生成只含 URL 的空白卡片,并把错误打到控制台,避免阻塞用户操作。成功/失败两条路径均有测试覆盖(useSyncDemo.test.ts#L139-L181)。
六、从 demo 走向生产:迁移到 useSync 与自托管模板
useSyncDemo 的价值在于快速验证。当原型成熟、需要持久化或鉴权时,官方推荐切换为 @tldraw/sync 包中的 useSync Hook(useSync.ts),并配合仓库中的 sync 模板,具体路径已在 sync-demo README 末尾点明。
useSync 与 useSyncDemo 的差异对照(接口定义见 useSync.ts#L463-L623):
| 维度 | useSyncDemo |
useSync |
|---|---|---|
| 服务器 | tldraw 托管,零配置 | 你自建,需提供连接地址 |
| 连接地址 | 自动拼接 ${host}/connect/${roomId} |
显式传 uri: 'wss://…'(ws:// 仅限本地开发),支持传 async () => string 以便携带动态鉴权 token |
| 资源处理 | 内置 demo 资产 store | 必须自备实现 upload/resolve 的 assets: TLAssetStore(否则大图/视频会以内联 base64 存储,序列化性能差) |
| 用户 | 可选 users(默认 localStorage 实现) |
可选 users;不传时回退到 presence 记录解析其他用户 |
| 连接方式 | 仅 WebSocket | uri 走默认 WebSocket,或传 connect 函数使用自定义传输 |
仓库中可直接参考的两套自托管实现:
- simple-server-example 模板:用 Hono 等框架在
GET /connect/:roomId建立 WebSocket,makeOrLoadRoom按 roomId 维护TLSocketRoom,客户端侧对应地使用useSync({ uri: \{roomId}` })(见 [App.tsx](https://gitcode.com/GitHub_Trending/tl/tldraw/blob/5c56811965bea1d406d637547295e5cf5d043fa9/templates/simple-server-example/src/client/App.tsx?utm_source=gitcode_repo_files#L18-L22)),同时用sanitizeRoomId` 清理 roomId 防路径穿越; - sync-cloudflare 模板:在 Cloudflare Workers / Durable Objects 上部署同步后端,客户端同样以
useSync接入。
useSync 还支持只读房间(服务器下发 readonly 后 store 自动进入只读模式)、断线自动重连与本地改动排队补偿等能力,具体 API 文档可进一步阅读 packages/sync/DOCS.md。
七、在仓库中运行示例与测试
想亲自体验,可在仓库的 examples 工程中直接运行多人协作示例集(包含 sync-demo 以及上文提到的 custom-presence、custom-shape、custom-user 等变体):
cd apps/examples
yarn dev
随后在浏览器中打开 sync-demo 示例,并把同一 URL 复制到第二个标签页——这是 README 推荐的验证方式:你将看到第二个标签页的实时光标与本页相互可见,绘图操作双向同步。注意多个标签页会形成同一房间内的多个独立会话,演示数据默认落在 demo 服务器(环境变量 TLDRAW_BEMO_URL 可覆盖)。
若想了解 useSyncDemo 在各种边界条件下(roomId 编码、上传被禁、上传失败、bookmark 失败、文件名清洗等)的行为约定,可运行它的单元测试:
yarn test packages/sync/src/useSyncDemo.test.ts
总结
从 sync-demo README 的核心设定出发,本文给出了 useSyncDemo 的完整实战路径:一行 Hook 接入 tldraw 托管房间实现「打开两个标签页即可看到光标与编辑同步」,并揭示了其背后的实现细节——WebSocket 地址拼接与保留参数、默认形状/绑定工具的合并、demo 服务器的资源上传限制、图片自适应优化、链接卡片解包,以及数据约一天清空、房间公开访问这两个重要边界。最后,当原型需要走向生产时,可平滑迁移到 useSync 加自托管模板(simple-server-example 与 sync-cloudflare)的组合,为真实业务提供持久化、鉴权与可控的基础设施。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00