Alright, buckle up everyone! Today we’re diving deep into the rabbit hole of real-time collaborative editors using Vue’s reactive powers. It’s gonna be a wild ride, but I promise we’ll emerge on the other side with a solid understanding of how to build something truly cool.
We’ll be tackling并发编辑, 光标同步, and 版本回退. Think Google Docs, but cooler because we built it!
第一部分:Vue 的响应式魔法与基础架构
First things first, let’s talk about Vue’s reactivity. It’s the cornerstone of everything we’re going to do. Imagine Vue’s data as a bunch of interconnected gears. When one gear moves (data changes), all the connected gears (components) react automatically. That’s the magic!
To create our collaborative editor, we need a central data store to hold the editor’s content. This is where Vuex, or a similar state management solution, becomes invaluable. But for simplicity, let’s start with a simple Vue component managing the state locally.
<template>
<textarea v-model="text" @input="handleInput"></textarea>
</template>
<script>
export default {
data() {
return {
text: '' // Our initial text content
};
},
methods: {
handleInput() {
// This is where the magic happens! We'll expand on this later.
console.log("Text Changed:", this.text);
}
}
};
</script>
This is a very basic editor. The v-model
directive provides two-way data binding. Any change in the <textarea>
updates this.text
, and vice versa. The @input
event triggers the handleInput
method. Right now, it just logs the text. But this is our hook for sending changes to other users.
第二部分:并发编辑的核心:Operational Transformation (OT) 简介
Now, the tricky part: allowing multiple users to edit simultaneously without creating a garbled mess. This is where Operational Transformation (OT) comes to the rescue. OT is a technique that allows us to transform operations (insertions, deletions) based on the operations of other users.
Think of it like this: Alice types "hello". Bob types "world". If we just naively apply Bob’s "world" to Alice’s "hello", we get "helloworld". But what if Bob meant to insert "world" after "hello"? OT allows us to figure out the correct position for Bob’s insertion, taking into account what Alice has already done.
OT can be complex, but we can simplify it for our example. We’ll focus on two basic operations:
- Insert (i, position, text): Insert
text
atposition
. - Delete (d, position, length): Delete
length
characters starting atposition
.
Here’s a simplified JavaScript implementation of our OT functions.
function transformInsert(op1, op2) {
// op1 is the operation to transform (Alice's operation)
// op2 is the remote operation (Bob's operation)
if (op2.type === 'insert') {
if (op1.position <= op2.position) {
//Alice inserts before Bob, no transformation needed.
return op1;
} else {
//Alice inserts after Bob, adjust Alice's position
return { ...op1, position: op1.position + op2.text.length };
}
} else if (op2.type === 'delete') {
if (op1.position <= op2.position) {
// Alice inserts before Bob deletes, no transformation needed.
return op1;
} else if (op1.position > op2.position + op2.length) {
// Alice inserts after Bob deletes, adjust Alice's position.
return { ...op1, position: op1.position - op2.length };
} else {
// Alice inserts within the deleted range, this is tricky.
// For simplicity, let's just delete Alice's insert. A more robust solution would require a more complex algorithm.
return null; // Indicate that the operation should be discarded.
}
}
return op1; // Default: no transformation needed
}
function transformDelete(op1, op2) {
// op1 is the operation to transform (Alice's operation)
// op2 is the remote operation (Bob's operation)
if (op2.type === 'insert') {
if (op1.position <= op2.position) {
//Alice deletes before Bob inserts, no transformation needed
return op1;
} else {
//Alice deletes after Bob inserts, adjust Alice's position
return { ...op1, position: op1.position + op2.text.length };
}
} else if (op2.type === 'delete') {
if (op1.position < op2.position) {
// Alice deletes before Bob deletes, no transformation needed.
return op1;
} else if (op1.position >= op2.position + op2.length) {
// Alice deletes after Bob deletes, adjust Alice's position
return { ...op1, position: op1.position - op2.length };
} else {
// Alice's delete overlaps Bob's. This is complex.
// Simplest approach: shorten Alice's delete
const overlapStart = Math.max(op1.position, op2.position);
const overlapEnd = Math.min(op1.position + op1.length, op2.position + op2.length);
const overlapLength = overlapEnd - overlapStart;
if (overlapLength > 0) {
// Shorten Alice's delete operation
const newLength = op1.length - overlapLength;
if (newLength <= 0) return null; // Discard the operation if length is zero or negative
return { ...op1, length: newLength };
} else {
return op1; // No overlap, no change needed
}
}
}
return op1; // Default: no transformation needed
}
These transformInsert
and transformDelete
functions are the heart of our OT implementation. They take two operations as input and return the transformed version of the first operation. Keep in mind, this is a simplified version. A production-ready OT implementation is significantly more involved. Handling edge cases and complex scenarios requires careful design and testing.
第三部分:构建一个简单的 WebSocket 服务器 (Node.js)
To enable real-time collaboration, we need a server that can broadcast changes to all connected clients. WebSocket is the ideal technology for this. Let’s use Node.js and the ws
library to create a simple WebSocket server.
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
let currentText = ""; // Server-side text state
const clients = [];
wss.on('connection', ws => {
console.log('Client connected');
clients.push(ws);
// Send the current text to the newly connected client
ws.send(JSON.stringify({ type: 'init', text: currentText }));
ws.on('message', message => {
try {
const data = JSON.parse(message);
if (data.type === 'operation') {
// Apply the operation to the server-side text
if (data.operation.type === 'insert') {
currentText = currentText.slice(0, data.operation.position) + data.operation.text + currentText.slice(data.operation.position);
} else if (data.operation.type === 'delete') {
currentText = currentText.slice(0, data.operation.position) + currentText.slice(data.operation.position + data.operation.length);
}
// Broadcast the operation to all other clients
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'operation', operation: data.operation }));
}
});
console.log("Current text on server: ", currentText);
} else if (data.type === 'cursor') {
// Broadcast cursor position to other clients
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'cursor', cursor: data.cursor, clientId: data.clientId }));
}
});
}
} catch (error) {
console.error('Error processing message:', error);
}
});
ws.on('close', () => {
console.log('Client disconnected');
const index = clients.indexOf(ws);
if (index > -1) {
clients.splice(index, 1);
}
});
});
console.log('WebSocket server started on port 8080');
This server does the following:
- Listens for WebSocket connections on port 8080.
- Stores the current text content on the server.
- When a new client connects, it sends the current text to the client (
init
message). - When a client sends an
operation
message (insert or delete), it applies the operation to the server-side text and broadcasts the operation to all other connected clients. - Handles client disconnections.
第四部分:将 Vue 组件与 WebSocket 服务器连接
Now let’s connect our Vue component to the WebSocket server and implement the OT logic. We’ll need a clientId
to uniquely identify each client. We’ll also add a queue to store operations that haven’t been acknowledged by the server.
<template>
<div>
<textarea v-model="text" @input="handleInput" @mousemove="handleMouseMove"></textarea>
<div v-for="(cursor, index) in remoteCursors" :key="index" :style="{ left: cursor.x + 'px', top: cursor.y + 'px', position: 'absolute', width: '2px', height: '16px', backgroundColor: cursor.color }"></div>
</div>
</template>
<script>
import { transformInsert, transformDelete } from './ot-functions'; // Import our OT functions
export default {
data() {
return {
clientId: Math.random().toString(36).substring(7), // Generate a unique client ID
text: '',
socket: null,
operationQueue: [],
cursorPosition: { x: 0, y: 0 },
remoteCursors: {} // { clientId: { x: 0, y: 0, color: 'red' } }
};
},
mounted() {
this.socket = new WebSocket('ws://localhost:8080');
this.socket.addEventListener('open', () => {
console.log('Connected to WebSocket server');
});
this.socket.addEventListener('message', event => {
const data = JSON.parse(event.data);
if (data.type === 'init') {
this.text = data.text;
} else if (data.type === 'operation') {
this.applyRemoteOperation(data.operation);
} else if (data.type === 'cursor') {
if(data.clientId !== this.clientId) {
this.$set(this.remoteCursors, data.clientId, {x: data.cursor.x, y: data.cursor.y, color: this.getRandomColor()});
}
}
});
this.socket.addEventListener('close', () => {
console.log('Disconnected from WebSocket server');
});
this.socket.addEventListener('error', error => {
console.error('WebSocket error:', error);
});
},
beforeUnmount() {
this.socket.close();
},
methods: {
getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
},
handleMouseMove(event) {
this.cursorPosition = { x: event.clientX, y: event.clientY };
this.sendCursorPosition();
},
sendCursorPosition() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'cursor', cursor: this.cursorPosition, clientId: this.clientId }));
}
},
handleInput(event) {
const newText = event.target.value;
const oldText = this.text;
const diff = this.findDiff(oldText, newText);
if (diff) {
let operation;
if (diff.added) {
operation = { type: 'insert', position: diff.position, text: diff.added };
} else {
operation = { type: 'delete', position: diff.position, length: diff.removedLength };
}
this.applyLocalOperation(operation);
}
},
findDiff(oldText, newText) {
let position = 0;
while (position < oldText.length && position < newText.length && oldText[position] === newText[position]) {
position++;
}
if (oldText.length > newText.length) {
// Characters were removed
return { position: position, removedLength: oldText.length - newText.length, added: null };
} else if (newText.length > oldText.length) {
// Characters were added
return { position: position, removedLength: 0, added: newText.substring(position) };
} else {
return null; // No difference
}
},
applyLocalOperation(operation) {
this.operationQueue.push(operation);
this.text = this.applyOperationToText(this.text, operation);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'operation', operation: operation }));
}
},
applyRemoteOperation(operation) {
// Transform the operation against all pending operations in the queue
let transformedOperation = operation;
for (let i = 0; i < this.operationQueue.length; i++) {
const pendingOp = this.operationQueue[i];
if (transformedOperation.type === 'insert') {
transformedOperation = transformInsert(transformedOperation, pendingOp);
} else if (transformedOperation.type === 'delete') {
transformedOperation = transformDelete(transformedOperation, pendingOp);
}
if (transformedOperation === null) {
// Operation should be discarded
return;
}
}
// Apply the transformed operation to the text
this.text = this.applyOperationToText(this.text, transformedOperation);
},
applyOperationToText(text, operation) {
if (operation.type === 'insert') {
return text.slice(0, operation.position) + operation.text + text.slice(operation.position);
} else if (operation.type === 'delete') {
return text.slice(0, operation.position) + text.slice(operation.position + operation.length);
}
return text;
},
}
};
</script>
Key improvements and explanations:
clientId
: A unique ID for each client. Crucial for cursor tracking and preventing feedback loops.operationQueue
: An array to hold operations that haven’t been acknowledged by the server. This is essential for handling latency and ensuring operations are applied in the correct order. (Simplified for demonstration; a more robust implementation would track server acknowledgements).findDiff()
: A function to efficiently determine the difference between the old and new text. This avoids sending the entire text content on every change.applyLocalOperation()
: Applies the local operation to the text and sends it to the server. It also adds the operation to theoperationQueue
.applyRemoteOperation()
: The core of the OT implementation. It transforms the remote operation against all pending operations in theoperationQueue
before applying it to the text. This ensures that the remote operation is applied correctly, even if there are local operations that haven’t been sent to the server yet.applyOperationToText()
: A helper function to apply an insert or delete operation to the text.- Cursor Synchronization: Tracks and displays remote cursors using
mousemove
event and CSS styling. Each cursor gets a random color for easy identification. ThesendCursorPosition
method broadcasts the cursor position. remoteCursors
: Stores the cursor positions of other clients.
第五部分:版本回退
To implement version history and rollback functionality, we need to store snapshots of the document’s state at regular intervals or at specific events (e.g., after a certain number of operations).
Here’s how you could modify the server-side code to store version history:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
let currentText = ""; // Server-side text state
const clients = [];
const versionHistory = [];
const MAX_HISTORY_SIZE = 10; // Limit the number of stored versions
function saveVersion() {
versionHistory.push(currentText);
if (versionHistory.length > MAX_HISTORY_SIZE) {
versionHistory.shift(); // Remove the oldest version
}
}
wss.on('connection', ws => {
console.log('Client connected');
clients.push(ws);
// Send the current text and version history to the newly connected client
ws.send(JSON.stringify({ type: 'init', text: currentText, history: versionHistory }));
ws.on('message', message => {
try {
const data = JSON.parse(message);
if (data.type === 'operation') {
// Apply the operation to the server-side text
if (data.operation.type === 'insert') {
currentText = currentText.slice(0, data.operation.position) + data.operation.text + currentText.slice(data.operation.position);
} else if (data.operation.type === 'delete') {
currentText = currentText.slice(0, data.operation.position) + currentText.slice(data.operation.position + data.operation.length);
}
// Save a new version after each operation
saveVersion();
// Broadcast the operation to all other clients
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'operation', operation: data.operation }));
}
});
console.log("Current text on server: ", currentText);
} else if (data.type === 'cursor') {
// Broadcast cursor position to other clients
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'cursor', cursor: data.cursor, clientId: data.clientId }));
}
});
} else if (data.type === 'rollback') {
const versionIndex = data.versionIndex;
if (versionIndex >= 0 && versionIndex < versionHistory.length) {
currentText = versionHistory[versionIndex];
//Broadcast the rollback to all clients
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'rollback', text: currentText }));
}
});
}
}
} catch (error) {
console.error('Error processing message:', error);
}
});
ws.on('close', () => {
console.log('Client disconnected');
const index = clients.indexOf(ws);
if (index > -1) {
clients.splice(index, 1);
}
});
});
console.log('WebSocket server started on port 8080');
And the corresponding changes in the Vue component:
<template>
<div>
<textarea v-model="text" @input="handleInput" @mousemove="handleMouseMove"></textarea>
<div v-for="(cursor, index) in remoteCursors" :key="index" :style="{ left: cursor.x + 'px', top: cursor.y + 'px', position: 'absolute', width: '2px', height: '16px', backgroundColor: cursor.color }"></div>
<div>
<button v-for="(version, index) in versionHistory" :key="index" @click="rollbackToVersion(index)">Version {{ index + 1 }}</button>
</div>
</div>
</template>
<script>
import { transformInsert, transformDelete } from './ot-functions'; // Import our OT functions
export default {
data() {
return {
clientId: Math.random().toString(36).substring(7), // Generate a unique client ID
text: '',
socket: null,
operationQueue: [],
cursorPosition: { x: 0, y: 0 },
remoteCursors: {}, // { clientId: { x: 0, y: 0, color: 'red' } }
versionHistory: []
};
},
mounted() {
this.socket = new WebSocket('ws://localhost:8080');
this.socket.addEventListener('open', () => {
console.log('Connected to WebSocket server');
});
this.socket.addEventListener('message', event => {
const data = JSON.parse(event.data);
if (data.type === 'init') {
this.text = data.text;
this.versionHistory = data.history;
} else if (data.type === 'operation') {
this.applyRemoteOperation(data.operation);
} else if (data.type === 'cursor') {
if(data.clientId !== this.clientId) {
this.$set(this.remoteCursors, data.clientId, {x: data.cursor.x, y: data.cursor.y, color: this.getRandomColor()});
}
} else if (data.type === 'rollback') {
this.text = data.text;
this.operationQueue = []; // Clear the operation queue on rollback
}
});
this.socket.addEventListener('close', () => {
console.log('Disconnected from WebSocket server');
});
this.socket.addEventListener('error', error => {
console.error('WebSocket error:', error);
});
},
beforeUnmount() {
this.socket.close();
},
methods: {
rollbackToVersion(index) {
this.socket.send(JSON.stringify({type: 'rollback', versionIndex: index}));
},
getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
},
handleMouseMove(event) {
this.cursorPosition = { x: event.clientX, y: event.clientY };
this.sendCursorPosition();
},
sendCursorPosition() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'cursor', cursor: this.cursorPosition, clientId: this.clientId }));
}
},
handleInput(event) {
const newText = event.target.value;
const oldText = this.text;
const diff = this.findDiff(oldText, newText);
if (diff) {
let operation;
if (diff.added) {
operation = { type: 'insert', position: diff.position, text: diff.added };
} else {
operation = { type: 'delete', position: diff.position, length: diff.removedLength };
}
this.applyLocalOperation(operation);
}
},
findDiff(oldText, newText) {
let position = 0;
while (position < oldText.length && position < newText.length && oldText[position] === newText[position]) {
position++;
}
if (oldText.length > newText.length) {
// Characters were removed
return { position: position, removedLength: oldText.length - newText.length, added: null };
} else if (newText.length > oldText.length) {
// Characters were added
return { position: position, removedLength: 0, added: newText.substring(position) };
} else {
return null; // No difference
}
},
applyLocalOperation(operation) {
this.operationQueue.push(operation);
this.text = this.applyOperationToText(this.text, operation);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'operation', operation: operation }));
}
},
applyRemoteOperation(operation) {
// Transform the operation against all pending operations in the queue
let transformedOperation = operation;
for (let i = 0; i < this.operationQueue.length; i++) {
const pendingOp = this.operationQueue[i];
if (transformedOperation.type === 'insert') {
transformedOperation = transformInsert(transformedOperation, pendingOp);
} else if (transformedOperation.type === 'delete') {
transformedOperation = transformDelete(transformedOperation, pendingOp);
}
if (transformedOperation === null) {
// Operation should be discarded
return;
}
}
// Apply the transformed operation to the text
this.text = this.applyOperationToText(this.text, transformedOperation);
},
applyOperationToText(text, operation) {
if (operation.type === 'insert') {
return text.slice(0, operation.position) + operation.text + text.slice(operation.position);
} else if (operation.type === 'delete') {
return text.slice(0, operation.position) + text.slice(operation.position + operation.length);
}
return text;
},
}
};
</script>
Changes:
- Server: Stores
versionHistory
, limits history size withMAX_HISTORY_SIZE
, saves a version after each operation withsaveVersion()
, broadcasts the rolled back text to all clients. Handlesrollback
messages. - Client: Stores and displays
versionHistory
. Sends arollback
message to the server when a version is selected. Clears theoperationQueue
after a rollback is performed.
第六部分:总结与下一步
That’s it! We’ve covered a lot of ground. We’ve built a basic real-time collaborative editor with concurrency handling, cursor synchronization, and version history, all powered by Vue’s reactivity and a simple WebSocket server.
关键要点复习:
Feature | Description |
---|---|
Reactivity | Vue’s reactive system automatically updates the UI when the data changes. This is crucial for keeping the editor content synchronized. |
OT | Operational Transformation allows multiple users to edit simultaneously without creating conflicts. We implemented simplified transformInsert and transformDelete functions. |
WebSockets | WebSockets provide real-time, bidirectional communication between the client and the server. This is essential for broadcasting changes to all connected users. |
operationQueue |
This holds the operations that haven’t been acknowledged by the server. This is critical for handling latency and ensuring operations are applied in the correct order. |
Versioning | Storing the text at intervals to allow for rollback capabilities. |
接下来可以做什么:
- Error Handling: Implement more robust error handling on both the client and server.
- Conflict Resolution: Implement a more sophisticated OT algorithm to handle more complex conflict scenarios.
- User Authentication: Add user authentication to restrict access to the editor.
- Persistence: Store the document content in a database to persist it across sessions.
- Rich Text Support: Extend the editor to support rich text formatting (bold, italics, etc.).
- Fine-grained OT: Implement OT at a more granular level (e.g., character level) for better performance and accuracy.
- Testing: Write thorough unit and integration tests to ensure the editor is reliable.
Building a real-time collaborative editor is a challenging but rewarding project. By understanding the fundamentals of Vue’s reactivity, Operational Transformation, and WebSockets, you can create a powerful and engaging application. Keep coding, keep experimenting, and most importantly, keep having fun!