Lexical DOMRenderExtension 完全指南:用中间件覆盖节点渲染与 HTML 导出
【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical
DOMRenderExtension是 Lexical 官方在 v0.44.0 引入、v0.45.0 大幅扩充的实验性扩展(@experimental),它允许你在 reconciliation(createDOM/updateDOM/decorateDOM周期)与 HTML 导出(剪贴板复制、$generateHtmlFromNodes)两条路径上,以统一的中间件风格覆盖节点的 DOM 渲染行为。读完本文你将掌握:如何用domOverride声明式地给节点打标记、插入包裹元素、改写导出 HTML、按渲染上下文做条件安装,以及这些 API 在 @lexical/html 源码 中的实现原理。
:::warning 实验性 API 声明
本文描述的DOMRenderExtension及全部相关内容均标记为@experimental,在任何两个 Lexical 版本之间都可能发生变化——包括破坏性重命名、签名变更或行为变更——直到该 API 稳定。破坏性变更会在发布说明中特别指出。依赖该扩展的应用应锁定 Lexical 版本,并把升级视为有意识的行为。
传统的节点类上的createDOM/updateDOM/exportDOM以及默认的$generateHtmlFromNodes入口保持不变,仍是那些不希望跟踪实验性 API 的生产应用所支持的默认方案。以当前仓库为例,@lexical/html 的版本为0.50.0,读者应以此为准核对 API 形态。 :::
一、它解决什么问题:为什么需要 DOMRenderExtension
DOMRenderExtension让你覆盖 Lexical 节点在 reconciliation 期间渲染为 DOM 的方式(createDOM/updateDOM/decorateDOM周期),以及它们被序列化为 HTML(剪贴板导出与$generateHtmlFromNodes)的方式。编辑器内的渲染路径与导出路径共享同一组覆盖声明——一次声明,两条路径同时生效。
反向方向——把 DOM 树转换回 Lexical 节点——参见 DOMImportExtension 文档。
何时使用它
当变更的本质是「一个节点如何变成 DOM」时,你应该选择DOMRenderExtension而不是子类化节点或registerMutationListener:
- 给每个渲染出的元素打上状态驱动的属性(如
data-id、data-color); - 在不子类化的前提下,为某个节点类型的子节点额外套一层包裹元素;
- 在 HTML 导出时剥离或改写属性(例如当 TextNode 不需要
white-space: pre-wrap样式时将其移除); - 定制
$generateDOMFromRoot返回的根元素; - 根据「这是剪贴板复制还是整篇文档序列化」来分支导出行为。
渲染与导出覆盖都是中间件形态——每一个都调用$next()获取默认(或较低优先级)的结果,再返回自己的结果。这让覆盖能够跨扩展干净地组合:每个扩展各自声明覆盖,无需与其他扩展协调。
从源码看,这一设计的落点非常清晰:扩展通过defineExtension注册,在init阶段捕获用户原始的dom/nodes配置,随后用 compileDOMRenderConfigOverrides 把覆盖编译进editorConfig.dom,成为EditorDOMRenderConfig的一部分(见 DOMRenderExtension.ts)。
二、快速开始
最小的可用示例:给每一个渲染出来的 TextNode 打上data-fluid="true"属性。
import { buildEditorFromExtensions, configExtension, } from '@lexical/extension'; import {DOMRenderExtension, domOverride} from '@lexical/html'; import {defineExtension, isHTMLElement, TextNode} from 'lexical'; const editor = buildEditorFromExtensions( defineExtension({ name: 'app', dependencies: [ configExtension(DOMRenderExtension, { overrides: [ domOverride([TextNode], { $createDOM(node, $next, editor) { const dom = $next(); dom.setAttribute('data-fluid', 'true'); return dom; }, }), ], }), ], }), );上面的覆盖给每个渲染的 TextNode 打上data-fluid="true",并与DOMRenderExtension为TextNode配置的任何其他行为组合生效——无论编辑器内还是 HTML 导出期间。
:::tip 关键认知
同一覆盖会在两个场景中触发:
- 编辑器内的就地 reconciliation(
createDOM/updateDOM/decorateDOM); - HTML 导出(
$exportDOM)。
当覆盖只想管渲染或只想管导出时,区别只在于你实现了哪些方法。 :::
domOverride的源码非常简单——它只是把nodes、中间件方法和可选options打包成一个DOMRenderMatch对象(见 domOverride.ts)。真正的编译工作发生在运行时。
三、Overrides 全面解析
一个 override 是用domOverride构建的DOMRenderMatch<T>。它针对一组节点类(或'*'),并提供以下中间件方法的任意子集:
| Override | 何时被调用 | 替换 / 包装 |
|---|---|---|
$createDOM | Reconciler 为节点创建 DOM 时 | node.createDOM |
$updateDOM | Reconciler 更新已有 DOM 节点时 | node.updateDOM |
$decorateDOM | 创建或更新之后、子节点 reconcile 完成之后 | (增量——没有需要替换的默认实现) |
$getDOMSlot | Reconciler 询问「子节点挂到哪里?」(针对ElementNode) | ElementNode.getDOMSlot |
$exportDOM | 为剪贴板或$generateHtmlFromNodes构建 HTML 时 | node.exportDOM |
$shouldExclude | 决定是否从 HTML 中省略某个节点时 | ElementNode.excludeFromCopy |
$shouldInclude | 决定是否把节点纳入 HTML(通常基于选区)时 | 默认的selection ? node.isSelected(selection) : true |
$extractWithChild | 因某个子节点被选中而纳入父节点时(即使父节点本不会被纳入) | node.extractWithChild |
除$decorateDOM外,全部是$next()风格的中间件。调用$next()返回默认值(或较低优先级覆盖的值);你可以原样使用、变换它,或整体替换。
:::warning$decorateDOM是唯一的例外
$decorateDOM没有$next参数。所有适用的$decorateDOM函数都会被无条件调用,其顺序等价于「隐式$next优先」——即低优先级处理器先运行,高优先级后运行。请用它做就地 DOM 微调(设置属性、应用状态驱动的样式),因为你总是希望叠加在别人已完成的成果之上。
源码印证:在 compileDOMRenderConfigOverrides.ts 中,$decorateDOM使用sequence4顺序组合(先默认后覆盖),而其他键使用merge2–merge5的$next链式组合;types.ts中的类型注释也明确说明「没有已知用例需要跳过下一实现」。 :::
domOverride还接受可选的第三个options参数,用于只在特定条件下安装覆盖——详见下文「条件覆盖」一节。
3.1 匹配节点
domOverride接受'*'(匹配所有节点)或NodeMatch<T>数组——每个条目可以是节点Klass(如TextNode、ParagraphNode),也可以是$isNodeGuard谓词:
// 应用到所有节点 domOverride('*', { $decorateDOM(node, _, dom) { /* … */ } }); // 应用到 TextNode 及其子类(Klass 形式覆盖子类) domOverride([TextNode], { $createDOM(node, $next) { /* … */ } }); // 应用到自定义 guard domOverride([$isQuoteNode], { $exportDOM(node, $next) { /* … */ } });:::tip 性能提示
使用Klass形式比 guard 函数显著更廉价——dispatcher 可以把基于类的匹配编译成按节点类型键控的直接查找。仅在「同一批 DOM 行为应作用于一组结构相似、却不共享公共祖先类的节点」时,才使用$isNodeGuard。
源码证据:在 compileDOMRenderConfigOverrides.ts 中,Klass会被展开为{NodeType: true}的类型查找表(TypeRender),并借助getRegisteredSubtypeMap把子类型一并编译进表;而 guard 只能保留为逐个节点求值的函数谓词。这正是「类匹配更高效」的底层原因。 :::
3.2 优先级
两个覆盖的相对优先级由以下规则决定(自上而下优先级从高到低):
- 通配符(
'*')优先级最高——它包裹一切。 - 谓词(
$isParagraphNode)其次。 - 子类先于父类——针对
ParagraphNode的覆盖先于针对ElementNode的覆盖运行。 - 更靠近根的扩展先运行——应用覆盖库的扩展。
- 更晚被依赖的扩展先运行——两个扩展处于同一深度时,后 merge 的胜出。
- 同一数组中后定义的覆盖先运行——
configExtension(DOMRenderExtension, {overrides: […]})数组中最后一个条目最先运行。
$next()沿这个优先级链向下走——你的覆盖先运行,然后轮到下一个较低优先级的覆盖(最终落到节点的默认实现)。
源码印证:sortedOverrides显式地把覆盖分成byNode/byPredicate/byWildcard三组,byNode按iterStaticNodeConfigChain计算的继承深度排序,最后以[...byNode, ...byPredicate, ...byWildcard]的顺序合并(见 compileDOMRenderConfigOverrides.ts)。扩展之间「更靠近根」「更晚依赖」的优先级则由扩展系统的mergeConfig追加语义保证——DOMRenderExtension.mergeConfig会把后传入的overrides追加到已有数组尾部(见 DOMRenderExtension.ts)。
3.3 只读上下文
这些覆盖在 reconciliation 和导出期间被调用,两者都是只读上下文。不要在覆盖内部调用editor.update()或修改节点状态——Lexical 正处在为已有状态产出 DOM 的过程中。如果需要响应变化,请使用节点 transform 或 update listener。
类型定义同样强调了这一点:DOMRenderMatch的注释明确写着「在这些调用期间不允许更新 Lexical 编辑器状态,只能做只读操作」(见 types.ts)。
四、实战示例
4.1 给每个节点打状态驱动属性
一个常见模式:每个节点都携带某种应用级状态(例如来自 NodeState 的唯一id),并希望它在编辑器与 HTML 导出中都以 DOM 属性呈现。
import {createState, $getState, $setState, $getStateChange} from 'lexical'; import {DOMRenderExtension, domOverride} from '@lexical/html'; const idState = createState('id', { parse: (v) => (typeof v === 'string' ? v : null), }); configExtension(DOMRenderExtension, { overrides: [ domOverride('*', { $createDOM(node, $next) { const dom = $next(); const id = $getState(node, idState); if (id) { dom.setAttribute('id', id); } return dom; }, $updateDOM(nextNode, prevNode, dom, $next) { if ($next()) { // 较低优先级的覆盖请求重新挂载;这里无需再做任何事 return true; } const change = $getStateChange(nextNode, prevNode, idState); if (change) { const [id] = change; if (id) { dom.setAttribute('id', id); } else { dom.removeAttribute('id'); } } return false; }, }), ], });注意$updateDOM的返回语义:返回true告诉 reconciler 卸载并重新创建 DOM(例如元素标签需要变化时),返回false表示已完成就地更新。调用$next()让较低优先级的处理器有机会发出「重新挂载」的信号——这一约定与types.ts中$updateDOM的文档一致(返回true时调用$createDOM重建节点)。
4.2 定制 ElementNode 的 slot
$getDOMSlot控制子节点在 DOM 中的挂载位置。$next()的结果是ElementNode.getDOMSlot返回的默认ElementDOMSlot;你可以基于它派生一个新 slot,在根createDOM只返回一个 HTMLElement 的前提下,插入额外的包裹元素:
domOverride([SectionNode], { $createDOM(node, $next) { const root = $next(); const wrapper = document.createElement('div'); wrapper.className = 'section-inner'; root.appendChild(wrapper); return root; }, $getDOMSlot(node, dom, $next) { // 子节点进入 .section-inner,而不是直接挂在根 <section> 下 const inner = dom.querySelector('.section-inner'); return $next().withElement(inner as HTMLElement); }, });结合源码看,$getDOMSlot的签名是(node, dom, $next, editor) => DOMSlotForNode<T>,且注释明确要求「createDOM返回的根必须恰好是一个 HTMLElement;子节点位置通过withElement等方法重新派生」(见 types.ts)。
4.3 调整 HTML 导出
$exportDOM返回DOMExportOutput({element, after?, append?, $getChildNodes?})。重写它以剥离多余属性,或为剪贴板 /$generateHtmlFromNodes改写输出:
domOverride([TextNode], { $exportDOM(_node, $next) { const result = $next(); if (isHTMLElement(result.element)) { // 不需要时去掉 white-space: pre-wrap const textContent = result.element.textContent || ''; if ( result.element.style.whiteSpace === 'pre-wrap' && !/^\s|\s$|\s\s/.test(textContent) ) { result.element.style.removeProperty('white-space'); if (result.element.getAttribute('style')?.trim() === '') { result.element.removeAttribute('style'); } } } return result; }, });4.4 选区感知的导出过滤器
$shouldExclude、$shouldInclude与$extractWithChild共同控制哪些节点进入 HTML 输出,尤其当存在选区时。它们按以下优先级顺序执行(从高到低):
$shouldExclude返回true⇒ 节点被省略(若它是ElementNode,其子节点仍可能被提升到它的位置);$shouldInclude返回true⇒ 包含该节点;- 任一子节点使
$extractWithChild返回true⇒ 包含该节点,以便被包含的子节点拥有正确的包裹结构(例如某个ListItemNode被选中时,ListNode应被包含)。
domOverride([CommentMarkNode], { // 导出的 HTML 中永远不包含评论标记,但保留其子节点。 $shouldExclude: () => true, });在 index.ts 的$appendNodesToHTML中可以看到这套顺序的实际执行:先算shouldInclude与shouldExclude,递归处理子节点时若「本节点未被包含但子节点被包含且$extractWithChild成立」则把shouldInclude提升为true,最后shouldInclude && !shouldExclude才真正把元素写入输出。
五、渲染上下文(Render context)
部分覆盖需要知道「这是导出还是编辑器渲染?」或「这是不是来自$generateDOMFromRoot的根调用?」。这时请使用渲染上下文。
5.1 createRenderState:铸造类型化上下文键
import {createRenderState} from '@lexical/html'; // 若本次序列化去往剪贴板(而非编辑器 reconciliation),则为 true。 const ClipboardCopyState = createRenderState('clipboardCopy', Boolean);createRenderState的签名是(name, getDefaultValue, isEqual?) => RenderStateConfig<V>,它内部通过createContextState挂到DOMRenderContextSymbol上(见 RenderContext.ts)。注意:由于支持 ValueOrUpdater 模式,V不能是函数类型(可把函数包进数组或对象)。
5.2 在覆盖内读取上下文
import {$getRenderContextValue} from '@lexical/html'; domOverride([TableNode], { $exportDOM(node, $next, editor) { const result = $next(); if ($getRenderContextValue(ClipboardCopyState, editor)) { // 为得到更干净的剪贴板 HTML,剥离仅编辑器使用的>configExtension(DOMRenderExtension, { contextDefaults: [ contextValue(ClipboardCopyState, false), ], overrides: [/* … */], })contextDefaults在扩展init/build时被createEditorContextRecord写入编辑器级上下文记录,成为所有导出与会话读取的基底层(见 DOMRenderExtension.ts 与 DOMRenderRuntime.ts)。
5.4 单次调用临时覆盖:$withRenderContext
import {$withRenderContext, contextValue} from '@lexical/html'; const html = $withRenderContext( [contextValue(ClipboardCopyState, true)], editor, )(() => $generateHtmlFromNodes(editor, selection));5.5 持久化到编辑器:$setRenderContextValue / $updateRenderContextValue
对于需要持久于编辑器、而非限定在单个回调内的值,用$setRenderContextValue命令式设置(或$updateRenderContextValue传入 updater)。它是$withRenderContext的编辑器级、持久化对应物,也正是条件覆盖的驱动源:一次「改变某个disabledForEditor谓词所读的值」的写入,会重编译渲染配置并重渲染受影响的节点。
从 DOMRenderRuntime.setContextValue 的实现可以看到完整机制:写入后重新执行filterEditorInstalled,若安装集发生变化,则对变化集求对称差、清空会话缓存、重编译editor._config.dom;若变化的覆盖含$createDOM/$getDOMSlot/$decorateDOM,还会通过一个临时的$updateDOM包装触发$fullReconcile(discrete: true)来重建受影响节点的 DOM——因为被移除的覆盖可能产出或装饰过元素,只有全新的$createDOM才能撤销。
5.6 内置渲染状态
@lexical/html开箱即用提供两个渲染状态:
RenderContextExport— 序列化为 HTML 期间($generateDOMFromNodes、$generateDOMFromRoot、$generateHtmlFromNodes)为true。用于在「编辑器内渲染」与「HTML 导出」之间分支行为。RenderContextRoot— 仅在最外层的$generateDOMFromRoot调用期间为true(即根节点本身正作为<div role="textbox">包裹结构被序列化时)。当根节点在整篇文档导出中应表现得与「作为其他元素的子节点」不同时很有用。
两者的定义见 RenderContext.ts。根节点的role="textbox"导出由DOMRenderExtension的html.export映射提供——它专门为RootNode注册了一个返回{element}的导出(见 DOMRenderExtension.ts)。
六、条件覆盖(Conditional overrides)
默认情况下每个覆盖总是被安装。向domOverride传入可选的第三个options参数,可以只在特定条件下安装覆盖——条件纯粹由渲染上下文决定:
domOverride(nodes, config, { // 仅当此函数返回 false 时,才把覆盖安装进编辑器的渲染管线 //(reconciliation + 导出基准)。默认:总是安装。 disabledForEditor?: (ctx) => boolean, // 仅当此函数返回 false 时,覆盖才参与单次导出/生成会话。 // 默认:总是参与。 disabledForSession?: (ctx) => boolean, });每个谓词接收渲染上下文的只读视图(ctx.get(state)),并决定覆盖是否存在——而不是在每个节点上运行后再内部 bail out。ctx的类型即RenderContextReader,其唯一方法是get(cfg)(见 types.ts)。
6.1 disabledForEditor:运行时开关
disabledForEditor读取持久化的编辑器上下文,决定覆盖是否属于编辑器编译后的渲染配置。由于编辑器内的渲染路径使用该配置,这就是控制实时 reconciliation的作用域。
用$setRenderContextValue切换它。当一次写入翻转了谓词的结果时,配置被重新编译,受影响的节点被重新渲染——因为「产生或装饰过元素的覆盖」只能靠全新的createDOM来撤销。
import { $setRenderContextValue, createRenderState, domOverride, DOMRenderExtension, } from '@lexical/html'; import {LineBreakNode} from 'lexical'; const LineBreakWrapDisabled = createRenderState( 'lineBreakWrapDisabled', () => false, ); configExtension(DOMRenderExtension, { overrides: [ domOverride( [LineBreakNode], { $createDOM(node, $next) { const wrapper = document.createElement('span'); wrapper.className = 'visible-non-printing-linebreak'; wrapper.appendChild($next()); return wrapper; }, // … $getDOMSlot 暴露内部的 <br>,$updateDOM 在换行状态变化时重建 … }, {disabledForEditor: (ctx) => ctx.get(LineBreakWrapDisabled)}, ), ], }); // 稍后——例如从设置变更触发。被禁用时该覆盖彻底从管线中移除 //(不再有逐节点检查),已有的换行会去掉包裹结构重新渲染。 $setRenderContextValue(LineBreakWrapDisabled, true, editor);因为被禁用时覆盖根本不在分派链中,所以没有逐节点开销——这正是它相对于「在 hook 内部检查标志位」的优势。源码中filterEditorInstalled与recreatePredicate的配合完整实现了这一点(见 DOMRenderRuntime.ts 与 DOMRenderRuntime.ts):$createDOM/$getDOMSlot/$decorateDOM任一变化都会触发重建,而$updateDOM与仅导出类 hook 不需要重建。
6.2 disabledForSession:单次导出门控
disabledForSession在每次导出/生成会话开始时($generateHtmlFromNodes、$generateDOMFromNodes、$generateDOMFromRoot)针对该会话的上下文求值一次。它控制覆盖是否参与「这一次遍历」,对实时 reconciliation 没有任何影响——reconciliation 不是会话,谓词无从读取。
这适合「只想在特定序列化中生效的导出变换」(例如一份 "terse" 精简副本),又不必为每次导出都付出中间件开销:
const TerseExport = createRenderState('terseExport', () => false); configExtension(DOMRenderExtension, { overrides: [ domOverride( '*', { $exportDOM(node, $next) { const result = $next(); // … 剥离主题类 / 多余样式 … return result; }, }, {disabledForSession: (ctx) => !ctx.get(TerseExport)}, ), ], }); // 普通导出完全跳过该覆盖… const html = editor.read(() => $generateHtmlFromNodes(editor)); // …而 terse 导出仅对这一次遍历选择加入: const terseHtml = editor.read(() => $withRenderContext([contextValue(TerseExport, true)], editor)(() => $generateHtmlFromNodes(editor), ), );实现上,getSessionConfig()会基于「被会话禁用的覆盖集合」做 memoized 编译(sessionCache以禁用覆盖下标为键),命中缓存则直接复用编译结果(见 DOMRenderRuntime.ts)。
6.3 如何选择作用域
| 你的诉求 | 使用 |
|---|---|
| 在运行时对整个编辑器开关某渲染行为 | disabledForEditor+$setRenderContextValue |
| 只为某些序列化引入导出变换 | disabledForSession+$withRenderContext |
| 在始终安装的覆盖内部做行为分支 | 用$getRenderContextValue读取上下文(无需 options) |
七、顶层入口:三个导出函数
三个顶层辅助函数消费配置好的覆盖:
| 函数 | 作用 |
|---|---|
$generateDOMFromNodes(container, selection?, editor?) | 遍历RootNode.getChildren()并把每个节点 append 进container。设置RenderContextExport=true。 |
$generateDOMFromRoot(container, root?) | 类似上者,但把根节点本身也包含进来(默认包裹在<div role="textbox">中)。设置RenderContextExport=true与RenderContextRoot=true。 |
$generateHtmlFromNodes(editor, selection?) | 便捷函数:创建一个<div>,调用$generateDOMFromNodes,返回其innerHTML。 |
三者都是只读的(请在editor.read()内调用,或配合你自己的editor.update()使用)。
源码细节:$generateDOMFromNodes内部以$withRenderContext([contextValue(RenderContextExport, true)], editor)包裹整次遍历,并通过$getSessionDOMRenderConfig(editor)解析当前会话的 DOM 配置(见 index.ts);$generateDOMFromRoot额外叠加RenderContextRoot=true,且默认以$getRoot()为根(见 index.ts)。另外$generateHtmlFromNodes在无 DOM 环境(headless)下会抛出提示,要求先初始化 JSDom 或使用@lexical/headless/dom的withDOM(见 index.ts)。
八、能力清单与演进方向
当前能力:
- 按节点类或全局覆盖
createDOM、updateDOM、decorateDOM、getDOMSlot、exportDOM、shouldExclude、shouldInclude、extractWithChild; - 中间件
$next()链跨扩展组合; - 类型化渲染上下文(
createRenderState、RenderContextExport、RenderContextRoot)让覆盖按调用模式分支; - 单次声明同时作用于编辑器内 reconciliation 与 HTML 导出;
- 通过
disabledForEditor/disabledForSession条件安装,配合命令式的$setRenderContextValue/$updateRenderContextValue在运行时切换编辑器级覆盖。
未来方向:
- 传统的
node.createDOM/node.updateDOM/node.exportDOM继续并行可用;本次迭代不会翻转默认行为。扩展选择加入覆盖管线后,所得覆盖对匹配节点取代类上的默认实现。
测试佐证方面,仓库提供了完整的单元测试覆盖,见 DOMRenderExtension.test.ts(覆盖'*'通配、TextNode类匹配、多节点匹配、LineBreakNode 条件覆盖等场景)以及 DOMRenderConditionalOverrides.test.ts 与 compileDOMRenderConfigOverrides.test.ts,可作为理解行为边界的活文档。
九、总结
DOMRenderExtension把 Lexical 最核心的「节点 → DOM」过程开放成了可组合、可条件化、渲染与导出共享的中间件管线。当你需要在不子类化、不动用 mutation listener 的前提下改变节点的 DOM 形态——打标记、套包裹、净化导出、按选区裁剪——它就是官方推荐的新入口。理解它的四块基石(domOverride匹配与优先级、$next()中间件语义、渲染上下文、条件安装)之后,你就能以极小的侵入成本写出跨扩展组合的渲染逻辑;而对尚未稳定 API 的生产项目,仍可继续依赖类上默认的createDOM/exportDOM路径。
【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考