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

各位观众老爷们,大家好!今天咱们聊点刺激的,搞一个基于 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>

这段代码做了以下几件事:

  1. mounted 钩子函数中,创建了一个 WebSocket 连接,连接到 ws://localhost:8080
  2. 监听 onmessage 事件,当收到服务器发送的消息时,更新 editorContent
  3. 监听 onopenonclose 事件,分别在连接建立和断开时打印日志。
  4. 使用 watch 监听 editorContent 的变化,当 editorContent 发生变化时,将新的内容发送到服务器。

现在,你可以打开多个浏览器窗口,访问同一个 Vue 应用。在任何一个窗口中编辑内容,其他窗口都会实时同步。

第四部分:光标同步,知道你在干啥

仅仅同步内容还不够,咱们还要同步光标位置,让大家知道彼此正在编辑的位置。这就要涉及到更复杂的操作。

首先,我们需要监听 textareaselectionStartselectionEnd 属性,获取光标位置:

// 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>

这段代码做了以下几件事:

  1. 监听 inputselect 事件,当 textarea 的内容发生变化或光标位置发生变化时,分别调用 handleInputhandleSelect 函数。
  2. handleInputhandleSelect 函数中,更新 cursorPosition,并调用 sendCursorPosition 函数。
  3. sendCursorPosition 函数中,将 cursorPosition 发送到服务器。
  4. 修改 onmessage 事件处理函数,判断消息类型,如果是 content,则更新 editorContent;如果是 cursor,则处理其他用户的光标位置(这里留空,稍后实现)。
  5. 将发送数据统一为 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>

这个组件接收 lefttopname 三个属性,分别表示光标的水平位置、垂直位置和用户名。

接下来,咱们需要在 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>

这段代码做了以下几件事:

  1. 引入 cursor-indicator 组件。
  2. template 中,使用 v-for 循环渲染 otherCursors 数组,为每个光标创建一个 cursor-indicator 组件。
  3. data 中,添加 otherCursors 数组,用来存储其他用户的光标位置。
  4. onmessage 事件处理函数中,判断消息类型,如果是 cursor,则调用 updateOtherCursor 函数更新 otherCursors 数组。
  5. updateOtherCursor 函数更新 otherCursors 数组,如果光标已经存在,则更新其位置;如果光标不存在,则创建一个新的光标。
  6. 生成客户端 ID,并在发送消息时带上客户端 ID,避免显示自己的光标。
  7. 计算光标位置,更精确地显示光标,考虑了字体大小、行高等因素。

现在,你可以打开多个浏览器窗口,访问同一个 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>

这段代码做了以下几件事:

  1. 添加 history 数组和 historyIndex 变量,用来存储编辑器的历史版本和当前版本索引。
  2. 添加 saveHistory 函数,用来保存编辑器的历史版本。
  3. 修改 handleInput 函数,每次内容改变后,调用 saveHistory 函数。
  4. 添加 undoredo 函数,用来回退和前进版本。
  5. 添加 UndoRedo 按钮,绑定 undoredo 函数。
  6. 初始化历史记录,在 mounted 钩子函数中调用 saveHistory 函数。

现在,你可以编辑 textarea 的内容,然后点击 UndoRedo 按钮,就可以回退和前进版本了。

第六部分:性能优化与扩展

咱们的实时协作编辑器已经基本成型了,但还有很多可以优化和扩展的地方:

  • Operational Transformation (OT): 使用 OT 算法解决并发冲突,提高数据同步的准确性。
  • WebSocket 连接优化: 使用心跳机制保持 WebSocket 连接,优化网络传输。
  • 内容Diff优化: 只发送内容Diff而不是完整内容,降低带宽消耗。
  • 富文本支持: 使用富文本编辑器代替 textarea,支持更多的文本格式。
  • 权限管理: 添加权限管理功能,控制用户的编辑权限。
  • 用户认证: 添加用户认证功能,区分不同的用户。

总结

咱们今天一起用 Vue 的响应式系统,构建了一个简单的实时协作编辑器,支持并发编辑、光标同步和版本回退。虽然这个编辑器还比较简陋,但它已经具备了实时协作编辑器的基本功能。希望通过今天的讲座,能够帮助大家更好地理解 Vue 的响应式系统,并掌握构建实时协作应用的基本思路。

最后,给大家留个思考题:如何使用 WebRTC 实现点对点的实时协作编辑器? 提示:WebRTC 可以实现浏览器之间的直接通信,无需通过服务器转发数据。

感谢大家的观看,下次再见!

发表回复

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