You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于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/delete API实现增量同步,提升性能。
  • 光标同步:使用Yjs的selection API实现多用户光标位置同步,提升协同体验。
  • 连接状态处理:监听WebsocketProvider的连接状态,添加重连提示、离线编辑等逻辑。
  • 内容格式化:针对脚本编辑器的需求,添加代码高亮、自动缩进等功能,同时确保格式化操作不破坏Yjs的同步逻辑。

内容的提问来源于stack exchange,提问作者Gaurav Bagul

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 05:35:55