Ask me anything
Ithy Logo
Sign in to save

Создание Совместного Редактора Кода: Ваш Путь от Monaco и React к NestJS с Yjs

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

collaborative-editor-monaco-react-nestjs-yjs-3qczliua

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

Ключевые Моменты для Успешной Реализации

  • Интеграция Yjs: Используйте Yjs для управления общим состоянием документа и разрешения конфликтов без потерь данных благодаря CRDT (Conflict-free Replicated Data Types).
  • WebSocket Коммуникация: Настройте NestJS WebSocket Gateway для эффективной двусторонней связи между клиентами и сервером, обеспечивая мгновенную доставку обновлений.
  • Frontend с Monaco и React: Используйте @monaco-editor/react для интеграции редактора Monaco, y-monaco для связи Yjs с Monaco, и y-websocket для подключения к серверу.

Основы Технологического Стека

Прежде чем погружаться в код, давайте разберемся с основными компонентами нашей системы совместного редактирования.

Что такое Yjs?

Yjs — это высокопроизводительная CRDT-библиотека, предназначенная для создания приложений с совместной работой в реальном времени. Она позволяет нескольким пользователям одновременно редактировать общие данные без необходимости централизованного сервера для разрешения конфликтов. Yjs предоставляет различные типы общих данных, включая текст (Y.Text), который идеально подходит для редакторов кода.

Ключевые концепции Yjs:

  • Y.Doc: Основной контейнер для всех общих данных. Каждый документ имеет уникальный идентификатор.
  • Y.Text: Тип данных для совместного редактирования текста, поддерживающий сложные операции, такие как вставка, удаление и форматирование.
  • Провайдеры (Providers): Модули, обеспечивающие синхронизацию Y.Doc с другими клиентами через различные протоколы (например, WebSockets, WebRTC). Мы будем использовать WebsocketProvider.
  • Осведомленность (Awareness): Протокол для обмена временными состояниями, такими как положение курсора, выделение текста или информация о пользователе.

Роль WebSockets и NestJS

WebSockets обеспечивают постоянное двустороннее соединение между клиентом и сервером, что критически важно для приложений реального времени. NestJS, прогрессивный фреймворк Node.js, предоставляет мощный модуль @nestjs/websockets для создания WebSocket-серверов (шлюзов), который упрощает обработку соединений, сообщений и комнат.

Почему NestJS для WebSockets?

  • Структурированный подход: Декораторы и модульная архитектура упрощают создание и поддержку WebSocket-шлюзов.
  • Масштабируемость: NestJS спроектирован с учетом масштабируемости.
  • Интеграция: Легко интегрируется с другими частями вашего бэкенд-приложения, такими как аутентификация и базы данных.

Monaco Editor в React

Monaco Editor — это мощный редактор кода, который лежит в основе Visual Studio Code. С помощью библиотеки @monaco-editor/react его легко интегрировать в React-приложения. Связка y-monaco позволяет Yjs отслеживать и синхронизировать изменения, вносимые в модель редактора Monaco.


Шаг 1: Настройка Frontend (React + Monaco + Yjs)

Начнем с клиентской части. Убедитесь, что у вас установлен Node.js и создан React-проект.

Установка Зависимостей


npm install yjs y-monaco @monaco-editor/react y-websocket
# или yarn add yjs y-monaco @monaco-editor/react y-websocket
  

Компонент Редактора Кода

Создадим React-компонент CollaborativeEditor, который будет инициализировать Monaco, Yjs и устанавливать WebSocket-соединение.


import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Editor } from '@monaco-editor/react';
import * as Y from 'yjs';
import { MonacoBinding } from 'y-monaco';
import { WebsocketProvider } from 'y-websocket';

const CollaborativeEditor = ({ documentId }) => {
  const editorRef = useRef(null);
  const yDocRef = useRef(null);
  const providerRef = useRef(null);
  const bindingRef = useRef(null);
  const [editorInstance, setEditorInstance] = useState(null);

  useEffect(() => {
    // 1. Инициализация Y.Doc
    yDocRef.current = new Y.Doc();

    // 2. Настройка WebsocketProvider
    // Замените 'ws://localhost:8080' на URL вашего NestJS WebSocket-сервера
    // 'ws://localhost:8080' - это стандартный URL для y-websocket сервера
    // если ваш NestJS Gateway работает на другом порту или пути, укажите его
    providerRef.current = new WebsocketProvider(
      'ws://localhost:8080', // URL вашего WebSocket сервера
      documentId,           // Уникальный идентификатор документа/комнаты
      yDocRef.current
    );

    // 3. Обработка состояния осведомленности (курсоры, выделения)
    // Пример: установка имени пользователя
    providerRef.current.awareness.setLocalStateField('user', {
      name: 'Пользователь ' + Math.floor(Math.random() * 100),
      color: '#' + Math.floor(Math.random()*0xFFFFFF).toString(16).padStart(6,'0') // Случайный цвет для курсора
    });

    providerRef.current.on('status', event => {
      console.log('WebSocket Status:', event.status); // 'connecting', 'connected', 'disconnected'
    });

    // Очистка при размонтировании компонента
    return () => {
      bindingRef.current?.destroy();
      providerRef.current?.disconnect(); // Используйте disconnect для y-websocket
      providerRef.current?.destroy();
      yDocRef.current?.destroy();
    };
  }, [documentId]);

  // Эта функция будет вызвана, когда редактор Monaco будет смонтирован
  const handleEditorDidMount = useCallback((editor, monaco) => {
    editorRef.current = editor;
    setEditorInstance(editor); // Сохраняем экземпляр редактора

    if (yDocRef.current && providerRef.current && editor) {
      // 4. Получение Y.Text для Monaco
      const yText = yDocRef.current.getText('monaco');

      // 5. Создание MonacoBinding
      // Связывает Y.Text с моделью редактора Monaco
      // и синхронизирует состояние осведомленности
      bindingRef.current = new MonacoBinding(
        yText,
        editor.getModel(),
        new Set([editor]),
        providerRef.current.awareness
      );
    }
  }, []);


  return (
    <div>
      <Editor
        height="80vh"
        width="100%"
        defaultLanguage="javascript"
        defaultValue="// Начните писать код здесь..."
        onMount={handleEditorDidMount}
        options={{
          // Дополнительные опции Monaco
          glyphMargin: true, // для отображения курсоров других пользователей
        }}
      />
    </div>
  );
};

export default CollaborativeEditor;
  

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

  1. Инициализируется Y.Doc для хранения общего состояния документа.
  2. WebsocketProvider подключается к WebSocket-серверу (адрес нужно будет заменить на ваш) и синхронизирует Y.Doc. documentId используется для создания или подключения к определенной "комнате" или документу.
  3. Настраивается осведомленность (awareness) для обмена метаданными, такими как информация о пользователе и положении курсора.
  4. После монтирования Monaco Editor с помощью handleEditorDidMount, создается MonacoBinding. Этот биндинг связывает текстовое содержимое модели Monaco (editor.getModel()) с Y.Text (yDocRef.current.getText('monaco')) и синхронизирует данные об осведомленности.
  5. В useEffect предусмотрена очистка ресурсов при размонтировании компонента.


Шаг 2: Настройка Backend (NestJS WebSocket Gateway)

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

Хотя y-websocket поставляется со своим стандартным сервером, для интеграции с существующей NestJS-инфраструктурой (например, для аутентификации, управления пользователями) или для более сложной логики управления комнатами, вы можете создать свой собственный шлюз или использовать NestJS для запуска экземпляра сервера y-websocket. Для простоты демонстрации протокола Yjs, NestJS-шлюз может выступать в роли ретранслятора сообщений Yjs. Однако, наиболее распространенный подход при использовании y-websocket на клиенте — это использование стандартного сервера y-websocket или совместимого с ним сервера.

В данном примере мы покажем, как создать NestJS Gateway, который будет совместим с сообщениями, ожидаемыми y-websocket. Однако, для полноценной реализации, часто используется готовый сервер y-websocket или его адаптация. Если вы хотите полностью кастомный бэкенд на NestJS для Yjs, вам придется глубже реализовать протокол синхронизации Yjs.

Для упрощения, мы будем использовать socket.io адаптер в NestJS, но y-websocket клиент по умолчанию использует ws. Если вы хотите, чтобы y-websocket клиент подключался к NestJS socket.io шлюзу, вам может потребоваться кастомный сетевой провайдер на клиенте или адаптация сервера. Для стандартного y-websocket сервера часто используется пакет ws.

Важно: Стандартный WebsocketProvider из y-websocket предназначен для работы с WebSocket-сервером, реализующим определенный протокол обмена сообщениями Yjs. Если вы создаете свой шлюз в NestJS, он должен либо реализовывать этот протокол, либо вы должны использовать на клиенте кастомный провайдер. Библиотека y-websocket включает готовый к использованию сервер. Если вы хотите использовать NestJS, вы можете либо запустить этот сервер внутри NestJS, либо реализовать совместимый шлюз. Пример ниже демонстрирует очень упрощенный шлюз для ретрансляции сообщений, но для полноценной поддержки Yjs (включая постоянное хранение, управление комнатами и т.д.) лучше использовать или адаптировать официальный y-websocket/bin/server.js.

Установка Зависимостей для NestJS


npm install @nestjs/websockets @nestjs/platform-socket.io socket.io yjs
# или yarn add @nestjs/websockets @nestjs/platform-socket.io socket.io yjs
# Для работы с 'ws' вместо 'socket.io', используйте @nestjs/platform-ws
  

Создание WebSocket Gateway

Пример ниже показывает шлюз, который использует socket.io. Клиентский WebsocketProvider из y-websocket работает с нативными WebSockets. Чтобы соединить их, можно использовать NestJS с @nestjs/platform-ws или адаптировать клиент/сервер.

Для совместимости с y-websocket клиентом, который использует нативные WebSockets, проще всего было бы запустить стандартный y-websocket сервер. Однако, если вы хотите интегрировать его в NestJS, вот пример, как мог бы выглядеть шлюз, если бы вы обрабатывали Yjs-сообщения вручную (это более сложный путь, чем использование готового сервера y-websocket). Ответ A предоставляет более подходящий пример NestJS шлюза для y-websocket.

Мы адаптируем пример из ответа A, так как он ближе к тому, что требуется для работы с y-websocket клиентом, но все еще требует внимания к деталям протокола.


import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
  WebSocketServer,
  ConnectedSocket,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; // Используем socket.io для этого примера
import * as Y from 'yjs';
import { setPersistence, setupWSConnection } from 'y-websocket/bin/utils'; // Используем утилиты y-websocket

// Эта часть кода предполагает, что вы хотите воссоздать логику сервера y-websocket в NestJS.
// Для реальных проектов часто проще запустить стандартный y-websocket сервер.

// Хранилище документов Y.Doc на сервере. В продакшене потребуется персистентное хранилище.
const docs = new Map<string, Y.Doc>();

// Функция для получения или создания Y.Doc
const getDoc = (docname: string, gc = true) => {
  if (!docs.has(docname)) {
    const doc = new Y.Doc();
    doc.gc = gc;
    // Здесь можно настроить персистентность, если нужно
    // setPersistence({bindState: ... , writeState: ... })
    docs.set(docname, doc);
  }
  return docs.get(docname);
};


@WebSocketGateway(8080, { // Порт, на котором будет слушать шлюз (например, 8080)
  cors: {
    origin: '*', // Разрешить подключения с любого источника (настройте для продакшена)
  },
  // transports: ['websocket'], // Если вы используете <code>ws адаптер
})
export class CollaborativeGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server; // Тип Server из 'socket.io'

  // Этот обработчик будет вызываться при подключении нового клиента
  // Для совместимости с y-websocket, здесь должна быть логика из y-websocket/bin/utils.js setupWSConnection
  handleConnection(client: Socket, ...args: any[]) {
    // y-websocket клиент ожидает определенный handshake и обработку комнат
    // URL подключения клиента обычно содержит имя комнаты, например: ws://localhost:8080/my-document-room
    // socket.io имеет свою систему комнат, которую нужно согласовать с y-websocket.
    
    const url = client.handshake.url; // или другой способ получить documentId/roomName
    const roomName = url.split('/').pop() || 'default-room'; // Пример извлечения имени комнаты
    
    console.log(Client ${client.id} attempting to connect to room: ${roomName});
    client.join(roomName); // Присоединяем клиента к комнате socket.io

    // setupWSConnection(client, req) - это то, что делает стандартный y-websocket сервер
    // Воспроизвести это здесь сложно без адаптации.
    // Вместо этого мы реализуем простую ретрансляцию обновлений.
    
    // Для простоты, мы будем слушать специальные события для Yjs-обновлений и осведомленности
    // Стандартный y-websocket клиент отправляет бинарные данные напрямую.
  }

  handleDisconnect(client: Socket) {
    console.log(Client disconnected: ${client.id});
    // Здесь должна быть логика очистки, если клиент покинул все Yjs комнаты
  }

  // Этот метод будет обрабатывать сообщения с темой 'sync-message'
  // Клиент y-websocket отправляет бинарные данные. Socket.IO может потребовать обертку.
  // Этот пример упрощен и может не полностью соответствовать протоколу y-websocket.
  @SubscribeMessage('yjs-update') // Используем кастомное событие
  handleYjsUpdate(
    @MessageBody() data: { documentId: string; update: number[] }, // Предполагаем, что update это Uint8Array, сериализованный в массив чисел
    @ConnectedSocket() client: Socket,
  ): void {
    const { documentId, update } = data;
    const ydoc = getDoc(documentId);
    
    if (ydoc && update) {
      try {
        Y.applyUpdate(ydoc, new Uint8Array(update));
        // Ретранслируем обновление всем клиентам в той же комнате, кроме отправителя
        client.to(documentId).emit('yjs-update', { documentId, update });
      } catch (error) {
        console.error('Failed to apply Yjs update:', error);
        // ydoc.destroy() // Возможно, нужно обработать ошибку иначе
      }
    }
  }

  @SubscribeMessage('yjs-awareness-update')
  handleYjsAwarenessUpdate(
    @MessageBody() data: { documentId: string; update: number[] },
    @ConnectedSocket() client: Socket,
  ): void {
    const { documentId, update } = data;
    // Просто ретранслируем обновления осведомленности
    client.to(documentId).emit('yjs-awareness-update', { documentId, update });
  }

  // При подключении нового клиента к документу, ему нужно отправить текущее состояние
  @SubscribeMessage('join-document')
  handleJoinDocument(
    @MessageBody() documentId: string,
    @ConnectedSocket() client: Socket,
  ): void {
    client.join(documentId); // Присоединение к комнате socket.io
    const ydoc = getDoc(documentId);
    const currentUpdate = Y.encodeStateAsUpdate(ydoc);
    client.emit('yjs-update', { documentId, update: Array.from(currentUpdate) }); // Отправляем текущее состояние
    console.log(Client ${client.id} joined document ${documentId});
  }
}
  

Важное замечание по серверу: Приведенный выше код NestJS Gateway является упрощенной демонстрацией. Для надежной работы с y-websocket клиентом рекомендуется либо использовать официальный y-websocket сервер (который можно запустить как отдельный процесс или интегрировать в NestJS), либо тщательно реализовать протокол Yjs WebSocket. Официальный сервер y-websocket уже обрабатывает управление документами, персистентность (при настройке) и синхронизацию осведомленности. Прямая реализация этого в NestJS требует глубокого понимания внутреннего устройства y-websocket.

В app.module.ts вашего NestJS-приложения не забудьте импортировать и зарегистрировать этот шлюз:


import { Module } from '@nestjs/common';
import { CollaborativeGateway } from './collaborative.gateway'; // Путь к вашему файлу шлюза

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

Визуализация Архитектуры

Для лучшего понимания взаимодействия компонентов, рассмотрим следующую диаграмму:

mindmap root["Архитектура Совместного Редактирования Кода"] id1["Клиент (React)"] id1_1["Monaco Editor"] id1_2["Yjs (Y.Doc, Y.Text)"] id1_3["MonacoBinding (y-monaco)"] id1_4["WebsocketProvider (y-websocket)"] id1_4_1["Отправка локальных изменений"] id1_4_2["Получение удаленных изменений"] id1_4_3["Синхронизация Awareness"] id2["Сервер (NestJS)"] id2_1["WebSocket Gateway (@nestjs/websockets)"] id2_1_1["Управление соединениями"] id2_1_2["Обработка комнат/документов (documentId)"] id2_1_3["Прием Yjs обновлений от клиента"] id2_1_4["Ретрансляция Yjs обновлений другим клиентам"] id2_2["Хранилище Y.Doc (в памяти/персистентное)"] id2_2_1["Применение обновлений к серверной копии Y.Doc"] id3["Канал Связи"] id3_1["WebSocket (двустороннее соединение)"]

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


Сравнение Аспектов Решения

Для оценки нашего решения, давайте рассмотрим его ключевые характеристики на радарной диаграмме. Оценки (от 1 до 5) являются субъективными и предназначены для иллюстрации сильных сторон подхода.

Эта диаграмма подчеркивает сильные стороны Yjs в разрешении конфликтов и синхронизации осведомленности, а также хорошую производительность. Гибкость настройки сервера может быть сложнее при попытке полностью воспроизвести логику y-websocket сервера.


Ключевые Компоненты и Их Роли

Давайте суммируем роли основных технологий в нашем проекте в таблице:

Компонент Технология Основная Роль
Клиентское приложение React Построение пользовательского интерфейса, управление состоянием редактора.
Редактор кода Monaco Editor Предоставление среды для ввода и редактирования кода с подсветкой синтаксиса и другими функциями.
Синхронизация данных Yjs Управление общим состоянием документа (CRDT), разрешение конфликтов, синхронизация изменений и метаданных (awareness).
Связь Yjs и Monaco y-monaco Обеспечение двусторонней привязки между моделью данных Yjs (Y.Text) и Monaco Editor.
Сетевое взаимодействие (клиент) y-websocket (WebsocketProvider) Установка WebSocket-соединения с сервером и передача Yjs-обновлений.
Серверная логика NestJS Создание WebSocket-сервера (Gateway), управление комнатами/документами, обработка подключений, ретрансляция сообщений.
Транспортный протокол WebSockets Обеспечение постоянного, двунаправленного канала связи между клиентом и сервером для обмена данными в реальном времени.
Пример совместной работы в редакторе

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


Видеоурок по NestJS WebSockets

Для более глубокого понимания создания WebSocket-шлюзов в NestJS, рекомендуем ознакомиться со следующим видеоуроком. Хотя он не охватывает Yjs напрямую, он дает хорошее представление об основах работы с WebSockets в NestJS, что является важной частью нашего решения.

Видео: NestJS Websockets Tutorial #1 - Creating a Websocket Gateway Server (Anson the Developer)

В этом видео подробно рассматривается процесс создания WebSocket Gateway в NestJS, что поможет вам понять, как настроить серверную часть для приема соединений и обмена сообщениями, что является основой для ретрансляции Yjs-обновлений.


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

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

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

Что такое "осведомленность" (awareness) в Yjs?

Осведомленность (Awareness) в Yjs — это протокол для обмена эфемерными (временными) состояниями между пользователями, такими как положение курсора, текущее выделение текста, имя пользователя или статус "печатает". Эти данные не являются частью основного контента документа и не сохраняются как его постоянная часть, но они важны для UX в реальном времени.

Можно ли сохранять состояние документа Yjs на постоянной основе?

Да, состояние Y.Doc можно сериализовать (используя Y.encodeStateAsUpdate(ydoc)) и сохранять в базе данных (например, PostgreSQL, MongoDB, Redis). При следующем подключении или открытии документа, его состояние можно восстановить из сохраненных данных (используя Y.applyUpdate(ydoc, storedUpdate)). Сервер y-websocket также имеет встроенные адаптеры для персистентности (например, LevelDB, или можно написать свой).

Как масштабировать WebSocket-сервер на NestJS?

Масштабирование WebSocket-серверов NestJS (особенно с socket.io) обычно включает использование нескольких экземпляров сервера за балансировщиком нагрузки и адаптера для синхронизации состояния между экземплярами (например, Redis adapter для socket.io). Это позволяет распределять клиентов по разным серверам, но при этом обеспечивать доставку сообщений внутри одной "комнаты" (документа Yjs) всем ее участникам, независимо от того, к какому экземпляру сервера они подключены.


Рекомендуемые Запросы для Дальнейшего Изучения


Источники и Полезные Ссылки

npmjs.com
yjs - npm

Last updated May 8, 2025

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

Ask Ithy AI