tldraw 多人在线协作:为 `useSyncDemo` 房间接入自定义用户身份与偏好(TLUserStore 实战指南)
将应用自己的用户体系接入 tldraw 多人同步会话,是构建协作白板类产品时的常见需求。本文基于 apps/examples/src/examples/users/sync-custom-user 官方示例展开,讲解如何让 tldraw 的多人在线编辑真正"认识"本地用户:既能在协作者列表中正确展示名字与颜色,又能让编辑器内置的偏好面板将用户设置写回你自己的存储。读完本文,你将掌握 TLUserStore、useSyncDemo 与 useTldrawCurrentUser 三个 API 的配合方式,并理解 useSync(自建服务器)下的同一套接入模型。
一、问题背景:多人在线会话为什么需要"用户身份"
一个多人房间的同步链路需要知道两件事:
- 展示身份——本地用户的名称与颜色要同步给其他协作者,用于光标、选框、涂鸦等"presence"(在场信息)展示,以及形状上的归属信息(attribution);
- 编辑身份——编辑器内置的名字、颜色、偏好设置控件,需要有一个读写本地用户偏好的统一入口,修改后能立即反映到编辑器界面并持久化到你自己的后端。
官方示例给出的结论是:把用户身份放在 React state(真实应用中是你的认证系统),用一层"桥"把它转成 useSyncDemo 能识别的 TLUserStore(其 currentUser 是一个响应式 signal),同时把 useTldrawCurrentUser 生成的 TLCurrentUser 传给 <Tldraw> 组件,让内置偏好控件能够回写。参考实现见 SyncCustomUser.tsx。
二、完整示例:一段代码看懂数据流
示例的完整组件代码如下(与仓库中的 SyncCustomUser.tsx 保持一致,代码中 [1]~[5] 注释对应下文拆解):
import { useSyncDemo } from '@tldraw/sync'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
atom,
computed,
createUserId,
Tldraw,
TldrawOptions,
TLUserPreferences,
TLUserStore,
UserRecordType,
useTldrawCurrentUser,
} from 'tldraw'
import 'tldraw/tldraw.css'
export default function SyncCustomUserExample({ roomId }: { roomId: string }) {
// [1] 用户偏好保存在 React state 中,示例里用 useState 模拟认证系统/后端
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>({
id: 'user-' + Math.random(),
name: 'Jimmothy',
color: 'palevioletred',
colorScheme: 'dark',
})
// [2] 用 atom 镜像 React state,再用 computed 派生出 TLUser 记录
const userPrefsAtom = useRef(atom<TLUserPreferences>('userPrefs', userPreferences)).current
useEffect(() => {
userPrefsAtom.set(userPreferences)
}, [userPreferences, userPrefsAtom])
const users: TLUserStore = useMemo(() => {
const currentUser = computed('currentUser', () => {
const p = userPrefsAtom.get()
return UserRecordType.create({
id: createUserId(p.id),
name: p.name ?? '',
color: p.color ?? '',
})
})
return { currentUser }
}, [userPrefsAtom])
// [3] 创建多人同步 store,并把用户 store 传进去
const store = useSyncDemo({ roomId, users })
// [4] 把偏好与 setter 变成编辑器读写用的 TLCurrentUser
const user = useTldrawCurrentUser({ userPreferences, setUserPreferences })
// [5] 用同步 store 与用户对象渲染编辑器
return (
<div className="tldraw__editor">
<Tldraw store={store} user={user} options={options} />
</div>
)
}
const options: Partial<TldrawOptions> = { deepLinks: true }
这段代码同时出现在 tldraw 示例源码与示例目录的 README 注释中,官方把每一段要做什么、为什么这样做都写成了行内注释,下面逐一拆解。
三、分步拆解 [1]~[5]:身份如何从 React 流入同步层
[1] 用户偏好:TLUserPreferences
useState 保存本地用户的偏好对象,类型为 TLUserPreferences。示例中只有 id、name、color、colorScheme 四个字段,但在真实应用中它来自你的用户上下文或后端接口。该类型完整定义在 packages/editor/src/lib/config/TLUserPreferences.ts,可用字段与取值如下:
| 字段 | 类型/取值 | 含义 |
|---|---|---|
id |
string |
用户唯一 ID,是后续生成 user 记录的基础 |
name |
string | null(可选) |
显示给协作者的名字 |
color |
string | null(可选) |
协作者光标/选择框等用的颜色 |
colorScheme |
'light' | 'dark' | 'system'(可选) |
UI 明暗主题,示例用 'dark' |
locale |
string | null(可选) |
界面语言 |
animationSpeed |
number | null(可选) |
动画速度 |
areKeyboardShortcutsEnabled |
boolean | null(可选) |
是否启用键盘快捷键 |
edgeScrollSpeed |
number | null(可选) |
边缘滚动速度 |
isSnapMode |
boolean | null(可选) |
默认是否开启吸附 |
isWrapMode |
boolean | null(可选) |
默认是否开启环绕模式 |
isDynamicSizeMode |
boolean | null(可选) |
动态尺寸模式 |
isPasteAtCursorMode |
boolean | null(可选) |
在光标处粘贴 |
enhancedA11yMode |
boolean | null(可选) |
增强可访问性模式 |
inputMode |
'trackpad' | 'mouse' | null(可选) |
输入设备模式 |
isZoomDirectionInverted |
boolean | null(可选) |
是否反转缩放方向 |
需要说明的是:这里 id 的可选字段也由同文件中的 userTypeValidator 定义(见 TLUserPreferences.ts),其中 colorScheme 仅接受 'light' | 'dark' | 'system' 三个枚举值,写错会在校验层被拒绝。
[2] 桥接层:React state → atom → computed → TLUserStore
useSyncDemo 需要的是一个 TLUserStore:它的 currentUser 必须是响应式 signal 而非 React state(同步引擎会在每次渲染/订阅变更时读取它)。于是示例用两层信号做桥:
- 用
atom('userPrefs', userPreferences)创建镜像原子,再通过useEffect在每次userPreferences变化时set进去; - 用
computed('currentUser', ...)派生一个响应式的TLUser记录:读取 atom 中的偏好,再通过UserRecordType.create(...)与createUserId(...)构造 user 记录。
TLUserStore 在此处的最小形态就是 { currentUser },currentUser 是一个返回 TLUser 的 signal。对应类型定义在 packages/sync/src/useSync.ts。把这段逻辑独立出来而不是直接改 state,正是为了给同步引擎提供"纯响应式"的数据源。
[3] 把用户 store 交给 useSyncDemo
const store = useSyncDemo({ roomId, users })
useSyncDemo 的选项类型 UseSyncDemoOptions 定义在 packages/sync/src/useSyncDemo.ts,关键参数包括:
roomId: string——要加入的房间 ID。演示服务器的命名空间是全公开共享的,官方注释建议用公司或项目名做前缀,或用 UUID 保证隐私;users?: TLUserStore——用于身份、presence 与归属信息的用户 store;若不传,则使用基于 localStorage 的默认实现;getUserPresence?——可选的 presence 数据定制函数(与useSync的UseSyncOptions.getUserPresence一致);host(内部使用)——演示服务器地址。
useSyncDemo 在内部实际是调用 useSync,把 uri 拼接为 ${host}/connect/${encodeURIComponent(roomId)},并对演示服务器做了资源托管与书签解链等封装(见 useSyncDemo.ts)。同步引擎创建时会按"本地的 currentUser 优先、否则回退到 localStorage 匿名偏好"的方式推导当前用户(见 useSync.ts)。
[4] 编辑器回写通道:useTldrawCurrentUser
const user = useTldrawCurrentUser({ userPreferences, setUserPreferences })
useTldrawCurrentUser 把"偏好对象 + setter"包装成编辑器统一读写的 TLCurrentUser,其接口定义为:
interface TLCurrentUser {
userPreferences: Signal<TLUserPreferences>
setUserPreferences: (userPreferences: TLUserPreferences) => void
}
实现见 packages/editor/src/lib/config/createTLCurrentUser.ts:当你不传任何选项时,createTLCurrentUser 会默认使用基于 localStorage 的偏好读写(数据键为 TLDRAW_USER_DATA_v3,见 TLUserPreferences.ts);一旦像示例这样传入 userPreferences 与 setUserPreferences,编辑器内置的名字、颜色、偏好控件就都会走你的 React state 回写,而不是写入 localStorage。这正是"Bring your own identity"的关键一步。
[5] 渲染
<Tldraw store={store} user={user} options={options} />
把同步 store(含房间状态与加载状态管理)和用户对象一起交给 <Tldraw>。options 里开启 deepLinks: true,便于多人会话通过 URL 深链进入同一房间。
四、底层支撑:TLUser 记录与 UserRecordType
示例中真正同步出去的"用户"是一条 TLUser 记录,它由 UserRecordType.create 生成。记录结构定义在 packages/tlschema/src/records/TLUser.ts:
export interface TLUser extends BaseRecord<'user', TLUserId> {
name: string
color: string
imageUrl: string
meta: JsonObject
}
其中 id 的类型是 TLUserId,由 createUserId(id: string): TLUserId 生成——它基于你偏好的原始 id 字符串构造出符合 user 记录校验规则的 ID(见 TLUser.ts)。示例只显式传了 id、name、color 三个字段,imageUrl 与 meta 则由默认属性补齐为空字符串与 {}。
值得注意的细节是 user 记录是"document scope"(文档级作用域)的,见 createUserRecordType 中 scope: 'document' 的声明:它随文档快照、剪贴板、.tldr 文件一起持久化,从而让归属(attribution)显示名跨看板、跨会话存活。如果想为用户记录附加自定义元数据(如 isAdmin、department),可以用 createUserRecordType({ meta: { ... } }) 自定义验证器。
五、如何验证:打开两个窗口
示例 README 提供了一个立即可行的验收方式:在两个浏览器窗口中打开该示例。由于每个窗口都会生成随机的 user-' + Math.random() id,但名字与颜色固定为 Jimmothy / palevioletred,你会看到两个同名的 "Jimmothy" 协作者出现在对方的会话中——光标、选择框等 presence 都由各自窗口的 TLUserStore 提供,以此确认身份桥接已生效。
六、从 useSyncDemo 到 useSync:自建服务器的同一套选项
useSyncDemo 面向 tldraw 官方托管的演示服务器(默认 https://demo.tldraw.xyz,可通过环境变量 TLDRAW_BEMO_URL 覆盖,见 useSyncDemo.ts)。官方对其数据有两条明确说明:
- 演示服务器上的数据约一天后会被删除;
- 数据对任何知道 roomId 的人公开可访问——因此建议用公司名做房间前缀,或使用 UUID 保证隐私。
当你运行自己的同步服务器时,示例中构建 users(即 TLUserStore)的逻辑可以原样移植:README 明确指出 useSync 接受完全相同的 users 选项。这一点在实现层面得到印证:useSyncDemo 本身就是把 useSyncDemo 的选项透传给 useSync 的封装(useSyncDemo.ts),因此 roomId、users、getUserPresence、schema 相关选项在两者间保持一致。
更进一步,若你的房间里有历史协作者(来自别的会话、name/color 未在你本地 currentUser 中命中),useSync 在未提供自定义 users.resolve 时会尝试从本地 instance_presence 记录中还原其名字与颜色作为兜底(见 useSync.ts)。示例只传了 currentUser,即使用该兜底实现。
七、关联阅读
- 示例本体:sync-custom-user/SyncCustomUser.tsx 与 sync-custom-user/README.md
- 不带同步、仅做"本地用户身份注入"的对照示例:users/custom-user
- 用户偏好类型与 localStorage 默认实现:packages/editor/src/lib/config/TLUserPreferences.ts
TLCurrentUser创建与useTldrawCurrentUser实现:packages/editor/src/lib/config/createTLCurrentUser.tsTLUser记录、UserRecordType、createUserId:packages/tlschema/src/records/TLUser.tsuseSyncDemo选项与演示服务器封装:packages/sync/src/useSyncDemo.tsuseSync的users/getUserPresence选项与默认 currentUser 推导:packages/sync/src/useSync.ts- 官方文档中的用户偏好与协作章节:apps/docs/content/sdk-features/user-preferences.mdx、apps/docs/content/sdk-features/collaboration.mdx
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 StartedRust0629
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证件照制作算法。Python07
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