资讯动态

Lexical 跨 Shadow DOM 边界构建嵌套编辑器:Shadow DOM 示例深入解析

发布时间:2026/9/12 4:21:24 来源:尧图企业网站定制
Lexical 跨 Shadow DOM 边界构建嵌套编辑器Shadow DOM 示例深入解析【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical导读本文以 Lexical 仓库中的 shadow-dom 开发示例 为主线完整剖析如何在 Vite React 应用中把一个 Lexical 富文本编辑器嵌入 open Shadow DOM并让内外两层编辑器在选区读取、焦点获取、选区写入与 selectionchange 归因上正确协同工作。读完本文你将掌握基于Element.attachShadowcreatePortal的编辑器隔离方案、用DecoratorNode在文档树中嵌套第二个编辑器的方法以及 Lexical 在源码层面对 Shadow DOM 平台能力的利用方式。一、示例概览嵌套形态的双编辑器这是一个最小化的 Vite React 应用演示 Lexical 跨越 Shadow DOM 边界、以**嵌套nested**形态运行**外层编辑器outer editor**位于 light DOM普通文档树中通过LexicalExtensionComposer注册 Rich Text、History、Tab Indentation 等扩展**内层编辑器inner editor**被挂载在一个 openShadowRoot内并通过DecoratorNodeNestedEditorNode嵌套在外层编辑器的文档树中——也就是说内层编辑器在外层文档里占据一个真实的节点位置。这种嵌套结构会真实触发 Lexical 的platform-only仅平台层Shadow DOM 支持这正是本示例最有价值的部分它不是一个简单的隔离样式演示而是同时检验选区selection与焦点focus在跨 shadow 边界时的平台行为。各源文件职责一览文件职责src/ShadowRoot.tsx用attachShadow创建 open shadow root并用createPortal把子节点含内层contentEditable送入 shadow 树src/App.tsx定义NestedEditorNodeDecoratorNode与内/外两个编辑器的扩展配置与初始内容src/Toolbar.tsx位于 light DOM 的工具栏通过命令FORMAT_TEXT_COMMAND、撤销/重做操作外层编辑器选区src/editor.css以?raw字符串形式注入 shadow root 内部的样式表tests/shadow-dom.spec.tsPlaywright 端到端测试playwright.config.tsPlaywright 配置自动启动 Vite dev server端口 4326二、为什么跨 Shadow DOM 需要专门处理选区与焦点的重定向在深入代码之前先理解问题本身。浏览器对 Shadow DOM 有一套重定向retargeting规则直接影响编辑器对选区与焦点的读取读取选区当选区位于 shadow 树内部时Selection.anchorNode/getRangeAt()会被重定向到 shadow host返回的是宿主节点而不是真实的锚点/焦点。因此 Lexical 改用原生Selection.getComposedRanges配合Selection.direction来读取跨 shadow 边界的真实选区。读取焦点document.activeElement只报告最外层的 shadow host无法得知焦点具体落在哪个 shadow 树内的元素上。因此 Lexical 改用ShadowRoot.activeElement即DocumentOrShadowRoot.activeElement逐层下钻。写入选区原生Selection.setBaseAndExtent可以直接以 shadow 树内的节点为参数设置选区键盘导航以及按词/按行删除则使用原生Selection.modify两者都直接作用在 shadow 树节点上。selectionchange 归因在嵌套布局下一次selectionchange事件可能同时被多个编辑器观察到shadow 内挂载的候选编辑器应优先获得归因——内层编辑器要赢过它的 light DOM 父级。这些平台 API 正是 Lexical 核心源码中使用的同一套机制详见本文第六节示例通过真实浏览器场景把它们串了起来。三、ShadowRoot 组件attachShadow createPortal 的隔离容器ShadowRoot.tsx 是整个示例的基石其核心逻辑非常紧凑export default function ShadowRoot({ children, styleSheet, }: { children: ReactNode; styleSheet?: string; }): JSX.Element { const [shadowRoot, setShadowRoot] useStateShadowRoot | null(null); const hostRef useCallbackRefCallbackHTMLElement( host setShadowRoot( host ? (host.shadowRoot ?? host.attachShadow({mode: open})) : null, ), [], ); return ( div ref{hostRef} classNameshadow-host {shadowRoot ! null ? createPortal( {styleSheet ! undefined ? style{styleSheet}/style : null} {children} /, shadowRoot, ) : null} /div ); }关键设计点宿主与 shadow root 分离宿主div创建在 light DOM类名shadow-host通过 ref 回调获取 DOM 节点后调用平台 APIElement.attachShadow({mode: open})附加 open shadow root。host.shadowRoot ?? host.attachShadow(...)的写法保证即使宿主已有 shadow root 也不会重复创建。React 上下文穿越 portalcreatePortal把子节点送入 shadow 树但 React context 仍然正常流动因此内层编辑器包括它的LexicalExtensionComposer与contentEditable在构建方式上与 light DOM 完全一致——只是它的 DOM 物理上生活在 shadow 边界之后。样式随编辑器走shadow 树不继承文档的样式表所以通过可选的styleSheet属性把样式作为style元素注入 shadow root 内部。在 App.tsx 中内层编辑器正是通过这个组件挂载的function NestedEditorView(): JSX.Element { return ( LexicalExtensionComposer extension{innerExtension} contentEditable{null} ShadowRoot styleSheet{editorStyleSheet} div classNameeditor-inner ContentEditable classNameeditor-input aria-placeholder{innerPlaceholder} placeholder{ div classNameeditor-placeholder{innerPlaceholder}/div } / /div /ShadowRoot /LexicalExtensionComposer ); }其中editorStyleSheet通过import editorStyleSheet from ./editor.css?raw以原始字符串导入——注释里明确说明了原因shadow 树不继承文档样式表必须把 CSS 注入 shadow 内部见 App.tsx 与 editor.css。四、NestedEditorNode用 DecoratorNode 把内层编辑器嵌入外层文档App.tsx 中的NestedEditorNode是一个DecoratorNode它的宿主 DOM 位于外层编辑器的树中decorate()返回的 React 子树承载内层编辑器的LexicalExtensionComposer与 shadow rootexport class NestedEditorNode extends DecoratorNodeJSX.Element { $config() { return this.config(nested-editor, {extends: DecoratorNode}); } createDOM(): HTMLElement { const dom document.createElement(div); dom.className nested-editor-host; return dom; } updateDOM(): boolean { return false; } decorate(): JSX.Element { return NestedEditorView /; } }createDOM只生成一个类名为nested-editor-host的容器 div真正的编辑区由decorate()的 React 子树渲染updateDOM返回false表示该节点无需在 DOM 更新时做同步通过装饰器挂载内层编辑器在外层 Lexical 树中占据了一个真实位置从而专门锻炼了onDocumentSelectionChange中的嵌套归因路径见 App.tsx 的注释。外/内两层编辑器的扩展配置外层编辑器light DOM注册的依赖为 Rich Text、History、AutoFocus、Tab Indentation并将NestedEditorNode声明为自定义节点nodes: [NestedEditorNode]内层编辑器shadow root则额外包含 List 与 Link两者共用ExampleThemeconst outerExtension defineExtension({ $initialEditorState: $prepopulateOuter, dependencies: [ RichTextExtension, HistoryExtension, AutoFocusExtension, TabIndentationExtension, ], name: lexical/examples/shadow-dom-outer, namespace: Shadow DOM Demo Outer, nodes: [NestedEditorNode], theme: ExampleTheme, }); const innerExtension defineExtension({ $initialEditorState: $prepopulateInner, dependencies: [ RichTextExtension, ListExtension, LinkExtension, HistoryExtension, AutoFocusExtension, TabIndentationExtension, ], name: lexical/examples/shadow-dom-inner, namespace: Shadow DOM Demo Inner, theme: ExampleTheme, });初始化状态也体现了嵌套结构外层初始内容先是一段文本紧接着root.append($createNestedEditorNode())把内层编辑器节点插入文档中间之后再接一段普通段落App.tsx。这样页面从上到下依次是外层文本 → 内层 shadow 编辑器 → 外层文本方便直观验证两边都能输入、选区归因正确。五、light DOM 工具栏跨边界分发命令Toolbar.tsx 本身位于 light DOM但它通过useLexicalComposerContext()拿到外层编辑器实例然后向编辑器的选区尽管其 DOM 位于 shadow 树内分发命令export default function Toolbar(): JSX.Element { const [editor] useLexicalComposerContext(); // 防止按钮点击抢走焦点从而清空编辑器选区 const keepEditorFocus (event: MouseEvent) event.preventDefault(); const format (formatType: TextFormatType) () editor.dispatchCommand(FORMAT_TEXT_COMMAND, formatType); return ( div classNametoolbar button typebutton onMouseDown{keepEditorFocus} onClick{format(bold)} aria-labelBoldbB/b/button button typebutton onMouseDown{keepEditorFocus} onClick{format(italic)} aria-labelItaliciI/i/button button typebutton onMouseDown{keepEditorFocus} onClick{format(underline)} aria-labelUnderlineuU/u/button span classNametoolbar-divider / button typebutton onMouseDown{keepEditorFocus} onClick{() editor.dispatchCommand(UNDO_COMMAND, undefined)}Undo/button button typebutton onMouseDown{keepEditorFocus} onClick{() editor.dispatchCommand(REDO_COMMAND, undefined)}Redo/button /div ); }值得注意的细节每个按钮都在onMouseDown上调用event.preventDefault()避免按钮获得焦点后清空编辑器中的选区——这是跨边界选区驱动的编辑能够成立的前提。工具栏只作用于外层编辑器的选区是刻意为之的简化演示。六、源码纵深Lexical 核心层的 Shadow DOM 平台支持示例并非孤例它验证的正是 Lexical 核心packages/lexical中已实现的一套 Shadow DOM 兼容策略。以下结论均可在仓库源码中找到对应实现。6.1 读取选区getComposedRanges 与 direction在 packages/lexical/src/LexicalSelection.ts 附近Lexical 使用Selection.getComposedRanges配合Selection.direction来读取跨 shadow 边界的真实选区——这是 README 中Selection.anchorNode/getRangeAt在选区位于 shadow 树内时会被重定向到 shadow host问题的官方解法。代码中还有对未来引擎提供getComposedRanges但缺少direction这一退化场景的防御分支说明该能力被当作渐进增强处理。6.2 读取焦点ShadowRoot.activeElement在 packages/lexical/src/LexicalSelection.ts 处Lexical 通过getActiveElement一类的辅助函数从 packages/lexical/src/LexicalUtils.ts 导出读取焦点元素并据此判断选区是否被捕获在装饰器输入中——因为document.activeElement只会报告最外层的 shadow host必须用DocumentOrShadowRoot.activeElement逐层下钻才能拿到真正持有焦点的元素。6.3 写入选区setBaseAndExtent 与 Selection.modify选区写入使用原生Selection.setBaseAndExtent见 LexicalSelection.ts它能直接以 shadow 树内的节点作为 base/extent 参数键盘导航与按词/按行删除使用原生Selection.modify见 LexicalSelection.ts该 API 同样直接作用于 shadow 树节点。这正是 README 中Writing selection uses the nativeSelection.setBaseAndExtent… and keyboard navigation / word line deletion use the nativeSelection.modify的源码出处。6.4 浏览器级回归测试核心层还配有专门的浏览器测试 packages/lexical/src/tests/browser/ShadowRootSelection.test.ts其文件头注释说明了为何必须用真实浏览器Selection.getComposedRanges/Selection.direction/ShadowRoot.activeElement/Selection.modify这些 API 在 jsdom 中都没有真实实现只能在真实浏览器Vitest 的browser项目中断言。测试还包含能力检测逻辑——当宿主浏览器不支持getComposedRanges时相关辅助函数退化为 light-DOM 读取测试跳过 shadow 专属断言而不是直接失败该文件 第 48-54 行。从源码结构可以推断Lexical 对 Shadow DOM 的支持是平台能力优先、渐进退化的设计——支持新 API 的现代引擎走完整路径老引擎回退到传统 DOM 读取。七、运行与手动验证7.1 启动开发服务器在仓库根目录执行pnpm install pnpm -C dev-examples/shadow-dom devpnpm -C dir指定在dev-examples/shadow-dom子目录中执行脚本。Vite 服务器端口固定为4326strictPort: true这是 vite.config.ts 中显式钉住的端口目的就是让 Playwright 的webServer能稳定指向它。7.2 手动体验清单打开终端打印的 URL 后可以依次验证在两个编辑器中输入——外层light DOM与内层shadow root都应是可编辑的格式化外层选区——在外层编辑器用Alt/CtrlShift 方向键选中单词点击工具栏的Bold/Italic/Underline跨边界按词删除——在两个编辑器中分别用Alt/CtrlBackspace/Delete做按词与按行删除。7.3 构建与预览pnpm -C dev-examples/shadow-dom build # tsc 类型检查 vite build pnpm -C dev-examples/shadow-dom preview # 预览构建产物其中build脚本是tsc vite build见 package.json先做 TypeScript 类型检查再打包。八、Playwright 端到端测试测试由 tests/shadow-dom.spec.ts 提供共 5 个用例覆盖了 README 中承诺的所有核心行为用例验证点renders both editors with the inner one inside an open shadow root页面恰好有一个.shadow-host其shadowRoot ! nulllight-DOM 的querySelectorAll(div[contenteditabletrue])只能数到 1 个内层编辑器的contentEditable对 light DOM 不可见而 Playwright 能穿透 shadow root 看到两个编辑器types and reconciles text in the inner shadow editor在内层编辑器输入hello shadow world并断言回显types and reconciles text in the outer light-DOM editor在外层编辑器输入hello light world并断言回显formats an outer selection from the light-DOM toolbar选中外层最后一段文本点击工具栏 Bold断言出现strongworld/strong注释特别提醒Playwright 会穿透 shadow root所以定位必须用外层编辑器容器内的p而不是p:last-of-typedeletes by word in the inner shadow editor输入hello world后按CtrlBackspacemacOS 为AltBackspace删除一个词断言剩hello注释说明反向按词删除使用原生Selection.modify并通过 shadow root 内的getComposedRanges读取结果测试会自动启动 dev server配置见 playwright.config.tswebServer.command pnpm run dev、端口 4326、reuseExistingServer: true因此运行前无需手动起服务pnpm -C dev-examples/shadow-dom exec playwright install chromium pnpm -C dev-examples/shadow-dom test第一条命令安装 Chromium 浏览器内核第二条执行全部测试test脚本本身也包含playwright install chromium见 package.json。九、小结与延伸这个示例的价值在于把Shadow DOM 隔离从单纯的样式封装提升到了编辑器行为层面通过attachShadowcreatePortal构建隔离容器通过DecoratorNode在文档树中嵌套第二个编辑器再依靠 Lexical 核心对getComposedRanges、ShadowRoot.activeElement、setBaseAndExtent、Selection.modify等平台 API 的利用让选区读写、焦点获取与归因在跨 shadow 边界时依然正确。如果你想进一步探索仓库中还有两个可直接对照的路径ShadowRootSelection.test.ts核心层针对 shadow 选区的浏览器级测试是理解各 API 调用细节的第一手资料lexical-extension 包示例所用的LexicalExtensionComposer/defineExtension扩展体系所在包可以了解扩展注册与依赖注入的完整机制。按需查阅这些文件你可以在本项目基础上复现、裁剪或扩展出属于自己的跨 Shadow DOM 编辑器方案。【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价