Chat
Ask me anything
Ithy Logo
Sign in to save

Разблокировка совместной работы в реальном времени: ваш путь к созданию редактора кода с NestJS, React, Monaco и Yjs

Пошаговое руководство по интеграции WebSockets и Yjs для бесшовного многопользовательского редактирования.

nestjs-yjs-monaco-collab-editor-rjg2dzjv

Создание среды для совместного редактирования кода в реальном времени может показаться сложной задачей, но с правильными инструментами и подходом это становится вполне достижимым. Это руководство проведет вас через процесс организации WebSocket-сервера на NestJS для поддержки совместного редактирования в редакторе Monaco, интегрированном в React-приложение, с использованием Yjs для синхронизации данных. Мы рассмотрим как серверную, так и клиентскую части, предоставив вам прочную основу для создания мощного инструмента для совместной работы.

Иллюстрация совместного редактирования в Monaco Editor

Визуализация совместного редактирования в Monaco Editor с использованием Yjs.


Ключевые моменты

  • Интеграция Yjs: Yjs является основой для синхронизации состояния документа без конфликтов между несколькими пользователями в реальном времени.
  • NestJS WebSocket Gateway: Серверная часть на NestJS использует WebSocket Gateway для эффективной передачи обновлений Yjs между клиентами.
  • React и Monaco Editor: Клиентская часть на React с Monaco Editor использует привязки Yjs (y-monaco) для отражения изменений непосредственно в редакторе.

Архитектура решения для совместного редактирования

Для успешной реализации совместного редактирования кода нам потребуется четко определенная архитектура. В основе лежит взаимодействие между клиентским приложением, где пользователи работают с кодом, и серверной частью, которая координирует все изменения.

Компоненты системы

Наша система будет состоять из следующих ключевых компонентов:

  • Клиентское приложение: Созданное на React, оно будет использовать Monaco Editor для отображения и редактирования кода. Библиотека yjs будет управлять локальным состоянием документа, а y-monaco обеспечит привязку этого состояния к редактору. Для связи с сервером будет использоваться y-websocket или кастомная реализация на базе socket.io-client.
  • Серверная часть (NestJS): Сервер на NestJS будет оснащен WebSocket Gateway. Этот шлюз будет действовать как ретранслятор, принимая обновления Yjs от одного клиента и рассылая их всем остальным клиентам, подключенным к тому же документу. Сервер также будет управлять экземплярами Yjs-документов.
  • Yjs (Conflict-free Replicated Data Types - CRDT): Эта библиотека обеспечивает структуру данных, которая позволяет нескольким пользователям одновременно вносить изменения в документ, автоматически разрешая конфликты и гарантируя согласованность данных у всех участников.
  • WebSockets: Протокол WebSocket обеспечит двустороннюю связь в реальном времени между клиентом и сервером, необходимую для мгновенной доставки обновлений Yjs.

Ниже представлена диаграмма, иллюстрирующая взаимодействие этих компонентов:

mindmap root["Система совместного редактирования"] id1["Клиент (React + Monaco)"] id1_1["Monaco Editor"] id1_2["Yjs (локальный Y.Doc)"] id1_3["y-monaco (привязка)"] id1_4["WebSocket-клиент
(y-websocket / socket.io-client)"] id2["Сервер (NestJS)"] id2_1["WebSocket Gateway
(@nestjs/websockets)"] id2_1_1["Обработка подключений"] id2_1_2["Управление комнатами (документами)"] id2_1_3["Ретрансляция Yjs-обновлений"] id2_2["Yjs (серверные Y.Doc экземпляры)"] id2_3["(Опционально) База данных
для персистентности документов"] id3["Протокол Yjs"] id3_1["Обновления (updates)"] id3_2["Состояние (state vectors)"] id3_3["Awareness (курсоры, выделения)"]

Эта архитектура позволяет создать гибкую и масштабируемую систему для совместной работы над кодом.


Настройка серверной части на NestJS

Серверная часть на NestJS будет отвечать за управление WebSocket-соединениями и синхронизацию Yjs-документов между клиентами. Мы создадим WebSocket Gateway для обработки этих задач.

1. Установка зависимостей

Сначала установим необходимые пакеты для нашего NestJS-проекта:

npm install @nestjs/websockets @nestjs/platform-socket.io socket.io yjs ws

Здесь @nestjs/websockets и @nestjs/platform-socket.io предоставляют инструменты для работы с WebSockets в NestJS, socket.io является популярной библиотекой для WebSocket, yjs — основная библиотека для CRDT, а ws — это быстрая реализация WebSocket, которую можно использовать с y-websocket, если вы выберете этот путь для серверной части.

2. Создание WebSocket Gateway

Создадим файл collaboration.gateway.ts. Этот шлюз будет управлять подключениями и пересылать обновления Yjs.


import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  OnGatewayConnection,
  OnGatewayDisconnect,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import * as Y from 'yjs';

// Для хранения Y.Doc по идентификатору документа (комнаты)
const documentRooms = new Map<string, Y.Doc>();
// Для хранения информации о том, какие клиенты в какой комнате
const roomConnections = new Map<string, Set<Socket>>();

@WebSocketGateway({
  cors: {
    origin: '*', // В продакшене укажите конкретный домен клиента
  },
})
export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  handleConnection(client: Socket, ...args: any[]) {
    console.log(<code>Client connected: ${client.id});
  }

  handleDisconnect(client: Socket) {
    console.log(Client disconnected: ${client.id});
    // Удаляем клиента из всех комнат, в которых он состоял
    roomConnections.forEach((sockets, docId) => {
      if (sockets.has(client)) {
        sockets.delete(client);
        // Если в комнате не осталось клиентов, можно удалить Y.Doc
        // или предусмотреть механизм сохранения, если это последний клиент
        if (sockets.size === 0) {
          // documentRooms.delete(docId); // Раскомментируйте, если нужно удалять документ
          console.log(Document room ${docId} is now empty.);
        }
      }
    });
  }

  @SubscribeMessage('join-document')
  handleJoinDocument(
    @MessageBody() data: { docId: string },
    @ConnectedSocket() client: Socket,
  ): void {
    const { docId } = data;
    client.join(docId); // Присоединяем клиента к комнате Socket.IO

    // Добавляем клиента в наш трекер подключений к комнате
    if (!roomConnections.has(docId)) {
      roomConnections.set(docId, new Set());
    }
    roomConnections.get(docId)?.add(client);

    let yDoc = documentRooms.get(docId);
    if (!yDoc) {
      yDoc = new Y.Doc();
      documentRooms.set(docId, yDoc);
      // Слушаем обновления на этом Y.Doc, чтобы рассылать их клиентам
      // Это нужно делать один раз при создании документа
      yDoc.on('update', (update: Uint8Array, origin: any) => {
        // Рассылаем обновление всем клиентам в комнате, кроме origin (если это клиент)
        const socketsInRoom = roomConnections.get(docId);
        socketsInRoom?.forEach(socketInRoom => {
          if (socketInRoom !== origin) { // 'origin' может быть клиентом, отправившим обновление
            socketInRoom.emit('document-update', update);
          }
        });
      });
    }

    // Отправляем текущее состояние документа новому клиенту
    const fullStateUpdate = Y.encodeStateAsUpdate(yDoc);
    client.emit('document-update', fullStateUpdate); // Отправляем состояние этому клиенту

    console.log(Client ${client.id} joined document room: ${docId});
  }

  @SubscribeMessage('document-change')
  handleDocumentChange(
    @MessageBody() data: { docId: string; update: Uint8Array },
    @ConnectedSocket() client: Socket,
  ): void {
    const { docId, update } = data;
    const yDoc = documentRooms.get(docId);

    if (yDoc) {
      // Применяем обновление к серверному Y.Doc, указывая клиента как origin,
      // чтобы избежать отправки обновления обратно ему через 'update' слушатель Y.Doc
      Y.applyUpdate(yDoc, new Uint8Array(update), client);
    }
  }

  // Обработка Awareness (информация о курсорах, выделениях и т.д.)
  @SubscribeMessage('awareness-update')
  handleAwarenessUpdate(
    @MessageBody() data: { docId: string; awarenessUpdate: Uint8Array },
    @ConnectedSocket() client: Socket,
  ): void {
    const { docId, awarenessUpdate } = data;
    // Просто ретранслируем awareness update всем остальным в комнате
    const socketsInRoom = roomConnections.get(docId);
    socketsInRoom?.forEach(socketInRoom => {
      if (socketInRoom !== client) {
        socketInRoom.emit('awareness-update', awarenessUpdate);
      }
    });
  }
}
  

В этом примере:

  • documentRooms хранит экземпляры Y.Doc для каждого активного документа.
  • roomConnections отслеживает, какие клиенты подключены к каким комнатам документов.
  • При подключении нового клиента к документу (join-document), ему отправляется текущее состояние документа.
  • Когда клиент отправляет изменения (document-change), эти изменения применяются к серверному Y.Doc, и благодаря слушателю yDoc.on('update', ...), они автоматически рассылаются всем остальным клиентам в той же комнате. Клиент, инициировавший изменение, передается как origin в Y.applyUpdate и в коллбэк yDoc.on('update'), что позволяет избежать отправки обновления обратно ему же.
  • Сообщения awareness-update просто ретранслируются остальным участникам комнаты.

3. Интеграция шлюза в модуль NestJS

Добавьте созданный шлюз в основной модуль вашего приложения (например, app.module.ts):


import { Module } from '@nestjs/common';
import { CollaborationGateway } from './collaboration.gateway';

@Module({
  providers: [CollaborationGateway],
})
export class AppModule {}
  

Теперь ваш NestJS-сервер готов принимать WebSocket-соединения и обрабатывать синхронизацию Yjs.


Настройка клиентской части на React с Monaco Editor

На клиенте мы будем использовать React, Monaco Editor, Yjs, y-monaco для связывания Yjs с редактором и socket.io-client для WebSocket-соединения.

1. Установка зависимостей

npm install react @monaco-editor/react yjs y-monaco socket.io-client

2. Создание React-компонента для редактора

Создадим компонент CollaborativeEditor.tsx:


import React, { useRef, useEffect, useState } from 'react';
import Editor, { Monaco } from '@monaco-editor/react';
import * as Y from 'yjs';
import { MonacoBinding } from 'y-monaco';
import { io, Socket } from 'socket.io-client';
import { Awareness } from 'y-protocols/awareness';

const SERVER_URL = 'http://localhost:3000'; // Адрес вашего NestJS сервера

interface CollaborativeEditorProps {
  docId: string;
  userColor?: string; // Цвет для курсора пользователя
  userName?: string;  // Имя пользователя
}

const CollaborativeEditor: React.FC<CollaborativeEditorProps> = ({
  docId,
  userColor = '#0088 discurso', // Default color
  userName = <code>User ${Math.floor(Math.random() * 100)},
}) => {
  const editorRef = useRef<any>(null); // monaco.editor.IStandaloneCodeEditor
  const monacoRef = useRef<Monaco | null>(null);
  const [isEditorReady, setIsEditorReady] = useState(false);

  // Yjs и WebSocket сущности
  const yDocRef = useRef<Y.Doc | null>(null);
  const monacoBindingRef = useRef<MonacoBinding | null>(null);
  const socketRef = useRef<Socket | null>(null);
  const awarenessRef = useRef<Awareness | null>(null);

  useEffect(() => {
    if (!isEditorReady || !editorRef.current || !monacoRef.current) {
      return;
    }

    // 1. Инициализация Y.Doc
    yDocRef.current = new Y.Doc();
    const yText = yDocRef.current.getText('monaco'); // Yjs тип для текста

    // 2. Подключение к WebSocket серверу
    socketRef.current = io(SERVER_URL);
    const socket = socketRef.current;

    // 3. Настройка Awareness (для курсоров и т.д.)
    awarenessRef.current = new Awareness(yDocRef.current);
    awarenessRef.current.setLocalStateField('user', {
      name: userName,
      color: userColor,
      // Дополнительная информация о курсоре
      cursor: null, // MonacoBinding будет это обновлять
    });

    // 4. Привязка Monaco Editor к Yjs тексту с Awareness
    monacoBindingRef.current = new MonacoBinding(
      yText,
      editorRef.current.getModel(),
      new Set([editorRef.current]),
      awarenessRef.current,
    );

    // 5. Логика обмена данными с сервером
    socket.on('connect', () => {
      console.log('Connected to WebSocket server');
      socket.emit('join-document', { docId });
    });

    socket.on('document-update', (update: Uint8Array) => {
      console.log('Received document update from server');
      if (yDocRef.current) {
        Y.applyUpdate(yDocRef.current, new Uint8Array(update), 'server'); // 'server' - это origin
      }
    });

    // Слушаем локальные изменения Y.Doc и отправляем их на сервер
    yDocRef.current.on('update', (update: Uint8Array, origin: any) => {
      if (origin !== 'server' && origin !== socket ) { // Не отправляем, если обновление пришло с сервера или от этого же сокета
        console.log('Sending document change to server');
        socket.emit('document-change', { docId, update });
      }
    });
    
    // Обмен Awareness данными
    awarenessRef.current.on('update', (changes: any) => {
      const awarenessUpdate = Y.encodeAwarenessUpdate(awarenessRef.current!, Array.from(changes.added).concat(Array.from(changes.updated)).concat(Array.from(changes.removed)));
      socket.emit('awareness-update', { docId, awarenessUpdate });
    });

    socket.on('awareness-update', (awarenessUpdate: Uint8Array) => {
      if (awarenessRef.current) {
        Y.applyAwarenessUpdate(awarenessRef.current, new Uint8Array(awarenessUpdate), 'server');
      }
    });


    return () => {
      // Очистка при размонтировании компонента
      console.log('Cleaning up collaborative editor resources...');
      socket.emit('leave-document', { docId }); // Сообщаем серверу об уходе
      socket.disconnect();
      monacoBindingRef.current?.destroy();
      yDocRef.current?.destroy();
      awarenessRef.current?.destroy();
      editorRef.current?.dispose();
    };
  }, [docId, isEditorReady, userName, userColor]);

  function handleEditorDidMount(editor: any, monacoInstance: Monaco) {
    editorRef.current = editor;
    monacoRef.current = monacoInstance;
    setIsEditorReady(true);
    // Пример установки начального значения, если требуется (Yjs обычно сам синхронизирует)
    // editor.setValue("// Добро пожаловать в совместный редактор!");
  }

  return (
    <div style={{ border: '1px solid #ccc', borderRadius: '4px', overflow: 'hidden' }}>
      <Editor
        height="80vh"
        defaultLanguage="javascript"
        theme="vs-dark"
        onMount={handleEditorDidMount}
      />
    </div>
  );
};

export default CollaborativeEditor;
  

В этом React-компоненте:

  • useEffect используется для инициализации Yjs, WebSocket-соединения и привязки к Monaco Editor, как только редактор будет готов.
  • yDocRef.current.on('update', ...): Локальные изменения в Yjs-документе (вызванные действиями пользователя в Monaco) отправляются на сервер. Проверяется origin, чтобы не отправлять обновления, которые только что пришли с сервера.
  • socket.on('document-update', ...): Обновления, полученные от сервера, применяются к локальному Yjs-документу.
  • MonacoBinding автоматически синхронизирует содержимое Yjs-текста с моделью Monaco Editor и обрабатывает отображение курсоров других пользователей через Awareness.
  • При размонтировании компонента все ресурсы (соединение, Yjs-документ, привязка) корректно освобождаются.
Иллюстрация концепции совместного кодирования

Концепция совместного редактирования, где несколько пользователей работают над одним кодом.


Сравнение аспектов реализации

Данный подход с использованием Yjs, NestJS и Monaco Editor предлагает сбалансированное решение для совместного редактирования. График ниже показывает оценку различных аспектов этой технологии:

Как видно из графика, Yjs обеспечивает отличное разрешение конфликтов и производительность в реальном времени, а также хорошую интеграцию с Monaco. Масштабируемость сервера NestJS зависит от реализации, но платформа предоставляет хорошие возможности для этого. Простота начальной настройки может потребовать некоторого времени на изучение Yjs и его экосистемы.


Таблица ключевых пакетов

Ниже приведена таблица с основными пакетами, используемыми в этом решении, и их ролями:

Пакет Сторона Описание
@nestjs/websockets Сервер Модуль NestJS для создания WebSocket-серверов.
@nestjs/platform-socket.io Сервер Платформенный адаптер для использования Socket.IO с NestJS.
socket.io Сервер Библиотека для реализации WebSocket-сервера.
yjs Сервер / Клиент Основная библиотека CRDT для синхронизации данных.
ws Сервер Высокопроизводительная реализация WebSocket-сервера (альтернатива/дополнение к Socket.IO, часто используется с y-websocket).
@monaco-editor/react Клиент React-компонент для легкой интеграции Monaco Editor.
monaco-editor Клиент Сам редактор кода Monaco.
y-monaco Клиент Привязка Yjs к Monaco Editor для синхронизации контента и отображения курсоров.
socket.io-client Клиент Клиентская библиотека Socket.IO для WebSocket-соединения с сервером.
y-protocols Клиент Содержит протокол Awareness для Yjs.

Видео: Создание совместного редактора кода

Для более глубокого понимания принципов создания совместных редакторов с использованием Yjs, рекомендую посмотреть следующее видео. Оно демонстрирует создание совместного текстового редактора с использованием Monaco, WebRTC (как альтернативы WebSockets для peer-to-peer) и Yjs, что может дать дополнительные инсайты:

В этом видео вы увидите, как можно использовать Yjs для синхронизации состояния, что очень похоже на наш подход, даже если транспортный механизм (WebRTC вместо WebSockets) отличается.


Часто задаваемые вопросы (FAQ)

Как Yjs обрабатывает конфликты при одновременном редактировании?

Yjs использует CRDT (Conflict-free Replicated Data Types). Это специальные структуры данных, которые по своей природе разработаны так, чтобы изменения, сделанные параллельно разными пользователями, могли быть объединены без конфликтов и без необходимости централизованного арбитра. Каждая операция имеет уникальный идентификатор и свойства, позволяющие Yjs детерминированно сливать изменения, гарантируя, что все копии документа в конечном итоге станут идентичными.

Можно ли использовать другую WebSocket-библиотеку с NestJS вместо Socket.IO?

Да, NestJS гибок в этом плане. Хотя @nestjs/platform-socket.io является популярным выбором, вы можете использовать и другие адаптеры, например, @nestjs/platform-ws для работы с библиотекой ws. Это может быть полезно, если вы хотите использовать готовый серверный компонент y-websocket, который часто работает поверх ws. Однако интеграция y-websocket напрямую в NestJS Gateway потребует более глубокой проработки взаимодействия, чем использование Socket.IO, для которого NestJS предлагает более высокоуровневые абстракции.

Как реализовать персистентность (сохранение) содержимого документа?

Yjs предоставляет функции для сериализации и десериализации состояния документа. На сервере вы можете использовать Y.encodeStateAsUpdate(yDoc) для получения бинарного представления всего документа или Y.encodeStateVector(yDoc) для получения вектора состояния. Эти данные можно сохранять в базе данных (например, PostgreSQL, MongoDB, Redis) или файловой системе. При инициализации документа на сервере (например, когда первый пользователь подключается к ранее сохраненному документу), вы загружаете эти данные и применяете их к новому экземпляру Y.Doc с помощью Y.applyUpdate(yDoc, savedUpdate). Рекомендуется периодически сохранять документ или при определенных событиях (например, когда последний пользователь покидает комнату).

Что такое "Awareness" в Yjs и как его использовать?

Awareness (осведомленность) в Yjs — это протокол и API для обмена эфемерной информацией о состоянии пользователей, такой как позиция курсора, текущее выделение текста, имя пользователя, цвет курсора и т.д. Эти данные не являются частью самого документа и не требуют такой же строгой гарантии доставки, как основные изменения контента. y-monaco использует Awareness для отображения курсоров других пользователей в редакторе. Клиент устанавливает свое локальное состояние (например, имя и цвет), а MonacoBinding автоматически обновляет информацию о позиции курсора. Эти обновления Awareness рассылаются другим клиентам через WebSocket-сервер, который просто ретранслирует их.


Рекомендуемые запросы


Источники

docs.yjs.dev
Monaco - Yjs Docs
codesandbox.io
Codesandbox

Last updated May 8, 2025

AI-assisted research. Sources, methodology and corrections · Source-checked guides and tools · Explore published answers

Ask Ithy AI
Download Article
Delete Article