You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于Yjs+CodeMirror+ActionCable的协同编辑器无报错但无法跨端同步

Rails + y-rb-actioncable + Angular Yjs 协同编辑器跨端同步问题排查

我用Rails后端的yrb-actioncable搭配Angular前端的Yjs构建协同文本编辑器,刚接触这些技术,解决完报错后无错误提示,但仅同一浏览器标签页能协同编辑,ActionCable能看到请求交互,但跨端(不同浏览器/设备)无法同步内容。

后端DocumentChannel代码

require 'y-rb'

module ApplicationCable
  class DocumentChannel < ApplicationCable::Channel
    include Y::Actioncable::Sync

    def initialize(connection, identifier, params = nil)
      super
      load { |id| load_doc 1 }
    end

    def subscribed
      sync_from("document-1")
    end

    def receive(data)
      sync_to("document-1", data)
    end

    def unsubscribed
    end

    private

    def load_doc(id)
      doc_content = Document.first.content
      ydoc = Y::Doc.new
      ytext = ydoc.get_text('mine')
      ytext << doc_content
      data = []
      data = ydoc.diff unless doc_content.nil?
      data
    end
  end
end

前端CodeEditorComponent代码

import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'
import { OnInit } from '@angular/core';
import * as Y from "yjs";
import { WebsocketProvider } from "@y-rb/actioncable";
import CodeMirror from "codemirror";
import { CodeMirrorBinding } from 'y-codemirror'
import ActionCable from 'actioncable'

@Component({
  selector: 'code-editor',
  templateUrl: './code-editor.component.html',
  styleUrls: ['./code-editor.component.scss']
})
export class CodeEditorComponent implements OnInit {
 

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
    const accessToken = localStorage.getItem('accessToken')
    const uid = localStorage.getItem('uid')
    const client = localStorage.getItem('client')

    const yDocument = new Y.Doc();
    const consumer = ActionCable.createConsumer(`ws://localhost:3000/cable?uid=${uid}&access-token=${accessToken}&client=${client}`);
    const provider = new WebsocketProvider(
      yDocument,
      consumer,
      "ApplicationCable::DocumentChannel",
      {}
    );
    const yText = yDocument.getText('codemirror')

    const yUndoManager = new Y.UndoManager(yText)

    const editorContainer = document.createElement('div')
    editorContainer.setAttribute('id', 'editor')
    document.body.insertBefore(editorContainer, null)

    const editor = CodeMirror(editorContainer, {
      mode: 'javascript',
      lineNumbers: true,
    })

    const binding = new CodeMirrorBinding(yText, editor, provider.awareness, { yUndoManager })

    // @ts-ignore
    //window.example = { provider, ydoc, yText, binding, Y }
  }
}

核心排查与修复方案

1. Yjs文本节点名称不匹配

后端load_doc中使用ydoc.get_text('mine'),但前端用的是yDocument.getText('codemirror')——两端操作的是不同的Yjs文本对象,自然无法同步。
修改后端load_doc方法:

def load_doc(id)
  doc_content = Document.first.content
  ydoc = Y::Doc.new
  # 改为和前端一致的节点名称
  ytext = ydoc.get_text('codemirror')
  ytext << doc_content
  data = []
  data = ydoc.diff unless doc_content.nil?
  data
end

2. 文档标识与频道订阅参数不匹配

后端sync_from("document-1")指定了文档标识,但前端WebsocketProvider未传递对应参数,导致多客户端可能未订阅到同一文档频道。
修改前端WebsocketProvider初始化:

const provider = new WebsocketProvider(
  yDocument,
  consumer,
  "ApplicationCable::DocumentChannel",
  { documentId: "document-1" } // 明确传递文档标识,与后端对应
);

同时后端初始化时避免硬编码文档ID:

def initialize(connection, identifier, params = nil)
  super
  # 从回调参数接收文档ID,而非固定传1
  load { |id| load_doc(id) }
end

3. Yjs状态持久化逻辑缺失

后端每次创建新的Y::Doc,未从持久化状态加载,新客户端无法获取历史同步数据。建议在Document模型新增yjs_state二进制字段保存完整Yjs状态:

def load_doc(id)
  document = Document.find(id)
  if document.yjs_state.present?
    # 从数据库加载已保存的Yjs状态
    ydoc = Y::Doc.new
    ydoc.sync(document.yjs_state)
    ydoc.diff
  else
    ydoc = Y::Doc.new
    ytext = ydoc.get_text('codemirror')
    ytext << document.content || ""
    # 保存初始状态到数据库
    document.update(yjs_state: ydoc.encode)
    ydoc.diff
  end
end

同步后更新数据库状态:

def subscribed
  sync_from("document-1") do |ydoc|
    # 每次同步完成后保存最新状态
    document = Document.find(1)
    document.update(yjs_state: ydoc.encode)
  end
end

4. 确认ActionCable连接独立性

检查后端connection.rb的用户认证逻辑,确保不同客户端生成独立连接实例:

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
      logger.info "User connected: #{current_user.id}"
    end

    private
    def find_verified_user
      User.find_by(uid: request.params[:uid], access_token: request.params['access-token'], client: request.params[:client]) || reject_unauthorized_connection
    end
  end
end

5. 前端编辑器DOM初始化优化

避免动态创建编辑器容器,直接在组件模板中定义:

<!-- code-editor.component.html -->
<div id="editor"></div>

前端代码中直接获取容器:

const editorContainer = document.getElementById('editor');
if (!editorContainer) return;
const editor = CodeMirror(editorContainer, {
  mode: 'javascript',
  lineNumbers: true,
})

内容的提问来源于stack exchange,提问作者DJA

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.20 17:17:23