Lexical Named Slots 详解:在单一 EditorState 中构建多区域隔离编辑模型
【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical
Named Slots(命名插槽)是 Lexical 中一个处于实验阶段的模型级特性,它让一个宿主节点(ElementNode 或 DecoratorNode)在自身 EditorState 内,通过名称同时拥有多个彼此隔离的可编辑区域——比如一张 Card 的title、一条 PullQuote 的quote与attribution。本指南基于 packages/lexical-website/docs/concepts/named-slots.md 展开,结合 packages/lexical/src/LexicalSlot.ts 等源码与 playground 中的真实节点实现,带你掌握插槽的声明、读写、渲染、编辑语义、序列化与协作同步全流程,并理解它相较于传统嵌套编辑器方案在架构上的取舍。
为什么需要 Named Slots:嵌套编辑器之外的新答案
在 Named Slots 出现之前,要让一个"宿主节点"拥有多个独立可编辑区域,通常的做法是每个区域一个嵌套编辑器(nested editor)。每个区域拥有独立的 EditorState,这带来了一系列连锁负担:在区域间移动节点需要序列化;历史记录与协作同步需要额外的editor.update与消息传递;区域边界上的 Backspace 行为、选中语义都要手工协调。
其他节点形态也无法直接胜任:
- 普通 ElementNode 的 children 共享同一条不分段的双向链表,区域起点的 Backspace 会把该区域"合并"进前一个区域;
- DecoratorNode 是原子节点,Lexical 无法在其内部拥有选择(selection)、协作(collab)与序列化能力。
Named Slots 的答案是:编辑一个插槽就是编辑同一棵树。插槽仍然位于宿主的 EditorState 内部,只是通过一条虚拟的"影子根"(shadow root)边界与普通 children 隔离——每种区域都有自己独立的选区、格式与历史,Cmd+A不会溢出到文档其余部分。
与$getDOMSlot的关系:渲染概念的模型级泛化
文档特别指出,Named Slots 是 DOM 渲染文档 中$getDOMSlot/ElementDOMSlot这一渲染概念的模型级泛化:每个 ElementNode 本来就有一个未命名的 children 通道,$getDOMSlot控制该通道内容挂载到节点 DOM 的哪个位置;Named Slots 则是并行的、显式命名的额外通道——对称之处在于每个插槽都渲染到宿主 DOM 中可控的位置——但插槽额外携带了未命名通道没有的模型级语义:隔离(虚拟影子根)、独立的 NodeKey 映射,以及各自的序列化与协作能力。
模型:宿主的第二条子通道与虚拟影子根
数据结构
一个宿主节点持有第二条子通道:一张Map<slotName, NodeKey>(名称到节点 key 的映射),与普通链表式 children 完全分离。关键约束是:插槽值的getParent() === null,同时它的插槽宿主指针被设置,二者恰好只有一个非空——向上攀登超出插槽边界时,只能通过$getSlotHost()走出。
在源码中,这套结构由两个接口承载(packages/lexical/src/LexicalNode.ts):
SlotHostNode携带__slots: null | Map<string, NodeKey>,由 ElementNode 与 DecoratorNode 实现。映射采用惰性分配(首次$setSlot前为null),大多数不使用插槽的节点不付出任何分配成本;SlotChildNode携带__slotHost: null | NodeKey,其向上指针是__slotHost而非__parent,二者互斥——这正是插槽边界表现为影子根的结构基础。
隔离是结构性的,而非约定
插槽链接本身在宿主与值之间充当一条虚拟不可见影子根。隔离不是"大家自觉遵守"的约定,而是结构性强制的:偶然的越界访问会以抛出 invariant的方式暴露,而不是静默损坏。$setSlot在开发环境下会做环检测(把节点插入自身后代会形成环),并拒绝把 inline 节点作为插槽值(见 LexicalSlot.ts)。
DOM 侧:隐藏占位容器
在 DOM 中,每个插槽值同步渲染进一个无 key 的<div>import { $create, $createParagraphNode, $setSlot, ElementNode, } from 'lexical'; class CardNode extends ElementNode { $config() { return this.config('card', {extends: ElementNode, slots: ['title']}); } createDOM(): HTMLElement { return document.createElement('div'); } updateDOM(): boolean { return false; } } function $createCardNode(): CardNode { const card = $create(CardNode); // 单行标题:裸 Paragraph 本身就是插槽值。空段落即空字段; // 要填充默认文本,追加一个非空 TextNode(空 TextNode 在 reconcile 时会被消除)。 $setSlot(card, 'title', $createParagraphNode()); // 普通正文子节点,像其他块一样编辑。 return card.append($createParagraphNode()); }
多块区域则使用影子根容器作为值:
class SlotContainerNode extends ElementNode { $config() { return this.config('slot-container', {extends: ElementNode}); } createDOM(): HTMLElement { return document.createElement('div'); } updateDOM(): boolean { return false; } isShadowRoot(): boolean { return true; } } $setSlot( pullQuote, 'quote', $create(SlotContainerNode).append( $createParagraphNode().append($createTextNode('First block')), $createParagraphNode().append($createTextNode('Second block')), ), );核心 API 一览(全部从lexical导出)
| API | 作用 |
|---|---|
$setSlot(host, name, node) | 把值放入命名插槽,替换同名旧值。移动语义,与append一致:先摘除值的旧宿主/旧父节点再链接;值必须非 inline;名称不得是保留的原型键(__proto__、constructor、prototype)。把节点插入自身后代会成环,仅在开发环境抛出 invariant——生产环境与未加防护的 children 通道行为一致 |
$getSlot(host, name) | 返回该名称下的值,空则null |
$getSlotNames(host) | 按规范顺序返回宿主已占用的插槽名 |
$removeSlot(host, name) | 摘除该名称下的值(子树会被 GC,除非重新挂到别处) |
$getSlotHost(node) | 返回值被插入的宿主,非插槽值返回null |
$getSlotNameWithinHost(node) | 返回值在宿主上占据的插槽名($getSlotHost的反向),非插槽值返回null |
$getSlotFrame(node) | 返回包含某节点的最内层插槽值("frame",其虚拟影子根划定编辑作用域),不在任何插槽内返回null |
$getSelectionSlotFrame(selection) | 返回选区所在的插槽 frame,插槽外返回null。选区驱动的导出器用它代替根 children 遍历——插槽内的选区永远不包含宿主,根遍历会导出空内容。适用于所有选区类型,不止 RangeSelection |
$isSlotHost(node)/$isSlotChild(node) | 针对SlotHostNode/SlotChildNode接口的类型守卫 |
$setSlot的实现(LexicalSlot.ts)还包含几个值得注意的细节:重复设置同一名称下的同一节点是幂等 no-op;被替换的旧值会被$detachSlottedNode摘除;移动语义保证重设插槽前无需手动 remove;插槽映射采用 copy-on-write 的 owner 标记(SLOT_MAP_OWNERsymbol),未被修改的版本间共享同一张 Map,避免每次克隆都复制。
插槽顺序:规范、派生、永不存储
插槽顺序是规范且派生的,绝不持久化:
- 在
$config()中声明的名称(slots: ['quote', 'attribution'])按声明顺序排在最前; - 未声明的名称排在它们之后,按UTF-16 码元字典序(纯 JavaScript 字符串比较,与 locale 无关)排列。
$setSlot在每次写入时都会重新规范化顺序,因此:文档在加载时自动归一化,协作场景下并发添加的名称在所有客户端收敛到相同顺序。如果展示顺序重要,请声明这些名称。对应的实现是$canonicalizeSlotOrder与compareSlotNames(LexicalSlot.ts),并且声明数组会经过校验:重复声明与保留名会在开发环境直接抛 invariant。
渲染:三种挂载方式
reconciler 总是同步渲染每个插槽子树,但渲染进的是隐藏占位容器——可见性是宿主显式的决定。三种挂载方式共享同一契约:挂载把容器移动到目标处(已在目标处则为 no-op)并揭示它(清除display: none);容器以普通块(block)渲染。注意源码明确不使用display: contents:Chromium 无法在无盒 contenteditable 子树中可靠编辑(点击命中的是相邻盒、原生文本插入会被丢弃),见 packages/lexical/src/LexicalUtils.ts 的注释。
方式一:同步在 Lexical 内部(DOMRenderMatch 覆盖)
为宿主的节点类注册一个$getSlotTargetElement的DOMRenderMatch覆盖(属于 DOM 渲染覆盖,是高级钩子)。reconciler 在创建或 reconcile 插槽容器时查询它,并在同一提交内完成挂载与揭示——没有监听器或框架跳跃。返回hostDom即在默认的"插槽优先"位置揭示插槽:
import {domOverride, DOMRenderExtension} from '@lexical/html'; import {configExtension, defineExtension} from 'lexical'; export const CardExtension = defineExtension({ dependencies: [ configExtension(DOMRenderExtension, { overrides: [ domOverride([CardNode], { // 在与渲染相同的提交内,把标题揭示到默认的插槽优先位置。 // 从宿主 DOM 更深处返回元素则挂载到那里; // $next() 让位给低优先级覆盖(默认 null,即隐藏占位)。 $getSlotTargetElement: (node, slotName, hostDom, $next, editor) => hostDom, }), ], }), ], name: 'card', nodes: [CardNode], });方式二:命令式 API
mountSlotContainer(editor, nodeKey, slotName, target)与unmountSlotContainer(editor, nodeKey, container)(从lexical导出)是与框架无关的原语,例如可在提交后触发的 mutation listener 中使用。mountSlotContainer基于已提交的editor state(editor.getEditorState())解析容器,因此它读取的模型与它揭示的已 reconcile DOM 一致;unmountSlotContainer只接受你已持有的容器,且只触碰 DOM:
import {mountSlotContainer} from 'lexical'; // 例如在扩展的 register(editor) 内部: const unregister = editor.registerMutationListener( CardNode, (mutations) => { for (const [nodeKey, mutation] of mutations) { if (mutation === 'destroyed') { continue; } const hostDom = editor.getElementByKey(nodeKey); if (hostDom !== null) { // 原地挂载:占位符已经停在宿主 DOM 中,这只是在插槽优先位置揭示它。 // 宿主 DOM 内的任意元素都可用作 target。 mountSlotContainer(editor, nodeKey, 'title', hostDom); } } }, {skipInitialization: false}, );unmountSlotContainer(editor, nodeKey, container)是逆操作:隐藏容器并把它放回宿主 DOM 作为前置隐藏占位符——用于挂载目标消失而宿主仍然存活的场景,插槽子树随文档留存而非随被摘除的目标离开。
方式三:从 React chrome 挂载(useLexicalSlotRef)
@lexical/react/useLexicalSlotRef的useLexicalSlotRef钩子封装了上面这对命令式原语,返回一个 ref,把插槽容器挂载进你的组件——这是 DecoratorNode 宿主的decorate()chrome 的常规选择(其容器会被自动重新纳入contentEditable,因为装饰器 DOM 本身不可编辑):
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {useLexicalSlotRef} from '@lexical/react/useLexicalSlotRef'; function PullQuoteComponent({nodeKey}: {nodeKey: NodeKey}) { const [editor] = useLexicalComposerContext(); const quoteRef = useLexicalSlotRef<HTMLDivElement>(editor, nodeKey, 'quote'); const attributionRef = useLexicalSlotRef<HTMLDivElement>( editor, nodeKey, 'attribution', ); return ( <blockquote> <div ref={quoteRef} /> <div ref={attributionRef} /> </blockquote> ); }从源码看(packages/lexical-react/src/useLexicalSlotRef.ts),该钩子每次渲染都会重跑,且具备幂等性:插槽在宿主首次渲染之后才加入、或容器被 remove/re-add 重建,都能被自动拾取;卸载或 nodeKey/slotName 变化时,旧容器通过unmountSlotContainer作为隐藏占位符停放回宿主 DOM。
playground 的 PullQuote 插件正是按此模式实现(packages/lexical-playground/src/plugins/PullQuoteExtension/PullQuoteNode.tsx):quote用SlotContainerNode(多块、影子根),attribution用裸 ParagraphNode(单行字段),宿主的decorate()返回PullQuoteComponent通过useLexicalSlotRef挂载两个插槽——并在$config()中显式声明slots: ['quote', 'attribution']来保证规范顺序(否则字典序会把 attribution 排在 quote 前面)。
不可编辑外壳中的 React chrome
一个contentEditable=false的 ElementNode 外壳可以用同样方式承载 React chrome:playground 的 Review demo 把 chrome 门户化(portal)进宿主 DOM,用useLexicalSlotRef挂载 author 插槽,驱动一个持久化到 NodeState 的交互式星标组件,并把同一套"先隐藏再挂载"的技术应用到其getDOMSlotchildren 元素上。这类外壳应在createDOM中调用setDOMUnmanaged(dom)——portal 与挂载移动会从 reconciler 之外改动外壳的 children,该标记赋予外壳与 DecoratorNode DOM 相同的 mutation-observer 豁免权(对应源码 packages/lexical/src/LexicalUtils.ts 附近的setDOMUnmanaged及其文档注释)。
可编辑状态:插槽始终跟随编辑器
渲染在不可编辑宿主(DecoratorNode,或contentEditable=false元素外壳)内的插槽不会自行跟踪编辑器的可编辑状态,因此 reconciler 会给它的容器一个显式contentEditable,跟随editor.isEditable(),并在setEditable切换时重渲染这些孤岛——只读编辑器的插槽不会被遗留为可编辑。无需任何扩展。插槽始终跟随编辑器;目前不存在让插槽覆盖自身可编辑状态的途径(源码见 packages/lexical/src/LexicalReconciler.ts 附近的$markSlotEditable调用)。
宿主挂载的非插槽容器的可编辑孤岛——例如 Review demo 的contentEditable=false外壳内那个getDOMSlotchildren 元素——则通过$markSlotEditable(element, editor)获得同样行为,并从updateDOM重新应用,以便可编辑状态切换能传导到孤岛。
编辑行为
边界语义
- 选区永不跨越插槽边界。选区的锚点在其插槽 frame 内被钳制,覆盖所有进入点(DOM 解析、
$setSelection、指针变更),因此跨边界的鼠标拖拽与shift+arrow落在同一个钳制结果上。 - 删除在边界停止。插槽开头的 Backspace 与末尾的前向 Delete 是 no-op,不会跨虚拟影子根合并。
Cmd+A在插槽内收窄到插槽 frame;在插槽之外,默认处理器保持传统的整文档行为。渐进式扩展(块 → 外层插槽 frame → 连续按键后到文档)由@lexical/extension的 SelectBlockExtension 提供(可选加入)。该扩展的源码(packages/lexical-extension/src/SelectBlockExtension.ts)通过注册SELECT_ALL_COMMAND并利用$getSlotFrame实现,配置项包括disabled与cascadeSelection。- 通用块转换跳过插槽值。插槽值没有父节点——它的上链是
$getSlotHost——因此基于选区的块转换器($setBlocksType、$wrapNodes、$insertList/$removeList、markdown 块快捷键)将其视为不合格目标而什么都不做,而不是替换它:插槽的分配由拥有该插槽的节点或扩展管理。直接在插槽值上调用LexicalNode.replace会抛出异常;请在宿主上改用$setSlot重新分配。 - 插槽通过
replace保持绑定在宿主上。替换插槽宿主(host.replace(other))不会把插槽转移给替换者——插槽未必能在节点类型间移植,所以插槽映射跟随节点、从不跟随位置。若被替换的宿主在同一更新中被重新挂载($wrapNodeInElement模式),它保留插槽;若保持游离,其插槽子树随它一起被 GC。同理:用$setBlocksType转换插槽宿主会得到一个无插槽的替换块。要移动插槽,显式地用$setSlot挂到另一个宿主上。 - 元素的 NodeSelection 携带其 children。整宿主 NodeSelection(例如 chrome 点击选中"整张 Card")的复制与导出会包含宿主的正文 children,即便它们不在选择内——旧的仅外壳(shell-only)输出会让剪切静默丢失内容。此行为仅适用于 NodeSelection;覆盖宿主的局部 RangeSelection 保持逐子切片。
遍历有意不对称
内容读取包含插槽子树,且插槽优先:getTextContent()、getAllTextNodes(),以及@lexical/utils的$dfsWithSlots一族,都会把插槽内容计入搜索、复制与无障碍。导航则排除它们:getChildren()、getFirstDescendant()等只走链表,因此光标移动不会意外走入插槽。请根据"此子树"应该指可导航树还是全部内容,在$dfs与$dfsWithSlots之间选择(后者实现在 packages/lexical-utils 中)。
序列化
JSON 序列化在两个方向都是自动的。宿主的插槽序列化在SerializedLexicalNode的保留键$slots下(NodeState 的保留'$'键的兄弟键),按插槽名键控:
{ "type": "card", "version": 1, "$slots": { "title": {"type": "paragraph", "children": [], "version": 1} }, "children": [] }解析时用$setSlot重新挂接每个子树,并在$slots出现在不能承载插槽的节点上时抛出异常。$前缀让框架自有的键不会与子类自行序列化的slots属性冲突。
HTML 序列化是按宿主选择性加入的,与 NodeState 相同:导出器不会自行进入插槽。宿主的exportDOM可以用@lexical/html的$appendNodeToHTML把每个插槽发射进包裹元素,宿主的特征标记上的 DOM 导入规则 再把包裹元素通过$setSlot映射回来。playground 的 PullQuote 正是这么做的(PullQuoteNode.tsx):exportDOM遍历$getSlotNames(this),为每个插槽创建带data-lexical-slot属性的包裹<div>,用$appendNodeToHTML写入内容——导入规则以宿主的 sentinel class 为键,保证往返与 lexical-rich-text 的<blockquote>导入器互不干扰。
协作:V1 与 V2 绑定双通道同步
插槽通过 V1 与 V2 两种 Yjs 绑定同步,机制是宿主共享类型上保留键__slots下的、按插槽逐个 diff 的Y.Map(该通道复用了宿主的__slots字段名,该字段本就已被排除在属性同步之外)。声明了插槽的宿主会急切创建该映射,因此两个客户端首次并发设置不同插槽名时,是按条目合并而非竞态。恶意或敌意的远端条目会被校验并跳过。
:::caution混合版本协作警告:尚未感知插槽的旧客户端收到升级后同伴发送的插槽数据会报错而非渲染。在长期存活的共享文档中启用插槽,应确保所有参与者都运行支持插槽的版本;新客户端以后会容忍未知插槽数据。 :::
保留名称
添加插槽会保留几个标识符,自定义节点子类不应为自身目的定义它们:
- ElementNode / DecoratorNode 上的
__slots与__slotHost字段——__slots同时也是插槽通道的 collab 属性键,因此在 Yjs 共享类型上同样保留; - 序列化 JSON 键
$slots。
当前限制
- caret / NodeCaret API 在跨越插槽边界时会抛出("no common ancestor");插槽感知的 caret 遍历是计划的后续工作。
- 嵌套插槽(插槽的宿主本身又被插槽化)目前只被运行时选区比较器处理一层深度。
- 上文提到的混合版本 collab 注意事项。
小结与上手路径
Named Slots 把"一个节点拥有多个隔离可编辑区域"从渲染层提升到了模型层:隔离由结构保证(__slotHost上链 + 虚拟影子根)、顺序是派生的规范序、序列化自动完成、协作按条目合并——所有编辑都发生在同一棵树上,这正是它与嵌套编辑器方案的根本分野。上手时,建议从 playground 的两个真实示例出发:PullQuoteNode.tsx(DecoratorNode 宿主 + 多块/单行两种插槽值 +useLexicalSlotRef挂载 + HTML 往返)与 Review demo(contentEditable=false外壳 +setDOMUnmanaged+ 门户 chrome),配合单元测试 packages/lexical/src/tests/unit/LexicalSlot.test.ts 与 packages/lexical/src/tests/unit/SlotParseEditorState.test.ts 理解边界语义与解析行为。需要注意:本特性所有 API 均标记@experimental,可能在非大版本更新中变更,序列化与 collab 格式在特性稳定前应视为不稳定。
【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考