基于Yjs的协同编辑中内容重复问题排查与解决
基于Yjs的协同编辑器多用户输入内容重复问题排查与修复
问题描述
在React应用中基于Yjs实现自定义contenteditable脚本编辑器,基础协同逻辑正常,但多用户同时输入时,每次按键都会导致Yjs文档内的全部内容重复,功能无法正常使用。
简化实现架构
Socket服务端(socket.mjs)
// socket.mjs import { createServer } from 'http'; import { parse } from 'url'; import next from 'next'; import { Server as SocketIOServer } from 'socket.io'; import * as Y from 'yjs'; import dotenv from 'dotenv'; const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; dotenv.config({ path: envFile }); const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); const ydoc = new Y.Doc(); const yxmlFragment = ydoc.getXmlFragment('shared'); const ydocMap = new Map(); const activeUsers = {}; app.prepare().then(() => { const server = createServer((req, res) => { const parsedUrl = parse(req.url, true); handle(req, res, parsedUrl); }); const io = new SocketIOServer(server, { cors: { origin: process.env.BASE_URL || "http://localhost:3000", methods: ["GET", "POST"], allowedHeaders: ["*"], credentials: true } }); io.on('connection', (socket) => { console.log('Initiating') socket.emit('init', Y.encodeStateAsUpdate(ydoc)); socket.on('updateScene', ({ sceneId, userId, update }) => { console.log(sceneId); const ydocForScene = ydocMap.get(sceneId); Y.applyUpdate(ydocForScene, update); socket.to(sceneId).emit(`update:${sceneId}`, { update: update, userId: userId }); }); }); const PORT = process.env.SOCKET_PORT || 4000; server.listen(PORT, () => { console.log(`> Ready on http://localhost:${PORT}`); }); });
客户端脚本编辑器组件(ScriptEditor.tsx)
// ScriptEditor.tsx import * as yup from 'yup' import io from 'socket.io-client'; import * as Y from 'yjs'; const ScriptEditor = (props: any) => { const ydoc = new Y.Doc(); const wsProvider = new WebsocketProvider('ws://localhost:3001', 'my-room', ydoc); initializeSocket(ydoc); useEffect(() => { if (!ydocRef.current) { ydocRef.current = new Y.Doc(); } if (!yxmlFragmentRef.current) { yxmlFragmentRef.current = ydocRef.current.getXmlFragment('shared'); } if (!socketRef.current) { socketRef.current = io(process.env.WEB_SOCKET_URL); socketRef.current.on('init', (update) => { if (update) { const updatedArray = new Uint8Array(update); Y.applyUpdate(ydocRef.current, updatedArray); } }); } const ydoc = ydocRef.current; const yxmlFragment = yxmlFragmentRef.current; const socket = socketRef.current; socket.on(`update:${sceneId}`, (data) => { if (data.userId != userData.user.id) { try { const update = new Uint8Array(data.update); Y.applyUpdate(ydoc, update); } catch (e) { console.error('Error applying update:', e); } } }); }) return ( <div> <div id="script-container" ref={editorRef} style={{ outline: 'none' }} contentEditable={!(project?.isDemoProject || !allowEdit)} dangerouslySetInnerHTML={{ __html: scriptText }} onInput={valueChange} onMouseUp={handleSelection} onKeyDown={handleKeyDown} onPaste={handlePaste} > </div> </div> ); }; export default ScriptEditor;
问题原因分析
1. 多同步通道冲突
客户端同时初始化了Yjs官方WebsocketProvider和自定义Socket连接,两套同步逻辑并行工作,导致同一份更新被重复发送、多次应用,最终内容重复。
2. Yjs文档实例不统一
组件内同时创建了全局ydoc和ydocRef存储的实例,两个实例状态独立,同步时状态混乱,引发内容叠加重复。
3. 服务端文档管理错误
服务端初始化了全局ydoc,但实际用ydocMap按sceneId管理文档,却在用户连接时发送全局ydoc的初始状态,新用户拿到错误的初始数据,后续更新叠加导致重复。
4. 全量更新而非增量同步
自定义contenteditable未与Yjs数据做增量绑定,每次输入可能将整个编辑器内容作为全量更新发送,而非仅同步修改部分,叠加后出现内容重复。
解决方案与代码优化
核心修复思路
- 移除自定义Socket逻辑,统一使用Yjs官方
WebsocketProvider处理同步 - 修正服务端文档管理逻辑,按
sceneId正确分配文档 - 实现contenteditable与Yjs数据的双向增量绑定
优化后的代码
服务端(socket.mjs)
// socket.mjs import { createServer } from 'http'; import { parse } from 'url'; import next from 'next'; import { Server as SocketIOServer } from 'socket.io'; import * as Y from 'yjs'; import { setupWSConnection } from 'y-websocket/bin/utils.js'; import dotenv from 'dotenv'; const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; dotenv.config({ path: envFile }); const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); // 按sceneId管理Yjs文档实例 const docMap = new Map(); app.prepare().then(() => { const server = createServer((req, res) => { const parsedUrl = parse(req.url, true); handle(req, res, parsedUrl); }); const io = new SocketIOServer(server, { cors: { origin: process.env.BASE_URL || "http://localhost:3000", methods: ["GET", "POST"], allowedHeaders: ["*"], credentials: true } }); io.on('connection', (socket) => { // 从握手参数获取当前场景ID const sceneId = socket.handshake.query.sceneId || 'default-scene'; socket.join(sceneId); // 获取或创建对应场景的Yjs文档 let ydoc = docMap.get(sceneId); if (!ydoc) { ydoc = new Y.Doc(); docMap.set(sceneId, ydoc); } // 使用Yjs官方工具处理websocket连接,自动处理初始化、增量同步、冲突解决 setupWSConnection(socket, ydoc, { docName: sceneId, gc: true // 开启垃圾回收,避免内存泄漏 }); }); const PORT = process.env.SOCKET_PORT || 4000; server.listen(PORT, () => { console.log(`> Ready on http://localhost:${PORT}`); }); });
客户端(ScriptEditor.tsx)
// ScriptEditor.tsx import { useEffect, useRef, useState } from 'react'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; const ScriptEditor = (props: { sceneId: string; userData: { user: { id: string } }; allowEdit: boolean; project?: { isDemoProject?: boolean } }) => { const { sceneId, userData, allowEdit, project } = props; const editorRef = useRef<HTMLDivElement>(null); const ydocRef = useRef<Y.Doc | null>(null); const providerRef = useRef<WebsocketProvider | null>(null); const yxmlFragmentRef = useRef<Y.XmlFragment | null>(null); const [isLocalUpdate, setIsLocalUpdate] = useState(false); useEffect(() => { // 初始化Yjs文档 if (!ydocRef.current) { ydocRef.current = new Y.Doc(); } const ydoc = ydocRef.current; // 初始化WebsocketProvider,统一处理同步逻辑 if (!providerRef.current) { providerRef.current = new WebsocketProvider( process.env.WEB_SOCKET_URL || 'ws://localhost:4000', sceneId, ydoc, { connect: true } ); } // 获取共享的XML片段 if (!yxmlFragmentRef.current) { yxmlFragmentRef.current = ydoc.getXmlFragment('shared'); } const yxmlFragment = yxmlFragmentRef.current; // 监听Yjs文档变化,更新编辑器内容(避免本地更新循环触发) const contentObserver = yxmlFragment.observe(() => { if (!isLocalUpdate && editorRef.current) { const content = Array.from(yxmlFragment).map(node => node.toString()).join(''); editorRef.current.innerHTML = content; } setIsLocalUpdate(false); }); // 监听编辑器输入,增量更新Yjs文档 const handleInput = () => { if (!editorRef.current || !yxmlFragment) return; setIsLocalUpdate(true); // 此处可优化为增量修改,当前为简化版全量替换 yxmlFragment.delete(0, yxmlFragment.length); const editorContent = editorRef.current.innerHTML; yxmlFragment.insert(0, [Y.XmlText(editorContent)]); }; const editor = editorRef.current; editor?.addEventListener('input', handleInput); // 清理资源 return () => { providerRef.current?.disconnect(); yxmlFragment.unobserve(contentObserver); editor?.removeEventListener('input', handleInput); }; }, [sceneId]); return ( <div> <div id="script-container" ref={editorRef} style={{ outline: 'none', minHeight: '200px', border: '1px solid #eee', padding: '8px' }} contentEditable={!(project?.isDemoProject || !allowEdit)} /> </div> ); }; export default ScriptEditor;
额外优化建议
- 实现增量更新:替换当前全量更新逻辑,通过监听contenteditable的输入事件获取具体修改位置,调用Yjs的
insert/deleteAPI实现增量同步,提升性能。 - 光标同步:使用Yjs的selection API实现多用户光标位置同步,提升协同体验。
- 连接状态处理:监听
WebsocketProvider的连接状态,添加重连提示、离线编辑等逻辑。 - 内容格式化:针对脚本编辑器的需求,添加代码高亮、自动缩进等功能,同时确保格式化操作不破坏Yjs的同步逻辑。
内容的提问来源于stack exchange,提问作者Gaurav Bagul
相关产品推荐
相关产品推荐

