一、前言
协同编辑已成为现代Web应用的核心功能之一,它允许多用户实时协作,共同修改同一份文档或数据。然而,实现这一功能往往涉及复杂的冲突解决、状态同步和实时通信问题。
Yjs作为一款高效的开源协同编辑框架,为开发者提供了简洁而强大的解决方案。它基于CRDT(无冲突复制数据类型)技术,确保数据一致性,同时支持与主流前端框架无缝集成。
本文将介绍如何利用Yjs快速构建一个协同编辑页面,涵盖从基础原理到实际实现的完整流程。无论你是初次接触协同技术,还是希望优化现有系统,本文都能提供清晰的指引和实用的代码示例。
二、YJS简介
Yjs是一个支持实时协作的CRDT框架,用于构建多人协同编辑的应用程序。
官网:Yjs官网地址
三、yjs+quill+vue实现协作markdown编辑器
1、依赖第三方包列表
【1】客户端(VUE)
包名 | 版本 | 备注 |
---|---|---|
vue | ^3.5.13 | |
yjs | ^13.6.27 | |
y-quill | ^1.0.0 | |
quill-cursors | ^1.0.0 | |
y-websocket | ^3.0.0 |
【2】服务端(Node)
包名 | 版本 | 备注 |
---|---|---|
node | v22.13.0 | |
express | ^5.1.0 | |
ws | ^8.18.2 | |
y-websocket | ^3.0.0 | |
yjs | ^13.6.27 |
2、数据流程图
3、功能实现
【1】服务端
- 创建一个websocket服务
// 引入必要的模块
const http = require('http');
const WebSocket = require('ws');
const { applyUpdate, encodeStateAsUpdate, encodeStateVector } = require('yjs');
const Y = require('yjs');
// 创建一个 HTTP 服务器
const server = http.createServer((req, res) => {
// 处理普通的 HTTP 请求(可选)
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('WebSocket Server Running\n');
});
// 创建 WebSocket 服务器,但不自动处理所有连接
const wss = new WebSocket.Server({ noServer: true });
// 自定义升级逻辑:只允许路径为 /my-room 的连接
wss.on('headers', (headers, req) => {
// 添加额外的响应头(可选)
headers.push('X-Custom-Header: Hello');
});
// 存储房间与对应的 Y.Doc 实例
const docs = {};
wss.on('connection', (ws, request) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const room = url.pathname; // 获取路径名,例如:/my-room
if (!docs[room]) {
docs[room] = new Y.Doc();
}
const ydoc = docs[room];
const stateConnection = encodeStateAsUpdate(ydoc);
applyUpdate(ydoc, stateConnection);
ws.on('message', (data) => {
try {
const state = encodeStateAsUpdate(ydoc);
applyUpdate(ydoc, state);
// 广播更新给其他所有连接到同一房间的客户端
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
} catch (error) {
console.error('处理更新时出错:', error);
}
});
ws.on('close', () => {
console.log(`客户端断开连接:${room}`);
});
});
// 拦截升级请求,根据路径决定是否允许升级为 WebSocket
server.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname;
if (pathname === '/my-room') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} else if (pathname === '/todo-room') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} else {
console.log(`拒绝连接:路径不匹配 (${pathname})`);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
}
});
// 让服务器监听特定端口
const PORT = process.env.PORT || 1234;
server.listen(PORT, () => {
console.log(`WebSocket 服务器正在运行于 ws://localhost:${PORT}/my-room`);
});
【2】web代码
- 创建一个QuillEditor类,用于二次封装quill组件
- 创建Collaboration服务类,用于封装yjs、y-websocket、y-quill、quill的绑定
1)QuillEditor.ts
import Quill from 'quill';
import QuillCursors from 'quill-cursors';
import 'quill/dist/quill.snow.css';
// 将 QuillCursors 模块注册到 Quill 编辑器中,以便在编辑器中使用光标模块。
Quill.register('modules/cursors', QuillCursors);
export class QuillEditor {
quillInstance: any;
constructor(editorContainer: any) {
this.quillInstance = new Quill(editorContainer, {
theme: 'snow',
modules: {
cursors: true
}
});
}
getInstance() {
return this.quillInstance;
}
}
2)collaborationService.ts
import { QuillBinding } from 'y-quill';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
// import Quill from 'quill';
export class CollaborationService {
wsUrl: string;
constructor(wsUrl: string) {
this.wsUrl = wsUrl;
}
setupCollaboration(docId: string, quill: any) {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(this.wsUrl, docId, ydoc);
const ytext = ydoc.getText('quill');
// 实现quill和ytext的绑定
new QuillBinding(ytext, quill, provider.awareness);
provider.on('status', event => {
console.log('y-js的status',event.status);
});
ydoc.on('update', update => {
//通知服务端更新文档
Y.applyUpdate(ydoc, update)
});
return { ydoc, provider };
}
}
3)APP.vue
<script setup lang="ts">
import { onMounted, ref, onUnmounted } from 'vue'
import { CollaborationService } from './components/collaboration/collaborationService.ts';
import { QuillEditor } from '@/common/utils/QuillEditor.ts';
const wsUrl = `ws://${location.hostname}:1234`;
const docId = 'my-room';
const editorContainer = ref(null);
onMounted(() => {
const quillEditor = new QuillEditor(editorContainer.value);
const collaborationService = new CollaborationService(wsUrl);
collaborationService.setupCollaboration(docId, quillEditor.getInstance());
})
</script>
<template>
<div id="app">
<div ref="editorContainer" class="quill-editor"></div>
</div>
</template>
<style scoped>
#app {
max-width: 800px;
margin: 50px auto;
}
.quill-editor {
height: 400px;
}
</style>
四、yjs+vue实现协作ToDoList
1、功能实现
【1】服务端
参考yjs+quill+vue实现协作markdown编辑器 的服务端代码
【2】web端
- 创建一个TODOLIST组件,
1) ToDoList.vue
<template>
<div >
<div>
<input v-model="newTodo" type="text" /><button @click="addTodoList">添加</button>
</div>
<ul>
<li v-for="(item, index) in todoList" :key="index">{{ item }}</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket';
const props = defineProps({
wsUrl: {
type: String,
required: true
},
docId: {
type: String,
required: true
}
})
const {wsUrl, docId} = props
const ydoc = new Y.Doc()
const yarray = ydoc.getArray('todoList')
const todoList = ref<any[]>([])
const newTodo = ref<string>('')
const addTodoList = () => {
// 会自动同步到服务端,变更model
yarray.push([newTodo.value]);
}
const provider = new WebsocketProvider(wsUrl, docId, ydoc);
provider.on('status', event => {
console.log('y-js的status',event.status);
});
yarray.observe((yarrayEvent: any) => {
// yarray 变化后更新dom
todoList.value = yarray.toArray()
})
</script>
2)App.vue
<script setup lang="ts">
import ToDoList from './components/ToDoList.vue';
const wsUrl = `ws://${location.hostname}:1234`;
const todoListID = 'todo-room'
</script>
<template>
<div id="app">
<ToDoList :wsUrl="wsUrl" :docId="todoListID"/>
</div>
</template>
<style scoped>
#app {
max-width: 800px;
margin: 50px auto;
}
</style>