JavaScript / CSS / Web Components
Shadow DOM 实战:构建真正可隔离的 Web Component
把一个组件复制到另一个项目,最容易出问题的往往不是 JavaScript,而是样式。宿主页面的一条 button { ... } 改掉组件按钮,组件内部的 .title 又意外影响外部同名元素。为了避免冲突,团队不断增加命名前缀,却仍无法保证第三方页面没有更强规则。
Shadow DOM 为 Web Components 提供 DOM 与样式封装。组件可以拥有自己的内部树、局部样式和插槽,同时通过明确的属性、事件与 CSS 自定义属性向外暴露能力。它不是“绝对隔离的 iframe”,但非常适合构建可复用的浏览器原生组件。
一、创建第一个 Shadow Root
先定义一个自定义元素:
1 | class UserBadge extends HTMLElement { |
页面中使用:
1 | <user-badge>雯风未动</user-badge> |
attachShadow({ mode: 'open' }) 创建 Shadow Root。内部 .avatar 不会被页面普通 .avatar 选择器匹配,内部 <style> 也不会直接影响外部文档。
二、宿主、Shadow Tree 和 Light DOM
理解三个概念就能理清大部分边界:
- 宿主(host):页面里的
<user-badge>元素。 - Shadow Tree:
shadowRoot内部的私有结构。 - Light DOM:用户写在
<user-badge>...</user-badge>之间的外部内容。
<slot> 把 Light DOM 内容投影到 Shadow Tree 的指定位置。具名插槽可以承载多个区域:
1 | <profile-card> |
1 | <article class="card"> |
插槽不会把 Light DOM 节点真正移动进 Shadow Tree;它建立的是渲染上的分配关系。页面脚本仍可以通过宿主的普通子节点访问这些内容。
三、使用 :host 和 CSS 变量设计主题接口
Shadow 内部通过 :host 选择宿主:
1 | :host { |
外部页面可以设置组件公开的 CSS 自定义属性:
1 | profile-card { |
CSS 变量能够跨过 Shadow 边界继承,是建立主题令牌的常用方式。变量名应带组件前缀并写进文档;如果直接让所有全局令牌渗透进来,组件仍会和宿主设计系统强耦合。
四、::slotted 能做什么,不能做什么
Shadow 样式可以选择直接分配给插槽的元素:
1 | ::slotted(img) { |
但 ::slotted() 只能匹配被分配的顶层节点,不能继续深入选择其后代:
1 | /* 不会像普通后代选择器那样深入工作 */ |
如果组件需要控制复杂 Light DOM 内部结构,应缩小插槽自由度、提供多个明确插槽,或把需要统一控制的结构放进 Shadow Tree,而不是依赖深层外部标记。
五、属性负责输入,事件负责输出
Web Component 的公共接口可以保持和原生元素相似:属性传入状态,事件通知变化。
1 | class ToggleSwitch extends HTMLElement { |
自定义事件想跨过 Shadow 边界让外部监听,需要根据场景设置 composed: true,通常也会设置 bubbles: true。不要把整个内部对象和可变 DOM 节点塞进 detail;传递稳定、最小的领域数据更容易维护。
浏览器会对跨边界事件进行 retargeting,外部看到的 event.target 可能是宿主元素而不是内部按钮。这是封装的一部分,外部代码不应依赖内部 DOM 结构。
六、open 与 closed 不是安全等级
mode: 'open' 允许通过 element.shadowRoot 访问内部树;closed 会让该属性返回 null。但 closed 不是保护密钥、支付信息或商业逻辑的安全边界。页面和组件运行在同一 JavaScript 环境,仍可能通过其他方式观察或修改行为。
大多数业务组件使用 open 更便于测试、调试和集成。选择 closed 应有明确封装理由,不能把它描述成“防止攻击”。
同样,Shadow DOM 也不是 iframe:网络权限、JavaScript 全局环境和 CSP 仍属于同一个页面。
七、无障碍不会自动完成
Shadow DOM 的语义仍来自内部真实元素。能用 <button> 就不要用 <div role="button">;图片仍需要合适的 alt;标题层级仍要和页面结构配合。
需要特别检查:
- 宿主上的标签是否能正确命名内部控件;
- 键盘焦点是否按预期进入和离开组件;
- 状态变化是否通过原生属性或 ARIA 表达;
- 错误信息与表单控件的关联是否跨边界有效;
- 页面缩放和高对比模式下样式是否仍可读。
表单关联、自定义焦点管理等复杂场景需要额外 API 与测试。不要因为视觉隔离成功,就假设辅助技术也获得了完整信息。
八、性能与渲染策略
示例使用 innerHTML 便于讲解,但大型列表中每个实例都重新解析一份模板和 style 可能产生开销。可以复用 <template>:
1 | const template = document.createElement('template'); |
更新时尽量修改具体文本和属性,不要每次状态变化都重建整个 Shadow Tree,否则会丢失焦点、选择状态和内部节点引用。
九、落地检查清单
- 组件真的需要样式和 DOM 隔离,还是普通语义组件已经足够?
- 属性、方法、事件和 CSS 变量是否形成了清晰公共接口?
- 插槽内容的结构自由度是否可控?
- 自定义事件是否需要
bubbles和composed? - 是否错误地把 closed Shadow Root 当作安全边界?
- 键盘、读屏、缩放和高对比模式是否实际测试?
- 大量实例是否复用模板并避免整树重建?
Shadow DOM 最有价值的地方不是把内部节点藏起来,而是迫使组件建立边界:内部结构自行负责,外部只能通过公开输入、事件和主题令牌交互。边界越清楚,组件跨项目复用时越不容易被偶然的全局样式击穿。
参考资料
- MDN:Using shadow DOM — https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM