如何利用 Vue 的响应式系统,构建一个实时协作编辑器,支持并发编辑、光标同步和版本回退?

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 at position.
  • Delete (d, position, length): Delete length characters starting at position.

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:

  1. Listens for WebSocket connections on port 8080.
  2. Stores the current text content on the server.
  3. When a new client connects, it sends the current text to the client (init message).
  4. 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.
  5. 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 the operationQueue.
  • applyRemoteOperation(): The core of the OT implementation. It transforms the remote operation against all pending operations in the operationQueue 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. The sendCursorPosition 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 with MAX_HISTORY_SIZE, saves a version after each operation with saveVersion(), broadcasts the rolled back text to all clients. Handles rollback messages.
  • Client: Stores and displays versionHistory. Sends a rollback message to the server when a version is selected. Clears the operationQueue 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!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注