纯前端实现协同光标:WebSocket、位置变换与断线重连
协同编辑里,最容易被感知到的不是合并算法,而是「谁在哪儿」。一个会移动的彩色光标、一个带名字的选区,能让协作变得具体。这篇文章讲三个实现要点:位置怎么表示、消息怎么传、断线怎么恢复。
一、位置不要用像素,用字符偏移
光标位置如果用屏幕坐标表示,窗口一滚动、字号一变化就全错了。正确做法是用文档内的字符偏移,配合区间表示选区:
{
userId: 'u_42',
name: '小林',
color: '#2f7cf6',
anchor: 128, // 选区起点
head: 135 // 光标所在位置
}
anchor 和 head 分开存,既能表示普通光标(两者相等),也能表示反向选区。渲染时把偏移映射成 DOM Range,再用 getBoundingClientRect 定位浮层。
二、别人编辑了,我的位置要跟着变
远端插入或删除文本后,本地保存的偏移会失效。最常用的处理是按操作类型平移:
function transform(pos, op) {
if (op.type === 'insert' && op.at <= pos) return pos + op.text.length;
if (op.type === 'delete') {
if (op.at + op.len <= pos) return pos - op.len;
if (op.at < pos) return op.at;
}
return pos;
}
这只是最小实现。真实场景要考虑并发操作、区间交叉删除等边界,复杂时就要上 OT 或 CRDT。即使最终用库,理解「偏移要随操作变换」这件事也很重要。
三、消息通道:WebSocket + 两类消息
协同光标不需要很高的可靠性,但需要低延迟。WebSocket 比轮询合适,消息可以简单分成两类:
第一类是「状态广播」,带上用户信息和当前位置,服务端转发给同一文档的其他连接;第二类是「心跳」,客户端定时发送,服务端超时未收到就广播该用户离线。
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', at: Date.now() }));
}
}, 15000);
光标移动很频繁,发送前要做节流,比如 80~120ms 一次,并合并同一帧内的多次移动,避免把通道打满。
四、断线重连:先恢复状态,再补增量
网络抖动时 WebSocket 会断开。重连不能只重新连上就完事,要先把本地状态和服务端对齐:
重连成功后先拉一次文档版本号和当前在线用户列表,再订阅增量。如果断线期间有本地未提交的编辑,需要带上版本号提交,服务端判断是否冲突。冲突时不要静默覆盖,至少要保留本地副本并提示用户。
function reconnect() {
const delay = Math.min(30000, 1000 * 2 ** attempt++);
setTimeout(connect, delay + Math.random() * 300);
}
退避加随机抖动可以避免所有客户端在同一时刻重连,把服务端打垮。
五、渲染细节
光标浮层要用绝对定位,挂在一个不参与排版的容器里,避免影响正文布局。选区高亮可以用 Range.getClientRects() 拿到多行矩形,逐行绘制,不要只画一个大框。
用户离开时记得移除浮层,否则会留下「幽灵光标」。可以给每个用户设置一个最后活跃时间,超过阈值就淡出。
六、小结
协同光标的技术栈不复杂:字符偏移表示位置,操作变换保持位置正确,WebSocket 负责广播,退避重连保证稳定。真正花时间的是边界情况和视觉细节,先把数据结构定对,后面的问题都会简单很多。
- 点赞
- 收藏
- 关注作者
评论(0)