<!-- title: Claude Code实时协作编辑器：CRDT全栈实战 -->

# Claude Code实时协作编辑器：从WebSocket到CRDT同步引擎全栈实战

## 概述

本教程使用Claude Code从零构建一个支持多人实时协作的代码编辑器。你将掌握CRDT（无冲突复制数据类型）的核心原理、Yjs库的工程集成、WebSocket服务端架构、以及用Claude Code高效驱动全栈复杂项目的工作流。适合有Node.js和React基础、想深入实时系统开发的工程师。

**学习目标**：完成一个支持多人光标、离线编辑、冲突自动解决的生产级协作编辑器原型。

**适用人群**：有1年以上前端或全栈经验的开发者，希望理解CRDT原理并构建实时协作应用。

## 前置条件

| 项目 | 要求 |
|------|------|
| Claude Code | 已安装并配置（`claude --version` ≥ 1.0） |
| Node.js | ≥ 20.x |
| 包管理器 | pnpm ≥ 8.x（推荐）或 npm |
| 编辑器 | VS Code 或任意支持LSP的编辑器 |
| 基础知识 | React Hooks、Express/WebSocket、TypeScript基础 |
| 可选 | Redis（生产扩展章节需要）、Docker |

## 第一步：项目初始化与CLAUDE.md配置

### 1.1 创建项目结构

用Claude Code驱动创建monorepo结构，将前后端分离管理：

```bash
mkdir collab-editor && cd collab-editor
pnpm init
mkdir -p packages/server packages/client
```

在项目根目录启动Claude Code：

```bash
claude
```

### 1.2 编写CLAUDE.md

向Claude Code下达指令：**"帮我创建根目录CLAUDE.md，内容包含项目架构说明、技术栈约定、代码风格"**。Claude Code会根据上下文自动生成。你也可以手动创建：

```markdown
# Collab Editor - 实时协作代码编辑器

## 项目架构
- packages/server: Node.js WebSocket服务端（TypeScript）
- packages/client: React前端，Monaco Editor + Yjs CRDT

## 技术栈
- 运行时: Node.js 20+, React 18+
- CRDT: Yjs (yjs, y-websocket, y-monaco)
- 编辑器: Monaco Editor (@monaco-editor/react)
- 通信: WebSocket (ws库)
- 构建: tsup (server), Vite (client)

## 代码约定
- 使用TypeScript严格模式
- 服务端使用ESM模块
- 前端组件使用函数式组件 + Hooks
- 文件命名: kebab-case
- 错误处理: 每个async函数必须有try-catch
```

这个文件让Claude Code理解整个项目的上下文边界，后续每一步操作都会遵循这些约定。

### 1.3 配置workspace

告诉Claude Code：**"初始化pnpm workspace，配置tsconfig和package.json"**

```json
// pnpm-workspace.yaml
packages:
  - 'packages/*'
```

```json
// packages/server/package.json
{
  "name": "@collab/server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsup src/index.ts --format esm",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "ws": "^8.18.0",
    "yjs": "^13.6.24",
    "y-websocket": "^2.0.4",
    "ioredis": "^5.6.0",
    "dotenv": "^16.4.7"
  },
  "devDependencies": {
    "typescript": "^5.7.0",
    "tsx": "^4.19.0",
    "tsup": "^8.4.0",
    "@types/ws": "^8.5.14",
    "@types/node": "^22.0.0"
  }
}
```

```json
// packages/client/package.json
{
  "name": "@collab/client",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "yjs": "^13.6.24",
    "y-websocket": "^2.0.4",
    "y-monaco": "^0.1.5",
    "@monaco-editor/react": "^4.7.0",
    "lucide-react": "^0.468.0"
  },
  "devDependencies": {
    "@types/react": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.0",
    "typescript": "^5.7.0",
    "vite": "^6.0.0"
  }
}
```

### 预期结果

```
collab-editor/
├── CLAUDE.md
├── pnpm-workspace.yaml
├── package.json
└── packages/
    ├── server/
    │   ├── package.json
    │   └── tsconfig.json
    └── client/
        ├── package.json
        └── tsconfig.json
```

### 常见问题

| 问题 | 解决方案 |
|------|----------|
| `pnpm install` 报 peer dependency 警告 | 在根目录 `.npmrc` 加 `strict-peer-dependencies=false` |
| y-websocket 与 yjs 版本不兼容 | 确保 yjs 使用 `^13.6.20` 以上，与 y-websocket `^2.0` 匹配 |
| Monaco Editor 加载慢 | 配置 Vite 的 Monaco Editor 使用本地 worker |

## 第二步：理解CRDT核心原理

在编写代码前，先让Claude Code帮你理解CRDT。输入：

> **"用简单的例子解释CRDT的冲突解决机制，对比OT（操作变换）方案"**

Claude Code会给出清晰的解释。这里是你需要理解的核心概念：

### CRDT vs OT 对比

```mermaid
flowchart LR
    subgraph OT["操作变换 (OT)"]
        A1[客户端A: 在位置3插入'X'] --> S1[中央服务器]
        A2[客户端B: 在位置5插入'Y'] --> S1
        S1 --> |变换后应用| R1[最终结果由服务器决定]
    end

    subgraph CRDT["CRDT (Yjs)"]
        B1[客户端A: 唯一ID+a在位置3] --> B3[自动合并<br/>无需中央仲裁]
        B2[客户端B: 唯一ID+b在位置5] --> B3
        B3 --> R2[数学保证一致]
    end

    style OT fill:#fff3cd
    style CRDT fill:#d4edda
```

**CRDT的关键特性**：

1. **交换律**：操作顺序不影响最终结果 — `op1 ∘ op2 = op2 ∘ op1`
2. **结合律**：操作的分组方式不影响结果 — `(op1 ∘ op2) ∘ op3 = op1 ∘ (op2 ∘ op3)`
3. **幂等性**：重复执行同一操作不改变结果

Yjs内部为每个字符分配**唯一位置标识符**（Lamport时间戳 + 客户端ID），即使两个用户同时编辑同一位置，Yjs也能通过标识符排序确保所有副本收敛到同一状态。

### Yjs核心数据类型

```typescript
import * as Y from 'yjs';

const doc = new Y.Doc();

// Y.Text - 协作文本
const ytext = doc.getText('content');
ytext.insert(0, 'Hello');
ytext.insert(5, ' World');

// Y.Map - 协作键值对
const ymap = doc.getMap('metadata');
ymap.set('title', 'Project A');
ymap.set('version', 1);

// Y.Array - 协作数组
const yarray = doc.getArray('comments');
yarray.push([{ user: 'alice', text: 'LGTM' }]);

// 监听变更
ytext.observe(event => {
  console.log('变更的delta:', event.changes.delta);
});
```

## 第三步：构建WebSocket服务端

这是整个系统的核心。向Claude Code输入：

> **"在packages/server/src目录下创建WebSocket服务端，支持Yjs文档同步、房间管理、用户感知（光标位置）、以及文档持久化"**

### 3.1 服务端入口

```typescript
// packages/server/src/index.ts
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils.js';
import { DocManager } from './doc-manager.js';
import { PresenceManager } from './presence-manager.js';

const PORT = parseInt(process.env.PORT || '1234', 10);
const server = createServer();

const wss = new WebSocketServer({ server });
const docManager = new DocManager();
const presenceManager = new PresenceManager();

wss.on('connection', (ws, req) => {
  const url = new URL(req.url || '/', `http://${req.headers.host}`);
  const roomName = url.pathname.slice(1); // 去掉前导 /
  const clientId = url.searchParams.get('clientId') || 'unknown';

  console.log(`[连接] 客户端 ${clientId} 加入房间 ${roomName}`);

  // 设置Yjs WebSocket连接
  setupWSConnection(ws, req, {
    roomName,
    doc: docManager.getOrCreateDoc(roomName),
  });

  // 用户感知（光标、选区）
  presenceManager.addClient(roomName, clientId, ws);

  ws.on('close', () => {
    console.log(`[断开] 客户端 ${clientId} 离开房间 ${roomName}`);
    presenceManager.removeClient(roomName, clientId);
    docManager.maybeCleanup(roomName);
  });
});

server.listen(PORT, () => {
  console.log(`🚀 协作服务运行在 ws://localhost:${PORT}`);
});
```

### 3.2 文档管理器

```typescript
// packages/server/src/doc-manager.ts
import * as Y from 'yjs';
import { promises as fs } from 'fs';
import path from 'path';

const DATA_DIR = process.env.DATA_DIR || './data';

export class DocManager {
  private docs = new Map<string, Y.Doc>();

  getOrCreateDoc(roomName: string): Y.Doc {
    let doc = this.docs.get(roomName);
    if (!doc) {
      doc = new Y.Doc();
      this.docs.set(roomName, doc);
      this.loadFromDisk(roomName, doc);
      this.setupAutoSave(roomName, doc);
    }
    return doc;
  }

  private async loadFromDisk(roomName: string, doc: Y.Doc): Promise<void> {
    try {
      const filePath = path.join(DATA_DIR, `${roomName}.yjs`);
      const data = await fs.readFile(filePath);
      Y.applyUpdate(doc, new Uint8Array(data));
      console.log(`[持久化] 已加载文档: ${roomName}`);
    } catch {
      // 文档不存在，使用空文档
      console.log(`[持久化] 新文档: ${roomName}`);
    }
  }

  private setupAutoSave(roomName: string, doc: Y.Doc): void {
    let saveTimeout: ReturnType<typeof setTimeout> | null = null;

    doc.on('update', (update: Uint8Array) => {
      if (saveTimeout) clearTimeout(saveTimeout);
      saveTimeout = setTimeout(() => {
        this.saveToDisk(roomName, doc);
      }, 2000); // 2秒防抖
    });
  }

  private async saveToDisk(roomName: string, doc: Y.Doc): Promise<void> {
    const state = Y.encodeStateAsUpdate(doc);
    const filePath = path.join(DATA_DIR, `${roomName}.yjs`);
    await fs.mkdir(DATA_DIR, { recursive: true });
    await fs.writeFile(filePath, Buffer.from(state));
  }

  maybeCleanup(roomName: string): void {
    // 当房间没有用户时，延迟清理内存
    setTimeout(() => {
      const doc = this.docs.get(roomName);
      if (doc) {
        this.saveToDisk(roomName, doc);
        this.docs.delete(roomName);
        doc.destroy();
      }
    }, 30000); // 30秒后清理
  }
}
```

### 3.3 用户感知管理器

```typescript
// packages/server/src/presence-manager.ts
import { WebSocket } from 'ws';

interface ClientInfo {
  ws: WebSocket;
  cursor?: { line: number; column: number };
  selection?: { startLine: number; endLine: number };
  color: string;
}

const CURSOR_COLORS = [
  '#ef4444', '#f97316', '#eab308', '#22c55e',
  '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899',
];

export class PresenceManager {
  private rooms = new Map<string, Map<string, ClientInfo>>();

  addClient(room: string, clientId: string, ws: WebSocket): void {
    if (!this.rooms.has(room)) {
      this.rooms.set(room, new Map());
    }
    const clients = this.rooms.get(room)!;
    const colorIndex = clients.size % CURSOR_COLORS.length;
    
    clients.set(clientId, {
      ws,
      color: CURSOR_COLORS[colorIndex],
    });

    // 广播用户列表更新
    this.broadcastPresence(room);
  }

  removeClient(room: string, clientId: string): void {
    this.rooms.get(room)?.delete(clientId);
    this.broadcastPresence(room);
  }

  updateCursor(room: string, clientId: string, cursor: any): void {
    const client = this.rooms.get(room)?.get(clientId);
    if (client) {
      client.cursor = cursor;
      this.broadcastPresence(room);
    }
  }

  private broadcastPresence(room: string): void {
    const clients = this.rooms.get(room);
    if (!clients) return;

    const presenceData = Object.fromEntries(
      Array.from(clients.entries()).map(([id, info]) => [
        id,
        { cursor: info.cursor, color: info.color },
      ])
    );

    const message = JSON.stringify({
      type: 'presence-update',
      clients: presenceData,
    });

    clients.forEach(({ ws }) => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(message);
      }
    });
  }
}
```

### 3.4 运行服务端

```bash
cd packages/server
pnpm install
pnpm dev
```

**预期输出**：

```
🚀 协作服务运行在 ws://localhost:1234
```

### 常见问题

| 问题 | 原因 | 解决方案 |
|------|------|----------|
| `setupWSConnection is not a function` | y-websocket v2 导入路径变化 | 使用 `import { setupWSConnection } from 'y-websocket/bin/utils.js'` |
| WebSocket连接立即断开 | 客户端未正确处理握手 | 检查URL格式：`ws://host:port/room-name` |
| 内存持续增长 | 未清理空闲房间的Y.Doc | DocManager的 `maybeCleanup` 方法处理此问题 |

## 第四步：构建React前端

向Claude Code输入：

> **"在packages/client中创建React前端，集成Monaco Editor和Yjs，支持实时协作编辑、多人光标显示、连接状态指示"**

### 4.1 协作编辑器核心组件

```typescript
// packages/client/src/components/CollaborativeEditor.tsx
import { useEffect, useRef, useState, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { MonacoBinding } from 'y-monaco';
import Editor, { OnMount } from '@monaco-editor/react';
import * as monaco from 'monaco-editor';

interface Props {
  roomName: string;
  userName: string;
  serverUrl?: string;
}

interface UserInfo {
  cursor?: { line: number; column: number };
  color: string;
}

export function CollaborativeEditor({
  roomName,
  userName,
  serverUrl = 'ws://localhost:1234',
}: Props) {
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
  const [connected, setConnected] = useState(false);
  const [users, setUsers] = useState<Record<string, UserInfo>>({});
  const ydocRef = useRef<Y.Doc | null>(null);
  const providerRef = useRef<WebsocketProvider | null>(null);

  // 编辑器挂载回调
  const handleEditorMount: OnMount = useCallback((editor) => {
    editorRef.current = editor;

    // 初始化Yjs文档
    const ydoc = new Y.Doc();
    ydocRef.current = ydoc;

    const ytext = ydoc.getText('monaco');

    // 连接WebSocket
    const provider = new WebsocketProvider(
      serverUrl,
      roomName,
      ydoc,
      {
        connect: true,
        params: { clientId: userName },
      }
    );
    providerRef.current = provider;

    // 监听连接状态
    provider.on('status', ({ status }: { status: string }) => {
      setConnected(status === 'connected');
    });

    // 监听感知数据
    provider.awareness.on('change', () => {
      const states = provider.awareness.getStates();
      const userMap: Record<string, UserInfo> = {};
      states.forEach((state, clientId) => {
        if (clientId !== provider.awareness.clientID) {
          userMap[clientId] = {
            cursor: state.cursor,
            color: state.color || '#3b82f6',
          };
        }
      });
      setUsers(userMap);
    });

    // 设置本地用户信息
    provider.awareness.setLocalStateField('user', {
      name: userName,
      color: getRandomColor(),
    });

    // 绑定Monaco Editor
    const model = editor.getModel();
    if (model) {
      new MonacoBinding(ytext, model, new Set([editor]), provider.awareness);
    }
  }, [roomName, userName, serverUrl]);

  // 清理
  useEffect(() => {
    return () => {
      providerRef.current?.disconnect();
      ydocRef.current?.destroy();
    };
  }, []);

  // 光标位置同步
  const handleEditorChange = useCallback(() => {
    if (!editorRef.current || !providerRef.current) return;
    const position = editorRef.current.getPosition();
    if (position) {
      providerRef.current.awareness.setLocalStateField('cursor', {
        line: position.lineNumber,
        column: position.column,
      });
    }
  }, []);

  return (
    <div className="flex flex-col h-full">
      {/* 状态栏 */}
      <div className="flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700">
        <div className="flex items-center gap-3">
          <div className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
          <span className="text-sm text-gray-300">
            {connected ? '已连接' : '连接中...'}
          </span>
          <span className="text-sm text-gray-500">房间: {roomName}</span>
        </div>
        <div className="flex items-center gap-2">
          {Object.entries(users).map(([id, info]) => (
            <div
              key={id}
              className="flex items-center gap-1 px-2 py-0.5 rounded text-xs"
              style={{ backgroundColor: `${info.color}30`, color: info.color }}
            >
              <div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: info.color }} />
              用户 {id.slice(0, 4)}
            </div>
          ))}
        </div>
      </div>

      {/* 编辑器 */}
      <Editor
        height="100%"
        language="typescript"
        theme="vs-dark"
        onMount={handleEditorMount}
        onChange={handleEditorChange}
        options={{
          fontSize: 14,
          minimap: { enabled: false },
          scrollBeyondLastLine: false,
          automaticLayout: true,
        }}
      />
    </div>
  );
}

function getRandomColor(): string {
  const colors = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#8b5cf6'];
  return colors[Math.floor(Math.random() * colors.length)];
}
```

### 4.2 应用入口

```typescript
// packages/client/src/App.tsx
import { useState } from 'react';
import { CollaborativeEditor } from './components/CollaborativeEditor';

export default function App() {
  const [joined, setJoined] = useState(false);
  const [roomName, setRoomName] = useState('default-room');
  const [userName, setUserName] = useState('');

  if (!joined) {
    return (
      <div className="min-h-screen bg-gray-950 flex items-center justify-center">
        <div className="bg-gray-900 rounded-xl p-8 w-96 border border-gray-800">
          <h1 className="text-2xl font-bold text-white mb-6 text-center">
            协作编辑器
          </h1>
          <div className="space-y-4">
            <div>
              <label className="block text-sm text-gray-400 mb-1">用户名</label>
              <input
                value={userName}
                onChange={(e) => setUserName(e.target.value)}
                className="w-full px-3 py-2 bg-gray-800 rounded text-white border border-gray-700 focus:border-blue-500 focus:outline-none"
                placeholder="输入你的名字"
              />
            </div>
            <div>
              <label className="block text-sm text-gray-400 mb-1">房间名</label>
              <input
                value={roomName}
                onChange={(e) => setRoomName(e.target.value)}
                className="w-full px-3 py-2 bg-gray-800 rounded text-white border border-gray-700 focus:border-blue-500 focus:outline-none"
                placeholder="房间标识"
              />
            </div>
            <button
              onClick={() => userName && setJoined(true)}
              disabled={!userName}
              className="w-full py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white font-medium transition-colors"
            >
              加入房间
            </button>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="h-screen bg-gray-950">
      <CollaborativeEditor roomName={roomName} userName={userName} />
    </div>
  );
}
```

### 4.3 Vite配置

```typescript
// packages/client/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      '/ws': {
        target: 'ws://localhost:1234',
        ws: true,
      },
    },
  },
});
```

### 预期结果

```bash
cd packages/client
pnpm install
pnpm dev
```

打开浏览器 `http://localhost:5173`，输入用户名并加入房间，打开第二个标签页模拟第二个用户，两人即可看到实时编辑同步。

## 第五步：数据流与同步机制详解

理解数据在系统中如何流转是排查问题和优化的关键：

```mermaid
sequenceDiagram
    participant U1 as 用户A
    participant C1 as 客户端A
    participant WS as WebSocket服务器
    participant C2 as 客户端B
    participant U2 as 用户B

    Note over U1,U2: 初始连接阶段
    U1->>C1: 输入文字
    C1->>C1: Yjs生成Update(Uint8Array)
    C1->>WS: 发送Update
    WS->>WS: Y.applyUpdate()到服务端副本
    WS->>C2: 广播Update
    
    Note over U1,U2: 冲突场景
    U1->>C1: 在位置5插入"X"
    U2->>C2: 在位置5插入"Y"
    C1->>WS: Update A (唯一ID: A-timestamp)
    C2->>WS: Update B (唯一ID: B-timestamp)
    WS->>WS: CRDT自动合并
    WS->>C1: 转发Update B
    WS->>C2: 转发Update A
    C1->>C1: CRDT合并 → "XY" 或 "YX"（确定性）
    C2->>C2: CRDT合并 → 相同结果
    
    Note over U1,U2: 离线恢复
    C1--xWS: 网络断开
    U1->>C1: 继续编辑（离线）
    C1->>C1: 本地累积Updates
    C1->>WS: 网络恢复后发送累积Updates
    WS->>WS: 合并离线变更
    WS->>C2: 广播合并结果
```

### Yjs Update的二进制格式

Yjs使用高效的二进制编码传输变更：

```typescript
// 调试：观察Update内容
import * as Y from 'yjs';
import * as encoding from 'lib0/encoding';

const doc = new Y.Doc();
doc.on('update', (update: Uint8Array) => {
  console.log(`Update大小: ${update.byteLength} bytes`);
  console.log(`Update内容:`, Array.from(update.slice(0, 20)));
});

// 手动合并两个文档状态
function mergeStates(base: Uint8Array, diff: Uint8Array): Uint8Array {
  const doc = new Y.Doc();
  Y.applyUpdate(doc, base);
  Y.applyUpdate(doc, diff);
  return Y.encodeStateAsUpdate(doc);
}
```

## 第六步：生产级扩展 — Redis Pub/Sub

当WebSocket服务需要水平扩展时，多个服务实例间需要通过Redis同步消息。

向Claude Code输入：

> **"给服务端添加Redis Pub/Sub支持，使多个WebSocket实例可以跨进程同步Yjs文档更新"**

```typescript
// packages/server/src/redis-sync.ts
import Redis from 'ioredis';
import * as Y from 'yjs';
import { DocManager } from './doc-manager.js';

export class RedisSync {
  private pub: Redis;
  private sub: Redis;

  constructor(
    private redisUrl: string,
    private docManager: DocManager,
  ) {
    this.pub = new Redis(redisUrl);
    this.sub = new Redis(redisUrl);
  }

  async init(): Promise<void> {
    await this.sub.psubscribe('yjs:update:*');
    
    this.sub.on('pmessage', (_pattern, channel, message) => {
      const roomName = channel.replace('yjs:update:', '');
      const update = new Uint8Array(JSON.parse(message));
      
      const doc = this.docManager.getOrCreateDoc(roomName);
      Y.applyUpdate(doc, update);
    });
  }

  broadcast(roomName: string, update: Uint8Array): void {
    const channel = `yjs:update:${roomName}`;
    this.pub.publish(channel, JSON.stringify(Array.from(update)));
  }

  async disconnect(): Promise<void> {
    await this.pub.quit();
    await this.sub.quit();
  }
}
```

**扩展架构**：

```mermaid
graph TB
    LB[负载均衡器 / Nginx] --> WS1[WebSocket实例 1]
    LB --> WS2[WebSocket实例 2]
    LB --> WS3[WebSocket实例 N]

    WS1 <--> Redis[(Redis Pub/Sub)]
    WS2 <--> Redis
    WS3 <--> Redis

    Redis --> PG[(PostgreSQL<br/>文档持久化)]

    WS1 <--> C1[客户端 A/B]
    WS2 <--> C2[客户端 C/D]
    WS3 <--> C3[客户端 E/F]

    style Redis fill:#dc382d,color:#fff
    style PG fill:#336791,color:#fff
    style LB fill:#009639,color:#fff
```

## 第七步：测试策略

### 7.1 让Claude Code生成测试

向Claude Code输入：

> **"为packages/server/src/doc-manager.ts编写单元测试，覆盖文档创建、持久化、清理场景，使用vitest"**

Claude Code会根据代码自动生成测试：

```typescript
// packages/server/src/__tests__/doc-manager.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as Y from 'yjs';
import { DocManager } from '../doc-manager.js';
import { promises as fs } from 'fs';
import path from 'path';

describe('DocManager', () => {
  const testDataDir = './test-data';
  let manager: DocManager;

  beforeEach(async () => {
    process.env.DATA_DIR = testDataDir;
    manager = new DocManager();
    await fs.mkdir(testDataDir, { recursive: true });
  });

  afterEach(async () => {
    await fs.rm(testDataDir, { recursive: true, force: true });
  });

  it('创建并返回新文档', () => {
    const doc = manager.getOrCreateDoc('test-room');
    expect(doc).toBeInstanceOf(Y.Doc);
  });

  it('同一房间返回同一文档实例', () => {
    const doc1 = manager.getOrCreateDoc('same-room');
    const doc2 = manager.getOrCreateDoc('same-room');
    expect(doc1).toBe(doc2);
  });

  it('文档变更后自动持久化', async () => {
    const doc = manager.getOrCreateDoc('persist-test');
    doc.getText('content').insert(0, 'Hello CRDT');

    // 等待防抖保存（2秒 + 缓冲）
    await new Promise(resolve => setTimeout(resolve, 3000));

    const filePath = path.join(testDataDir, 'persist-test.yjs');
    const data = await fs.readFile(filePath);
    expect(data.length).toBeGreaterThan(0);

    // 验证能正确加载
    const newDoc = new Y.Doc();
    Y.applyUpdate(newDoc, new Uint8Array(data));
    expect(newDoc.getText('content').toString()).toBe('Hello CRDT');
  });
});
```

### 7.2 并发冲突测试

```typescript
// packages/server/src/__tests__/crdt-conflict.test.ts
import { describe, it, expect } from 'vitest';
import * as Y from 'yjs';

describe('CRDT冲突解决', () => {
  it('两个客户端同时编辑同一位置，结果一致', () => {
    // 模拟两个独立客户端
    const docA = new Y.Doc();
    const docB = new Y.Doc();

    const textA = docA.getText('content');
    const textB = docB.getText('content');

    // 初始同步
    const initState = Y.encodeStateAsUpdate(docA);
    Y.applyUpdate(docB, initState);

    // 同时在位置0插入不同内容
    textA.insert(0, 'AAA');
    textB.insert(0, 'BBB');

    // 双向同步
    Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB));
    Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA));

    // 结果必须一致
    expect(textA.toString()).toBe(textB.toString());
    expect(textA.toString().length).toBe(6); // AAA + BBB
  });

  it('离线编辑后重连，状态正确合并', () => {
    const docA = new Y.Doc();
    const docB = new Y.Doc();

    const textA = docA.getText('content');
    const textB = docB.getText('content');

    // 初始同步
    Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA));

    // A离线编辑
    textA.insert(0, 'Offline-Edit ');

    // B在线编辑
    textB.insert(0, 'Online-Edit ');

    // 重连后同步
    Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB));
    Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA));

    expect(textA.toString()).toBe(textB.toString());
  });
});
```

运行测试：

```bash
cd packages/server && pnpm test
```

## 第八步：使用Claude Code驱动整个开发的工作流技巧

本教程的核心不仅是技术实现，更是展示如何用Claude Code高效完成复杂全栈项目。

### 8.1 分层Prompt策略

```mermaid
flowchart TD
    A[第1轮: 架构设计] --> B["Prompt: 设计协作编辑器的<br/>系统架构和数据流"]
    B --> C[Claude Code生成<br/>CLAUDE.md + 架构文档]
    C --> D[第2轮: 服务端实现]
    D --> E["Prompt: 基于架构实现<br/>WebSocket服务端"]
    E --> F[Claude Code生成<br/>server代码]
    F --> G[第3轮: 前端实现]
    G --> H["Prompt: 实现React前端<br/>集成Monaco + Yjs"]
    H --> I[Claude Code生成<br/>client代码]
    I --> J[第4轮: 测试与扩展]
    J --> K["Prompt: 编写测试<br/>添加Redis扩展"]
    K --> L[Claude Code生成<br/>测试 + Redis模块]
```

### 8.2 关键Prompt模式

| 阶段 | Prompt示例 | 效果 |
|------|-----------|------|
| 初始化 | "创建pnpm monorepo，包含server和client两个包，配置TypeScript" | 一次性生成完整项目骨架 |
| 架构讨论 | "我要构建实时协作编辑器，请先分析技术选型：OT vs CRDT，帮我选择并给出理由" | Claude Code给出分析而非直接写代码 |
| 分步实现 | "先实现doc-manager.ts，只关注文档生命周期管理" | 避免一次性生成过多代码 |
| 问题排查 | "运行服务端后客户端连接报错 `WebSocket is not open`，请分析可能原因" | Claude Code结合上下文排查 |
| 代码审查 | "审查doc-manager.ts，检查是否有内存泄漏风险" | 利用Claude Code的代码理解能力 |
| 测试生成 | "为presence-manager.ts编写测试，覆盖用户加入、离开、光标更新" | 自动生成针对性测试 |

### 8.3 上下文管理技巧

长对话中上下文管理至关重要：

```bash
# 定期压缩上下文，保留关键信息
/compact

# 当需要Claude Code重新理解项目结构时
"请重新阅读CLAUDE.md和packages/server/src/index.ts，然后继续"

# 使用worktree隔离实验性改动
"在worktree中实验Monaco Editor的多光标API，不要影响主分支"
```

## 完整实战案例：部署到生产环境

### Docker Compose 一键部署

```yaml
# docker-compose.yml
version: '3.8'

services:
  server:
    build:
      context: ./packages/server
      dockerfile: Dockerfile
    ports:
      - "1234:1234"
    environment:
      - PORT=1234
      - REDIS_URL=redis://redis:6379
      - DATA_DIR=/data
    volumes:
      - doc-data:/data
    depends_on:
      - redis

  client:
    build:
      context: ./packages/client
      dockerfile: Dockerfile
    ports:
      - "80:80"
    depends_on:
      - server

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  doc-data:
  redis-data:
```

```dockerfile
# packages/server/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 1234
CMD ["node", "dist/index.js"]
```

### 部署命令

```bash
# 本地测试
docker compose up --build

# 生产部署
docker compose -f docker-compose.prod.yml up -d
```

## 总结：技术栈全景

```mermaid
graph TB
    subgraph 前端["前端 (React + Monaco)"]
        M[Monaco Editor] --> YB[Yjs Binding]
        YB --> WP[WebSocket Provider]
        WP --> AW[Awareness Protocol<br/>光标/选区同步]
    end

    subgraph 服务端["服务端 (Node.js)"]
        WSS[WebSocket Server] --> DM[DocManager<br/>文档生命周期]
        WSS --> PM[PresenceManager<br/>用户感知]
        DM --> PERSIST[持久化层]
        PERSIST --> FS[文件系统 / S3]
        PERSIST --> PG[PostgreSQL]
    end

    subgraph 扩展层["生产扩展"]
        RS[Redis Pub/Sub] --> WSS1[实例 1]
        RS --> WSS2[实例 2]
        RS --> WSS3[实例 N]
    end

    WP -->|二进制Update| WSS
    WSS -->|广播| WP
    DM -.->|跨实例同步| RS

    style 前端 fill:#1a1b26,color:#a9b1d6
    style 服务端 fill:#2d2f3f,color:#a9b1d6
    style 扩展层 fill:#1e3a5f,color:#a9b1d6
```

## 延伸阅读

- **Yjs官方文档** — [yjs.dev](https://yjs.dev/) — CRDT库的完整API参考和生态工具
- **CRDT: The Hard Parts** — [josephg.com](https://josephg.com/crdts-go-brrr/) — 深入理解CRDT实现中的工程挑战
- **Monaco Editor + Yjs集成指南** — [docs.yjs.dev/ecosystem/editor-bindings/monaco](https://docs.yjs.dev/ecosystem/editor-bindings/monaco) — 编辑器绑定的官方文档
- **WebSocket扩展模式** — [ably.com/blog/scaling-pub-sub-with-websockets-and-redis](https://ably.com/blog/scaling-pub-sub-with-websockets-and-redis) — 生产级WebSocket水平扩展
- **Claude Code项目结构最佳实践** — [claudearchitect.com/docs/claude-code/project-structure](https://claudearchitect.com/docs/claude-code/project-structure/) — monorepo组织方式

## 训练营适用性评估

| 维度 | 说明 |
|------|------|
| 适合训练营类型 | 全栈工程师训练营、实时系统专题班、AI辅助开发工作坊 |
| 预计教学时长 | 6-8小时（含动手实践） |
| 难度等级 | 中高级 |
| 分段建议 | 第1-3步（3h）：基础架构 → 第4-5步（2h）：前端集成 → 第6-8步（2h）：生产扩展 |
| 学员要求 | 需提前掌握React Hooks和Node.js基础 |
| 评估方式 | 最终交付：能支持3+人同时在线编辑、离线重连不丢数据的协作编辑器 |

## 参考来源

1. [Mastering Real-Time Collaborative Editing with Yjs and WebSockets](https://dev.to/hexshift/mastering-real-time-collaborative-editing-with-yjs-and-websockets-12n) — Yjs + WebSocket协作编辑器完整教程，包含代码示例
2. [Beyond WebSockets: Architecting Multiplayer SaaS with CRDTs](https://smarttechdevs.in/blog/multiplayer-saas-architecture-websockets-crdts-yjs) — CRDT在B2B SaaS中的架构设计，深入解析"Last Write Wins"问题
3. [Building a Real-Time Collaborative Code Editor with WebSockets and CRDTs](https://www.samahshakir.dev/blog/real-time-collaborative-editor-websockets-crdt) — 包含Redis Pub/Sub跨实例同步的完整生产架构代码
4. [Scaling WebSocket Applications with Redis Pub/Sub for Horizontal Deployment](https://dev.to/hexshift/scaling-websocket-applications-with-redis-pubsub-for-horizontal-deployment-2mb2) — WebSocket水平扩展的Redis Pub/Sub实现方案
5. [Build a Real-Time Collaborative AI Writing Tool with WebSockets and Claude](https://levelup.gitconnected.com/build-a-real-time-collaborative-ai-writing-tool-with-websockets-and-claude-d34b52fd6095) — Claude API与WebSocket结合构建AI协作写作工具

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
