# 使用Y.js、WebSocket和React构建实时协作编辑器

实时协作已成为现代Web应用的必备功能。从Google Docs到Figma,用户期望能够同时编辑内容而不产生冲突。在本教程中,你将使用Y.js(一个CRDT库)、用于实时同步的WebSocket,以及React配合TipTap来从零构建一个功能完整的协作文本编辑器。

你将构建的内容

  • 多用户可同时编辑的富文本编辑器
  • 实时光标和选区共享(Awareness)
  • 在客户端之间同步文档状态的WebSocket服务器
  • 支持自动重连的离线编辑
  • 文档持久化到文件系统
  • 前置要求

  • Node.js 18+ 和 npm/pnpm
  • 了解React、TypeScript和WebSocket基础
  • 了解富文本编辑器有帮助但非必需
  • 理解CRDT和Y.js

    CRDT(无冲突复制数据类型)是一种可以在不同副本上独立更新并始终合并为一致状态的数据结构——无需协调。Y.js是JavaScript中最流行的CRDT实现,提供:

  • Y.Text — 带格式属性的协作文本
  • Y.Array — 共享有序列表
  • Y.Map — 共享键值映射
  • Y.XmlFragment — 共享XML/HTML树(TipTap/ProseMirror使用)
  • 与Google Docs使用的操作转换(OT)不同,CRDT不需要中央服务器来解决冲突。所有节点自动收敛到相同状态。

    步骤1:项目设置

    使用monorepo结构创建项目:

    mkdir collab-editor && cd collab-editor
    

    mkdir client server

    # 初始化React客户端

    cd client

    npm create vite@latest . -- --template react-ts

    npm install

    # 安装协作编辑依赖

    npm install yjs y-websocket @tiptap/react @tiptap/starter-kit \

    @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor \

    @tiptap/pm

    # 初始化服务端

    cd ../server

    npm init -y

    npm install ws yjs y-protocols lib0

    npm install -D typescript @types/ws tsx

    添加服务端的tsconfig.json

    {
    

    "compilerOptions": {

    "target": "ES2022",

    "module": "ESNext",

    "moduleResolution": "bundler",

    "outDir": "dist",

    "strict": true,

    "esModuleInterop": true

    },

    "include": ["src"]

    }

    步骤2:构建WebSocket服务器

    创建server/src/index.ts

    import { WebSocketServer, WebSocket } from 'ws';
    

    import * as Y from 'yjs';

    import { encoding, decoding } from 'lib0';

    import fs from 'fs';

    import path from 'path';

    const PORT = 4444;

    const PERSISTENCE_DIR = './docs';

    const messageSync = 0;

    const messageAwareness = 1;

    interface Room {

    doc: Y.Doc;

    clients: Set<WebSocket>;

    awareness: Map<number, any>;

    }

    const rooms = new Map<string, Room>();

    if (!fs.existsSync(PERSISTENCE_DIR)) {

    fs.mkdirSync(PERSISTENCE_DIR, { recursive: true });

    }

    function getRoom(roomName: string): Room {

    if (rooms.has(roomName)) return rooms.get(roomName)!;

    const doc = new Y.Doc();

    // 加载持久化状态

    const filePath = path.join(PERSISTENCE_DIR, ${roomName}.yjs);

    if (fs.existsSync(filePath)) {

    const persistedState = fs.readFileSync(filePath);

    Y.applyUpdate(doc, new Uint8Array(persistedState));

    console.log(已加载持久化文档: ${roomName});

    }

    // 每次更新时持久化(生产环境使用防抖)

    let persistTimeout: NodeJS.Timeout | null = null;

    doc.on('update', () => {

    if (persistTimeout) clearTimeout(persistTimeout);

    persistTimeout = setTimeout(() => {

    const state = Y.encodeStateAsUpdate(doc);

    fs.writeFileSync(filePath, Buffer.from(state));

    console.log(已持久化文档: ${roomName});

    }, 1000);

    });

    const room: Room = {

    doc,

    clients: new Set(),

    awareness: new Map(),

    };

    rooms.set(roomName, room);

    return room;

    }

    function broadcastToRoom(

    room: Room,

    message: Uint8Array,

    exclude?: WebSocket

    ) {

    room.clients.forEach((client) => {

    if (client !== exclude && client.readyState === WebSocket.OPEN) {

    client.send(message);

    }

    });

    }

    const wss = new WebSocketServer({ port: PORT });

    wss.on('connection', (ws, req) => {

    const roomName = req.url?.slice(1) || 'default';

    const room = getRoom(roomName);

    room.clients.add(ws);

    console.log(

    客户端连接到房间 "${roomName}" (${room.clients.size} 个客户端)

    );

    // 发送当前文档状态

    const encoder = encoding.createEncoder();

    encoding.writeVarUint(encoder, messageSync);

    const sv = Y.encodeStateVector(room.doc);

    encoding.writeVarUint8Array(encoder, Y.encodeStateAsUpdate(room.doc, sv));

    ws.send(encoding.toUint8Array(encoder));

    // 发送现有的awareness状态

    room.awareness.forEach((state, clientId) => {

    const awarenessEncoder = encoding.createEncoder();

    encoding.writeVarUint(awarenessEncoder, messageAwareness);

    encoding.writeVarString(

    awarenessEncoder,

    JSON.stringify({ clientId, state })

    );

    ws.send(encoding.toUint8Array(awarenessEncoder));

    });

    ws.on('message', (data: Buffer) => {

    const message = new Uint8Array(data);

    const decoder = decoding.createDecoder(message);

    const msgType = decoding.readVarUint(decoder);

    switch (msgType) {

    case messageSync: {

    const update = decoding.readVarUint8Array(decoder);

    Y.applyUpdate(room.doc, update);

    broadcastToRoom(room, message, ws);

    break;

    }

    case messageAwareness: {

    const awarenessStr = decoding.readVarString(decoder);

    const { clientId, state } = JSON.parse(awarenessStr);

    room.awareness.set(clientId, state);

    broadcastToRoom(room, message, ws);

    break;

    }

    }

    });

    ws.on('close', () => {

    room.clients.delete(ws);

    console.log(

    客户端断开房间 "${roomName}" (${room.clients.size} 个客户端)

    );

    if (room.clients.size === 0) {

    setTimeout(() => {

    if (room.clients.size === 0) {

    rooms.delete(roomName);

    console.log(房间 "${roomName}" 已清理);

    }

    }, 30000);

    }

    });

    });

    console.log(WebSocket服务器运行在 ws://localhost:${PORT});

    该服务器管理文档房间,将状态持久化到磁盘,并在客户端之间广播更新。

    步骤3:使用y-websocket Provider(简化方案)

    生产环境中,使用内置的y-websocket provider代替上面的自定义服务器:

    npx y-websocket --port 4444
    

    或创建带持久化的自定义服务器:

    import { setupWSConnection } from 'y-websocket/bin/utils';
    

    import { WebSocketServer } from 'ws';

    import http from 'http';

    const server = http.createServer();

    const wss = new WebSocketServer({ server });

    wss.on('connection', (ws, req) => {

    setupWSConnection(ws, req, {

    docName: req.url?.slice(1) || 'default',

    gc: true,

    });

    });

    server.listen(4444, () => {

    console.log('y-websocket服务器运行在端口4444');

    });

    步骤4:构建协作编辑器组件

    创建client/src/CollaborativeEditor.tsx

    import { useEditor, EditorContent } from '@tiptap/react';
    

    import StarterKit from '@tiptap/starter-kit';

    import Collaboration from '@tiptap/extension-collaboration';

    import CollaborationCursor from '@tiptap/extension-collaboration-cursor';

    import * as Y from 'yjs';

    import { WebsocketProvider } from 'y-websocket';

    import { useEffect, useMemo, useState } from 'react';

    const colors = [

    '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',

    '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F',

    ];

    const getRandomColor = () => colors[Math.floor(Math.random() * colors.length)];

    const getRandomName = () => {

    const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank'];

    return names[Math.floor(Math.random() * names.length)];

    };

    interface CollaborativeEditorProps {

    roomName: string;

    }

    export default function CollaborativeEditor({ roomName }: CollaborativeEditorProps) {

    const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');

    const [userCount, setUserCount] = useState(0);

    const { ydoc, provider } = useMemo(() => {

    const ydoc = new Y.Doc();

    const provider = new WebsocketProvider(

    'ws://localhost:4444',

    roomName,

    ydoc,

    { connect: true }

    );

    provider.awareness.setLocalStateField('user', {

    name: getRandomName(),

    color: getRandomColor(),

    });

    return { ydoc, provider };

    }, [roomName]);

    useEffect(() => {

    const onStatus = ({ status: s }: { status: string }) => {

    setStatus(s as any);

    };

    provider.on('status', onStatus);

    const onAwarenessChange = () => {

    setUserCount(provider.awareness.getStates().size);

    };

    provider.awareness.on('change', onAwarenessChange);

    return () => {

    provider.off('status', onStatus);

    provider.awareness.off('change', onAwarenessChange);

    provider.destroy();

    ydoc.destroy();

    };

    }, [provider, ydoc]);

    const editor = useEditor({

    extensions: [

    StarterKit.configure({ history: false }),

    Collaboration.configure({ document: ydoc }),

    CollaborationCursor.configure({

    provider,

    user: provider.awareness.getLocalState()?.user,

    }),

    ],

    editorProps: {

    attributes: {

    class: 'prose prose-lg max-w-none focus:outline-none min-h-[500px] p-6',

    },

    },

    }, [ydoc, provider]);

    return (

    <div className="collaborative-editor">

    <div className="flex items-center justify-between p-3 bg-gray-100 rounded-t-lg border border-b-0">

    <div className="flex items-center gap-2">

    <div className={w-2 h-2 rounded-full ${

    status === 'connected' ? 'bg-green-500'

    : status === 'connecting' ? 'bg-yellow-500'

    : 'bg-red-500'

    }} />

    <span className="text-sm text-gray-600 capitalize">{status}</span>

    </div>

    <span className="text-sm text-gray-500">

    {userCount} 位用户在线

    </span>

    </div>

    {editor && (

    <div className="flex gap-1 p-2 border border-b-0 bg-white">

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleBold().run()}

    active={editor.isActive('bold')}

    >B</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleItalic().run()}

    active={editor.isActive('italic')}

    >I</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}

    active={editor.isActive('heading', { level: 1 })}

    >H1</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}

    active={editor.isActive('heading', { level: 2 })}

    >H2</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleBulletList().run()}

    active={editor.isActive('bulletList')}

    >• 列表</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleCodeBlock().run()}

    active={editor.isActive('codeBlock')}

    >代码</ToolbarButton>

    </div>

    )}

    <div className="border rounded-b-lg">

    <EditorContent editor={editor} />

    </div>

    </div>

    );

    }

    function ToolbarButton({ onClick, active, children }: {

    onClick: () => void; active: boolean; children: React.ReactNode;

    }) {

    return (

    <button

    onClick={onClick}

    className={px-3 py-1 rounded text-sm font-medium transition-colors ${

    active ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100 text-gray-700'

    }}

    >{children}</button>

    );

    }

    步骤5:设置协作光标样式

    创建client/src/editor.css

    .collaboration-cursor__caret {
    

    border-left: 2px solid;

    border-right: none;

    margin-left: -1px;

    margin-right: -1px;

    pointer-events: none;

    position: relative;

    word-break: normal;

    }

    .collaboration-cursor__label {

    border-radius: 3px 3px 3px 0;

    color: white;

    font-size: 11px;

    font-weight: 600;

    left: -1px;

    line-height: 1;

    padding: 2px 6px;

    position: absolute;

    top: -1.4em;

    user-select: none;

    white-space: nowrap;

    }

    步骤6:整合应用

    更新client/src/App.tsx

    import { useState } from 'react';
    

    import CollaborativeEditor from './CollaborativeEditor';

    import './editor.css';

    export default function App() {

    const [roomName, setRoomName] = useState('my-document');

    const [joined, setJoined] = useState(false);

    if (!joined) {

    return (

    <div className="min-h-screen flex items-center justify-center bg-gray-50">

    <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">

    <h1 className="text-2xl font-bold mb-6 text-center">

    协作编辑器

    </h1>

    <input

    type="text"

    value={roomName}

    onChange={(e) => setRoomName(e.target.value)}

    placeholder="输入房间名..."

    className="w-full px-4 py-2 border rounded-lg mb-4"

    />

    <button

    onClick={() => setJoined(true)}

    className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700"

    >

    加入房间

    </button>

    </div>

    </div>

    );

    }

    return (

    <div className="min-h-screen bg-gray-50 p-8">

    <div className="max-w-4xl mx-auto">

    <h1 className="text-2xl font-bold mb-6">房间: {roomName}</h1>

    <CollaborativeEditor roomName={roomName} />

    </div>

    </div>

    );

    }

    步骤7:添加离线支持

    Y.js原生支持离线编辑。添加IndexedDB持久化,让更改在浏览器刷新后依然保留:

    cd client
    

    npm install y-indexeddb

    更新CollaborativeEditor.tsx中的provider设置:

    import { IndexeddbPersistence } from 'y-indexeddb';
    
    

    const { ydoc, provider, indexeddbProvider } = useMemo(() => {

    const ydoc = new Y.Doc();

    const indexeddbProvider = new IndexeddbPersistence(roomName, ydoc);

    indexeddbProvider.on('synced', () => {

    console.log('已从IndexedDB加载');

    });

    const provider = new WebsocketProvider(

    'ws://localhost:4444',

    roomName,

    ydoc,

    { connect: true }

    );

    provider.awareness.setLocalStateField('user', {

    name: getRandomName(),

    color: getRandomColor(),

    });

    return { ydoc, provider, indexeddbProvider };

    }, [roomName]);

    用户离线后可以继续编辑。重新连接时,Y.js自动合并所有更改——无冲突。

    步骤8:使用Y.UndoManager实现撤销/重做

    Y.js提供协作感知的撤销/重做。只会撤销当前用户的更改:

    import { UndoManager } from 'yjs';
    
    

    const type = ydoc.getXmlFragment('default');

    const undoManager = new UndoManager(type, {

    trackedOrigins: new Set([null]),

    });

    步骤9:生产部署

    Docker化服务器

    FROM node:20-alpine
    

    WORKDIR /app

    COPY package*.json ./

    RUN npm ci --production

    COPY dist/ ./dist/

    EXPOSE 4444

    VOLUME /app/docs

    CMD ["node", "dist/index.js"]

    Docker Compose

    version: '3.8'
    

    services:

    server:

    build: ./server

    ports:

    - '4444:4444'

    volumes:

    - doc-data:/app/docs

    restart: unless-stopped

    client:

    build: ./client

    ports:

    - '3000:80'

    depends_on:

    - server

    volumes:

    doc-data:

    Nginx反向代理

    upstream ws_backend {
    

    server localhost:4444;

    }

    server {

    listen 443 ssl;

    server_name collab.yourdomain.com;

    location /ws {

    proxy_pass http://ws_backend;

    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;

    proxy_set_header Connection "upgrade";

    proxy_read_timeout 86400;

    }

    location / {

    root /var/www/collab-editor;

    try_files $uri $uri/ /index.html;

    }

    }

    步骤10:扩展考虑

    超越单服务器的扩展方案:

    1. Redis Pub/Sub:使用y-redis在多个服务器实例间同步文档更新

    2. 数据库持久化:用PostgreSQL或MongoDB替代文件持久化

    3. 认证:在WebSocket connection处理器中添加JWT验证

    4. 速率限制:限制每个客户端的更新频率以防滥用

    // 示例:WebSocket连接的JWT认证
    

    import jwt from 'jsonwebtoken';

    wss.on('connection', (ws, req) => {

    const url = new URL(req.url!, http://${req.headers.host});

    const token = url.searchParams.get('token');

    try {

    const user = jwt.verify(token!, process.env.JWT_SECRET!);

    // 继续连接

    } catch {

    ws.close(4401, 'Unauthorized');

    return;

    }

    });

    总结

    你已经构建了一个功能完整的实时协作编辑器:

    | 功能 | 技术 |

    |------|------|

    | CRDT文档模型 | Y.js |

    | 实时同步 | WebSocket + y-websocket |

    | 富文本编辑器 | TipTap (ProseMirror) |

    | 共享光标 | Awareness协议 |

    | 离线支持 | y-indexeddb |

    | 持久化 | 文件系统 / LevelDB |

    | 撤销/重做 | Y.UndoManager |

    Y.js让协作编辑变得出奇地简单。CRDT方法消除了冲突解决的复杂性,丰富的provider生态系统(WebSocket、WebRTC、IndexedDB)为部署方式提供了灵活性。以此为基础,添加认证、权限、评论和版本历史,构建生产级协作平台。

    延伸阅读

  • Y.js 文档
  • TipTap 协作指南
  • CRDT入门 by Martin Kleppmann
  • y-websocket GitHub