如何在服务端初始化 Lexical 协作文档的初始内容以避免并发创建导致损坏?
2026/9/13 11:41:05 网站建设 项目流程

如何在服务端初始化 Lexical 协作文档的初始内容以避免并发创建导致损坏?

【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical

基于@lexical/reactCollaborationPlugin@lexical/yjs(配合y-websocket)构建协作编辑器时,必须决定由谁来创建文档的初始内容。官方协作文档(collaboration/react.md)给出的结论是:生产环境中应在服务端引导(bootstrap)编辑器的初始内容——如果把初始化留给客户端,两个客户端同时连接时可能各自尝试初始化内容,最终导致文档损坏(document corruption)。本文按官方文档走通这条路径:服务端用@lexical/headless生成带初始内容的Y.Doc,客户端编辑器连接后直接同步该文档。

为什么初始内容不能留给客户端

react.md 在 "Initial editor content" 一节明确说明了风险与对策:

In a production environment, you should bootstrap the editor's initial content on the server. If bootstrapping was left to the client and two clients connected at the same time, they could both try to initialize the content resulting in document corruption.

客户端侧其实存在一条"便捷"路径:给CollaborationPlugininitialEditorStateshouldBootstrap两个 props 让编辑器在本地初始化状态。但文档对这两个 props 的注释是// Dev-testing only, do not use in real-world cases.,即只用于本地调试,不能作为生产方案。

与之配合的一个关键配置:LexicalComposerinitialConfig中必须把editorState设为null。文档注释解释了原因——这表示编辑器不应尝试设置任何默认状态(哪怕空状态),把状态交给协作插件设置:

const initialConfig = { // NOTE: This is critical for collaboration plugin to set editor state to null. It // would indicate that the editor should not try to set any default state // (not even empty one), and let collaboration plugin do it instead editorState: null, namespace: 'Demo', nodes: [], onError: (error: Error) => { throw error; }, theme: {}, };

准备依赖与 Yjs WebSocket 服务

按 react.md 的 "Getting started",安装客户端最小依赖集:

npm i -S @lexical/react @lexical/yjs lexical react react-dom y-websocket yjs

其中y-websocket是目前唯一官方支持的 Yjs 连接 provider(其他 provider 可能也能用,但文档只背书这一个)。

服务端引导初始内容需要无 DOM 环境运行的 Lexical。安装@lexical/headless(lexical-headless/README.md):

npm install --save @lexical/headless

该包允许在 Node.js 等不依赖 DOM 的环境中使用editor.update()editor.registerNodeTransform()editor.registerUpdateListener()来创建、更新和遍历编辑器状态——这正是服务端生成初始Y.Doc需要的能力。

启动 Yjs WebSocket 服务,让不同浏览器窗口互相发现并同步 Lexical 状态。YPERSISTENCE参数把 Yjs 文档落盘保存,服务重启后客户端可以重新连接并继续编辑:

HOST=localhost PORT=1234 YPERSISTENCE=./yjs-wss-db npx y-websocket

在服务端创建带初始内容的 Y.Doc

服务端引导使用的核心工具是 faq.md "InitializingEditorStatefrom Yjs Document" 一节给出的withHeadlessCollaborationEditor。它创建一个 headless 编辑器、本地Y.Doc和一个 no-op provider(不连接任何消息分发基础设施),并建立编辑器与 Y.Doc 之间的双向同步——文档更新会写入 Y.Doc,Y.Doc 的变更会同步回编辑器。完整实现(可直接保存为createHeadlessCollaborativeEditor.ts):

import type {Binding, Provider} from '@lexical/yjs'; import type { Klass, LexicalEditor, LexicalNode, LexicalNodeReplacement, SerializedEditorState, SerializedLexicalNode, } from 'lexical'; import {createHeadlessEditor} from '@lexical/headless'; import { createBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical, } from '@lexical/yjs'; import {type YEvent, applyUpdate, Doc, Transaction} from 'yjs'; export default function headlessConvertYDocStateToLexicalJSON( nodes: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>, yDocState: Uint8Array, ): SerializedEditorState<SerializedLexicalNode> { return withHeadlessCollaborationEditor(nodes, (editor, binding) => { applyUpdate(binding.doc, yDocState, {isUpdateRemote: true}); editor.update(() => {}, {discrete: true}); return editor.getEditorState().toJSON(); }); } /** * Creates headless collaboration editor with no-op provider (since it won't * connect to message distribution infra) and binding. It also sets up * bi-directional synchronization between yDoc and editor */ function withHeadlessCollaborationEditor<T>( nodes: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>, callback: (editor: LexicalEditor, binding: Binding, provider: Provider) => T, ): T { const editor = createHeadlessEditor({ nodes, }); const id = 'main'; const doc = new Doc(); const docMap = new Map([[id, doc]]); const provider = createNoOpProvider(); const binding = createBinding(editor, provider, id, doc, docMap); const unsubscribe = registerCollaborationListeners(editor, provider, binding); const res = callback(editor, binding, provider); unsubscribe(); return res; } function registerCollaborationListeners( editor: LexicalEditor, provider: Provider, binding: Binding, ): () => void { const unsubscribeUpdateListener = editor.registerUpdateListener( ({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags, }) => { if (tags.has('skip-collab') === false) { syncLexicalUpdateToYjs( binding, provider, prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags, ); } }, ); const observer = (events: Array<YEvent<any>>, transaction: Transaction) => { if (transaction.origin !== binding) { syncYjsChangesToLexical(binding, provider, events, false); } }; binding.root.getSharedType().observeDeep(observer); return () => { unsubscribeUpdateListener(); binding.root.getSharedType().unobserveDeep(observer); }; } function createNoOpProvider(): Provider { const emptyFunction = () => {}; return { awareness: { getLocalState: () => null, getStates: () => new Map(), off: emptyFunction, on: emptyFunction, setLocalState: emptyFunction, }, connect: emptyFunction, disconnect: emptyFunction, off: emptyFunction, on: emptyFunction, }; }

有了这个工具,按 react.md 的示例,在服务端创建已引导(bootstrapped)的Y.Doc

import type {CreateEditorArgs} from 'lexical'; import {$getRoot, $createParagraphNode} from 'lexical'; import {Doc} from 'yjs'; import {withHeadlessCollaborationEditor} from './withHeadlessCollaborationEditor'; function createBootstrappedYDoc(nodes: CreateEditorArgs['nodes']): Doc { return withHeadlessCollaborationEditor(nodes, (editor) => { const yDoc = new Doc(); editor.update(() => { $getRoot().append($createParagraphNode()); }, {discrete: true}); return yDoc; }); }

$getRoot().append($createParagraphNode())是文档给出的最小初始内容示例;实际引导时,把文档需要的初始节点写入editor.update的回调即可。由于 no-op provider 不对外发送消息,这段引导过程完全发生在服务端进程内,不存在两个客户端并发写入初始内容的竞争。

客户端接入已引导的文档

客户端编辑器按 react.md 的 "Getting started" 配置,核心是让CollaborationPlugin通过 provider 连接到与文档id对应的房间:

function Editor() { const initialConfig = { editorState: null, namespace: 'Demo', nodes: [], onError: (error: Error) => { throw error; }, theme: {}, }; const getDocFromMap = (id: string, yjsDocMap: Map<string, Y.Doc>): Y.Doc => { let doc = yjsDocMap.get(id); if (doc === undefined) { doc = new Y.Doc(); yjsDocMap.set(id, doc); } else { doc.load(); } return doc; } const providerFactory = useCallback( (id: string, yjsDocMap: Map<string, Y.Doc>) => { const doc = getDocFromMap(id, yjsDocMap); return new WebsocketProvider('ws://localhost:1234', id, doc, { connect: false, }); }, [], ); return ( <LexicalCollaboration> <LexicalComposer initialConfig={initialConfig}> <RichTextPlugin contentEditable={<ContentEditable className="editor-input" />} placeholder={<div className="editor-placeholder">Enter some rich text...</div>} ErrorBoundary={LexicalErrorBoundary} /> <CollaborationPlugin id="lexical/react-rich-collab" providerFactory={providerFactory} /> </LexicalComposer> </LexicalCollaboration> ); }

providerFactory中的ws://localhost:1234与前面启动y-websocket时使用的PORT=1234对应,id参数决定连接哪个 Yjs 房间,需要与服务端引导文档所用的房间标识一致。文档默认使用 YjsDoc上名为rootXmlText共享类型作为根,因此每个编辑器需要自己的文档。

仅本地调试的替代路径(可选):如果只是跟着示例在本地玩,文档允许给CollaborationPlugin添加下面两个 props 在客户端初始化状态:

// Dev-testing only, do not use in real-world cases. initialEditorState={$initialEditorState} shouldBootstrap={true}

客户端的 bootstrap 逻辑只在根节点为空时写入初始状态(root.isEmpty()判定),并且文档源码(useYjsCollaboration.tsx)把 bootstrap 写入标记为非用户编辑、不进入 Yjs UndoManager 的栈。这条路径正是文档警告不要在生产使用的原因:多个客户端各自判定"为空"并写入时,就可能发生标题所述的并发创建冲突。

验证结果

服务端验证:faq.md 同一段代码里提供了headlessConvertYDocStateToLexicalJSON,它把一个 Y.Doc 状态(Uint8Array)通过applyUpdate应用到绑定上,再把编辑器状态序列化为 Lexical JSON 返回。生成 bootstrappedY.Doc之后,可以用这条链路确认文档中确实包含预期内容——返回的 JSON 里应能看到你写入的初始节点(如示例中的初始段落),而不是空文档。

客户端验证:provider 同步完成后,editorState: null的编辑器由协作插件接管状态设置,编辑器内容来自 Y.Doc 而非任何本地默认值。如果此时文档为空,说明房间里的Y.Doc没有被正确引导,需要回到服务端检查createBootstrappedYDoc的产出。

边界与替代方案

  • 数据源选择:faq.md "Source of truth" 一节建议把 Yjs 模型当作 source of truth(数据库只做索引);也可以让数据库当 source of truth,其中文档给出一个更简单的变体:客户端连接时若房间内容为空,由服务端填充房间内容,全部客户端断开超时后服务端忘掉房间内容。如果你的架构里已有服务端填充逻辑,它可以与本文的服务端引导二选一,但不要把"客户端 bootstrap"和"服务端填充"混用。
  • provider 范围y-websocket是唯一官方支持的 Yjs 连接 provider;更换 provider 时文档不做保证。
  • 完整参考实现:文档指出的协作示例在仓库 examples/react-rich-collab,可对照本文的依赖安装、y-websocket启动方式与编辑器配置。
  • 限制:服务端如何把 bootstrappedY.Doc写入y-websocket的房间(具体传输与服务框架)由应用自身实现,Lexical 文档只给出createBootstrappedYDoc这一产出物;shouldBootstrap+initialEditorState的客户端初始化明确仅限开发测试。

完成后的状态是:房间中的Y.Doc在服务端就带着初始内容,任意数量的客户端并发接入都只是同步同一份文档,不再有客户端争抢初始化的路径;服务端验证 JSON 与客户端同步后的编辑器内容应一致。

【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询