Lexical DOMRenderExtension 完全指南:用中间件覆盖节点渲染与 HTML 导出 Lexical DOMRenderExtension 完全指南用中间件覆盖节点渲染与 HTML 导出【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexicalDOMRenderExtension是 Lexical 官方在 v0.44.0 引入、v0.45.0 大幅扩充的实验性扩展experimental它允许你在 reconciliationcreateDOM/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 形态。 :::一、它解决什么问题为什么需要 DOMRenderExtensionDOMRenderExtension让你覆盖 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-fluidtrue属性。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-fluidtrue并与DOMRenderExtension为TextNode配置的任何其他行为组合生效——无论编辑器内还是 HTML 导出期间。:::tip 关键认知同一覆盖会在两个场景中触发编辑器内的就地 reconciliationcreateDOM/updateDOM/decorateDOMHTML 导出$exportDOM。当覆盖只想管渲染或只想管导出时区别只在于你实现了哪些方法。 :::domOverride的源码非常简单——它只是把nodes、中间件方法和可选options打包成一个DOMRenderMatch对象见 domOverride.ts。真正的编译工作发生在运行时。三、Overrides 全面解析一个 override 是用domOverride构建的DOMRenderMatchT。它针对一组节点类或*并提供以下中间件方法的任意子集Override何时被调用替换 / 包装$createDOMReconciler 为节点创建 DOM 时node.createDOM$updateDOMReconciler 更新已有 DOM 节点时node.updateDOM$decorateDOM创建或更新之后、子节点 reconcile 完成之后增量——没有需要替换的默认实现$getDOMSlotReconciler 询问「子节点挂到哪里」针对ElementNodeElementNode.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接受*匹配所有节点或NodeMatchT数组——每个条目可以是节点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) DOMSlotForNodeT且注释明确要求「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?) RenderStateConfigV它内部通过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 单次调用临时覆盖$withRenderContextimport {$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包装触发$fullReconcilediscrete: true来重建受影响节点的 DOM——因为被移除的覆盖可能产出或装饰过元素只有全新的$createDOM才能撤销。5.6 内置渲染状态lexical/html开箱即用提供两个渲染状态RenderContextExport— 序列化为 HTML 期间$generateDOMFromNodes、$generateDOMFromRoot、$generateHtmlFromNodes为true。用于在「编辑器内渲染」与「HTML 导出」之间分支行为。RenderContextRoot— 仅在最外层的$generateDOMFromRoot调用期间为true即根节点本身正作为div roletextbox包裹结构被序列化时。当根节点在整篇文档导出中应表现得与「作为其他元素的子节点」不同时很有用。两者的定义见 RenderContext.ts。根节点的roletextbox导出由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。设置RenderContextExporttrue。$generateDOMFromRoot(container, root?)类似上者但把根节点本身也包含进来默认包裹在div roletextbox中。设置RenderContextExporttrue与RenderContextRoottrue。$generateHtmlFromNodes(editor, selection?)便捷函数创建一个div调用$generateDOMFromNodes返回其innerHTML。三者都是只读的请在editor.read()内调用或配合你自己的editor.update()使用。源码细节$generateDOMFromNodes内部以$withRenderContext([contextValue(RenderContextExport, true)], editor)包裹整次遍历并通过$getSessionDOMRenderConfig(editor)解析当前会话的 DOM 配置见 index.ts$generateDOMFromRoot额外叠加RenderContextRoottrue且默认以$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),仅供参考