上一轮迭代把 Lite 版从"能跑"推到了"能用",快照循环、断线重连、离线授权这些大坑逐一填平之后,系统整体稳定了不少。但真正放到多人协作场景里跑起来,还是会冒出一些只在特定交互组合下才触发的 BUG。这篇文章复盘的就是三个这样的问题:新用户加入时拿到过时的 sceneId、爆炸视图下移动部件在预览端闪烁、以及场景切换时控制权被其他控制端抢走。
三个 BUG 表面看互不相干,但深挖下去有一条共同的暗线:它们都源于"状态的真实来源"没有被明确。sceneId 的真实来源到底是 URL 还是服务端?部件位置的真实来源到底是本地爆炸计算还是远端操控指令?控制权的真实归属到底是先连上的人还是发起切换的人?每一个回答都会直接影响代码该怎么写。
一、BUG 1:新用户加入时 sceneId 过时
1.1 问题现象
Lite 版控制页和预览页通过 URL 参数 sceneId 决定加载哪个场景。最初 sceneId 是从路由直接取的 computed:
const sceneId = computed(() => Number(getSceneIdFromRoute(route) || 0))
单用户没问题。但多人协作时,控制端可能已经通过 scene:switch 切换到场景 B,而新加入的预览端用户拿到的 URL 还是场景 A 的链接。他打开页面,从 URL 读到 sceneId=A,加载了场景 A——但频道里其他人都在看场景 B。
1.2 根因
后端 controlHub 有 clients、history、controller 这些 map,但唯独没有 currentScene。后端不知道当前频道在看哪个场景,所以快照里也无法告知新用户。sceneId 的权威来源应该是服务端,但代码里前端在从 URL 读取——URL 只是初始入口,不是权威状态。
1.3 修复:后端追踪 currentScene,前端从快照获取
// control.go — 新增 currentScene 字段
type controlHub struct {
mu sync.RWMutex
clients map[string]map[*websocket.Conn]*controlClient
history map[string][]json.RawMessage
controller map[string]string
currentScene map[string]uint64 // 新增
maxStore int
seq uint64
}
// add 方法增加 sceneID 参数,首次连接时初始化
func (h *controlHub) add(channelID, role string, sceneID uint64, conn *websocket.Conn) *controlClient {
// ... existing logic ...
if sceneID > 0 && h.currentScene[channelID] == 0 {
h.currentScene[channelID] = sceneID
}
return client
}
// scene:switch 处理中更新 currentScene
if messageType == "scene:switch" {
if sid := extractSceneID(message); sid > 0 {
hub.setCurrentScene(channelID, sid)
}
hub.resetHistory(channelID)
}
// 快照中加入 sceneId
snapshotPayload := map[string]any{
"type": "session:snapshot",
"channelId": channelID,
"sceneId": hub.getCurrentScene(channelID), // 新增
// ...
}
前端改为先连 WS,等快照后再加载场景:
// ControlPage.vue
const urlSceneId = computed(() => Number(getSceneIdFromRoute(route) || 0))
const sceneId = ref(urlSceneId.value)
onMounted(async () => {
ws = new WebSocket(`${protocol}//${host}/ws/control?id=${channelId}&role=control&sceneId=${urlSceneId.value}`)
ws.onmessage = (event) => {
const cmd = JSON.parse(event.data)
if (cmd.type === 'session:snapshot') {
const sid = cmd.sceneId || cmd.scene_id
if (sid && Number(sid) > 0) sceneId.value = Number(sid)
loadSceneData(sceneId.value)
// ... 处理 commands ...
return
}
onWsMessage(cmd)
}
})
1.4 踩坑:快照里 sceneId 为什么全是 0?
第一版改完后测试,快照里 sceneId 始终为 0。原因:第一个用户连接时 URL 带了 sceneId,但后端 add 方法原来不接收这个参数,currentScene 从未被初始化。修复就是给 add 增加 sceneID 参数。另一个遗漏:connectWs 重连 URL 也必须带 sceneId,否则重连后快照可能丢失 sceneId。
二、BUG 2:爆炸视图下移动部件,预览端闪烁
2.1 问题现象
控制端打开爆炸视图,选中一个部件拖动。控制端正常,预览端看到部件在"正确位置"和"爆炸计算位置"之间快速闪烁,最终停在爆炸计算位置——而不是控制端拖到的位置。
2.2 根因
updateExplosions() 每帧执行,用 assemblyLocalPos 重新计算部件位置,覆盖任何之前设置的 partPosition。控制端有 skip 条件:当 transformControls.object 指向正在拖动的子对象时跳过。但预览端没有 transformControls,skip 条件永远不成立,导致每帧覆盖远端设置的位置。
2.3 修复:追踪远端部件编辑状态
// 新增远端编辑追踪变量
let remotePartEditingModelId = ''
let remotePartEditClearTimer = 0
// 收到远端 model:transform 带 partUuid 时标记
if (targetPart) {
remotePartEditingModelId = String(modelId)
if (remotePartEditClearTimer) clearTimeout(remotePartEditClearTimer)
remotePartEditClearTimer = setTimeout(() => {
remotePartEditingModelId = ''
remotePartEditClearTimer = 0
}, 500)
// ... 设置 partPosition ...
}
// updateExplosions 扩展 skip 条件
const isLocalSubObjectEditing = transformControls.object
&& transformControls.object.userData.isSubObject
&& model.explosion.enabled && intensity > 0
const isRemoteSubObjectEditing = remotePartEditingModelId === String(model.id)
&& model.explosion.enabled && intensity > 0
if (isLocalSubObjectEditing || isRemoteSubObjectEditing) {
continue
}
500ms 的 debounce 是必要的:两条 model:transform 之间可能有几十毫秒间隔,如果收到一条就清除标记,下一帧 updateExplosions 就会覆盖位置。500ms 足够覆盖正常拖动频率,又不会在拖动结束后长时间阻止爆炸计算。
2.4 为什么不在预览端创建 TransformControls?
TransformControls 不仅是引用,它还会渲染坐标轴、响应鼠标、拦截 raycaster。预览端是只读的,不应该有任何可交互 3D 控件。用一个字符串变量追踪远端状态,轻量且无渲染副作用。
三、BUG 3:场景切换时控制权被其他控制端抢走
3.1 问题现象
控制端 A 发起场景切换,发出 scene:switch。所有客户端收到后跳转重连。谁先重连上谁先拿到控制权——如果 B 网络更快,B 就成了控制者,A 反而变成 viewer。
3.2 根因
经典竞态条件。后端无状态,按连接到达顺序分配控制权——先到先得。正常首次连接时合理,但场景切换后重连时,"先到先得"变成了"网速快的得"。
3.3 修复:非发起方延迟 2 秒
// ControlPage.vue
if (cmd.type === 'scene:switch') {
const isMySwitch = cmd.userId === myClientId.value
|| cmd.userId === userId.value
if (isMySwitch) {
applySceneSwitch(cmd) // 自己发起,立即执行
} else {
setTimeout(() => applySceneSwitch(cmd), 2000) // 他人发起,延迟 2 秒
}
return
}
// PreviewPage.vue — 预览端总是延迟
if (cmd.type === 'scene:switch') {
setTimeout(() => reloadToScene(sid, uid), 2000)
return
}
2 秒的选择:页面跳转+重连通常在 500ms-1500ms,2 秒能覆盖绝大多数情况,又不至于让预览端等太久。
3.4 为什么不在后端解决?
后端记住发起者需要额外状态(发起者 ID + 过期时间),增加复杂度。如果发起者断网不重连,还要处理过期释放。前端延迟方案简单、可靠、易维护。"简单且有效"往往比"优雅但复杂"更有价值。
四、共同线索:状态权威的归属
三个 BUG 的共同暗线:每个状态都有一个应该被信任的权威来源,但代码却在从其他来源读取。
- sceneId:权威来源是服务端
currentScene,前端在从 URL 读。 - 部件位置:权威来源取决于上下文(拖动时是控制端操作,否则是爆炸计算),
updateExplosions不区分上下文。 - 控制权:权威来源应是"发起切换者",后端在用"连接到达顺序"分配。
工程启示:在多端同步系统里,每个共享状态都必须明确权威来源,所有读取方应从权威来源获取状态。这条原则在实际编码中太容易被违反了。
五、防御性设计原则
快照是连接时的"真相时刻"。新用户需要的所有状态都应从快照获取,不从 URL 或 localStorage 推断。如果快照不完整,新用户就会读到过期状态。
每帧函数必须感知上下文。updateExplosions 不能假设"部件位置总由爆炸计算决定"。在有远端编辑、本地编辑等多种上下文时,必须有条件地跳过"当前不该由我管"的状态。
竞态条件用时间差解决并不丢人。在轻量级 WebSocket 系统里,分布式锁和共识协议的代价远大于收益。2 秒延迟满足了一个关键标准:在当前系统规模下,它是最简单且足够可靠的方案。过度设计比简单方案更危险,因为复杂度本身就是 BUG 的温床。
六、修改文件清单
editor-backend-lite/internal/controller/ws/control.go
- controlHub 新增 currentScene map[string]uint64
- 新增 setCurrentScene / getCurrentScene / extractSceneID
- add() 增加 sceneID 参数,初始化 currentScene
- remove() 频道为空时清理 currentScene
- writeSnapshotWithState 快照增加 sceneId
- scene:switch 处理调用 setCurrentScene + resetHistory
- Control() 读取 sceneId query 参数
3d-editor-lite/src/views/ControlPage.vue
- sceneId 改为 ref,新增 urlSceneId computed
- onMounted 先连 WS,等快照后加载场景
- WS URL 增加 sceneId 参数(含 connectWs 重连)
- 远端 scene:switch 延迟 2 秒
3d-editor-lite/src/views/PreviewPage.vue
- 同 ControlPage 的 sceneId 改造
- WS URL 增加 sceneId 参数(含 connectWs 重连)
- 远端 scene:switch 延迟 2 秒
3d-editor-lite/src/components/viewport/EditorViewport.vue
- 新增 remotePartEditingModelId / remotePartEditClearTimer
- 远端 model:transform 带 partUuid 时设置追踪标记 + 500ms 清除
- updateExplosions() skip 条件扩展 isRemoteSubObjectEditing
七、总结
这三个 BUG 每一个单看都不算"惊天动地",但它们有一个共同特征:只在多用户、多端协作的场景下才会暴露。单用户测试时一切正常,一旦放到真实的多人环境里,网络延迟、操作时序、状态竞争这些因素就会把隐藏的逻辑缺陷放大成可见的体验问题。
修复它们的过程让我更加确信一条经验:多端同步系统的正确性,不能只靠单端逻辑的自洽来保证,还必须确保每个共享状态的权威来源是明确的、所有读取方都从权威来源获取状态。这条原则说起来简单,但在实际编码中,URL 参数、本地缓存、帧循环函数这些"方便的本地来源"太容易成为默认选择,直到多人场景下出了问题才发现它们不是权威来源。
如果你也在做类似的 3D 远程控制、多端联动或实时协作系统,希望这篇文章能帮你少踩几个同类坑。