本教程使用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 Code驱动创建monorepo结构,将前后端分离管理:
mkdir collab-editor && cd collab-editor
pnpm init
mkdir -p packages/server packages/client
在项目根目录启动Claude Code:
claude
向Claude Code下达指令:"帮我创建根目录CLAUDE.md,内容包含项目架构说明、技术栈约定、代码风格"。Claude Code会根据上下文自动生成。你也可以手动创建:
# 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理解整个项目的上下文边界,后续每一步操作都会遵循这些约定。
告诉Claude Code:"初始化pnpm workspace,配置tsconfig和package.json"
// pnpm-workspace.yaml
packages:
- 'packages/*'
// 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"
}
}
// 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 |
在编写代码前,先让Claude Code帮你理解CRDT。输入:
"用简单的例子解释CRDT的冲突解决机制,对比OT(操作变换)方案"
Claude Code会给出清晰的解释。这里是你需要理解的核心概念:
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的关键特性:
op1 ∘ op2 = op2 ∘ op1(op1 ∘ op2) ∘ op3 = op1 ∘ (op2 ∘ op3)Yjs内部为每个字符分配唯一位置标识符(Lamport时间戳 + 客户端ID),即使两个用户同时编辑同一位置,Yjs也能通过标识符排序确保所有副本收敛到同一状态。
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);
});
这是整个系统的核心。向Claude Code输入:
"在packages/server/src目录下创建WebSocket服务端,支持Yjs文档同步、房间管理、用户感知(光标位置)、以及文档持久化"
// 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}`);
});
// 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秒后清理
}
}
// 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);
}
});
}
}
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 方法处理此问题 |
向Claude Code输入:
"在packages/client中创建React前端,集成Monaco Editor和Yjs,支持实时协作编辑、多人光标显示、连接状态指示"
// 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)];
}
// 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>
);
}
// 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,
},
},
},
});
cd packages/client
pnpm install
pnpm dev
打开浏览器 http://localhost:5173,输入用户名并加入房间,打开第二个标签页模拟第二个用户,两人即可看到实时编辑同步。
理解数据在系统中如何流转是排查问题和优化的关键:
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内容
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);
}
当WebSocket服务需要水平扩展时,多个服务实例间需要通过Redis同步消息。
向Claude Code输入:
"给服务端添加Redis Pub/Sub支持,使多个WebSocket实例可以跨进程同步Yjs文档更新"
// 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();
}
}
扩展架构:
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
向Claude Code输入:
"为packages/server/src/doc-manager.ts编写单元测试,覆盖文档创建、持久化、清理场景,使用vitest"
Claude Code会根据代码自动生成测试:
// 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');
});
});
// 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());
});
});
运行测试:
cd packages/server && pnpm test
本教程的核心不仅是技术实现,更是展示如何用Claude Code高效完成复杂全栈项目。
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模块]
| 阶段 | 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编写测试,覆盖用户加入、离开、光标更新" | 自动生成针对性测试 |
长对话中上下文管理至关重要:
# 定期压缩上下文,保留关键信息
/compact
# 当需要Claude Code重新理解项目结构时
"请重新阅读CLAUDE.md和packages/server/src/index.ts,然后继续"
# 使用worktree隔离实验性改动
"在worktree中实验Monaco Editor的多光标API,不要影响主分支"
# 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:
# 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"]
# 本地测试
docker compose up --build
# 生产部署
docker compose -f docker-compose.prod.yml up -d
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
| 维度 | 说明 |
|---|---|
| 适合训练营类型 | 全栈工程师训练营、实时系统专题班、AI辅助开发工作坊 |
| 预计教学时长 | 6-8小时(含动手实践) |
| 难度等级 | 中高级 |
| 分段建议 | 第1-3步(3h):基础架构 → 第4-5步(2h):前端集成 → 第6-8步(2h):生产扩展 |
| 学员要求 | 需提前掌握React Hooks和Node.js基础 |
| 评估方式 | 最终交付:能支持3+人同时在线编辑、离线重连不丢数据的协作编辑器 |
[!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 等场景全覆盖。
