最近在使用 Tiptap 结合 yjs 编写一个协同的文档编辑功能,在协同编辑过程中,除了保证数据的一致性外,为了优化交互体验,还需要提供诸如:当前有哪些用户在编辑,每个用户在编辑哪部分,光标在哪里等等信息。
如下图所示:
这些信息在 Yjs 的语义中被称之为 Awareness。通常,这些数据量都比较少,因此 Yjs 内部采用了 State-based CRDT 这种处理起来更简单的方式来发送更新。
什么是 Awareness
Yjs 的 Awareness (意识) 是一个可选但非常重要的功能,它与 Yjs 的核心协同编辑能力相辅相成,主要用于在实时协作应用中传递和管理非结构化的、瞬时性的用户状态信息。它不是 Yjs 核心模块的一部分,而是由 y-protocols 定义,并通常由 Yjs 的各种“Provider”实现(例如 y-websocket、y-webrtc 等)。
简单来说,Awareness 协议实现了一个简单的、与网络无关的 CRDT (Conflict-free Replicated Data Type)。它的主要目标是:
-
管理用户在线状态: 知道哪些用户当前在线。
-
传播“意识”信息: 分享用户的各种实时状态,例如:
-
光标位置: 在文档中的精确位置,以便其他用户看到。
-
选择范围: 用户当前选中的文本或内容。
-
用户名/头像: 显示当前在线用户的身份。
-
任意自定义数据: 任何你希望在用户之间实时共享的、非持久化的数据(例如,用户正在拖拽一个元素时的坐标,或者用户当前正在查看的某个特定区域)
-
Yjs 的 Awareness 功能是基于一种 State-based CRDT(无冲突复制数据类型) 实现的。它采用了一种基于 时钟 (clock) 的、纯状态 (pure state-based) 的同步机制,而不是基于操作。它的核心机制如下:
以下是其核心原理和特点:
-
独立的 CRDT:
Awareness并不是 Yjs 核心文档 CRDT 的一部分。它是一个独立的、专门用于管理用户状态和感知信息的 CRDT。这意味着它有自己的数据结构和同步逻辑。 -
网络无关性: Yjs 的 Awareness 协议设计是网络无关的。这意味着它不依赖于特定的网络传输协议(如 WebSocket、WebRTC 等),而是由具体的 "Provider"(提供者)来实现数据传输。Yjs 官方提供的
y-websocket或y-webrtc等都是实现了 Awareness 协议的 Provider。 -
纯状态同步: Awareness CRDT 是 纯状态的(purely state-based)。它不保留历史更新记录。每个客户端维护自己的本地 Awareness 状态,并定期将其广播给所有连接的对等端。当一个客户端收到远程的 Awareness 状态时,它会用接收到的最新状态覆盖本地存储的该客户端的状态。
-
递增时钟 (Increasing Clock): 每个 Awareness 状态都附带一个递增的时钟(或者说版本号)。当客户端更新自己的本地 Awareness 状态时,它会增加这个时钟。接收方只接受时钟比当前已知状态新的更新。这确保了状态的最终一致性。
-
不固定 Schema 的 JSON 对象: Awareness 允许你传输任何 JSON 可编码的数据。这意味着你可以自定义要共享的感知信息,例如:
-
光标位置
-
用户名称和颜色
-
用户是否在线
-
用户的选择区域
-
甚至是更复杂的应用特定状态(例如,在白板应用中共享用户的视图区域或画笔颜色)
{ "clock": 123, // 内部递增的时钟 "state": { // 真正的用户自定义数据 "user": { "name": "Bob", "color": "#00ff00" }, "cursor": { "x": 500, "y": 300 }, "isTyping": true } } -
-
自动离线检测: 如果一个客户端在设定的时间(通常是 30 秒)内没有收到来自某个对等端的 Awareness 更新,它就会将该对等端标记为离线。因此,客户端需要定期广播自己的 Awareness 状态以保持在线。
-
由 Provider 实现: 虽然 Awareness 的核心逻辑在
y-protocols/awareness.js中定义,但实际的网络传输和与其他客户端的同步是由 Yjs 的 "Providers"(例如y-websocket或y-webrtc)负责实现的。这些 Provider 封装了底层的网络通信细节,并确保 Awareness 状态能在不同客户端之间正确传播。
总结来说,Yjs 的 Awareness 机制提供了一个轻量级、灵活且最终一致的解决方案,用于在协作应用中共享实时用户状态信息,例如光标位置、在线状态等。它通过一个独立的、基于状态的 CRDT 和递增时钟来实现,并由 Yjs 的各种网络 Provider 负责实际的数据传输。
Awareness 中的“时钟”(Clock)机制
在 Yjs Awareness 中,每一个客户端(或者说每一个“参与者”)都维护着自己的一个 Awareness 状态。为了确保这些状态的最终一致性,并处理网络延迟和乱序到达的消息,Yjs 引入了一个简单的递增时钟(Increasing Clock)概念。
当一个客户端 A 加入协作会话时,它会初始化自己的 Awareness 状态,并为其分配一个初始时钟值(通常是 0 或 1)。此后,每当客户端 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 会认为这是一个过时或重复的消息。它会直接丢弃这个更新,不做任何处理。
为什么需要时钟?原因很简单,在处理乱序消息: 在分布式系统中,网络延迟可能导致消息乱序到达。例如:
-
客户端 A 发送状态
S1,时钟1。 -
客户端 A 紧接着更新状态到
S2,时钟2,并发送S2。 -
由于网络原因,客户端 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 状态。这种“自治”的设计使得每个客户端对自己的状态拥有完全的控制权。当客户端需要更新自己的状态时,它只需简单地调用 setLocalState 或 setLocalStateField。
离线检测也是由各个客户端独立进行的。每个客户端都会监测其他客户端最后一次发送 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);
}
});
我们可以使用 setLocalState 或 setLocalStateField 来更新自己的状态。
使用 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 实现数据传输。客户端直接传递这些信息,而非服务器统一管理,以确保低延迟和高效率。通过 setLocalState 或 setLocalStateField 设置本地状态,并监听 update 事件来获取其他用户的状态变化,实现实时用户感知,从而极大提升协作体验。
我正在筹备一套前端工程化体系的实战课程。如果你在学习前端的过程中感到方向模糊、技术杂乱无章,那么前端工程化将是你实现系统进阶的最佳路径。它不仅能帮你建立起对现代前端开发的整体认知,还能提升你在项目开发、协作规范、性能优化等方面的工程能力。
✅ 本课程覆盖构建工具、测试体系、脚手架、CI/CD、Docker、Nginx 等核心模块,内容体系完整,贯穿从开发到上线的全流程。每一章节都配有贴近真实场景的企业级实战案例,帮助你边学边用,真正掌握现代团队所需的工程化能力,实现从 CRUD 开发者到工程型前端的跃迁。
详情请看前端工程化实战课程
学完本课程,对你的简历和具体的工作能力都会有非常大的提升。如果你对此项目感兴趣,或者课程感兴趣,可以私聊我微信 yunmz777