各位观众老爷们,大家好!今天咱们聊点刺激的,搞一个基于 Vue 的实时协作编辑器。这玩意儿可不是简单的文本框,它要能支持多人同时编辑,还能看到别人的光标在哪儿晃悠,甚至还能回到过去,看看以前的版本。听起来是不是有点像科幻电影?别怕,咱们一步一步来,用 Vue 的响应式系统,把这玩意儿给整出来。
第一部分:搭台唱戏,Vue 项目基础
首先,咱们得有个舞台,也就是一个 Vue 项目。如果你已经有了,可以直接跳过这部分。如果没有,咱就用 Vue CLI 快速创建一个:
vue create collaborative-editor
一路回车,选择默认配置就行。
创建好项目后,咱们进入项目目录,启动一下,看看有没有问题:
cd collaborative-editor
npm run serve
如果一切顺利,你的浏览器应该会显示一个 Vue 的欢迎页面。
第二部分:响应式数据,编辑器的灵魂
实时协作编辑器的核心在于实时。而 Vue 的响应式系统,就是实现实时的利器。咱们先定义一个 editorContent
,用来存储编辑器的内容:
// src/App.vue
<template>
<textarea v-model="editorContent"></textarea>
</template>
<script>
export default {
data() {
return {
editorContent: 'Hello, collaborative world!',
};
},
};
</script>
这段代码非常简单,就是一个 textarea
绑定了 editorContent
。现在,你在 textarea
里输入任何内容,editorContent
都会实时更新。这就是 Vue 响应式的魅力!
第三部分:并发编辑,数据同步的难题
光有本地编辑还不够,咱们要让多人同时编辑。这就要涉及到数据同步的问题。假设有两个用户 A 和 B,同时编辑同一段文本,如果直接覆盖,就会出现数据冲突。所以,我们需要一种更智能的同步方式。
这里,咱们可以使用 Operational Transformation (OT) 算法。OT 算法的核心思想是,将用户的操作转换为操作对象,然后在服务器端将这些操作对象进行转换,以解决并发冲突。
当然,实现 OT 算法比较复杂,这里咱们先用一个简化的方案,就是将用户的操作广播到所有客户端,然后每个客户端根据操作更新本地内容。这种方案虽然简单,但足以演示实时协作的基本原理。
首先,我们需要一个 WebSocket 服务器来广播消息。这里咱们用 Node.js 搭建一个简单的 WebSocket 服务器:
// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', ws => {
console.log('Client connected');
ws.on('message', message => {
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
ws.on('close', () => {
console.log('Client disconnected');
});
});
console.log('WebSocket server started on port 8080');
这段代码创建了一个 WebSocket 服务器,监听 8080 端口。当有客户端连接时,服务器会监听客户端发送的消息,并将消息广播给所有其他客户端。
接下来,咱们需要在 Vue 项目中连接 WebSocket 服务器,并将编辑器的内容同步到服务器:
// src/App.vue
<template>
<textarea v-model="editorContent"></textarea>
</template>
<script>
export default {
data() {
return {
editorContent: 'Hello, collaborative world!',
socket: null,
};
},
mounted() {
this.socket = new WebSocket('ws://localhost:8080');
this.socket.onmessage = event => {
this.editorContent = event.data;
};
this.socket.onopen = () => {
console.log('Connected to WebSocket server');
};
this.socket.onclose = () => {
console.log('Disconnected from WebSocket server');
};
},
watch: {
editorContent(newContent) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(newContent);
}
},
},
};
</script>
这段代码做了以下几件事:
- 在
mounted
钩子函数中,创建了一个 WebSocket 连接,连接到ws://localhost:8080
。 - 监听
onmessage
事件,当收到服务器发送的消息时,更新editorContent
。 - 监听
onopen
和onclose
事件,分别在连接建立和断开时打印日志。 - 使用
watch
监听editorContent
的变化,当editorContent
发生变化时,将新的内容发送到服务器。
现在,你可以打开多个浏览器窗口,访问同一个 Vue 应用。在任何一个窗口中编辑内容,其他窗口都会实时同步。
第四部分:光标同步,知道你在干啥
仅仅同步内容还不够,咱们还要同步光标位置,让大家知道彼此正在编辑的位置。这就要涉及到更复杂的操作。
首先,我们需要监听 textarea
的 selectionStart
和 selectionEnd
属性,获取光标位置:
// src/App.vue
<template>
<textarea v-model="editorContent" @input="handleInput" @select="handleSelect"></textarea>
</template>
<script>
export default {
data() {
return {
editorContent: 'Hello, collaborative world!',
socket: null,
cursorPosition: {
start: 0,
end: 0,
},
};
},
mounted() {
this.socket = new WebSocket('ws://localhost:8080');
this.socket.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'content') {
this.editorContent = data.content;
} else if (data.type === 'cursor') {
// TODO: 处理其他用户的光标位置
}
};
this.socket.onopen = () => {
console.log('Connected to WebSocket server');
};
this.socket.onclose = () => {
console.log('Disconnected from WebSocket server');
};
},
watch: {
editorContent(newContent) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendContent(newContent);
}
},
},
methods: {
handleInput(event) {
this.cursorPosition = {
start: event.target.selectionStart,
end: event.target.selectionEnd,
};
this.sendCursorPosition();
},
handleSelect(event) {
this.cursorPosition = {
start: event.target.selectionStart,
end: event.target.selectionEnd,
};
this.sendCursorPosition();
},
sendContent(content) {
this.socket.send(JSON.stringify({ type: 'content', content }));
},
sendCursorPosition() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'cursor', cursor: this.cursorPosition }));
}
},
},
};
</script>
这段代码做了以下几件事:
- 监听
input
和select
事件,当textarea
的内容发生变化或光标位置发生变化时,分别调用handleInput
和handleSelect
函数。 - 在
handleInput
和handleSelect
函数中,更新cursorPosition
,并调用sendCursorPosition
函数。 - 在
sendCursorPosition
函数中,将cursorPosition
发送到服务器。 - 修改
onmessage
事件处理函数,判断消息类型,如果是content
,则更新editorContent
;如果是cursor
,则处理其他用户的光标位置(这里留空,稍后实现)。 - 将发送数据统一为 JSON 格式,包含
type
字段,区分消息类型。
接下来,我们需要修改 WebSocket 服务器,将光标位置广播给所有其他客户端:
// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', ws => {
console.log('Client connected');
ws.on('message', message => {
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
ws.on('close', () => {
console.log('Client disconnected');
});
});
console.log('WebSocket server started on port 8080');
服务器代码不需要修改,因为我们已经将客户端发送的消息广播给所有其他客户端。
最后,我们需要在 Vue 项目中处理其他用户的光标位置。这里,咱们创建一个 cursor-indicator
组件,用来显示其他用户的光标:
// src/components/CursorIndicator.vue
<template>
<div
class="cursor-indicator"
:style="{
left: left + 'px',
top: top + 'px',
}"
>
<div class="cursor-line"></div>
<div class="cursor-name">{{ name }}</div>
</div>
</template>
<script>
export default {
props: {
left: {
type: Number,
required: true,
},
top: {
type: Number,
required: true,
},
name: {
type: String,
default: 'Anonymous',
},
},
};
</script>
<style scoped>
.cursor-indicator {
position: absolute;
z-index: 1;
pointer-events: none; /* 阻止光标指示器捕获鼠标事件 */
}
.cursor-line {
width: 2px;
height: 1em; /* 假设行高是 1em */
background-color: red; /* 可以自定义颜色 */
}
.cursor-name {
font-size: 0.8em;
color: red;
position: absolute;
top: 1em; /* 位于光标线下方 */
left: -10px; /* 调整位置 */
white-space: nowrap;
}
</style>
这个组件接收 left
、top
和 name
三个属性,分别表示光标的水平位置、垂直位置和用户名。
接下来,咱们需要在 App.vue
中使用 cursor-indicator
组件:
// src/App.vue
<template>
<div style="position: relative;">
<textarea v-model="editorContent" @input="handleInput" @select="handleSelect"></textarea>
<cursor-indicator
v-for="cursor in otherCursors"
:key="cursor.id"
:left="cursor.left"
:top="cursor.top"
:name="cursor.name"
></cursor-indicator>
</div>
</template>
<script>
import CursorIndicator from './components/CursorIndicator.vue';
export default {
components: {
CursorIndicator,
},
data() {
return {
editorContent: 'Hello, collaborative world!',
socket: null,
cursorPosition: {
start: 0,
end: 0,
},
otherCursors: [],
clientId: Math.random().toString(36).substring(7), // 生成一个随机的客户端 ID
};
},
mounted() {
this.socket = new WebSocket('ws://localhost:8080');
this.socket.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'content') {
this.editorContent = data.content;
} else if (data.type === 'cursor') {
if (data.clientId !== this.clientId) {
// 排除自己的光标
this.updateOtherCursor(data.clientId, data.cursor);
}
}
};
this.socket.onopen = () => {
console.log('Connected to WebSocket server');
};
this.socket.onclose = () => {
console.log('Disconnected from WebSocket server');
};
},
watch: {
editorContent(newContent) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendContent(newContent);
}
},
},
methods: {
handleInput(event) {
this.cursorPosition = {
start: event.target.selectionStart,
end: event.target.selectionEnd,
};
this.sendCursorPosition();
},
handleSelect(event) {
this.cursorPosition = {
start: event.target.selectionStart,
end: event.target.selectionEnd,
};
this.sendCursorPosition();
},
sendContent(content) {
this.socket.send(JSON.stringify({ type: 'content', content, clientId: this.clientId }));
},
sendCursorPosition() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
// 计算光标相对于 textarea 的位置
const textarea = document.querySelector('textarea');
const rect = textarea.getBoundingClientRect();
const fontSize = parseFloat(window.getComputedStyle(textarea).fontSize);
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || fontSize * 1.2; // 默认行高
const lines = this.editorContent.substring(0, this.cursorPosition.start).split('n');
const row = lines.length - 1; // 当前行号
const col = lines[lines.length - 1].length; // 当前列号
const left = rect.left + col * (fontSize * 0.6) + textarea.clientLeft - textarea.scrollLeft; // 粗略计算字符宽度,可以优化
const top = rect.top + row * lineHeight + textarea.clientTop - textarea.scrollTop;
this.socket.send(
JSON.stringify({
type: 'cursor',
cursor: { left: left, top: top },
clientId: this.clientId,
})
);
}
},
updateOtherCursor(clientId, cursor) {
const existingCursor = this.otherCursors.find(c => c.id === clientId);
if (existingCursor) {
existingCursor.left = cursor.left;
existingCursor.top = cursor.top;
} else {
this.otherCursors.push({
id: clientId,
left: cursor.left,
top: cursor.top,
name: `User ${this.otherCursors.length + 1}`, // 简单的用户名
});
}
this.otherCursors = [...this.otherCursors]; // 触发响应式更新
},
},
};
</script>
<style>
textarea {
width: 500px;
height: 300px;
font-family: monospace;
font-size: 16px;
line-height: 1.5;
padding: 10px;
border: 1px solid #ccc;
box-sizing: border-box;
resize: none;
}
</style>
这段代码做了以下几件事:
- 引入
cursor-indicator
组件。 - 在
template
中,使用v-for
循环渲染otherCursors
数组,为每个光标创建一个cursor-indicator
组件。 - 在
data
中,添加otherCursors
数组,用来存储其他用户的光标位置。 - 在
onmessage
事件处理函数中,判断消息类型,如果是cursor
,则调用updateOtherCursor
函数更新otherCursors
数组。 updateOtherCursor
函数更新otherCursors
数组,如果光标已经存在,则更新其位置;如果光标不存在,则创建一个新的光标。- 生成客户端 ID,并在发送消息时带上客户端 ID,避免显示自己的光标。
- 计算光标位置,更精确地显示光标,考虑了字体大小、行高等因素。
现在,你可以打开多个浏览器窗口,访问同一个 Vue 应用。在任何一个窗口中移动光标,其他窗口都会显示你的光标位置。
第五部分:版本回退,时光倒流的魔法
实时协作编辑器的另一个重要功能是版本回退。咱们要让用户能够回到过去的某个版本,查看或恢复内容。
这里,咱们可以使用 Git 的思想,将每次编辑操作都保存为一个版本。当用户需要回退时,咱们就将内容恢复到指定的版本。
首先,咱们需要在 Vue 项目中创建一个 history
数组,用来存储编辑器的历史版本:
// src/App.vue
<template>
<div>
<textarea v-model="editorContent" @input="handleInput" @select="handleSelect"></textarea>
<button @click="undo">Undo</button>
<button @click="redo">Redo</button>
</div>
</template>
<script>
import CursorIndicator from './components/CursorIndicator.vue';
export default {
components: {
CursorIndicator,
},
data() {
return {
editorContent: 'Hello, collaborative world!',
socket: null,
cursorPosition: {
start: 0,
end: 0,
},
otherCursors: [],
clientId: Math.random().toString(36).substring(7), // 生成一个随机的客户端 ID
history: [],
historyIndex: -1,
};
},
mounted() {
this.socket = new WebSocket('ws://localhost:8080');
this.socket.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'content') {
this.editorContent = data.content;
} else if (data.type === 'cursor') {
if (data.clientId !== this.clientId) {
// 排除自己的光标
this.updateOtherCursor(data.clientId, data.cursor);
}
}
};
this.socket.onopen = () => {
console.log('Connected to WebSocket server');
};
this.socket.onclose = () => {
console.log('Disconnected from WebSocket server');
};
this.saveHistory(); // 初始化历史记录
},
watch: {
editorContent(newContent) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendContent(newContent);
}
},
},
methods: {
handleInput(event) {
this.cursorPosition = {
start: event.target.selectionStart,
end: event.target.selectionEnd,
};
this.sendCursorPosition();
this.saveHistory();
},
handleSelect(event) {
this.cursorPosition = {
start: event.target.selectionStart,
end: event.target.selectionEnd,
};
this.sendCursorPosition();
},
sendContent(content) {
this.socket.send(JSON.stringify({ type: 'content', content, clientId: this.clientId }));
},
sendCursorPosition() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
// 计算光标相对于 textarea 的位置
const textarea = document.querySelector('textarea');
const rect = textarea.getBoundingClientRect();
const fontSize = parseFloat(window.getComputedStyle(textarea).fontSize);
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || fontSize * 1.2; // 默认行高
const lines = this.editorContent.substring(0, this.cursorPosition.start).split('n');
const row = lines.length - 1; // 当前行号
const col = lines[lines.length - 1].length; // 当前列号
const left = rect.left + col * (fontSize * 0.6) + textarea.clientLeft - textarea.scrollLeft; // 粗略计算字符宽度,可以优化
const top = rect.top + row * lineHeight + textarea.clientTop - textarea.scrollTop;
this.socket.send(
JSON.stringify({
type: 'cursor',
cursor: { left: left, top: top },
clientId: this.clientId,
})
);
}
},
updateOtherCursor(clientId, cursor) {
const existingCursor = this.otherCursors.find(c => c.id === clientId);
if (existingCursor) {
existingCursor.left = cursor.left;
existingCursor.top = cursor.top;
} else {
this.otherCursors.push({
id: clientId,
left: cursor.left,
top: cursor.top,
name: `User ${this.otherCursors.length + 1}`, // 简单的用户名
});
}
this.otherCursors = [...this.otherCursors]; // 触发响应式更新
},
saveHistory() {
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1); // 移除撤销后的历史记录
}
this.history.push(this.editorContent);
this.historyIndex = this.history.length - 1;
},
undo() {
if (this.historyIndex > 0) {
this.historyIndex--;
this.editorContent = this.history[this.historyIndex];
}
},
redo() {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.editorContent = this.history[this.historyIndex];
}
},
},
};
</script>
这段代码做了以下几件事:
- 添加
history
数组和historyIndex
变量,用来存储编辑器的历史版本和当前版本索引。 - 添加
saveHistory
函数,用来保存编辑器的历史版本。 - 修改
handleInput
函数,每次内容改变后,调用saveHistory
函数。 - 添加
undo
和redo
函数,用来回退和前进版本。 - 添加
Undo
和Redo
按钮,绑定undo
和redo
函数。 - 初始化历史记录,在
mounted
钩子函数中调用saveHistory
函数。
现在,你可以编辑 textarea
的内容,然后点击 Undo
和 Redo
按钮,就可以回退和前进版本了。
第六部分:性能优化与扩展
咱们的实时协作编辑器已经基本成型了,但还有很多可以优化和扩展的地方:
- Operational Transformation (OT): 使用 OT 算法解决并发冲突,提高数据同步的准确性。
- WebSocket 连接优化: 使用心跳机制保持 WebSocket 连接,优化网络传输。
- 内容Diff优化: 只发送内容Diff而不是完整内容,降低带宽消耗。
- 富文本支持: 使用富文本编辑器代替
textarea
,支持更多的文本格式。 - 权限管理: 添加权限管理功能,控制用户的编辑权限。
- 用户认证: 添加用户认证功能,区分不同的用户。
总结
咱们今天一起用 Vue 的响应式系统,构建了一个简单的实时协作编辑器,支持并发编辑、光标同步和版本回退。虽然这个编辑器还比较简陋,但它已经具备了实时协作编辑器的基本功能。希望通过今天的讲座,能够帮助大家更好地理解 Vue 的响应式系统,并掌握构建实时协作应用的基本思路。
最后,给大家留个思考题:如何使用 WebRTC 实现点对点的实时协作编辑器? 提示:WebRTC 可以实现浏览器之间的直接通信,无需通过服务器转发数据。
感谢大家的观看,下次再见!