协同光标:它是如何让实时编辑 “活” 起来的❓❓❓

849 阅读17分钟

最近在使用 Tiptap 结合 yjs 编写一个协同的文档编辑功能,在协同编辑过程中,除了保证数据的一致性外,为了优化交互体验,还需要提供诸如:当前有哪些用户在编辑,每个用户在编辑哪部分,光标在哪里等等信息。

项目地址

如下图所示:

image.png

这些信息在 Yjs 的语义中被称之为 Awareness。通常,这些数据量都比较少,因此 Yjs 内部采用了 State-based CRDT 这种处理起来更简单的方式来发送更新。

什么是 Awareness

Yjs 的 Awareness (意识) 是一个可选但非常重要的功能,它与 Yjs 的核心协同编辑能力相辅相成,主要用于在实时协作应用中传递和管理非结构化的、瞬时性的用户状态信息。它不是 Yjs 核心模块的一部分,而是由 y-protocols 定义,并通常由 Yjs 的各种“Provider”实现(例如 y-websockety-webrtc 等)。

简单来说,Awareness 协议实现了一个简单的、与网络无关的 CRDT (Conflict-free Replicated Data Type)。它的主要目标是:

  1. 管理用户在线状态: 知道哪些用户当前在线。

  2. 传播“意识”信息: 分享用户的各种实时状态,例如:

    • 光标位置: 在文档中的精确位置,以便其他用户看到。

    • 选择范围: 用户当前选中的文本或内容。

    • 用户名/头像: 显示当前在线用户的身份。

    • 任意自定义数据: 任何你希望在用户之间实时共享的、非持久化的数据(例如,用户正在拖拽一个元素时的坐标,或者用户当前正在查看的某个特定区域)

Yjs 的 Awareness 功能是基于一种 State-based CRDT(无冲突复制数据类型) 实现的。它采用了一种基于 时钟 (clock) 的、纯状态 (pure state-based) 的同步机制,而不是基于操作。它的核心机制如下:

以下是其核心原理和特点:

  1. 独立的 CRDT:Awareness 并不是 Yjs 核心文档 CRDT 的一部分。它是一个独立的、专门用于管理用户状态和感知信息的 CRDT。这意味着它有自己的数据结构和同步逻辑。

  2. 网络无关性: Yjs 的 Awareness 协议设计是网络无关的。这意味着它不依赖于特定的网络传输协议(如 WebSocket、WebRTC 等),而是由具体的 "Provider"(提供者)来实现数据传输。Yjs 官方提供的 y-websockety-webrtc 等都是实现了 Awareness 协议的 Provider。

  3. 纯状态同步: Awareness CRDT 是 纯状态的(purely state-based)。它不保留历史更新记录。每个客户端维护自己的本地 Awareness 状态,并定期将其广播给所有连接的对等端。当一个客户端收到远程的 Awareness 状态时,它会用接收到的最新状态覆盖本地存储的该客户端的状态。

  4. 递增时钟 (Increasing Clock): 每个 Awareness 状态都附带一个递增的时钟(或者说版本号)。当客户端更新自己的本地 Awareness 状态时,它会增加这个时钟。接收方只接受时钟比当前已知状态新的更新。这确保了状态的最终一致性。

  5. 不固定 Schema 的 JSON 对象: Awareness 允许你传输任何 JSON 可编码的数据。这意味着你可以自定义要共享的感知信息,例如:

    • 光标位置

    • 用户名称和颜色

    • 用户是否在线

    • 用户的选择区域

    • 甚至是更复杂的应用特定状态(例如,在白板应用中共享用户的视图区域或画笔颜色)

    {
      "clock": 123, // 内部递增的时钟
      "state": {
        // 真正的用户自定义数据
        "user": {
          "name": "Bob",
          "color": "#00ff00"
        },
        "cursor": {
          "x": 500,
          "y": 300
        },
        "isTyping": true
      }
    }
    
  6. 自动离线检测: 如果一个客户端在设定的时间(通常是 30 秒)内没有收到来自某个对等端的 Awareness 更新,它就会将该对等端标记为离线。因此,客户端需要定期广播自己的 Awareness 状态以保持在线。

  7. 由 Provider 实现: 虽然 Awareness 的核心逻辑在 y-protocols/awareness.js 中定义,但实际的网络传输和与其他客户端的同步是由 Yjs 的 "Providers"(例如 y-websockety-webrtc)负责实现的。这些 Provider 封装了底层的网络通信细节,并确保 Awareness 状态能在不同客户端之间正确传播。

总结来说,Yjs 的 Awareness 机制提供了一个轻量级、灵活且最终一致的解决方案,用于在协作应用中共享实时用户状态信息,例如光标位置、在线状态等。它通过一个独立的、基于状态的 CRDT 和递增时钟来实现,并由 Yjs 的各种网络 Provider 负责实际的数据传输。

Awareness 中的“时钟”(Clock)机制

在 Yjs Awareness 中,每一个客户端(或者说每一个“参与者”)都维护着自己的一个 Awareness 状态。为了确保这些状态的最终一致性,并处理网络延迟和乱序到达的消息,Yjs 引入了一个简单的递增时钟(Increasing Clock)概念。

当一个客户端 A 加入协作会话时,它会初始化自己的 Awareness 状态,并为其分配一个初始时钟值(通常是 01)。此后,每当客户端 A 的 Awareness 状态发生变化时(例如,光标移动了,或者用户名字更新了),客户端 A 都会递增它自己的这个时钟值。

当客户端 A 广播它的 Awareness 状态给其他客户端(B、C、D 等)时,它会连同当前的时钟值一起发送。例如,客户端 A 发送 {"clientId": "A", "clock": 5, "data": {"cursor": [10, 20]}}

当客户端 B 收到来自客户端 A 的 Awareness 状态更新时,它会检查这个更新中包含的时钟值(例如 5)。客户端 B 也会维护一个它所知道的所有客户端的最新时钟值的记录。例如,它可能知道上次收到 A 的更新时,A 的时钟是 3

它的比较逻辑主要有以下两个方面:

  • 如果收到的时钟值 (5) 大于 客户端 B 当前已知 A 的最新时钟值 (3),那么客户端 B 会认为这是一个更新的、更有效的状态。它会接受这个更新,用 5 覆盖 A 之前存储的时钟 3,并更新 A 的 Awareness 数据。

  • 如果收到的时钟值 (5) 小于或等于 客户端 B 当前已知 A 的最新时钟值 (3),那么客户端 B 会认为这是一个过时或重复的消息。它会直接丢弃这个更新,不做任何处理。

为什么需要时钟?原因很简单,在处理乱序消息: 在分布式系统中,网络延迟可能导致消息乱序到达。例如:

  1. 客户端 A 发送状态 S1,时钟 1

  2. 客户端 A 紧接着更新状态到 S2,时钟 2,并发送 S2

  3. 由于网络原因,客户端 B 先收到了 S2(时钟 2),然后才收到了 S1(时钟 1)。

    • 如果没有时钟:客户端 B 可能会错误地用过时的 S1 覆盖了 S2

    • 有了时钟:当 B 收到 S2 时,它记录 A 的时钟为 2。当 S1 到达时,B 发现 S1 的时钟 1 小于它已知的 2,因此会丢弃 S1,保持 S2 为 A 的最新状态。

Yjs Awareness 采用递增时钟的机制,为每个客户端的实时状态更新附加一个版本号。这种方法能有效处理乱序到达的消息,因为客户端只会接受时钟值更高的新状态,并用其覆盖旧状态。因此,即使网络存在延迟或丢包,它也能确保所有参与者的感知信息最终达到一致。相较于处理复杂数据结构合并的 CRDT 算法,这种基于时钟的比较和覆盖模式,为实时感知信息同步提供了一个轻量且高效的解决方案。

尽管时钟机制高效,它本身并不直接处理并发冲突:当两个客户端同时更新自身状态时,时钟无法决定哪个更改“更优”。然而,对于 Awareness 这种关注最终状态而非合并历史的应用场景,Yjs 采取“后来者居上”的策略——接收到更高时钟值的状态即被视为最新有效状态。

此外,Awareness 也不仅依赖时钟判断客户端是否在线。它结合了超时机制:如果某个客户端在设定时间内(例如 30 秒)未能广播其状态更新,即使其时钟未变,其他客户端也会将其标记为离线,从而动态管理在线用户列表。

为什么 Awareness 的信息是由客户端先传递的呢,而不是有服务器统一管理

这个问题我们就需要对去中心化和点对点(P2P)通信的理念有一些了解了, Yjs 从一开始就被设计成一个能够支持 P2P 协作的框架(尤其通过 y-webrtc)。在 P2P 场景下,根本就没有中心服务器来“统一管理”Awareness 信息。客户端必须能够直接相互发现并交换信息。

如果所有 Awareness 信息都必须先发送到服务器,由服务器处理后再广播给所有客户端,那么对于高并发的协作应用(例如,几十上百个用户同时编辑一个文档),服务器将承受巨大的实时消息处理和广播压力。尤其是光标位置这种高频变化的瞬时数据,频繁地通过服务器中转会消耗大量服务器资源。

在 P2P 场景下,即使中心服务器(如果存在的话,用于信令或发现)暂时离线,客户端之间仍然可以通过已建立的 P2P 连接继续交换 Awareness 和 Yjs Doc 信息。这种设计增强了系统的容错性和弹性。

通过客户端直接传递信息(即使是通过服务器中继,服务器也只是简单转发),可以减少信息从发送方到接收方的网络跳数和处理延迟。如果服务器需要对 Awareness 信息进行复杂处理(例如,合并、验证、历史记录等),会引入额外的延迟。

而且 Awareness 数据是瞬时且高频变化的。例如,光标移动需要毫秒级的实时反馈。任何额外的延迟都会显著影响用户体验。客户端之间的直接或准直接通信能更好地满足这种实时性要求。最重要的是 Awareness 数据通常是非持久化的。服务器没有必要存储每个客户端的 Awareness 历史。客户端只需要知道其他客户端的当前最新状态。

通过每个客户端负责维护和广播自己的 Awareness 状态。这种“自治”的设计使得每个客户端对自己的状态拥有完全的控制权。当客户端需要更新自己的状态时,它只需简单地调用 setLocalStatesetLocalStateField

离线检测也是由各个客户端独立进行的。每个客户端都会监测其他客户端最后一次发送 Awareness 更新的时间。如果超时,就将其标记为离线。这种分布式的离线检测机制比中心化的检测更具鲁棒性,因为它不会因为服务器的单点故障而失效。

鲁棒性是 robustness 的音译,在中文中常常也被表达为健壮性和强壮性,

虽然在实际部署中(如使用 y-websocket),会有一个中心服务器来作为 WebSocket 连接的枢纽,并负责中继 Yjs Doc 和 Awareness 的数据包,但这个服务器的角色更接近于一个智能路由或信令服务器,而不是一个完全掌控所有数据逻辑的中央管理系统。

Awareness 的设计理念是尽可能让客户端之间直接地、以最低的延迟、最高效的方式交换瞬时数据。这种设计哲学与 Yjs 整个库的去中心化、弹性、P2P 优先的特性是一致的。它将状态管理的权力下放到各个客户端,大大减轻了服务器的负担,提升了系统的实时性和鲁棒性。

Awareness 和 Yjs Doc 的区别与联系

先来聊聊它们的区别,最重要的一个问题就是数据模型的不同,Yjs Doc 存储的是结构化的、持久化的文档内容,支持复杂的并发编辑、撤销/重做、历史版本。它使用基于操作的 CRDT(OT-like)或者更复杂的基于状态的 CRDT 来实现。而 Awareness 存储的是非结构化的、瞬时性的用户状态,它不支持历史回溯哦,也不支持撤销重做,它仅仅使用简单的基于时钟的纯状态 CRDT。

Yjs Doc 保证所有客户端的文档内容最终一致,而 Awareness 保证所有客户端对其他用户的意识信息最终一致。

Yjs Doc 的更新通常是差异化的(只传输发生变化的增量操作)。Awareness 的更新通常是整个状态的(虽然内部可能做了一些优化,但概念上是传输整个状态)。由于 Awareness 状态通常很小,这并不是问题。

在一个典型的 Yjs 应用中,Awareness 和 Yjs Doc 是互补的:

用户操作触发 Awareness 更新:

  • 用户移动鼠标 → Awareness.setLocalStateField('cursor', ...)

  • 用户选中一段文本 → Awareness.setLocalStateField('selection', ...)

  • 用户开始打字 → Awareness.setLocalStateField('isTyping', true) 这些操作不会修改 Yjs Doc。

用户完成操作触发 Yjs Doc 更新:

  • 用户敲击键盘(修改文档内容) → Yjs Doc.get('text').insert(...)

  • 用户拖拽并释放一个图片(修改图片在文档中的最终位置) → Yjs Doc.get('images').set(imageId, newPosition) 这些操作会修改 Yjs Doc,并被持久化。

远程更新的处理:

  • 当收到 Awareness 更新 时:你的 UI 会立即更新其他用户的光标、选择、打字状态等。这些变化通常是短暂的 UI 效果。

  • 当收到 Yjs Doc 更新 时:你的 UI 会更新文档内容,例如渲染新的文本、调整图片位置。这些变化是持久的,会影响文档的最终状态。

这种分离确保了 Yjs Doc 的高效和数据完整性,同时通过 Awareness 提供了流畅的实时协作体验。

基本使用

接下来我们将提供一些简单的代码示例来讲解一下 Awareness。

在我们的 Yjs Provider 连接建立后,就可以访问其 awareness 属性。

import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";

const ydoc = new Y.Doc();

const provider = new WebsocketProvider("ws://localhost:1234", "moment", ydoc);

// 获取 Awareness 实例
const awareness = provider.awareness;

// 可以在 provider 连接状态变化时,获取客户端 ID
provider.on("status", ({ status }) => {
  if (status === "connected") {
    console.log("Connected! Client ID:", ydoc.clientID);
  }
});

我们可以使用 setLocalStatesetLocalStateField 来更新自己的状态。

使用 setLocalState(state) 来设置你的客户端的完整 Awareness 状态。

// 设置初始状态,包含用户信息、初始光标位置和打字状态
awareness.setLocalState({
  user: {
    name: "Alice",
    color: "#FF0000", // 用户头像颜色
    emoji: "😊", // 可选的表情符号
  },
  cursor: { x: 100, y: 50 }, // 示例光标坐标
  isTyping: false,
});

console.log(awareness.getLocalState());

使用 setLocalStateField(key, value) 来更新状态的某个字段,这会更高效且简洁。

// 模拟鼠标移动,更新光标位置
document.addEventListener("mousemove", (event) => {
  // 节流处理,避免过于频繁的更新
  requestAnimationFrame(() => {
    awareness.setLocalStateField("cursor", {
      x: event.clientX,
      y: event.clientY,
    });
  });
});

// 模拟用户开始打字
const inputElement = document.getElementById("my-input");
inputElement.addEventListener("focus", () => {
  awareness.setLocalStateField("isTyping", true);
});

// 模拟用户停止打字(例如,失去焦点或一段时间内无输入)
inputElement.addEventListener("blur", () => {
  awareness.setLocalStateField("isTyping", false);
});
// 也可以结合 setTimeout 实现“用户停止输入一段时间后停止显示打字状态”
let typingTimeout;
inputElement.addEventListener("input", () => {
  awareness.setLocalStateField("isTyping", true);
  clearTimeout(typingTimeout);
  typingTimeout = setTimeout(() => {
    awareness.setLocalStateField("isTyping", false);
  }, 1500); // 1.5 秒没有输入则认为停止打字
});

通过 awareness.on('update', callback) 订阅其他客户端的状态更新。

awareness.on("update", ({ added, updated, removed }) => {
  console.log("--- Awareness Update ---");
  console.log("Clients added:", added); // 新上线的客户端 ID 列表
  console.log("Clients updated:", updated); // 状态更新的客户端 ID 列表
  console.log("Clients removed:", removed); // 已离线的客户端 ID 列表

  // 遍历所有当前在线客户端的 Awareness 状态
  awareness.getStates().forEach((state, clientID) => {
    // 渲染 UI:例如显示在线用户列表,或者绘制光标
    const user = state.user || { name: "Unknown User", color: "#888" };
    const cursor = state.cursor;
    const isTyping = state.isTyping ? " (Typing...)" : "";

    if (clientID !== ydoc.clientID) {
      // 排除自己
      console.log(`Client ${clientID} (${user.name}):`, state);
      // 假设有一个函数来更新 UI 中的用户列表或光标
      updateUIForRemoteUser(clientID, user, cursor, isTyping);
    }
  });

  // 处理离线用户:移除其 UI 元素
  removed.forEach((removedClientID) => {
    console.log(`Client ${removedClientID} has gone offline.`);
    removeUIForRemoteUser(removedClientID);
  });
});

// 示例:更新或移除 UI 元素的占位函数
function updateUIForRemoteUser(clientID, user, cursor, isTyping) {
  // 在这里实现你的逻辑,例如:
  // 1. 在一个 `div` 中显示用户名字和打字状态
  // 2. 根据 `cursor` 坐标在屏幕上绘制一个带颜色的光标
  console.log(
    `  UI Update: ${user.name}${isTyping} at (${cursor?.x}, ${cursor?.y})`
  );
}

function removeUIForRemoteUser(clientID) {
  // 在这里实现移除离线用户相关 UI 元素的逻辑
  console.log(`  UI Removal: Removing elements for client ${clientID}`);
}

awareness.getStates() 会返回一个 Map,包含所有在线客户端的最新 Awareness 状态。

// 在任何时候,你都可以获取所有在线客户端的当前状态
const allCurrentStates = awareness.getStates();

allCurrentStates.forEach((state, clientID) => {
  if (clientID === ydoc.clientID) {
    console.log("My current state:", state);
  } else {
    console.log(`Remote client ${clientID}'s state:`, state);
  }
});

当我们的应用关闭、用户离开页面时,主动将我们的 Awareness 状态设置为 null 是一个好习惯。

window.addEventListener("beforeunload", () => {
  // 将本地 Awareness 状态设置为 null,通知所有其他客户端我已离线
  awareness.setLocalState(null);
  console.log("Broadcasting offline status.");

  // 同时,销毁 Yjs Provider 来关闭 WebSocket 连接,释放资源
  provider.destroy();
  console.log("Provider destroyed.");
});

在我们的应用组件卸载或不再需要 Yjs 实例时,务必清理相关的监听器和资源。

// 假设在一个框架的生命周期钩子中 (例如 React useEffect)
function setupAwareness() {
  const onUpdate = ({ added, updated, removed }) => {
    // 处理 Awareness 更新...
  };
  awareness.on("update", onUpdate);

  // 如果你的状态不频繁更新,考虑添加一个心跳机制
  // 注意:大多数 Provider 会自动处理连接心跳,但如果 Awareness 状态长期不变,
  // 最好定期触发 setLocalState 来维持“在线”状态。
  const heartbeatInterval = setInterval(() => {
    if (awareness.getLocalState()) {
      // 确保本地状态已设置
      // 重新设置当前状态,强制广播一个更新包
      awareness.setLocalState(awareness.getLocalState());
      console.log("Heartbeat: Broadcasting my state.");
    }
  }, 5000); // 每 5 秒广播一次心跳

  // 返回清理函数
  return () => {
    // 移除事件监听器
    awareness.off("update", onUpdate);
    console.log("Awareness update listener removed.");

    // 清除心跳定时器
    clearInterval(heartbeatInterval);
    console.log("Heartbeat interval cleared.");

    // 告知离线并销毁 Provider (如果在这个清理阶段需要完全断开连接)
    awareness.setLocalState(null);
    provider.destroy();
    console.log("Awareness state set to null and provider destroyed.");
  };
}

// 调用 setupAwareness() 并在适当时候执行返回的清理函数
// const cleanup = setupAwareness();
// // 当组件卸载时:
// cleanup();

总结

Yjs 的 Awareness 机制专门用于在协作应用中同步用户光标、在线状态等瞬时、非持久化的信息。它是一个独立的、基于纯状态 CRDT 和递增时钟的协议,由 Yjs 的 Provider 实现数据传输。客户端直接传递这些信息,而非服务器统一管理,以确保低延迟和高效率。通过 setLocalStatesetLocalStateField 设置本地状态,并监听 update 事件来获取其他用户的状态变化,实现实时用户感知,从而极大提升协作体验。

我正在筹备一套前端工程化体系的实战课程。如果你在学习前端的过程中感到方向模糊、技术杂乱无章,那么前端工程化将是你实现系统进阶的最佳路径。它不仅能帮你建立起对现代前端开发的整体认知,还能提升你在项目开发、协作规范、性能优化等方面的工程能力。

✅ 本课程覆盖构建工具测试体系脚手架CI/CDDockerNginx 等核心模块,内容体系完整,贯穿从开发到上线的全流程。每一章节都配有贴近真实场景的企业级实战案例,帮助你边学边用,真正掌握现代团队所需的工程化能力,实现从 CRUD 开发者到工程型前端的跃迁。

详情请看前端工程化实战课程

学完本课程,对你的简历和具体的工作能力都会有非常大的提升。如果你对此项目感兴趣,或者课程感兴趣,可以私聊我微信 yunmz777