tldraw SDK 的@tldraw/sync公共 API 全解析:useSync、useSyncDemo与多人协同集成实战
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
@tldraw/sync是 tldraw SDK 中为 React 应用提供实时多人协同能力的官方绑定层,本指南以该包的公共 API 报告(packages/sync/api-report.api.md)为骨架,结合 packages/sync/src/useSync.ts、packages/sync/src/useSyncDemo.ts 等源码与测试,系统讲解useSync/useSyncDemo两个 React Hook 的全部公开类型、配置项、底层连接机制与实战用法。读完本文,你将能在一小时内为自己的 React 应用接入 tldraw 多人白板:从连接 WebSocket、上传图片资源,到展示在线光标、处理断线重连与鉴权失败。
@tldraw/sync是什么
@tldraw/sync是 tldraw SDK 的多人同步 React 绑定包(packages/sync/package.json 中描述为 "tldraw infinite canvas SDK (multiplayer sync react bindings)"),它把底层基于 WebSocket 的同步引擎@tldraw/sync-core包装成两个声明式的 React Hook:
useSync:连接任意自建服务器,uri或connect二选一;useSyncDemo:直接连接 tldraw 官方演示服务器,一行代码即可完成原型。
从源码看,包的入口 packages/sync/src/index.ts 做了两件事:一是export * from '@tldraw/sync-core'把底层同步协议全部透传出去(例如TLSyncClient、TLPersistentClientSocket、ClientWebSocketAdapter等,详见 packages/sync-core/src/index.ts),二是显式导出本包新增的useSync、useSyncDemo及其配套类型,并在模块加载时注册库版本信息。也就是说,@tldraw/sync本质上是一个"薄封装 + 完整透传"的包:公共 API 报告中的每一项导出,都在 React 与同步引擎之间扮演明确角色。
RemoteTLStoreWithStatus:远程同步 store 的状态机
类型定义
export type RemoteTLStoreWithStatus = | Exclude< TLStoreWithStatus, { status: 'synced-local' } | { status: 'not-synced' } | { status: 'synced-remote' } > | (Extract<TLStoreWithStatus, { status: 'synced-remote' }> & { readonly objectAccess: TLObjectStoreAccess })RemoteTLStoreWithStatus是useSync与useSyncDemo的返回值类型。与基础类型TLStoreWithStatus不同,它显式排除了not-synced(本地未同步)与synced-local(仅本地)两种状态——因为远程协同 store 的生命周期只有三种可能:正在连接、已同步到服务器、出错。源码 packages/sync/src/useSync.ts 中的注释明确写道:远程 store 始终处于 loading、connected 或 error 之一。
三种状态与 objectAccess
在synced-remote分支上,类型额外挂载了一个readonly objectAccess: TLObjectStoreAccess字段。它表示服务器为本次会话授予的对象存储通道(object-store lane,例如评论记录)的写权限,与画布只读状态相互独立——也就是说,一个会话可以被允许评论但不被允许编辑画布。该字段默认值为'write'(见 packages/sync/src/useSync.ts)。
从 packages/sync/src/useSync.ts 的返回值推导逻辑可以看出完整状态机:
| 内部状态 | 返回状态 | 说明 |
|---|---|---|
| 尚未初始化 / 无 readyClient | { status: 'loading' } | 正在建立连接并同步初始状态 |
| 出现 error | { status: 'error', error } | 连接失败或同步错误(TLRemoteSyncError) |
| 连接成功 | { status: 'synced-remote', connectionStatus, store, objectAccess } | store可直接传给<Tldraw /> |
其中connectionStatus取值来自TLPersistentClientSocketStatus,为'online' | 'offline' | 'error'(见 packages/sync-core/src/lib/TLSyncClient.ts),且当 socket 处于 error 时会归一化为'offline'。
典型消费模式
function MyCollaborativeApp() { const store: RemoteTLStoreWithStatus = useSync({ uri: 'wss://myserver.com/sync/room-123', assets: myAssetStore }) if (store.status === 'loading') { return <div>Connecting to multiplayer session...</div> } if (store.status === 'error') { return <div>Connection failed: {store.error.message}</div> } // store.status === 'synced-remote' return <Tldraw store={store.store} /> }<Tldraw store={store} />会自行处理三种状态(loading/error/synced-remote)并渲染对应的界面;而显式分支写法则适合做自定义加载页或错误提示。值得注意的是 packages/sync/src/useSync.test.tsx 中专门有一条测试验证:当房间从旧 URI(例如NOT_FOUND)切换到新房间时,旧客户端的 error 会被清除,新房间正常进入synced-remote——这是onLoad回调中刻意保留objectAccess、丢弃旧 error 的防护逻辑(packages/sync/src/useSync.ts)。
useSync:连接自建服务器的核心 Hook
签名与选项类型
export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatusUseSyncOptions是联合类型,由两个互斥分支构成:
export type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn export interface UseSyncOptionsWithUri extends UseSyncOptionsBase { uri: string | (() => string | Promise<string>) connect?: never } export interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase { connect: UseSyncConnectFn uri?: never }uri模式:WebSocket 地址,可静态字符串,也可异步函数(每次连接尝试都会重新调用,便于携带会过期的鉴权 token 或做动态房间路由)。HTTP/HTTPS 地址会被自动升级为 WebSocket 连接。connect模式:自定义传输层工厂函数,返回TLPersistentClientSocket,适合不想用默认 WebSocket 传输的场景(如自定义长连接协议)。
源码 packages/sync/src/useSync.ts 对两种模式做了强制校验:同时传uri与connect会抛出'uri and connect cannot be used together';两者都不传则抛出'uri or connect must be provided'。
uri模式的保留查询参数
在uri模式下,useSync内部通过ClientWebSocketAdapter建立连接(packages/sync-core/src/lib/ClientWebSocketAdapter.ts),并自动在 URI 上追加两个查询参数:
sessionId:当前浏览器标签页的会话标识(TAB_ID),同一用户的多标签页会被识别为不同会话;storeId:本次 hook 实例生成的唯一 store 标识(uniqueId())。
sessionId与storeId是保留参数名——如果用户自己的 URI 中已经包含它们,会抛出明确错误(packages/sync/src/useSync.ts):
if (withParams.searchParams.has('sessionId')) { throw new Error('useSync. "sessionId" is a reserved query param name. Please use a different name') }服务端正是依据这两个参数区分会话、识别同一房间内的多个连接。你自己的鉴权参数(如?token=...)需使用其他名字。
选项总览:UseSyncOptionsBase
| 选项 | 类型 | 必填 | 说明 |
|---|---|---|---|
assets | TLAssetStore | 是 | 图片/视频等二进制资源的上传与解析实现。生产环境必须提供,否则大文件会以 base64 内联存储,导致序列化性能问题 |
uri | string \| (() => string \| Promise<string>) | 二选一 | WebSocket 服务器地址,可动态返回(如携带刷新后的 token) |
connect | UseSyncConnectFn | 二选一 | 自定义传输层工厂,返回TLPersistentClientSocket |
users | TLUserStore | 否 | 用户身份、在线状态与操作归属(attribution)数据源 |
getUserPresence | (store, user) => TLPresenceStateInfo \| null | 否 | 自定义广播给其他客户端的 presence 数据(光标、选区等) |
onCustomMessageReceived | (data: any) => void | 否 | 接收其他客户端经TLSyncClient.sendMessage发送的自定义消息 |
themes | Partial<TLThemes> | 否 | 自定义主题色名,会在 store 构建前自动注册,保证持久化数据通过校验 |
onMount | (editor: Editor) => void | 否(@internal) | 编辑器挂载回调 |
roomId | string | 否(@internal) | 房间标识,仅用于埋点 |
trackAnalyticsEvent | (name, data) => void | 否(@internal) | 分析事件上报 |
选项详解
assets: TLAssetStore(必填)——资源存储抽象,必须实现upload(上传新文件并返回 URL)与resolve(展示时按上下文解析 URL)。原型阶段可用inlineBase64AssetStore,但生产环境必须替换为外部对象存储。源码注释特别强调:不提供 assets 时,大图片与视频会被内联为 base64,序列化性能显著下降(packages/sync/src/useSync.ts)。
const myAssetStore: TLAssetStore = { upload: async (asset, file) => { const url = await uploadToCloudStorage(file) return { src: url } }, resolve: (asset, context) => { return getOptimizedUrl(asset.src, context) } }users?: TLUserStore——用户 store,currentUser提供当前用户身份(用于 presence 广播与形状归属),可选resolve(userId)按 ID 解析其他用户。两者都返回响应式Signal。若不提供,useSync会回退到基于 localStorage 默认偏好实现的defaultUserStore,并用createCachedUserResolve包装:先尝试从 store 中的instance_presence记录反查用户信息(packages/sync/src/useSync.ts)。匿名会话时currentUser会退化为基于getUserPreferences()创建的匿名用户记录,但归属(attribution)仍正确返回 null(见 packages/sync/src/useSync.ts 的注释说明)。
getUserPresence?——响应式函数,store 状态变化时被调用,决定广播给其他客户端的 presence 对象。默认实现getDefaultUserPresence包含标准光标位置与选区状态;自定义实现可附加当前工具、视图状态等数据,返回null则隐藏 presence(packages/sync/src/useSync.ts):
getUserPresence: (store, user) => ({ userId: user.id, userName: user.name, cursor: { x: 100, y: 200 }, currentTool: 'select', isActive: true })onCustomMessageReceived?——自定义消息通道。配合TLSyncClient.sendMessage使用,可在形状与 presence 同步之外实现任意客户端间通信,例如聊天消息:
onCustomMessageReceived: (data) => { if (data.type === 'chat') { displayChatMessage(data.message, data.userId) } }themes?: Partial<TLThemes>——自定义主题。传入后useSync会在构建 store 前调用resolveThemes、registerColorsFromThemes、registerFontsFromThemes完成注册,使带自定义颜色的持久化数据在加载时能通过校验(packages/sync/src/useSync.ts)。
动态鉴权示例
function AuthenticatedApp() { const store = useSync({ // 每次连接尝试都会重新执行,token 过期后可以刷新 uri: async () => { const token = await getAuthToken() return `wss://myserver.com/sync/room-123?token=${token}` }, assets: authenticatedAssetStore, users: myUserStore, getUserPresence: (store, user) => ({ userId: user.id, userName: user.name, cursor: getCurrentCursor(store) }) }) return <Tldraw store={store} /> }底层生命周期:TLSyncClient与连接状态
useSync内部把配置编译为一个TLSyncClient<TLRecord, TLStore>(packages/sync/src/useSync.ts),并围绕它做了四件关键工作:
- 连接状态信号化:把 socket 的
connectionStatus包装成atom('collaboration status', ...),onStatusChange时更新,并以collaboration.status注入 store(packages/sync/src/useSync.ts)。 - 只读模式切换:
onAfterConnect时根据服务端下发的isReadonly在事务内设置syncMode为readonly或readwrite,同时调用store.ensureStoreIsUsable()做崩溃恢复兜底(packages/sync/src/useSync.ts)。 - presence 派生:通过
createPresenceStateDerivation(currentUser, { getUserPresence })(store)构建响应式 presence;还用一个computed根据instance_presence记录数量判断当前房间是否只有自己一个会话(otherSessions.size === 0),决定 presence 同步频率走solo还是full模式(packages/sync/src/useSync.ts)。 - 错误分类上报:
onSyncError根据TLSyncErrorCloseEventReason区分NOT_FOUND(房间不存在)、FORBIDDEN(无权限)、NOT_AUTHENTICATED(未认证)、RATE_LIMITED(限流)等场景,记录埋点后以TLRemoteSyncError设置错误状态并关闭 socket(packages/sync/src/useSync.ts)。
组件卸载时(effect cleanup)会依次执行client.close()与socket.close(),并通过didCancel标记防止异步回调在卸载后继续写入状态(packages/sync/src/useSync.ts)。
useSyncDemo:一行代码接入官方演示服务器
签名与选项
export function useSyncDemo(options: UseSyncDemoOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus export interface UseSyncDemoOptions { roomId: string // 房间 ID,命名空间与所有使用 demo 服务器的人共享 users?: TLUserStore // 可选,默认基于 localStorage host?: string // @internal,演示服务器地址 getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null }使用示例
function MyApp() { const store = useSyncDemo({ roomId: 'my-app-test-room' }) return <Tldraw store={store} /> }这是接入多人白板的最小可用代码:useSyncDemo内部自动完成三件事(packages/sync/src/useSyncDemo.ts):
- 构造连接地址
`${host}/connect/${encodeURIComponent(roomId)}`,默认 host 为https://demo.tldraw.xyz(可通过TLDRAW_BEMO_URL环境变量覆盖); - 用
createDemoAssetStore(host)生成一套开箱即用的资源存取实现(上传到 demo 服务器、经TLDRAW_IMAGE_URL指向的图片优化服务做按需缩放); - 在
onMount中注册 URL 外部资源处理器,通过{host}/bookmarks/unfurl服务抓取链接元数据生成 bookmark 卡片。
此外它还会把自定义的shapeUtils/bindingUtils与默认实现合并(packages/sync/src/useSyncDemo.ts),测试 packages/sync/src/useSyncDemo.test.ts 对这一行为有明确断言。
官方演示服务器的限制
请务必注意 demo 服务器的数据语义(源码 JSDoc 明确声明,见 packages/sync/src/useSyncDemo.ts):
- 数据大约一天后删除,只适合原型与演示;
- 数据公开:任何知道 roomId 的人都能访问。建议用公司/项目名做前缀避免碰撞,追求隐私请直接生成 UUID 作为房间名;
- 图片上传被禁用:当 host 属于
tldraw.com/tldraw.xyz域(含子域)时,upload会弹出提示并抛错,防止滥用 demo 基础设施(packages/sync/src/useSyncDemo.ts,测试见 packages/sync/src/useSyncDemo.test.ts)。
Demo 资源存储的智能图片优化
createDemoAssetStore的resolve实现展示了 tldraw 的资源优化思路(packages/sync/src/useSyncDemo.ts):
- 视频、data: URL、动图(
isAnimatedImageType)、矢量图(isVectorImageType)不做变换,直接返回原 URL; - 只有托管在 tldraw 自有域名上的图片才走优化服务,外部域名原样返回;
- 对 ≥ 1.5 MB 的图片按
steppedScreenScale、dpr与网络类型(非 4g 网络补偿系数 0.5)计算目标宽度,追加w参数交给图片 worker 缩放。
这些逻辑与上传文件名校验(非字母数字字符替换为-)一起,由 packages/sync/src/useSyncDemo.test.ts 中的多组用例覆盖验证。
从 API 到实战:仓库中的完整接入范例
仓库的 templates/sync-cloudflare 模板展示了useSync在真实前后端架构中的完整用法。其客户端房间页 templates/sync-cloudflare/client/pages/Room.tsx 直接从 URL 参数取房间号并建立连接:
export function Room() { const { roomId } = useParams<{ roomId: string }>() // Create a store connected to multiplayer. const store = useSync({ // We need to know the websockets URI... uri: `${window.location.origin}/api/connect/${roomId}`, // ...and how to handle static assets like images & videos assets: multiplayerAssetStore, }) return ( <RoomWrapper roomId={roomId}> <Tldraw store={store} options={{ deepLinks: true }} onMount={(editor) => { editor.registerExternalAssetHandler('url', getBookmarkPreview) }} /> </RoomWrapper> ) }这个例子浓缩了接入useSync的全部要素:URI 拼接(/api/connect/${roomId}由同源的 Cloudflare Worker 代理 WebSocket)、资源存储(multiplayerAssetStore处理图片/视频)、store 透传(<Tldraw store={store} />自动处理 loading 与光标/在线用户等多人 UX)、书签解包(registerExternalAssetHandler('url', ...))。类似的用法还出现在 templates/simple-server-example/src/client/App.tsx 与 templates/socketio-server-example/src/client/App.tsx 中。
环境要求与安装
- 安装:
yarn add @tldraw/sync(或npm install @tldraw/sync)。 - React 版本:peer 依赖要求
react/react-dom为^18.2.0 || ^19.2.1(见 packages/sync/package.json)。 - Node 版本:
>= 22.12.0。 - 许可:
@tldraw/sync是 tldraw SDK 的一部分,遵循 packages/sync/LICENSE.md 中的 SDK 许可协议;免费使用需保留画布上的 "Made with tldraw" 水印,移除水印需购买商业许可(详见 packages/sync/README.md)。 - 版本注册:包入口会调用
registerTldrawLibraryVersion注册库名、版本与模块信息(packages/sync/src/index.ts),便于 SDK 内部识别与调试。
常见问题速查
Q1:useSync报错 "uri or connect must be provided"?两者必须二选一提供;同时提供则报 "uri and connect cannot be used together"(packages/sync/src/useSync.ts)。
Q2:URI 里带了sessionId/storeId参数?这两个是保留参数名,会被 hook 自动注入,用户自定义同名参数会触发显式报错。请改用其他名字携带鉴权信息。
Q3:为什么连上了却看不到其他用户的光标?检查users与getUserPresence:presence 数据由getUserPresence响应式生成并广播;默认实现依赖instance_presence记录,多标签页/多设备属于不同会话,需要各自连接同一房间才会互相可见。
Q4:生产环境能否用useSyncDemo?不能。demo 数据约一天后删除、完全公开,且 tldraw 官方域名禁止图片上传。生产场景应自建服务器并使用useSync+ 自定义TLAssetStore。
Q5:服务端返回不同错误时如何区分处理?store.status === 'error'时store.error为TLRemoteSyncError,其 reason 可对应NOT_FOUND、FORBIDDEN、NOT_AUTHENTICATED、RATE_LIMITED等场景(见 packages/sync-core/src/lib/TLSyncClient.ts 中的TLSyncErrorCloseEventReason),据此可定制房间不存在、未登录、被限流等不同 UI。
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考