ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Lexical Editor State 深入解析:为什么状态模型是富文本编辑器的真相之源

Lexical Editor State 深入解析:为什么状态模型是富文本编辑器的真相之源 Lexical Editor State 深入解析为什么状态模型是富文本编辑器的真相之源【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical本篇技术指南围绕 Lexical 富文本编辑器框架的Editor State编辑器状态核心概念展开阐明DOM 不是数据真相这一设计哲学、Editor State 的两阶段生命周期与 JSON 序列化机制、editor.update()双缓冲更新模型、监听器与变换的调用时序以及discrete同步提交等实战要点。读完本文你将能够正确初始化、读取、持久化与同步更新 Lexical 编辑器的状态并避免空状态、异步提交等常见陷阱。为什么 Editor State 是必要的Lexical 的核心设计理念是真相之源source of truth不是 DOM而是由 Lexical 维护并关联到编辑器实例上的底层状态模型state model。HTML 非常适合存储富文本内容但对于文本编辑来说它过于灵活。例如下面三行 HTML 内容渲染结果完全相同ibLexical/b/i ibLexbbical/b/i biLexical/i/b查看渲染结果LexicalLexicalLexical虽然可以通过 DOM 操作把所有变体归一化为一种规范形式canonical form但代价是需要重新渲染内容。为了克服这一问题可以引入 Virtual DOM 或State状态方案——Lexical 选择了后者。结构Structure与格式Formatting的解耦HTML 状态还存在另一个问题内容结构被内容格式所污染。看下面这段存储为 HTML 的内容pWhy did the JavaScript developer go to the bar? bBecause he couldnt handle his iPromise/is/b/p在上图中p的嵌套层级由b、i标签的先后顺序决定——结构跟随格式变化。而 Lexical 通过将格式信息偏移到节点的属性attributes中实现了结构与格式的解耦从而保证无论样式以何种顺序应用都能得到规范的文档结构从源码看Lexical 状态的最小序列化单元是SerializedEditorState它只包含一个root字段LexicalEditorState.ts根节点内部递归挂载子节点树——文本节点上的format等属性承载了样式信息这正是扁平结构 属性化格式的落地形态。理解 Editor State获取最新状态可以通过调用editor.getEditorState()获取编辑器最新的状态。从源码实现看该方法直接返回编辑器内部的_editorState字段LexicalEditor.ts。两阶段生命周期可变与不可变快照Editor State 具有两个阶段更新期间During an update可以视作可变的mutable通过editor.update()内的$前缀辅助函数修改更新之后After an update状态被锁定从此视为不可变的immutable可以视作一份快照snapshot。状态的核心组成一个 Editor State 包含两样核心内容编辑器节点树editor node tree从根节点root node开始编辑器选区editor selection可以为null。对应源码中EditorState类的两个核心字段_nodeMap节点映射与_selection选区构造函数签名(nodeMap, selection null, slotsUsed false)清楚展示了这一结构LexicalEditorState.ts。JSON 序列化与反序列化Editor State 可序列化为 JSON编辑器实例也提供了反序列化字符串化状态的方法editor.parseEditorState()内部通过JSON.parse后交给parseEditorState处理见 LexicalEditor.ts。以下示例演示如何用初始状态初始化编辑器并在提交时持久化// 获取编辑器初始状态例如从后端加载 const loadContent async () { // empty 编辑器 const value {root:{children:[{children:[],direction:null,format:,indent:0,type:paragraph,version:1}],direction:null,format:,indent:0,type:root,version:1}}; return value; } const initialEditorState await loadContent(); const editor createEditor(...); registerRichText(editor, initialEditorState); ... // 存储内容的处理器例如用户提交表单时 const onSubmit () { await saveContent(JSON.stringify(editor.getEditorState())); }注意上方初始状态 JSON 的结构root节点下有一个type: paragraph的段落节点这就是文档中所说的默认空段落。toJSON()的实现LexicalEditorState.ts在只读上下文中对根节点递归调用exportJSON()与parseEditorState形成一对互逆的序列化原语。在 React 中可以这样结合LexicalComposer使用const initialEditorState await loadContent(); const editorStateRef useRef(undefined); LexicalComposer initialConfig{{ editorState: initialEditorState }} LexicalRichTextPlugin / LexicalOnChangePlugin onChange{(editorState) { editorStateRef.current editorState; }} / Button labelSave onPress{() { if (editorStateRef.current) { saveContent(JSON.stringify(editorStateRef.current)) } }} / /LexicalComposerinitialConfig.editorState的取值类型Lexical 只在编辑器创建时读取一次initialConfig.editorState之后再传入不同的值不会生效。若要在初始化之后变更状态请使用下文更新状态中介绍的正确方式。editorState字段可接受以下取值JSON 字符串通过editor.parseEditorState()解析如上例EditorState实例直接通过editor.setEditorState()应用函数(editor) void在editor.update(...)内部执行并且仅当根节点仍为空时才被调用已填充内容的根节点不会被触碰null完全跳过默认初始化。将此值与协作插件配合使用让 Yjs 文档而非 Lexical拥有初始状态的所有权。null与undefined的关键区别省略该字段或传入undefined会用默认的空ParagraphNode填充根节点而null则让根节点没有任何子节点。两者不可互换null→ 根节点无子节点适合协作场景等待 Yjs 文档填充undefined→ 产生一个单独的空行默认段落。如果loadContent对新建文档可能返回null或undefined应合并coalesce为undefined例如(await loadContent()) ?? undefined这样编辑器仍能获得默认段落而不是进入协作风格的未初始化状态。更新状态Updating state提示如需深入了解状态更新机制可阅读 Lexical 贡献者 DaniGuardiola 撰写的状态更新深度解析博客。editor.update()最常用的更新方式更新编辑器最常用的方式是editor.update()。调用时需要传入一个函数该函数将获得修改底层编辑器状态的访问权editor.update在源码中直接委托给updateEditor见 LexicalEditor.ts。从技术角度看启动一次全新更新时当前状态会被克隆并作为起点——这就是所谓的**双缓冲double-buffering**机制当前current冻结状态代表最近一次已调和reconciled到 DOM 的内容待定pending进行中状态代表下一次调和要应用的新变更。调和reconciliation通常是异步过程Lexical 借此将多个同步状态更新批量合并成一次 DOM 更新以提升性能。当 Lexical 准备把更新提交到 DOM 时这批更新中的底层变更会形成一个新的不可变 Editor State此后editor.getEditorState()返回的就是包含这些变更的最新状态。以下示例演示如何更新编辑器实例import {$getRoot, $getSelection} from lexical; import {$createParagraphNode} from lexical; // 在 editor.update 内部可以使用 $ 前缀的特殊辅助函数。 // 这些函数不能在闭包之外使用否则会报错。 // 如果你熟悉 React可以把它们想象成在 React 函数组件之外使用 hook editor.update(() { // 从 EditorState 获取 RootNode const root $getRoot(); // 从 EditorState 获取选区 const selection $getSelection(); // 创建一个新的 ParagraphNode const paragraphNode $createParagraphNode(); // 创建一个新的 TextNode const textNode $createTextNode(Hello world); // 将文本节点追加到段落 paragraphNode.append(textNode); // 最后把段落追加到根节点 root.append(paragraphNode); });$前缀辅助函数只能在editor.update()、editor.read()或editorState.read()的回调中同步使用。若在闭包外调用getActiveEditorState()/getActiveEditor()会抛出 invariant 错误LexicalUpdates.ts这正是不可在闭包外使用约束的源码级体现。setEditorState()整体替换状态另一种设置状态的方式是setEditorState方法它用传入的参数整体替换当前状态。下面示例演示如何从字符串化的 JSON 设置状态const editorState editor.parseEditorState(editorStateJSONString); editor.setEditorState(editorState);⚠️警告空状态会抛异常当传入的EditorState满足editorState.isEmpty()即根节点是唯一节点且没有选区时setEditorState会抛出异常报错信息为setEditorState: the editor state is empty. Ensure the editor states root node never becomes empty.该异常在源码中的setEditorState入口处即被触发LexicalEditor.ts而isEmpty()的判断条件是节点映射大小 1且无选区LexicalEditorState.ts。用editorState: null初始化且从未追加内容所产出的状态典型场景协作文档在 peers 连接之前恰好符合此形态因此持久化并重新加载这样的状态会在setEditorState调用处失败。注意parseEditorState本身会成功——异常发生在应用apply步骤。解决方案在调用setEditorState前先用editorState.isEmpty()做防护或者在序列化前先为文档填充一个ParagraphNode。状态更新监听器State update listener如果希望在编辑器更新时做出响应可以为编辑器注册更新监听器editor.registerUpdateListener(({editorState}) { // 最新 EditorState 可以通过 editorState 获取。 // 要读取 EditorState 的内容请使用以下 API editorState.read(() { // 与 editor.update() 类似.read() 期望一个闭包 // 在闭包内可以使用 $ 前缀辅助函数。 }); });从源码实现看更新监听器在调和reconciliation完成后触发payload 包含dirtyElements、dirtyLeaves、editorState、mutatedNodes、prevEditorState、tags等字段LexicalEditor.ts其中mutatedNodes字段自 v0.28.0 起提供仅在至少注册了一个 MutationListener 时才会计算。监听器、变换与命令的调用时机与 Editor State 更新相关的回调有几种类型它们的触发时机各不相同| 回调类型Callback Type | 触发时机When Its Called | | -- | -- | | 更新监听器Update Listener | 调和之后After reconciliation | | 变更监听器Mutation Listener | 调和之后After reconciliation | | 节点变换Node Transform | 在editor.update()中当回调执行完毕、且其注册的节点类型有实例被更新时Duringeditor.update(), after the callback finishes, if any instances of the node type they are registered for were updated | | 命令Command | 命令一经派发给编辑器即触发从一次隐式的editor.update()内调用 |理解这一时序有助于避免常见的回调顺序误区——例如在更新监听器中再触发更新时要注意 Lexical 的级联防护_cascadeCount预算会检测无终止条件的更新监听器递归并抛出无限递归错误LexicalUpdates.ts。离散更新的同步调和Synchronous reconciliation with discrete updates虽然提交调度commit scheduling与批处理batching通常是理想行为但有时它们会成为阻碍。考虑这个例子你试图在服务端上下文中操作编辑器状态并持久化到数据库editor.update(() { // 操作状态... }); saveToDatabase(editor.getEditorState().toJSON());这段代码不会按预期工作因为saveToDatabase会在状态提交之前执行——被保存的状态仍是更新之前的那份。幸运的是LexicalEditor.update的discrete选项可以强制更新立即提交editor.update(() { // 操作状态... }, {discrete: true}); saveToDatabase(editor.getEditorState().toJSON());从源码看discrete是EditorUpdateOptions的可选字段官方注释明确说明为 true 时阻止本次更新被批处理强制其同步执行LexicalEditor.ts。$flushSyncAfterUpdate()也提供了等效能力——相当于对所在editor.update设置{discrete: true}常用于浏览器预期在事件监听器返回前原生完成事件处理的场景LexicalUpdates.ts。克隆状态Cloning stateLexical 状态可以被克隆且可选择性携带自定义选区。一个典型场景是设置编辑器状态但不强制任何选区// 传入 null 作为选区值避免聚焦编辑器 editor.setEditorState(editorState.clone(null));从实现看clone()会基于现有节点映射创建一个新的EditorStateselection undefined时保留原选区显式传入null则清空选区克隆出来的状态被标记为只读_readOnly true并且会保留原状态的_parsed标记这正是应用状态但不聚焦编辑器这一文档化用法得以生效的底层保证LexicalEditorState.ts。小结一条贯穿 Editor State 的主线回顾全文Lexical 的 Editor State 设计可以用一条主线串联DOM 只是渲染层状态模型才是真相之源。结构通过规范化的节点树表达格式通过节点属性表达二者解耦后带来可序列化、可克隆、可双缓冲调和的状态管理能力。无论是初始化时的initialConfig.editorState、读取时的getEditorState()、更新时的update()/setEditorState()还是异步提交场景下的discrete选项本质上都在围绕当前快照与待定工作副本这一对双缓冲关系运转。掌握这套模型你就掌握了 Lexical 编辑器状态管理的全部核心。【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表