前端知库

Declarative Shadow DOM 适合解决什么问题?

解释 template shadowrootmode 如何让服务端直接输出 Shadow DOM,覆盖 SSR 首屏、样式封装、客户端 hydration、slot、渐进增强、兼容性和安全边界。

参考答案

简答

  • template 加 shadowrootmode 属性可以让服务端直接输出 Shadow DOM,首屏无需 JavaScript 也能显示封装后的结构。
  • 它和普通 template 不同,解析时会直接附加为 Shadow Root。
  • 自定义元素升级时应先复用已有的 this.shadowRoot,没有时才调用 attachShadow。
  • slot 与 light DOM 的边界仍然存在,交互状态仍需客户端接管。

传统 Shadow DOM 通常在浏览器执行 JavaScript 后才创建:自定义元素构造器调用 attachShadow(),再把模板插入根节点。这样组件在脚本下载和执行前没有内部结构,服务端渲染的 HTML 也无法直接包含封装后的 Shadow DOM。

Declarative Shadow DOM(声明式 Shadow DOM)允许 HTML 解析器直接把一个带 shadowrootmode 的 <template> 变成 Shadow Root:

HTML
<user-card>
  <template shadowrootmode="open">
    <style>
      :host { display: block; padding: 12px; border: 1px solid #ccc; }
      .name { font-weight: 600; }
    </style>
    <span class="name">服务端输出的用户名</span>
  </template>
</user-card>

浏览器支持该能力时,模板内容会成为 user-card 的 Shadow Root,模板元素本身不再作为普通 light DOM 节点保留。组件的内部样式在解析后即可生效,不必等待客户端脚本创建根。这样服务端可以输出可见内容和封装样式,减少首屏空白,并为后续自定义元素升级保留结构。

声明式模板和普通 template 不是同一回事

普通 <template> 的内容默认处于惰性文档片段中,不会自动渲染:

HTML
<template>
  <p>这段内容不会直接显示</p>
</template>

只有识别 shadowrootmode="open" 或 shadowrootmode="closed" 的浏览器,才把对应模板当作声明式 Shadow DOM。旧浏览器可能把它当普通 template,用户看不到模板里的主要内容;因此不能把模板本身当成完整的跨浏览器渐进增强方案,需要提供兼容路径或服务端输出可用的 light DOM。

open 允许脚本通过 element.shadowRoot 获取根,closed 则不暴露这个属性。两者都不是安全沙箱,也不能阻止同一页面中的脚本读取、修改或观察组件逻辑。选择 open/closed 是封装和调试 API 设计,不是权限控制。

自定义元素升级时怎样接管已存在的 Shadow Root

声明式 Shadow DOM 的价值之一是:HTML 可以先显示结构,随后自定义元素类再接管交互。升级时元素上已经有声明式根,可以直接通过 this.shadowRoot 复用;对已有声明式根调用 attachShadow() 会返回该根但清空其内容,所以只有在没有根时才创建:

JavaScript
class UserCard extends HTMLElement {
  constructor() {
    super();
    // 复用服务端输出的根,避免 attachShadow() 清空已有内容
    const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
    if (!root.querySelector('.name')) {
      root.innerHTML = '<span class="name"></span>';
    }
    this.nameElement = root.querySelector('.name');
  }

  connectedCallback() {
    this.addEventListener('click', this.handleClick);
  }

  disconnectedCallback() {
    this.removeEventListener('click', this.handleClick);
  }

  handleClick = () => {
    this.dispatchEvent(new CustomEvent('user-card-activated', {
      bubbles: true,
      composed: true,
    }));
  };
}

customElements.define('user-card', UserCard);

closed 模式下外部读不到 this.shadowRoot,组件类可以通过 ElementInternals.shadowRoot 取得声明式根。不要把 closed 的根改成 open 来“修复”升级。真实框架集成还要处理服务端输出和客户端状态是否一致、重复绑定事件、属性初始值以及异步数据到达顺序。Shadow DOM 已经存在并不代表交互逻辑自动完成,hydration 仍需有明确的组件状态模型。

如果浏览器不支持声明式根,attachShadow() 的行为和模板仍会走兼容路径。应用可以在自定义元素升级时检测 this.shadowRoot、创建普通 Shadow Root,并从模板或服务器数据恢复结构;但不要假设旧浏览器已经解析并保存了 shadowrootmode 的内容。兼容策略应由构建目标和实际浏览器测试决定。

SSR 输出要区分可见结构和交互状态

声明式 Shadow DOM 让服务端能输出样式和文本,但服务端仍不能替浏览器保存任意运行时对象、事件监听器和用户权限。下列内容可以作为首屏结构:

  • 已经脱敏、允许当前用户看到的文字和图片;
  • 组件的语义结构、slot 占位和初始展示状态;
  • 不依赖浏览器测量结果的 CSS 和可访问名称。

下列内容要在客户端根据可信状态重新验证:

  • 登录权限、支付按钮是否可用和用户特有操作;
  • 依赖 window、设备能力、尺寸或当前时间的结果;
  • 事件监听、网络连接、计时器和 Worker;
  • 不能直接信任的服务端输出或用户输入。

Shadow DOM 不会让输出数据自动保密。只要内容出现在 HTML、可访问树、客户端属性或脚本能读到的状态里,用户就可能看到它。服务端必须在生成 HTML 前完成授权和字段筛选,不能把“组件内部”当成隐藏敏感信息的方法。

slot 和 light DOM 的边界仍然存在

声明式根内部可以使用 slot,让外部 light DOM 内容参与显示:

HTML
<notice-box>
  <span slot="title">保存结果</span>
  <p>内容来自宿主页面。</p>
  <template shadowrootmode="open">
    <style>:host { display: block; }</style>
    <h2><slot name="title">提示</slot></h2>
    <slot></slot>
  </template>
</notice-box>

slot 是内容分发,不是复制 DOM。外部节点仍属于 light DOM,内部 CSS 不能随意选择其后代;继承、自定义属性和事件的 composed/bubbles 规则仍影响组件接口。服务端渲染 slot 时要确保 name、顺序和无障碍阅读顺序一致,不能只根据视觉位置猜测屏幕阅读器会怎样朗读。

自定义事件如果要穿过 Shadow Root,需要显式设置 composed: true,但 detail 中不要放未经筛选的内部对象、DOM 节点或敏感数据。事件 API 是组件公共契约的一部分,应记录名称、目标、detail 结构和取消语义。

资源和样式并不会自动零成本

Shadow DOM 隔离选择器范围,有助于组件样式不互相污染,但每个组件模板仍会产生解析、样式计算和布局成本。大量重复内联 <style>、大规模服务器输出和深层 slot 结构可能增加 HTML 体积和渲染工作。共享样式表、构建时抽取和组件按需加载是否更合适,要结合浏览器支持和实际性能测量决定。

声明式根中的样式会随着 HTML 到达而解析,仍可能造成样式更新和布局变化。给图片和媒体预留尺寸、控制自定义字体加载、避免首屏脚本修改服务端结构,通常比单独追求“使用 Shadow DOM”更能改善体验。Shadow DOM 不能替代资源提示、缓存、骨架策略和性能预算。

与框架 SSR 和 hydration 的关系

React、Vue、Svelte 或其他框架可以把自定义元素当作输出目标,但框架的 hydration 机制和 Declarative Shadow DOM 的升级机制不是同一个协议。需要明确:

  • 服务端输出的 <template shadowrootmode> 是否会被框架保留或重新渲染;
  • 客户端是否会再次创建节点,导致事件重复或 DOM 不匹配;
  • 属性字符串、property 对象和自定义事件如何从框架边界传递;
  • 不支持声明式 Shadow DOM 的浏览器是否有同等可用的渲染路径;
  • 路由导航和局部更新时,旧 Shadow Root 的事件与资源是否正确清理。

不要仅凭一个静态 HTML 示例宣布框架支持。应在目标框架版本中验证首屏、刷新、客户端导航、错误恢复、无 JavaScript、脚本延迟和自定义元素升级顺序。服务端组件输出的内容仍需要按框架和浏览器的实际协议处理,不能把 template 当成任意框架的通用 hydration 格式。

安全、无障碍和兼容性验收

声明式 Shadow DOM 不是 XSS 防护。服务端输出到模板中的用户内容必须按文本节点或安全渲染规则处理,不能把用户输入直接拼入 innerHTML。Shadow Root 的封装也不能阻止同源恶意脚本修改宿主、监听事件或调用组件公开方法。

无障碍验收要从用户任务开始:宿主的 label、标题、错误文本和焦点是否可获得;slot 内容是否有正确阅读顺序;关闭或禁用状态是否同步到语义属性;组件在无 JavaScript 时是否仍提供合理的静态内容。Shadow DOM 的边界可能影响选择器和测试工具查找,测试应使用用户可见语义和公开契约,而不是依赖内部 class 名。

目标浏览器和 WebView 对 Declarative Shadow DOM、closed root、模板流式解析以及相关序列化能力的支持存在差异。应做能力检测并保留回退,不要把未知属性被忽略的行为当成可靠降级。至少测试:

  • 支持和不支持声明式 Shadow DOM 的浏览器;
  • SSR 首屏、脚本延迟、无脚本和自定义元素晚注册;
  • open/closed、slot、事件穿透和表单关联组件;
  • 页面导航、组件移除、重复升级和异步数据迟到;
  • 用户输入转义、权限变化、键盘焦点和屏幕阅读器。

Declarative Shadow DOM 的核心价值是把 Shadow DOM 纳入 HTML/SSR 管线,让组件在客户端脚本执行前就能拥有封装结构。它不替代自定义元素生命周期、框架 hydration、服务端授权和渐进增强;只有把结构输出、状态接管、事件清理和兼容回退一起设计,首屏收益才不会变成新的维护问题。