使用 Shadow DOM 和 CSS 为 Web Components 设置样式
如果你曾经将 Web Component 放入页面,然后疑惑为什么全局 CSS 在其内部不起作用,那么你就遇到了 shadow 边界。这并不是 bug——而正是其设计初衷。Shadow DOM 为自定义元素提供了独立的作用域 DOM 树,这意味着样式默认不会渗入或泄漏出来。但这并不意味着无法设置样式,而是意味着样式设置是 有意为之 的。
本文涵盖了 Shadow DOM 样式设置的主要机制:内部样式、:host、::slotted()、CSS 自定义属性、::part() 以及 adoptedStyleSheets。
核心要点
- Shadow DOM 按设计对样式进行作用域隔离,因此全局 CSS 无法到达组件内部,内部样式也无法泄漏出来。
- Shadow 样式作为默认值,在 CSS 级联中位于页面作者样式之下,类似于浏览器的用户代理样式表。
- 使用
:host和:host()为组件元素设置样式,使用::slotted()为投影的 light DOM 子元素(仅限直接后代)设置样式。 - CSS 自定义属性和
::part()共同构成了一个有意设计的公共样式 API:变量用于主题令牌,parts 用于结构化样式。 adoptedStyleSheets允许多个组件共享单个已解析的CSSStyleSheet,在大规模场景下提升性能。
为什么 Shadow DOM 会改变 CSS 的行为方式
当你在元素上调用 attachShadow() 时,你会创建一个独立的 DOM 树,附加到该元素(即 shadow host)。页面样式表中的选择器无法到达该树内部,shadow 树内部的选择器也无法触及外部。
有一个重要的细节值得理解:shadow DOM 样式在 CSS 级联中位于你的作者样式 之下。这意味着像 * { color: red } 这样的全局规则会覆盖 shadow 内部的 :host { color: green } 规则——即使全局规则在源代码顺序中出现得更早。可以将 shadow 样式看作组件的默认样式,类似于浏览器的用户代理样式表为 <button> 或 <input> 提供默认值的方式。
使用 :host 为 Shadow Host 设置样式
在 shadow 树内部,:host 选择附加 shadow 的那个元素:
:host {
display: block;
font-family: sans-serif;
}
你也可以使用 :host(selector) 根据 host 上的属性或类有条件地应用样式:
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
由于 host 元素位于 light DOM 中,文档级样式会覆盖 :host 规则。如果你希望组件的默认值胜出,可以在 shadow 内部使用 !important——这是真正适合使用它的少数场景之一。
使用 ::slotted() 为插槽内容设置样式
插槽(slot)允许你将 light DOM 内容投影到 shadow 树中。::slotted() 伪元素允许你从 shadow 内部为这些投影元素设置样式:
::slotted(p) {
margin: 0;
color: #333;
}
重要限制: ::slotted() 只匹配直接分配给插槽的元素——不包括其后代。像 ::slotted(p span) 这样的选择器不会生效,并且 ::slotted() 只接受复合选择器(不能使用后代组合符)。要进行更深层的样式设置,请依赖 CSS 继承,或让 light DOM 自己的样式来处理。
CSS 自定义属性可以跨越 Shadow 边界
CSS 变量(自定义属性)可以自由穿透 shadow 边界。这使它们成为 Web Components 主题化 最灵活的工具:
/* In the shadow tree */
:host {
background: var(--card-bg, white);
color: var(--card-color, black);
}
/* In your page stylesheet */
my-card {
--card-bg: #1a1a2e;
--card-color: #eee;
}
组件定义钩子,使用者设置值。始终包含回退值,这样组件在没有任何外部配置的情况下也能工作。
Discover how at OpenReplay.com.
使用 CSS Shadow Parts (::part()) 暴露样式钩子
CSS Shadow Parts 是从外部为 Web Component 内部元素设置样式的现代解决方案。在组件内部,你用 part 属性标记元素:
<button part="trigger">Open</button>
在组件外部,使用者可以使用 ::part() 直接定位该 part:
my-dialog::part(trigger) {
background: royalblue;
border-radius: 4px;
}
这是一个有意设计的样式 API——组件作者决定暴露什么。对于结构化样式(布局、边框、背景),它比 CSS 变量更强大,同时保持内部实现细节的私密性。
使用 adoptedStyleSheets 高效共享样式
adoptedStyleSheets 允许你将 CSSStyleSheet 对象直接附加到 shadow root 上。它在现代浏览器中得到广泛支持,非常适合需要在多个实例间共享一个已解析样式表的组件库:
const sheet = new CSSStyleSheet();
sheet.replaceSync(`:host { display: block; }`);
class MyComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sheet];
}
}
customElements.define('my-component', MyComponent);
无论有多少组件使用,浏览器都只解析一次样式表——在大规模场景下这是一个显著的性能优势。你可以通过修改 adoptedStyleSheets 数组来添加或移除样式表,对共享 CSSStyleSheet 的更改会应用到所有采用它的地方。
选择合适的方案
| 目标 | 工具 |
|---|---|
| 为组件的 host 元素设置样式 | :host / :host() |
| 为投影的 light DOM 内容设置样式 | ::slotted() |
| 向使用者暴露主题令牌 | CSS 自定义属性 |
| 向使用者暴露结构化样式 | ::part() |
| 在多个组件间共享样式 | adoptedStyleSheets |
总结
Shadow DOM 样式设置并不是要把 CSS 锁在外面——而是要让封装变得明确。使用内部样式和 :host 设置默认值,使用 CSS 变量和 ::part() 作为公共样式 API,在大规模性能至关重要时使用 adoptedStyleSheets。一旦建立了这种心智模型,为 Web Components 设置样式就会变得简单明了。
常见问题
因为该组件使用了 Shadow DOM,它创建了一个与主文档隔离的作用域 DOM 树。页面样式表无法选择 shadow 树内的元素。要为内部元素设置样式,组件作者必须暴露钩子,例如 CSS 自定义属性,或通过 part 属性暴露 CSS Shadow Parts,然后你可以从外部使用 ::part() 来定位它们。
对于颜色、间距和字体等可以通过继承自然流动的设计令牌,使用自定义属性。当使用者需要对特定的内部元素进行结构化控制时(例如覆盖按钮或标题的边框、背景或布局),使用 ::part()。Parts 提供更细粒度的访问,而变量则更简单、作用域更广。
::slotted() 伪元素只匹配直接分配给插槽的顶层节点,不包括其后代。它也只接受复合选择器,因此后代组合符是无效的。要为插槽元素的子元素设置样式,可以依赖来自插槽元素的 CSS 继承,或让使用者自己的 light DOM 样式表直接处理这些后代。
可以。它在所有现代常青浏览器中都得到了支持,是在多个 shadow root 之间共享样式而无需重新解析的推荐方式。一个已解析的 CSSStyleSheet 可以附加到多个 shadow root 上,从而减少内存使用并改善启动时间。共享样式表还可以在多个组件间高效地更新和重用。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.