🚀前端必学!告别样式冲突
什么是影子DOM?
影子DOM是 Web Components 标准套件中的一项关键技术。它允许你将一个隐藏的、独立的 DOM 树附加到一个常规的 DOM 元素上。
你可以把它想象成一个“DOM 中的 DOM”,但它具有封装特性,外部的样式或者js无法影响到其内部:
- 主文档树
- 我们平时用
document.getElementById
等 API 直接操作的就是主 DOM 树
- 我们平时用
- 影子树
- 影子 DOM 内部的节点是独立于主文档的,它们不会被主文档的 JavaScript 或 CSS 所影响,反之亦然,影子dom里面的内容也无法影响到外面。
创建影子 DOM
可以通过 Element.attachShadow()
方法来创建一个影子DOM
<script>
const dom = document.querySelector('.container')
const shadowRoot = dom.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `
<style>
.shadow-box {
margin-top:50%;
width: 100%;
height: 50%;
background-color: rgba(0, 128, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
</style>
<div class="shadow-box">
<p>这是影子DOM内容</p>
</div>
`
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
可以看到在 .container
下面多出了一个 shadow-root
,这个就是我们的影子DOM
影子DOM的特性
这里我在外面设置一个样式
但是里面的 shadow-box
的样式表完全没有继承到上面的样式,这就是影子DOM样式隔离性
我用js去获取 .shadow-box
也是获取不到这个 dom
的,外部的js也是没有办法直接获取到它的
获取影子DOM
如果想要获取到影子DOM,并且修改内部的内容或者样式,就需要通过 shadowRoot
这个对象
注意:前提是创建影子dom的时候模式要设置为
open
const shadowRoot = dom.attachShadow({ mode: 'open' })
像上面的这个例子,我就通过有影子dom的父容器里面的 shadowrRoot
对象访到了前面创建的影子dom
如果你创建影子dom的时候用的是 closed
模式,那么外部的js就获取不到shadowrRoot对象
影子DOM的作用
影子DOM最重要的特性就是隔离性
- 🛡️ 内部样式不会"泄漏":在Shadow DOM里写的
p { color: red; }
只会影响组件内部的段落,完全不用担心会影响到页面其他地方 - 🚫 外部样式无法"入侵":全局样式、UI框架、甚至
!important
都无法穿透进来干扰你的组件(除了少数继承属性) - 🎯 告别命名冲突:再也不需要BEM、CSS Modules那些复杂的命名约定,直接用最简单的选择器就行
如果设置了closed模式,那么:
- 🔒 外部JavaScript无法直接通过
document.querySelector
窥探或操作你的组件内部 - 🛡️ 第三方脚本再也无法意外破坏你的精心布局
- 💪 组件真正实现了"高内聚",内部细节被完美隐藏
应用场景
得益于影子DOM的高度隔离性,它非常适合在做组件库或者微前端架构的时候使用,这样可以保证你的样式隔离,不会被奇奇怪怪的全局样式污染.
举个🌰
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML = `
<style>
button {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #007bff;
color: white;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('my-button', MyButton);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
像这样在封装组件的时候,可以像这样通过 影子dom
去创建.
或者在微前端中用 影子DOM
去包裹子应用
// 主应用加载微前端
function loadMicroApp(container, appUrl) {
const shadowContainer = container.attachShadow({mode: 'open'});
// 加载微前端内容到 shadowContainer
}
2
3
4
5
这样就可以避免样式冲突引发一些奇奇怪怪的问题
还有一种就是接入第三方组件的时候,也可以用这个影子dom去包裹,让他的样式不会影响到外面
修改影子DOM样式的几种方式
当然最重要的时候,我们要如何修改影子DOM的样式,有以下几种方式
CSS变量
由于 shadow dom
是可以读取到我们外部设置的 css 变量
,所以可以使用和修改变量的方式,来改写样式,就像下面这样
/* 在主文档中定义主题变量 */
:root {
--primary-color: #007bff;
--component-bg: #ffffff;
--component-padding: 1rem;
}
2
3
4
5
6
const dom = document.querySelector('.container')
const shadowRoot = dom.attachShadow({ mode: 'closed' })
shadowRoot.innerHTML = `
<style>
.shadow-box {
margin-top:50%;
width: 100%;
height: 50%;
background-color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
</style>
<div class="shadow-box">
<p>这是影子DOM内容</p>
</div>
`
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
影子DOM内部可以访问到外部的css变量
::part() 伪元素
::part
CSS 伪元素表示在阴影树中任何匹配 part
属性的元素。
只要给影子DOM设置 part
属性,就可以通过这个伪元素去修改它的样式
.container::part(shadow-box) {
background: red;
}
2
3
shadowRoot.innerHTML = `
<style>
.shadow-box {
margin-top:50%;
width: 100%;
height: 50%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
</style>
<div class="shadow-box" part="shadow-box">
<p>这是影子DOM内容</p>
</div>
`
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
通过js处理
这种方式不是很推荐,因为破坏了Shadow DOM的设计初衷,导致组件脆弱难维护
前面说过如果创建影子dom的模式是 open
,那么我们就可以通过 shadowRoot
去获取里面的DOM,能获取到里面的dom,修改样式就很容易了
通过内部修改
这部分我觉得作用不是那么的大,了解就好.
:host
用于设置 宿主元素(即组件本身)的默认样式或状态样式。
<!-- 在 Shadow DOM 的 <style> 标签内 -->
<style>
/* 设置组件自身的默认样式 */
:host {
display: block; /* 最重要!自定义元素默认是inline */
margin: 0.5rem;
padding: 1rem;
border: 1px solid #ccc;
}
/* 当组件有 'active' 属性时 */
:host([active]) {
border-color: blue;
background-color: aliceblue;
}
/* 当组件有 'disabled' 属性时 */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
场景:定义组件容器的基础样式,或根据属性(如 disabled
, size="large"
)改变整体外观。
:host-context()
用于根据组件所在的外部祖先元素来应用样式。
<style>
/* 当我的某个祖先元素有 .dark-theme 类时 */
:host-context(.dark-theme) .card {
background-color: #333;
color: white;
}
/* 当我在一个侧边栏内时 */
:host-context(app-sidebar) {
margin: 0;
border-left: none;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
场景:让组件自动适配外部主题(如深色模式)或特定布局容器。
::slotted()
用于修饰通过 <slot>
插槽投射进来的用户提供的 Light DOM 内容。注意,只能改变它的字体、颜色等样式,不能改变布局(如 display, margin)。
<style>
/* 为所有插槽元素添加基础样式 */
::slotted(*) {
margin-bottom: 0.5rem;
}
/* 特别修饰插槽中的 h3 标签 */
::slotted(h3) {
color: var(--primary-color, blue);
border-bottom: 2px solid currentColor;
}
/* 修饰带有 .highlight 类的插槽元素 */
::slotted(.highlight) {
background-color: yellow;
padding: 0.25rem;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
场景:对用户传入的内容进行基础的样式装饰,保持与组件风格的统一。
总结
总而言之,Shadow DOM 绝非一个遥远而晦涩的概念,而是现代前端开发中解决**“隔离”与“封装”**两大核心痛点的利器。它通过创建独立的 DOM 树,带来了真正的样式和 DOM 隔离,让你能够:
- 自信地编写组件:无需再担心选择器命名冲突,可以使用最简单直观的CSS。
- 构建可靠的应用:无论是微前端架构还是引入第三方库,Shadow DOM 都是一道可靠的屏障,确保各个部分互不干扰。
- 提供灵活的API:通过 CSS 变量和
::part()
等方式,对外提供可控、安全的样式定制接口,而不是暴露脆弱的内部实现。
掌握 Shadow DOM,意味着你掌握了构建高内聚、低耦合、易于维护的现代化 Web 应用的关键技能。它将帮助你从被动地解决样式冲突,转向主动地设计封装良好的组件体系。
导读:Web组件不只是自定义元素那么简单。Shadow DOM、HTML模板和自定义元素各有其作用。在本文中,Russell Beswick将展示Shadow DOM在整体架构中的定位,解释其重要性、适用场景以及有效应用方法。
人们常将 Web组件
与 框架组件
做对比,但大多数示例实际上仅针对自定义元素——而这只是Web组件的一部分。我们很容易忽略一个事实:Web组件其实是一组可单独使用的Web平台API集合,包括:
- 自定义元素(Custom Elements)
- HTML模板(HTML Templates)
- Shadow DOM
也就是说,我们可以创建不依赖 Shadow DOM 或 HTML模板 的 自定义元素,但将这些特性结合使用,能增强组件的稳定性、可复用性、可维护性和安全性。它们是同一功能集的组成部分,既可单独使用,也可组合应用。
话虽如此,我想重点谈谈Shadow DOM及其定位。通过使用 Shadow DOM,我们可以在Web应用的各个部分之间划定清晰边界——将相关HTML和CSS封装在 DocumentFragment 中,实现组件隔离、防止冲突,并保持关注点的清晰分离。
如何利用这种封装特性,涉及到权衡取舍和多种实现方式。本文将深入探讨这些细节,而在后续文章中,我们将深入讲解如何处理封装样式。
为什么会有Shadow DOM?
大多数现代Web应用都由来自不同提供商的各种库和组件组合而成。在传统(或“light”)DOM中,样式和脚本很容易“泄露”或相互冲突。使用框架,你可以信任所有代码能无缝协作,但仍需努力确保所有元素都有唯一ID,而且CSS规则要做作用域限制。这往往会导致代码过于冗长,增加应用加载时间,降低可维护性。
<!-- div嵌套地狱 -->
<div id="my-custom-app-framework-landingpage-header" class="my-custom-app-framework-foo">
<div><div><div><div><div><div>等等……</div></div></div></div></div></div>
</div>
2
3
4
Shadow DOM的出现就是为了解决这些问题——它提供了一种隔离组件的方式。<video>
和 <details>
元素就是很好的例子,这些原生HTML元素内部默认都使用了 Shadow DOM,避免全局样式或脚本的干扰。正是这种驱动原生浏览器组件的“隐藏能力”,让Web组件与框架组件能真正区分开来。
可承载阴影根的元素
大多数情况下,阴影根(shadow root)与自定义元素关联,但它们也可以用在任何HTML UnknownElement
,许多标准元素也支持阴影根,包括:
<aside>
<blockquote>
<body>
<div>
<footer>
<h1>至<h6>
<header>
<main>
<nav>
<p>
<section>
<span>
每个元素只能有一个阴影根。有些元素(如 <input>
和 <select>
)已有内置的阴影根,且无法通过脚本访问。你可以在开发者工具(Developer Tools)中启用“显示用户代理Shadow DOM( Show User Agent Shadow DOM) ”设置来查看它们(默认是关闭的)。
创建阴影根
要利用Shadow DOM的优势,首先需要在元素上创建阴影根。这可以通过命令式或声明式方式实现。
命令式创建
使用JavaScript创建阴影根时,可调用元素的 attachShadow({ mode })
方法。mode
参数可以是 open
(允许通过 element.shadowRoot
访问)或 closed
(对外部脚本隐藏阴影根)。
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>来自Shadow DOM的问候!</p>';
document.body.appendChild(host);
2
3
4
在这个示例中,我们创建了一个 open
模式的阴影根。这意味着元素内容可从外部访问,我们可以像查询其他DOM节点一样查询它:
host.shadowRoot.querySelector('p'); // 查询<p>元素
如果想完全阻止外部脚本访问内部结构,可将模式设为 closed
。此时元素的 shadowRoot
属性会返回 null
,但我们仍能通过创建阴影根时的 shadow
引用来访问它:
shadow.querySelector('p');
这是一项关键的安全特性。通过 closed
模式的阴影根,我们可以确保恶意攻击者无法从组件中提取用户隐私数据。例如,一个显示银行信息的组件可能包含用户账号——若使用 open
模式,页面上的任何脚本都能深入组件并解析内容;而在 closed
模式下,只有用户通过手动复制或检查元素才能执行此类操作。
我建议使用Shadow DOM时采用封闭优先(closed-first approach) 原则:养成使用 closed
模式的习惯,仅在调试时或确实遇到无法避免的实际限制时,才使用 open
模式。
遵循这种方式,你会发现真正需要
open
模式的场景其实很少。
声明式创建
我们不必依赖JavaScript来使用Shadow DOM,也可以通过声明式方式注册阴影根。在任何支持的元素内嵌套带有 shadowrootmode
属性的 <template>
,浏览器会自动为该元素升级并创建阴影根。即使禁用JavaScript,这种方式也能生效。
<my-widget>
<template shadowrootmode="closed">
<p>声明式Shadow DOM内容</p>
</template>
</my-widget>
2
3
4
5
模式同样可以是 open
或 closed
。使用 open
模式前需考虑安全影响,但要注意:除非与已注册的自定义元素结合使用,否则无法通过脚本访问 closed
模式的内容。
若结合自定义元素,可使用 ElementInternals
访问自动附加的阴影根:
class MyWidget extends HTMLElement {
#internals;
#shadowRoot;
constructor() {
super();
this.#internals = this.attachInternals();
this.#shadowRoot = this.#internals.shadowRoot;
}
connectedCallback() {
const p = this.#shadowRoot.querySelector('p')
console.log(p.textContent); // 正常工作
}
};
customElements.define('my-widget', MyWidget);
export { MyWidget };
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Shadow DOM配置
除了 mode
,Element.attachShadow()
还支持另外三个配置选项。
选项1:clonable:true
直到最近,如果一个标准元素已附加阴影根,当你使用 Node.cloneNode(true)
或 document.importNode(node,true)
克隆它时,只能得到宿主元素的浅拷贝,而不包含阴影根内容。我们刚才看到的示例实际上会返回一个空的 <div>
。但对于内部构建阴影根的自定义元素,是没有这个问题。
但对于声明式Shadow DOM,这意味着每个元素都需要自己的模板,无法复用。有了这个新增特性,我们可以在需要时选择性地克隆组件:
<div id="original">
<template shadowrootmode="closed" shadowrootclonable>
<p>这是一个测试</p>
</template>
</div>
<script>
const original = document.getElementById('original');
const copy = original.cloneNode(true);
copy.id = 'copy';
document.body.append(copy); // 包含阴影根内容
</script>
2
3
4
5
6
7
8
9
10
11
选项2:serializable:true
启用此选项后,可保存元素阴影根内内容的字符串表示。调用宿主元素的 Element.getHTML()
会返回Shadow DOM当前状态的模板副本,包括所有嵌套的 shadowrootserializable
实例。这可用于将阴影根副本注入另一个宿主,或缓存供以后使用。
在Chrome中,即使是封闭的阴影根也支持此功能,因此使用时需注意避免意外泄露用户数据。更安全的方案是使用 closed
模式的外层容器隔离内部内容,同时保持内部为 open
模式:
<wrapper-element></wrapper-element>
<script>
class WrapperElement extends HTMLElement {
#shadow;
constructor() {
super();
this.#shadow = this.attachShadow({ mode:'closed' });
this.#shadow.setHTMLUnsafe(`
<nested-element>
<template shadowrootmode="open" shadowrootserializable>
<div id="test">
<template shadowrootmode="open" shadowrootserializable>
<p>深层Shadow DOM内容</p>
</template>
</div>
</template>
</nested-element>
`);
this.cloneContent();
}
cloneContent() {
const nested = this.#shadow.querySelector('nested-element');
const snapshot = nested.getHTML({ serializableShadowRoots: true });
const temp = document.createElement('div');
temp.setHTMLUnsafe(`<another-element>${snapshot}</another-element>`);
const copy = temp.querySelector('another-element');
copy.shadowRoot.querySelector('#test').shadowRoot.querySelector('p').textContent = '修改后的内容!';
this.#shadow.append(copy);
}
}
customElements.define('wrapper-element', WrapperElement);
const wrapper = document.querySelector('wrapper-element');
const test = wrapper.getHTML({ serializableShadowRoots: true });
console.log(test); // 因封闭阴影根,返回空字符串
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
注意这里使用了 setHTMLUnsafe()
——因为内容包含 <template>
元素,注入此类可信内容时必须调用此方法。若使用 innerHTML
插入模板,无法触发阴影根的自动初始化。
选项3:delegatesFocus:true
此选项本质上让宿主元素像内部内容的 <label>
一样工作。启用后,点击宿主元素的任意位置或调用其 .focus()
方法,光标会移动到阴影根中第一个可聚焦的元素上。同时,宿主元素会应用 :focus
伪类,这在创建需参与表单交互的组件时特别有用。
<custom-input>
<template shadowrootmode="closed" shadowrootdelegatesfocus>
<fieldset>
<legend>自定义输入框</legend>
<p>点击此元素任意位置聚焦输入框</p>
<input type="text" placeholder="输入一些内容...">
</fieldset>
</template>
</custom-input>
2
3
4
5
6
7
8
9
这个示例仅展示了焦点委托。封装特性有一个特殊点:表单提交不会自动关联,这意味着输入框的值默认不会包含在表单提交数据中。表单验证和状态也无法传出Shadow DOM。无障碍访问也存在类似的关联问题——阴影根边界可能干扰ARIA属性。这些表单特有的问题可通过 ElementInternals
解决,这也让我们思考:是否可以依赖light DOM表单替代?
插槽内容
到目前为止,我们只讨论了完全封装的组件。Shadow DOM的一个关键特性是使用插槽(slots)有选择地向组件内部结构注入内容。每个阴影根可以有一个默认(未命名)的 <slot>
,其他插槽必须命名。命名插槽允许我们为组件的特定部分提供内容,同时为用户未提供内容的插槽设置后备内容:
<my-widget>
<template shadowrootmode="closed">
<h2><slot name="title"><span>后备标题</span></slot></h2>
<slot name="description"><p>占位描述文本。</p></slot>
<ol><slot></slot></ol>
</template>
<span slot="title">插槽标题</span>
<p slot="description">使用插槽填充组件部分内容的示例。</p>
<li>Foo</li>
<li>Bar</li>
<li>Baz</li>
</my-widget>
2
3
4
5
6
7
8
9
10
11
12
默认插槽也支持默认内容(fallback content),任何零散的文本节点都会填充它。因此,只有折叠宿主元素标记中的所有空白,默认内容才能生效:
<my-widget><template shadowrootmode="closed">
<slot><span>后备内容</span></slot>
</template></my-widget>
2
3
当插槽的 assignedNodes()
添加或移除时,<slot>
元素会触发 slotchange
事件。这些事件不包含对插槽或节点的引用,因此需要在事件处理程序中传入这些信息:
class SlottedWidget extends HTMLElement {
#internals;
#shadow;
constructor() {
super();
this.#internals = this.attachInternals();
this.#shadow = this.#internals.shadowRoot;
this.configureSlots();
}
configureSlots() {
const slots = this.#shadow.querySelectorAll('slot');
console.log({ slots });
slots.forEach(slot => {
slot.addEventListener('slotchange', () => {
console.log({
changedSlot: slot.name || '默认',
assignedNodes: slot.assignedNodes()
});
});
});
}
}
customElements.define('slotted-widget', SlottedWidget);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
多个元素可分配给同一个插槽,既可通过 slot
属性声明式实现,也可通过脚本实现:
const widget = document.querySelector('slotted-widget');
const added = document.createElement('p');
added.textContent = '通过命名插槽添加的第二段文本。';
added.slot = 'description';
widget.append(added);
2
3
4
5
注意,此示例中的 <p>
元素被添加到宿主元素中。插槽内容实际上属于“light DOM”,而非Shadow DOM。
与前文示例不同,这些元素可直接通过 document
对象查询:
const widgetTitle = document.querySelector('my-widget [slot=title]');
widgetTitle.textContent = '不同的标题';
2
如果想在类定义内部访问这些元素,可使用 this.children
或 this.querySelector
。只有 <slot>
元素本身可通过Shadow DOM查询,其内容不行。
从陌生到精通
现在你已经了解了为什么要使用Shadow DOM、何时应将其纳入开发工作,以及如何开始使用它。
但你的Web组件之旅不应止步于此。