惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
Spread Privacy
Spread Privacy
T
The Exploit Database - CXSecurity.com
Simon Willison's Weblog
Simon Willison's Weblog
P
Privacy & Cybersecurity Law Blog
L
LINUX DO - 热门话题
T
Threat Research - Cisco Blogs
T
Tenable Blog
TaoSecurity Blog
TaoSecurity Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
AI
AI
P
Proofpoint News Feed
A
About on SuperTechFans
P
Privacy International News Feed
月光博客
月光博客
雷峰网
雷峰网
S
Secure Thoughts
博客园 - 叶小钗
博客园 - 聂微东
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Project Zero
Project Zero
The Cloudflare Blog
SecWiki News
SecWiki News
The Hacker News
The Hacker News
V
Vulnerabilities – Threatpost
罗磊的独立博客
A
Arctic Wolf
阮一峰的网络日志
阮一峰的网络日志
Know Your Adversary
Know Your Adversary
酷 壳 – CoolShell
酷 壳 – CoolShell
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Troy Hunt's Blog
The Last Watchdog
The Last Watchdog
Schneier on Security
Schneier on Security
小众软件
小众软件
有赞技术团队
有赞技术团队
博客园 - 司徒正美
T
Tailwind CSS Blog
量子位
C
Cybersecurity and Infrastructure Security Agency CISA
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
S
Security @ Cisco Blogs
大猫的无限游戏
大猫的无限游戏
S
SegmentFault 最新的问题
Apple Machine Learning Research
Apple Machine Learning Research
宝玉的分享
宝玉的分享
L
Lohrmann on Cybersecurity

V2EX

我用 AI 写代码,但终端管理反而成了累赘——于是我做了 codux - V2EX [调研] 各位在公司都用什么 ide 和 agent 写代码? 老运维 share 一个运维平台 新电脑 brew install node 之后,一个小设置可以提升对供应链投毒的防御 - V2EX GLM-Coding 调用持续报错: z.ai 的 Lite 套餐几乎无法使用,官方 Pro/Max 是否稳定? - V2EX 上海漕河泾内推,本组有 2 个 hc,一个后端,一个前端,预算都是 20k 左右,不打卡,氛围好 如果 V2EX 上有一组不永久保存聊天记录(比如只保存 7 天或者 24 小时)的聊天室,那么会开启哪些有用或者有趣的可能? - V2EX gemini cli 貌似挂了,一直返回 403 - V2EX 第一次在自媒体上赚到钱 收集了最近在使用的低价 GPT, Gemini,邮箱等 AI 会员的小店合集 讨论个大实话:现在企业还在说 AI 编程提效 20%, 30%的,真的太落后,没用懂 AI。因为包括很多前沿公司,已经狂奔到提效 200%-500%的情况 [招聘][远程][币安] 前端/后端/QA/iOS/Android 至少 3 年以上经验 目前有大量 HC 欢迎投递 Chatgpt Pro 用量用不完的可以开这些设置 面试的时候好像遇到钓鱼了,给各位避个坑 cursor 年续费 22 号到期, 自动续费是否还是老的计次套餐呢 - V2EX 被两件破事毁掉的一下午,琐碎的内耗消磨人的精力 使用 Planet 存储 Codex 的会话或者重要信息 - V2EX 如果业务部门领导不要你开发功能,而是要求你教会它用 claude code 开发功能,你会怎么做? 分享一个 MacOS 接绿联 CM818 USB 转 DP 转接器使用感受 - V2EX 我的 HR 朋友 10 年老 Java ,非全大专,大家帮忙看看简历 开源了一个 AI 口语练习工具,音素级发音评分,完全免费可自部署 V2EX 上有哪些你觉得很有趣、印象深刻的妹纸? 字节为啥不出个国内版 Vercel? 有在大马的朋友吗? 问个运营商问题 你们在有领导的公司大群发过的最大胆的消息是什么 公司裁员,目前没有工作。想试试摆摊,做一个移动鲜啤打酒车 我的硬盘 Memblaze Pblaze 5 Linux 下不识别,给 Linux 内核提交了补丁, AI 说有望被合并 - V2EX 只有我一个人觉得 codex 不好用? 做了个 AI + 真人专家监督的广告投放平台 Auxora, 7 个品牌跑出 6x ROAS 如何走出至亲的离世 Claude Web 端貌似 claude-opus-4-7 偷偷上了? 现在 Apple 开发者帳號應該是用哪个地区会更好? - V2EX 用回测筛选因子的一点经验分享 给女儿 vibe 了一个故事类的 app,做完发现,这类应用似乎上线难度极大? - V2EX 手机格式化 bitget 钱包没了,里面开通的银行卡还有机会拿到吗 - V2EX [送码] TransVoice - 我的第一款 App 上架啦!实时转写+翻译+字幕,会议听课好助手! PictureHub 高清摄影作品的画廊 Planet 的第一个使用 macOS 26 SDK 构建的 Insider 版本 20260416-1 - V2EX 成都二手房是不是在涨价,有点坐不住了 - V2EX claude 生态(skill mcp plugin)等 Studio Display XDR VESA 适配器脱落 有在用印度区 applestore 的大哥嘛,请教一下礼品卡去哪里买呢 - V2EX 我好像知道京东家政爆火的原因了 - V2EX 薅了公司的 a 家 api key,用机场 ip 做代理容易被封吗 如何在初期就识别 HR 在刷 KPI,没打算招你? [分享]精心打造一个 AI 编程知识库(算法/设计模式/提示词/Skills),助力程序员转型 港版 iPhone 在国内支持联通 5GA 吗?在广东用 想办港卡 AI 对 it 行业影响太大了 我做了个把照片变成 iOS 小组件贴纸的 App ChatGPT Pro 5x 套餐 量真的很足! I have found a method to directly generate advertising video materials using scripts 在小城市开个店,给人写软件,有前途吗 chrome 最新的 147 版直接卡爆炸了 - V2EX 为什么厂家不在 skill/mcp 这类的工具中塞广告呢?这样不是可以大赚嘛? minimax 真是脸都不要了,工作日下午 14:00 定时开启 529,脸都不要了。训练模型居然占用用户使用时间 外资非核心部门 vs 另一家外资的核心部门,该跳吗? iTad 标签 扩展 加小动作 ? - V2EX 去年 H200 能买,不让买是代替快出来了? - V2EX AI 赛事通 - 2026 年 4 月中国区新增 AI 竞赛和黑客松汇总 - V2EX V2EX › 登录 现在安卓开发都在做啥 - V2EX 浏览器插件 沉浸式翻译 是不支持自定义模型了吗? - V2EX Codex 里的 GPT5.4 也能降智?上午让它改两个问题,改了一个小时了, plus 额度用了一半了还是没改好,和前几天用的体感完全不一样。要它改的问题也不复杂。服了。 目前有使用 claude code 的收到人脸认证的吗 - V2EX 分享一个自己做的 Nginx 管理工具,实时请求动态预览!(无奈市面上实在找不到好用的,自己撸了个) - V2EX claude code 崩了么? 今天在反重力上用 claude 一点都不丝滑,有同样的感受吗? opencode 消息周知插件 今天 claude opus 和前两天比,质的飞跃 - V2EX 999 包月价? - V2EX 一个版本, 50 项更新:我们几乎重做了整个播放页 本地大模型多大显存够用? GOGDNS 一款简易的私人 DNS 服务器 - V2EX API key (GLM) 怎么使用 claude code desktop ? Claude 这样订阅有问题吗 - V2EX 帮我爸找回了一篇赛博兰亭集序 求推荐稳定、高性价比使用 Claude Opus 4.6 的渠道/平台 搞个云端 claude code 防止 封号 - V2EX 用 Claude 要实名了,内地用户怎么办? OpenAI Plus 和 Team 都缩水了吗 海外 Android 手机有什么好用的国内第三方应用市场推荐吗 - V2EX 把电脑伪装成电视,用 DLNA 投屏拿到视频号直播流地址 - V2EX claude 认证莫慌 北京互联网法院有什么攻略么?起诉北京智谱华章科技股份有限公司退款可行么? - V2EX Claude 开始引入身份验证 求 vscode 做笔记软件的插件推荐 - V2EX 讯飞星辰的 Coding Plan 如何? Anthropic 宣布在 Claude 平台推行身份验证机制 科普一下低价 gpt 是怎么来的 有没有长期关注 Claude 的朋友,我建了一个 Channel 自动抓取 Claude Team 的推文 啃了那篇 54 页的 Agent Harness 综述, 给大伙讲个省流版 现在那家的 coding plan 还能买到 是不是最近会有什么更聪明的大模型要发布了呀? 用多了 AI 后,有没有觉得 AI 生成的文章有很强的既视感? 如何 实践 Harness 工程? 今日份 GPT 5.4 笑话 如何建一个自己的号池,让 cursor 真正实现 token 自由 写了三个月 Agent Harness,我终于敢让 Claude Code 全自动写代码了
一杯茶时间带你基于 Yjs 和 reactflow 构建协同流程图编辑器
moment082 · 2026-01-08 · via V2EX

在开始之前,先提供 github 地址:FlowSync

⚠️ 说明: 本项目是 DocFlow 项目协同编辑功能的基础实现示例。如果你想学习更完整的协同编辑解决方案(包括富文本编辑、权限管理、AI 集成等企业级功能),请访问 DocFlow 项目

如果想参与 DocFlow 开发或者想学习更详细的 Tiptap 和 Yjs 协同原理的,可以添加我微信获取更详细的了解:yunmz777

想象一下这样的场景:多个用户同时打开同一个流程图,拖动节点、创建连接、实时看到彼此的光标位置,就像在同一块白板上协作一样流畅。这就是我们要实现的效果。这个案例会涵盖协同编辑的核心场景:数据同步、状态管理、光标协同,以及前后端的完整实现。

项目架构

让我们先看看整体架构。这个协同流程图应用由三个核心部分组成:前端使用 React + ReactFlow 构建可视化流程图界面,后端使用 NestJS 搭建 WebSocket 服务器处理协同逻辑,Yjs 作为协同引擎在前后端之间同步数据。

20260105084942

前端负责用户交互和视图渲染,Yjs Doc 存储流程图的节点和连接数据,WebsocketProvider 负责与服务器建立连接并同步数据。后端的 WebSocket Server 接收来自多个客户端的消息,协调它们之间的数据同步,WSSharedDoc 存储每个房间的文档状态。

后端实现

让我们从后端开始,因为它是整个协同系统的中枢。后端的职责很清晰:接收客户端连接,转发同步消息,管理 Awareness 状态。

我们使用 NestJS 框架搭建后端服务,整个协同逻辑封装在一个 Service 中。首先创建项目结构:

20251231154346

依赖安装

在开始编写代码前,需要安装必要的依赖:

pnpm add yjs y-protocols ws lib0
pnpm add -D @types/ws

核心数据结构

src/yjs/yjs.service.ts 文件中,首先导入需要的模块,然后定义 WSSharedDoc 类:

import { Injectable, OnModuleInit, Logger } from "@nestjs/common";
import * as WebSocket from "ws";
import * as Y from "yjs";
import * as syncProtocol from "y-protocols/sync";
import * as awarenessProtocol from "y-protocols/awareness";
import * as encoding from "lib0/encoding";
import * as decoding from "lib0/decoding";
import * as map from "lib0/map";

const wsReadyStateOpen = 1;
const messageSync = 0;
const messageAwareness = 1;

const docs: Map<string, WSSharedDoc> = new Map();

这些导入包括:NestJS 的装饰器和日志工具,WebSocket 库用于建立连接,Yjs 核心库和协议库,lib0 提供的编解码和 Map 工具。常量定义了消息类型和连接状态。

WSSharedDoc 类继承自 Y.Doc ,是服务端维护的共享文档:

class WSSharedDoc extends Y.Doc {
  name: string;
  conns: Map<WebSocket, Set<number>>;
  awareness: awarenessProtocol.Awareness;

  constructor(name: string) {
    super({ gc: true });
    this.name = name;
    this.conns = new Map();
    this.awareness = new awarenessProtocol.Awareness(this);
  }
}

这个类扩展了 Yjs 文档,添加了连接管理和 Awareness 支持。name 是文档的唯一标识(通常是房间名),conns 维护了当前所有连接到这个文档的 WebSocket 客户端,awareness 用于同步用户的临时状态(如光标位置)。

在同一文件中,文档管理使用一个全局 Map ,确保同一个房间的所有客户端共享同一个文档实例:

const docs: Map<string, WSSharedDoc> = new Map();

const getYDoc = (docname: string): WSSharedDoc =>
  map.setIfUndefined(docs, docname, () => new WSSharedDoc(docname));

处理客户端连接

接下来在 YjsService 类中实现连接处理。当客户端连接时,我们需要建立 WebSocket 连接,从 URL 中提取房间名,然后设置消息处理逻辑:

onModuleInit() {
  this.wss = new WebSocket.Server({ port: 1234 });

  this.wss.on('connection', (conn: WebSocket, req: any) => {
    const url = req.url || '/';
    const docName = url.slice(1).split('?')[0] || 'default';
    this.setupWSConnection(conn, docName);
  });

  this.logger.log('WebSocket server initialized on port 1234');
}

这里从请求 URL 中提取房间名,比如 ws://localhost:1234/flow-room 会提取出 flow-room 作为文档名称。这样不同的房间使用不同的文档,互不干扰。

消息处理

setupWSConnection 是整个服务端的核心,它处理客户端的所有消息:

private setupWSConnection(conn: WebSocket, docName: string) {
  conn.binaryType = 'arraybuffer';
  const doc = getYDoc(docName);
  doc.conns.set(conn, new Set());

  conn.on('message', (message: ArrayBuffer | Buffer) => {
    const uint8Array = message instanceof ArrayBuffer
      ? new Uint8Array(message)
      : new Uint8Array(message.buffer, message.byteOffset, message.byteLength);

    const encoder = encoding.createEncoder();
    const decoder = decoding.createDecoder(uint8Array);
    const messageType = decoding.readVarUint(decoder);

    switch (messageType) {
      case messageSync:
        encoding.writeVarUint(encoder, messageSync);
        syncProtocol.readSyncMessage(decoder, encoder, doc, null);
        if (encoding.length(encoder) > 1) {
          send(doc, conn, encoding.toUint8Array(encoder));
        }
        break;
      case messageAwareness:
        awarenessProtocol.applyAwarenessUpdate(
          doc.awareness,
          decoding.readVarUint8Array(decoder),
          conn,
        );
        break;
    }
  });
}

消息分为两种类型:Sync 消息用于同步文档数据(节点、连接等持久化数据),Awareness 消息用于同步用户状态(光标位置等临时数据)。服务端收到 Sync 消息后,使用 y-protocols 的 readSyncMessage 处理,如果有响应数据就发送回客户端。收到 Awareness 消息后,应用更新并广播给其他客户端。

初始同步和状态广播

新客户端连接后,需要立即发送当前文档状态,让新用户能看到已有的内容:

setImmediate(() => {
  if (conn.readyState === wsReadyStateOpen) {
    const encoder = encoding.createEncoder();
    encoding.writeVarUint(encoder, messageSync);
    syncProtocol.writeSyncStep1(encoder, doc);
    send(doc, conn, encoding.toUint8Array(encoder));

    const awarenessStates = doc.awareness.getStates();
    if (awarenessStates.size > 0) {
      const awarenessEncoder = encoding.createEncoder();
      encoding.writeVarUint(awarenessEncoder, messageAwareness);
      encoding.writeVarUint8Array(
        awarenessEncoder,
        awarenessProtocol.encodeAwarenessUpdate(
          doc.awareness,
          Array.from(awarenessStates.keys())
        )
      );
      send(doc, conn, encoding.toUint8Array(awarenessEncoder));
    }
  }
});

使用 setImmediate 延迟发送,确保连接完全建立。首先发送 Sync Step1 消息,包含当前文档的完整状态向量,然后如果有其他用户在线,发送他们的 Awareness 状态,这样新用户能立即看到其他人的光标。

监听和广播更新

服务端需要监听文档的更新,并将更新广播给所有连接的客户端:

const updateHandler = (update: Uint8Array) => {
  const encoder = encoding.createEncoder();
  encoding.writeVarUint(encoder, messageSync);
  syncProtocol.writeUpdate(encoder, update);
  const message = encoding.toUint8Array(encoder);
  doc.conns.forEach((_, c) => send(doc, c, message));
};
doc.on("update", updateHandler);

const awarenessChangeHandler = ({ added, updated, removed }) => {
  const changedClients = added.concat(updated, removed);
  const connControlledIDs = doc.conns.get(conn);
  if (connControlledIDs !== undefined) {
    added.forEach((clientID) => connControlledIDs.add(clientID));
    removed.forEach((clientID) => connControlledIDs.delete(clientID));
  }

  const encoder = encoding.createEncoder();
  encoding.writeVarUint(encoder, messageAwareness);
  encoding.writeVarUint8Array(
    encoder,
    awarenessProtocol.encodeAwarenessUpdate(doc.awareness, changedClients)
  );
  const buff = encoding.toUint8Array(encoder);
  doc.conns.forEach((_, c) => send(doc, c, buff));
};
doc.awareness.on("update", awarenessChangeHandler);

当文档有更新时(比如用户移动了节点),将增量更新编码后广播给所有客户端。当 Awareness 状态变化时(比如用户移动了光标),也广播给所有客户端。注意这里会追踪每个连接控制的客户端 ID ,用于后续清理。

连接清理

当客户端断开连接时,需要清理相关资源,移除其 Awareness 状态,如果房间空了就销毁文档:

conn.on("close", () => {
  const controlledIds = doc.conns.get(conn);
  doc.conns.delete(conn);
  doc.awareness.off("update", awarenessChangeHandler);
  doc.off("update", updateHandler);

  if (controlledIds) {
    awarenessProtocol.removeAwarenessStates(
      doc.awareness,
      Array.from(controlledIds),
      null
    );
  }

  if (doc.conns.size === 0) {
    doc.destroy();
    docs.delete(docName);
  }
});

这里移除事件监听器防止内存泄漏,清除该客户端的 Awareness 状态,如果是最后一个客户端离开,销毁文档释放内存。

这些逻辑都编写完成之后,我们就可以执行如下名来来启动项目:

pnpm start:dev

前端实现

前端的实现更加复杂一些,因为要处理用户交互、UI 渲染和协同同步。我们使用 ReactFlow 作为流程图渲染引擎,Yjs 处理数据同步。

前端项目结构如下:

20251231154502

前端依赖安装

前端需要安装以下依赖:

pnpm add yjs y-websocket reactflow

React Flow 是一个用于构建交互式图形和流程图的库,提供了丰富的功能,如拖拽节点、连接线、缩放、平移等,常用于可视化编辑器、流程设计器等应用中。它支持高度定制,可以很容易地与其他前端库和框架集成。

初始化协同环境

src/components/Canvas.tsx 文件中,首先导入需要的库:

import { useCallback, useEffect, useRef, useState } from "react";
import {
  ReactFlow,
  Controls,
  Background,
  useNodesState,
  useEdgesState,
  addEdge,
  BackgroundVariant,
  ConnectionMode,
  useReactFlow,
  ReactFlowProvider,
  type Connection,
  type Edge,
  type Node,
  NodeChange,
  EdgeChange,
} from "reactflow";
import "reactflow/dist/style.css";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import Cursor from "./Cursor";

然后在 Canvas 组件中创建 Yjs 文档和 WebSocket 连接:

useEffect(() => {
  const doc = new Y.Doc();
  const wsProvider = new WebsocketProvider(
    "ws://localhost:1234",
    "flow-room",
    doc,
    {
      connect: false,
      resyncInterval: -1,
    }
  );

  setTimeout(() => {
    wsProvider.connect();
  }, 50);

  const nodesMap = doc.getMap("nodes");
  const edgesMap = doc.getMap("edges");

  ydoc.current = doc;
  provider.current = wsProvider;

  // ... 后续逻辑
}, []);

这里延迟连接是为了避免过快重连的警告,使用 resyncInterval: -1 禁用自动重新同步。我们使用两个 Y.Map 分别存储节点和连接,这样可以独立管理它们的增删改。

初始化 Awareness

在同一个 useEffect 中,设置本地用户的 Awareness 状态,包括随机生成的颜色和客户端 ID:

const clientId = wsProvider.awareness.clientID;
const userColor = useRef(generateRandomColor());

wsProvider.awareness.setLocalState({
  cursor: null,
  color: userColor.current,
  clientId: clientId,
});

wsProvider.awareness.on("change", () => {
  const states = new Map(wsProvider.awareness.getStates());
  setCursors(states);
});

监听 Awareness 变化,当其他用户的光标移动时,更新本地状态并重新渲染光标。

同步节点数据

继续在 useEffect 中,如果是首次打开,初始化一些默认节点,然后监听节点变化,将 Yjs 数据同步到 React 状态:

if (nodesMap.size === 0) {
  initialNodes.forEach((node) => {
    nodesMap.set(node.id, JSON.parse(JSON.stringify(node)));
  });
}

nodesMap.observe(() => {
  const yNodes = Array.from(nodesMap.values());
  const validNodes = yNodes.map((node) => ({
    id: node.id,
    type: node.type || "default",
    data: node.data,
    position: {
      x: node.position.x,
      y: node.position.y,
    },
  }));
  setNodes(validNodes);
});

使用 JSON.parse(JSON.stringify()) 确保存储的是纯数据对象,避免 Yjs 无法序列化 React 组件。observe 方法监听 Y.Map 的变化,任何修改都会触发回调,我们将 Yjs 数据转换为 ReactFlow 需要的格式。

处理节点拖动

当用户拖动节点时,需要将位置更新同步到 Yjs 文档:

const handleNodesChange = useCallback(
  (changes: NodeChange[]) => {
    onNodesChange(changes);

    if (!ydoc.current) return;

    changes.forEach((change) => {
      if (change.type === "position") {
        const node = nodes.find((n) => n.id === change.id);
        if (node) {
          const updatedNode = {
            ...node,
            position: change.position || node.position,
          };
          ydoc.current
            ?.getMap("nodes")
            .set(change.id, JSON.parse(JSON.stringify(updatedNode)));
        }
      }
    });
  },
  [nodes, onNodesChange]
);

ReactFlow 使用 changes 数组描述节点的变化,我们过滤出位置变化,更新到 Yjs 文档。这个更新会触发 observe 回调,同时通过 WebSocket 发送给其他客户端。

创建连接

当用户连接两个节点时,创建一条边并同步到 Yjs:

const onConnect = useCallback(
  (connection: Connection) => {
    if (!connection.source || !connection.target) return;

    const newEdge = {
      id: `e${connection.source}-${connection.target}`,
      source: connection.source,
      target: connection.target,
      sourceHandle: connection.sourceHandle || undefined,
      targetHandle: connection.targetHandle || undefined,
    };

    if (ydoc.current) {
      ydoc.current.getMap("edges").set(newEdge.id, newEdge);
    }

    setEdges((eds) => addEdge(connection, eds));
  },
  [setEdges]
);

生成唯一的边 ID ,同时更新本地状态和 Yjs 文档。本地状态用于立即响应,Yjs 文档用于同步给其他客户端。

光标同步

光标同步是协同编辑中很重要的用户体验细节,让用户能看到彼此的位置:

const handleMouseMove = useCallback(
  (e: React.MouseEvent) => {
    if (!provider.current?.awareness || !flowRef.current) return;

    const bounds = flowRef.current.getBoundingClientRect();
    const x = e.clientX - bounds.left;
    const y = e.clientY - bounds.top;

    const flowPosition = reactFlowInstance.screenToFlowPosition({ x, y });

    provider.current.awareness.setLocalState({
      cursor: flowPosition,
      color: userColor.current,
      clientId: provider.current.awareness.clientID,
    });
  },
  [reactFlowInstance]
);

const handleMouseLeave = useCallback(() => {
  if (!provider.current?.awareness) return;

  provider.current.awareness.setLocalState({
    cursor: null,
    color: userColor.current,
    clientId: provider.current.awareness.clientID,
  });
}, []);

获取鼠标相对于容器的坐标,使用 ReactFlow 的 screenToFlowPosition 转换为画布坐标(考虑缩放和平移),然后更新到 Awareness 。当鼠标离开画布时,将光标设置为 null ,其他用户就看不到这个光标了。

渲染协同光标

最后在 Canvas 组件的 JSX 中渲染其他用户的光标,使用不同颜色区分:

{
  Array.from(cursors.entries()).map(([clientId, state]) => {
    if (!state.cursor || clientId === currentClientId) {
      return null;
    }

    const screenPosition = reactFlowInstance.flowToScreenPosition({
      x: state.cursor.x,
      y: state.cursor.y,
    });

    return (
      <Cursor
        key={clientId}
        x={screenPosition.x}
        y={screenPosition.y}
        color={state.color}
      />
    );
  });
}

过滤掉自己的光标,将画布坐标转换回屏幕坐标(因为光标是绝对定位在屏幕上的),使用每个用户的颜色渲染光标组件。

协同光标组件

src/components/Cursor.tsx 中实现光标组件,我们使用的 SVG 的方式来实现这个光标组件:

interface CursorProps {
  x: number;
  y: number;
  color: string;
}

export default function Cursor({ x, y, color }: CursorProps) {
  return (
    <div
      style={{
        position: "absolute",
        left: x,
        top: y,
        pointerEvents: "none",
        zIndex: 9999,
      }}
    >
      <svg width="24" height="24" viewBox="0 0 24 24">
        <path
          d="M5.65376 12.3673L5.46026 12.4668L5.70974 12.8619L11.3877 21.5659L12.6377 23.192L13.1897 21.2687L16.855 8.71373L19.0002 0.939087L10.5578 3.70771L0.458496 7.88092L-1.56467 8.73732L0.0837585 10.0768L5.65376 12.3673Z"
          fill={color}
        />
      </svg>
    </div>
  );
}

这个组件接收坐标和颜色,使用 SVG 绘制一个指针形状,通过 pointerEvents: "none" 确保不会干扰用户的鼠标操作。

协同流程详解

让我们通过一个完整的场景来理解整个协同过程。假设用户 A 拖动了一个节点,数据是如何同步到用户 B 的?

20251231155352

整个过程分为三个阶段:

  1. 第一阶段,用户 A 拖动节点,触发 handleNodesChange ,更新 Yjs 文档,Yjs 文档生成增量更新。

  2. 第二阶段,WebsocketProvider 监听到 update 事件,将增量编码为二进制消息,通过 WebSocket 发送给服务器,服务器应用更新到服务端文档。

  3. 第三阶段,服务器将更新广播给所有其他客户端,用户 B 的 WebsocketProvider 收到消息,解码后应用到本地文档,触发 observe 回调,更新 React 状态,ReactFlow 重新渲染,用户 B 看到节点移动了。

这个过程非常高效,因为传输的是增量数据而不是完整文档,一次节点移动可能只需要传输几十个字节。Yjs 的 CRDT 算法保证了即使有并发修改,最终所有客户端的状态也会一致。

因为我们项目使用的是 Monorepo 项目,所以我们只需要在终端上启动如下命令即可:

pnpm dev

如果看到这样的输出,说明我们的前后端都启动成功了:

20260105085523

现在我们需要在不同的浏览器都打开相同的链接:

http://localhost:5173/

因为这是用 vite 创建的项目,所以默认启动的是 5173 (我要吃饭端口)

20260105090001

20260105090032

最终你能看到两端鼠标实时同步过来,并且拖动节点能同步到另外一段浏览器,这就是我们实现的最简单的协同编辑器。

基于这个简单的协同编辑器架构,我们可以进一步扩展和丰富其功能。例如,可以增加用户身份、编辑历史回溯、权限管理、节点间的关系建立等更多功能,进一步提升协作体验和工具的实用性。

Yjs 和 WebSocket 的分工

在这个架构中,Yjs 和 WebSocket 各司其职,共同完成协同编辑。

Yjs 的职责是维护文档状态和处理冲突。它使用 CRDT 算法确保多个客户端并发编辑时数据最终一致,生成增量更新而不是完整快照,大大减少了网络传输量,提供了 Y.Map 、Y.Array 等数据结构,让我们能方便地组织复杂的文档数据。Yjs 不关心数据如何传输,它只负责生成和应用更新。

WebSocket 的职责是网络通信。它建立客户端和服务器之间的双向连接,传输 Yjs 生成的二进制消息,处理连接断开、重连等网络问题,维护房间隔离,确保不同房间的消息不会混淆。WebSocket 不关心消息的内容,它只负责可靠地传输数据。

y-websocket 将两者连接起来,监听 Yjs 文档的更新,自动发送给服务器,接收服务器的消息,自动应用到 Yjs 文档,处理同步协议( State Vector 、Update )的编解码。这种职责分离的设计非常优雅,我们可以轻松替换传输层(比如用 WebRTC 代替 WebSocket )而不影响 Yjs 的使用。

使用 Hocuspocus 简化开发

虽然手动实现 WebSocket 服务器能让我们深入理解协同原理,但在实际项目中,我们通常会使用更成熟的解决方案。Hocuspocus 就是 Yjs 官方推荐的协同后端框架,它提供了开箱即用的功能。

Hocuspocus 的核心优势在于功能完整和易于扩展。它内置了持久化支持,可以将文档保存到数据库(支持 Redis 、PostgreSQL 、SQLite 等),提供了身份验证和权限控制的钩子,支持文档历史和版本管理,内置了扩展系统,可以轻松添加自定义逻辑,还提供了监控和日志功能,便于调试和运维。

使用 Hocuspocus 搭建服务器非常简单:

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  port: 1234,

  async onAuthenticate({ token, documentName }) {
    // 验证用户身份
    return { user: { id: 1, name: "John" } };
  },

  async onLoadDocument({ documentName }) {
    // 从数据库加载文档
    return Y.encodeStateAsUpdate(doc);
  },

  async onStoreDocument({ documentName, state }) {
    // 保存文档到数据库
    await db.save(documentName, state);
  },
});

server.listen();

前端使用 HocuspocusProvider 替代 WebsocketProvider:

import { HocuspocusProvider } from "@hocuspocus/provider";

const provider = new HocuspocusProvider({
  url: "ws://localhost:1234",
  name: "flow-room",
  document: doc,
  token: "user-auth-token",
});

Hocuspocus 还提供了很多实用的扩展,比如 Webhooks 扩展可以在文档变化时触发 HTTP 请求,Logger 扩展可以记录所有操作日志,Throttle 扩展可以限制更新频率防止滥用,Database 扩展提供了多种数据库的开箱即用支持。

对于生产环境的应用,建议使用 Hocuspocus 而不是自己实现 WebSocket 服务器,这样可以节省大量开发时间,获得更稳定可靠的服务。

总结

通过这个实战案例,我们完整地实现了一个协同流程图编辑器,涵盖了协同编辑的核心要素。

首先是架构设计。前端使用 React + ReactFlow 构建 UI ,后端使用 NestJS + WebSocket 处理协同,Yjs 作为协同引擎在前后端之间同步数据。这种架构清晰地分离了视图层、业务逻辑层和传输层。

其次是实现细节。后端管理文档实例和连接,处理 Sync 和 Awareness 消息,监听文档更新并广播给所有客户端。前端初始化 Yjs 文档和 WebSocket 连接,使用 Y.Map 存储节点和连接,监听用户操作并更新 Yjs 文档,渲染其他用户的协同光标。

再看协同流程。用户操作触发 Yjs 文档更新,生成增量数据,通过 WebSocket 发送给服务器,服务器广播给其他客户端,其他客户端应用更新并重新渲染。整个过程高效且可靠,得益于 Yjs 的 CRDT 算法和增量同步机制。

最后是工程化建议。在理解了底层原理后,实际项目中建议使用 Hocuspocus 简化开发,它提供了持久化、权限控制、监控等生产级功能,让我们能专注于业务逻辑而不是底层实现。

通过这个案例,我们不仅学会了如何构建协同应用,更重要的是理解了协同编辑的核心思想:通过 CRDT 算法保证最终一致性,通过增量同步减少网络传输,通过 Awareness 实现实时协作体验。这些理念可以应用到各种协同场景中,无论是文档编辑、画板协作还是数据表格。

参考资料