很多人第一次接触 Shadow DOM,都是在改别人封装好的组件时被逼的。你打开 DevTools,明明能看到一层层节点,可外部写的 CSS 就是进不去;你想用 JS 去 document.querySelector 抓里面的元素,结果抓到 null。这时候你就得搞清楚一件事:shadow 元素到底是什么,它怎么判断自己是不是在 shadow 内,又要怎么把 CSS 追加进去。这篇文章以 Web Components 里的 Shadow DOM 为主线,把这三个问题一次性讲透,核心关键词就是 Shadow DOM、CSS 作用域、样式隔离。
我尽量不端着讲术语。你以为是"神秘黑魔法"的东西,拆开其实就是一棵树、一道边界、几条规则。适合刚接触 Web Components 的开发者,也适合被第三方组件封装逼疯的"样式改造困难户"。看完你至少能写出自己的 isInShadow 判断函数、能穿透 Shadow 树去查节点、能通过官方口子优雅地给组件的内部元素上 CSS。
1. Shadow 元素到底是什么?先拆开 Shadow DOM 的"结界"
1.1 用"围墙小区"理解 Shadow DOM 结构
Shadow 元素不是一个具体的 HTML 标签,它是"住在 shadow root 下面的所有子树节点"的统称。如果把整个文档比作一座城市,普通 DOM 元素就是路边随便能进去的店铺,而 Shadow DOM 里的节点在围墙小区里——门卫就是 shadow root,只有拿到门禁卡才能进去。
技术上的定义是:Shadow DOM 是 Web Components 的三大支柱之一(另外两个是 Custom Elements 和 HTML Templates),它能给一棵子树提供独立的 DOM 容器和独立的样式作用域。浏览器的原生控件就是典型例子:<video>的控制条、<input type="date">的日期面板、<select>的下拉列表,内部都藏着 user agent shadow DOM。你之所以感觉这些控件"顽固不化",本质上是因为它们内部节点根本不归你页面的 CSS 管。
1.2 几个绕不开的名词:host、root、shadow tree
先记住四个词,后面所有代码都靠它们沟通:
- shadow host:挂在普通 DOM 里的宿主元素,比如一个自定义标签
<my-card>; - shadow root:shadow 树的根节点,也是样式和 DOM 的边界,相当于"门卫";
- shadow tree:shadow root 下面长出来的所有节点;
- light DOM:宿主元素原来的子节点,通过
<slot>能投射进 shadow 内去渲染。
创建一个 shadow root 非常简单:
const host = document.querySelector('#host'); const shadow = host.attachShadow({ mode: 'open' });这里的mode决定了外部能不能直接访问。open模式下,外部可以用host.shadowRoot拿到 shadow root;closed模式下,host.shadowRoot返回 null,外部拿不到引用,只有组件内部才知道自己 root 在哪。
1.3 为什么要用 Shadow DOM?核心就两个字:隔离
没有 Shadow DOM 的年代,一个团队写按钮组件,另一个团队写弹窗组件,样式表一合并就是一场混战:类名撞车、!important互砸、改一个组件崩三个页面。Shadow DOM 把样式作用域彻底封死了——外部的全局 CSS 进不去,内部的样式默认也出不来。有人问"css 文件需要写<style>吗",放到这个场景里就很好回答:你外链一个全局 CSS,里面写一万条规则也管不到 shadow 内部;组件内部样式必须写在<style>标签里,或者用构造样式表绑进去。这个隔离特性加上 DOM 封装,让组件可以自包含地交付,不会被外部浮躁的类名污染,也不会污染别人。
不过我也得泼盆冷水:不是所有组件都需要 Shadow DOM。一个静态展示组件、一个纯逻辑工具函数,硬套 attachShadow 只会增加调试成本。Shadow DOM 解决的是"隔离和复用"问题,不是"炫技"问题。
2. 判断元素是否在 Shadow 内:getRootNode() 与手写检测函数
2.1 getRootNode():从"根"判断归属
要判断一个元素是不是 shadow 子元素,最靠谱的做法不是一路查父节点,而是直接问"你的根是谁"。DOM 节点有一个方法叫getRootNode(),它返回当前节点所在树的根节点:
- 普通文档里的元素,
getRootNode()返回document; - Shadow 树里的元素,
getRootNode()返回它所属的ShadowRoot; - 如果这个节点是脱离文档的游离节点,则返回它自己或所在的 DocumentFragment。
写段代码实测:
const normalEl = document.querySelector('.normal'); const shadowEl = document.querySelector('#host').shadowRoot.querySelector('.inner'); console.log(normalEl.getRootNode()); // #document console.log(shadowEl.getRootNode()); // #shadow-root (open)这里有个细节值得注意:ShadowRoot本身是一个DocumentFragment,它的nodeType是 11,而且它有一个host属性指向宿主元素。普通DocumentFragment没有host。这个差异就是判断的关键。
2.2 手写 isInShadow 工具函数的边界情况
把上面的结论封装成一个工具函数:
function isInShadow(element) { const root = element.getRootNode(); return ( root !== document && root.nodeType === Node.DOCUMENT_FRAGMENT_NODE && Boolean(root.host) ); }三个条件缺一不可。root !== document过滤掉普通文档节点;nodeType === 11过滤掉游离节点自己当根的情况;root.host确保它是真正的 ShadowRoot,而不是document.createDocumentFragment()创建的普通片段。
还有一个更巧妙的思路:getRootNode()支持{ composed: true }参数,它会返回"穿透所有 shadow 边界后最外层那个根"。所以对任何在页面里的节点,getRootNode({ composed: true })都是 document。你只要比较两次结果是否一致就能判断:
function isInShadow(element) { return element.getRootNode() !== element.getRootNode({ composed: true }); }这个写法更简短,不过可读性稍差,我一般用在代码压缩或者单行判断的场合。
2.3 一个容易踩的坑:document.contains()
有人习惯用document.contains(el)判断元素是否在页面里,然后想当然地以为返回 false 就是"在 shadow 内"。这个推理是有漏洞的。document.contains()默认不会跨进 shadow 树,shadow 内的子节点在文档眼里确实不存在,所以返回 false 没错;但一个createElement出来还没挂载的游离节点,document.contains()同样返回 false。用 contains 判断 shadow 归属,会把游离节点误判成 shadow 节点。所以别偷懒,老老实实用getRootNode()判断。
另外要强调一个容易忽略的事实:shadow host 本身不算 shadow 子元素。宿主元素活在 light DOM 里,它的getRootNode()返回的是 document。shadow 内与外,边界就在 host 这一层划开。这跟我们直觉里"组件的根也是组件的一部分"有点冲突,但在 API 层面就是这么分的。
3. 获取 Shadow 元素内部节点:open 直取与递归穿透
3.1 open 模式直接拿 shadowRoot 查询内部
如果你的组件用的是mode: 'open',那获取内部节点异常简单:
const shadowRoot = host.shadowRoot; const innerBtn = shadowRoot.querySelector('button'); const firstItem = shadowRoot.querySelectorAll('.item');ShadowRoot支持querySelector、querySelectorAll、getElementById这些常用查询方法,跟document上的用法几乎一样。注意它不会越界到外面的 light DOM,所以shadowRoot.querySelector('body > div')这种跨边界的选择器是查不到的。
用 DevTools 调试的时候还有个快捷键:在 Elements 面板选中一个节点,控制台里$0就是它。所以你可以直接在 Console 里写$0.shadowRoot.querySelector('.toys'),比来回复制选择器快得多。
3.2 递归穿透所有 Shadow 树的 deepQueryAll
open 模式只能逐层手动取 root,遇到组件里面套组件、shadow 里还有 shadow 的情况就很痛苦。我把一个深度遍历函数贴出来,它会像"地铁换乘"一样,在每个节点的 shadowRoot 处换乘继续往下查:
function deepQueryAll(root, selector) { const results = []; const seen = new Set(); (function walk(node) { if (!node || !node.querySelectorAll || seen.has(node)) return; seen.add(node); results.push(...Array.from(node.querySelectorAll(selector))); // 当前节点自身有 shadowRoot,先进去 if (node.shadowRoot) walk(node.shadowRoot); // 再遍历所有子元素,发现谁身上还挂着 shadowRoot for (const child of Array.from(node.querySelectorAll('*'))) { if (child.shadowRoot) walk(child.shadowRoot); } })(root); return [...new Set(results)]; }用法:
const allButtons = deepQueryAll(document, 'button'); const allPartNodes = deepQueryAll(document, '[part], [data-theme]');这个函数有几个注意点:一是性能,对超大页面反复querySelectorAll('*')是有开销的,建议只在初始化或事件回调里调用,别放高频循环里;二是动态渲染的元素它管不了,如果组件内部是异步渲染,得等它渲染完再查;三是它只能穿透 open 模式的 shadowRoot,closed 模式在外部拿不到引用,这是设计使然。
3.3 closed 模式能不能获取?该不该用
closed 模式下,从外部拿host.shadowRoot会得到 null,标准 API 没有后门。网上流传的"打补丁"方案是在attachShadow被调用前重写它,把每次创建的 shadowRoot 偷偷记到 WeakMap 里:
const shadowRegistry = new WeakMap(); const originalAttach = Element.prototype.attachShadow; Element.prototype.attachShadow = function (init) { const root = originalAttach.call(this, init); shadowRegistry.set(this, root); return root; }; function getShadowRoot(host) { return host.shadowRoot || shadowRegistry.get(host) || null; }这种方案我只建议在测试环境里用,生产环境别这么干。它改变了框架的封装假设,一旦组件作者有意隐藏内部实现,你用补丁绕过去,人家升级版本换个写法,你的代码就原地爆炸。
更重要的是,我实际项目里几乎不用 closed 模式。前端要协作、要调试、要写单测,open 模式已经提供了足够的隔离;closed 只会让排查问题的人骂娘。现代 Web Components 的主流实践是 open + CSS 自定义属性 + ::part() 的"半开放"设计——DOM 不直接暴露,但留了官方样式口子。
4. 给 Shadow 元素追加 CSS 的六种方式
这一节是重头戏。很多人的诉求是"组件已经写好了,我不想动源码,就想在外面给它加点样式"。实现路径其实分两类:一类是直接侵入 shadow 内部改样式,另一类是通过官方口子优雅定制。
4.1 最直接:手动注入<style>到 shadowRoot
拿到 shadowRoot 之后,往里塞一个<style>标签是最朴素的做法:
const shadowRoot = document.querySelector('#host').shadowRoot; const style = document.createElement('style'); style.textContent = '.inner { color: red; font-size: 14px; }'; shadowRoot.appendChild(style);这条路径能走通的关键是:shadow 内部的样式表,本来就是以<style>标签形式存在的,你追加一个就相当于"在门卫那里补了一条新规定"。它的优势是简单、没有兼容性问题、几乎能打到任何内部选择器;劣势也同样明显:对于同一个组件的多个实例,你得在每个 shadowRoot 里重复注入;样式和组件源码割裂,别人维护时根本不知道这条规则是从哪里来的;而且它不是响应式的,组件内部如果也写了.inner { color: blue },你得靠权重和顺序去硬碰硬。
所以我把这招定位成"紧急修补手段"。比如第三方组件出了样式 bug,发版周期赶不上,临时注入一条 override,可以。
4.2 最优雅:CSS 自定义属性穿透边界
CSS 自定义属性(即--xxx变量)是少数能自然穿过 shadow 边界的东西,因为自定义属性是继承属性,shadow 内部的元素会从 host 上继承下来。所以组件作者只要在内部用变量承接关键样式,外部就能像换皮肤一样改:
组件内部:
:host { --btn-bg: #1677ff; --btn-color: #fff; } button { background: var(--btn-bg); color: var(--btn-color); }外部样式:
my-button { --btn-bg: #ff4d4f; --btn-color: #222; }外部把变量定义在 host 元素上,内部通过var()消费,样式就"穿越"进去了。这段逻辑很像给小区递了张"物资通行证"——你碰不到里面的家具,但能把需要的物资送进去。
它的限制也很明确:只有组件作者预先暴露出的变量能改,变量没暴露的部位依然动不了。所以自定义属性是组件设计阶段的"主题系统",不是一个万能锤子。
4.3 最正规:::part() 与 ::slotted() 对外接口
::part()是 Shadow DOM 规范专门为外部定制留的官方接口。组件作者在内部节点上标注part属性:
shadow.innerHTML = ` <button part="btn"> <slot></slot> </button> `;外部就能精准命中这个内部按钮:
my-button::part(btn) { border-radius: 20px; font-weight: 700; } my-button::part(btn):hover { filter: brightness(1.2); transform: scale(1.02); }而且外部通过::part()写的规则,在优先级上会压过 shadow 内部同权重规则,所以组件作者不用担心内部样式把外部定制"打回去"。::slotted()则是管slot投影内容的,外部传进来的元素在 light DOM 里原本归外部管,但一旦被投影进 slot,组件内部可以用::slotted(selectors)给这些内容套一层基础样式:
::slotted(.title) { font-size: 18px; color: #333; }一句话总结:::part()是"给内部节点开门",::slotted()是"给外来内容把关"。
4.4 最高效:adoptedStyleSheets 构造样式表
构造样式表(Constructable Stylesheets)是相对现代的方案。它的核心是用CSSStyleSheet对象直接创建一张样式表,然后通过adoptedStyleSheets绑给 shadowRoot:
const sheet = new CSSStyleSheet(); sheet.replaceSync('button { font-family: "Segoe UI", sans-serif; }'); document.querySelectorAll('my-button').forEach((el) => { el.shadowRoot.adoptedStyleSheets = [sheet]; });好处是同一张样式表可以被多个 shadowRoot 共享,不用每个实例都塞一个<style>标签;运行时的样式更新可以通过sheet.insertRule()/sheet.deleteRule()精确操作。兼容性方面,Chrome、Edge、Safari、Firefox 新版都支持,老浏览器就得回退到 4.1 的注入方案。判断是否支持很简单:
if (document.adoptedStyleSheets !== undefined) { // 支持构造样式表 }4.5 内外配合::host 在组件里写主题钩子
:host是 shadow 内部的伪类,用来选中宿主元素本身。初学者容易懵:"我都在 shadow 里了,还能管到外面去吗?"其实 host 对 shadow 内部来说就是一个特殊的"根",:host样式就是为了定义组件外壳的默认行为:
:host { display: inline-block; font-size: 16px; } :host(.dark) { color: #ddd; }外部对 host 元素也可以直接写样式,因为 host 本身活在 light DOM 里。比如你在页面里写了my-button { margin: 20px auto; },这没问题,它管的是组件外壳的布局。想根据外部环境条件改组件内部,可以搭配:host-context()——它能匹配"处于某个祖先元素之内的 host":
:host-context(.theme-dark) button { color: #eee; }实战中:host主要是组件作者写默认值用的,外部使用方别指望通过它控制内部细节,那是::part()的活。
5. 实战案例:给涟漪光圈按钮组件追加外部 CSS
光讲理论不够,我拿最近很常见的"涟漪光圈扩散"按钮效果做个完整例子。这个组件用自定义元素实现,内部用 Shadow DOM 封装,我们分别用几种方式从外部追加 CSS,看它们的效果和局限。
5.1 组件设计与代码实现
需求很简单:做一个<ripple-btn>,点击时边缘扩散一个光圈,配色和字体要支持外部主题化。完整代码可以直接跑:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>ripple-btn 组件</title> </head> <body> <ripple-btn id="primary">确定</ripple-btn> <ripple-btn id="danger">删除</ripple-btn> <script> class RippleBtn extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { --btn-bg: #1677ff; --btn-color: #fff; --ripple-color: rgba(255, 255, 255, 0.35); display: inline-block; } button { position: relative; overflow: hidden; border: none; outline: none; cursor: pointer; padding: 10px 22px; font-size: 16px; border-radius: 6px; background: var(--btn-bg); color: var(--btn-color); transition: filter .2s, transform .2s; } button:hover { filter: brightness(1.08); } button:active { transform: scale(0.96); } .ripple { position: absolute; border-radius: 50%; pointer-events: none; background: var(--ripple-color); transform: translate(-50%, -50%) scale(0); animation: ripple 500ms ease-out forwards; } @keyframes ripple { to { transform: translate(-50%, -50%) scale(4); opacity: 0; } } </style> <button part="btn"> <slot></slot> </button> `; this._btn = shadow.querySelector('button'); this._btn.addEventListener('click', (e) => this._spawnRipple(e)); } _spawnRipple(e) { const btn = this._btn; const rect = btn.getBoundingClientRect(); const ripple = document.createElement('span'); const size = Math.max(rect.width, rect.height); ripple.className = 'ripple'; ripple.style.width = size + 'px'; ripple.style.height = size + 'px'; ripple.style.left = (e.clientX - rect.left) + 'px'; ripple.style.top = (e.clientY - rect.top) + 'px'; btn.appendChild(ripple); ripple.addEventListener('animationend', () => ripple.remove()); } } customElements.define('ripple-btn', RippleBtn); </script> </body> </html>组件内部用--btn-bg、--btn-color、--ripple-color三个变量做主题钩子,按钮上挂了part="btn",外部定制就有两套入口了。
5.2 外部样式定制的完整 CSS
第一种方式:CSS 变量定主题。外部不需要知道内部 DOM,就像换手机壳一样:
ripple-btn#danger { --btn-bg: #ff4d4f; --ripple-color: rgba(255, 255, 255, 0.4); }第二种方式:::part()精细化调整。比如想把按钮改成胶囊形、加粗字体、鼠标移入时字距撑开一点:
ripple-btn::part(btn) { border-radius: 20px; font-weight: 700; } ripple-btn::part(btn):hover { letter-spacing: 1px; }第三种方式:外部动画参与。有人可能以为 shadow 内的@keyframes是封闭的,但通过::part()挂外部动画完全可行,比如做一个流光渐变背景:
@keyframes gradientMove { 0% { background-position: 0% 50%; } 100% { background-position: 200% 50%; } } ripple-btn#fancy::part(btn) { background: linear-gradient(90deg, #1677ff, #ff4d4f, #1677ff); background-size: 200% 100%; animation: gradientMove 3s linear infinite; }注意这里改了背景色之后,--btn-bg就失效了,因为background简写属性把自定义属性设置的背景整个覆盖了。如果你既想要主题变量又想要渐变,得在组件内部把背景拆成background-color和background-image两层才能兼得——这就是我前面说的,思路得跟着组件的样式结构走。
5.3 硬注入 vs 软定制:方案对比表
最后对比一下所有追加 CSS 的方式,方便你选型:
| 方案 | 侵入性 | 维护成本 | 适用场景 |
|---|---|---|---|
注入<style> | 高,直接进内部 | 高,多实例需重复 | 紧急 patch、无其他入口 |
| CSS 自定义属性 | 低,只动变量 | 低 | 主题换肤、品牌色定制 |
| ::part() | 低,走官方接口 | 中,需组件开放部分 | 局部节点精细化改造 |
| ::slotted() | 低,管外部传入内容 | 中 | 定制 slot 投影样式 |
| adoptedStyleSheets | 高,但可共享 | 中 | 批量组件、运行时动态换样式 |
我的建议是:能用自定义属性和::part()解决的,绝对不要上硬注入。组件封装把 DOM 藏起来,本质上是把变更范围缩小到可控区域。你绕过封装,短期图快,长期就要吃维护的苦。
6. 常见问题与排查技巧实录
6.1 一张表解决问题速查
我把高频问题整理成一张速查表,按现象对号入座即可:
| 现象 | 原因 | 解法 |
|---|---|---|
| 外部 CSS 选择器选不到内部元素 | Shadow 边界隔离,选择器不跨树 | 改用 ::part() / 自定义属性 / 注入样式 |
| document.querySelector 查内部节点返回 null | 文档查询不穿透 shadow 树 | shadowRoot.querySelector 或深度遍历函数 |
| 写满 !important 也改不动内部样式 | 样式作用域根本没进去,不是优先级问题 | 把规则写进 shadow 内部,或用 ::part() |
| CSS 变量改了却没反应 | 变量被组件内部:host默认值兜住了,或者名称拼错 | 检查实际消费变量的位置,确认外部定义的宿主选择器命中 |
| 视频控件的样式怎么改都无效 | 浏览器 user agent shadow DOM 的外部规则极少开放 | 用浏览器私有伪元素,或隐藏原生控件换自定义控制条 |
| 控制台看不到 shadow 内部节点 | DevTools 默认把 user agent shadow DOM 折叠了 | 设置里打开 "Show user agent shadow DOM" |
6.2 DevTools 调试 Shadow DOM 的实操技巧
调试 Shadow DOM 最怕两眼一抹黑。我常用的三招:
第一,在 Elements 面板里,shadow root 会显示为#shadow-root (open)这样的节点,展开它就能像普通 DOM 一样审查内部节点和样式来源。如果是 user agent shadow DOM,默认是折叠的,要去 DevTools 的 Settings → Preferences → Elements 里勾选 "Show user agent shadow DOM"。
第二,控制台里选中元素后,$0就是当前选中节点,所以$0.shadowRoot.querySelector('button')可以秒查内部元素。如果你想看某个内部节点的样式计算值,用getComputedStyle($0)就行。
第三,遇到 open 模式但不确定哪个元素有 shadowRoot 时,直接在控制台跑一段扫描脚本:
// 找出页面里所有带有 shadowRoot 的元素 const hosts = []; document.querySelectorAll('*').forEach((el) => { if (el.shadowRoot) hosts.push(el); }); console.table(hosts.map((el) => ({ tag: el.tagName, id: el.id, cls: el.className })));6.3 我个人踩过的三个坑
第一个坑:多实例重复注入样式。早期我给一个组件硬注入<style>,页面里组件有几十个实例,for 循环里每个 shadowRoot 都 append 一遍,结果样式表膨胀得离谱。后来改成建一个缓存,同一个规则只注入一次:
const injected = new WeakSet(); function injectShadowStyle(host, cssText) { const root = host.shadowRoot; if (!root || injected.has(root)) return; const style = document.createElement('style'); style.textContent = cssText; root.appendChild(style); injected.add(root); }第二个坑:判断 shadow 只看 nodeType。我最早写的判断函数只检查root.nodeType === 11,结果把一个普通 DocumentFragment 里的元素也误判成了 shadow 子元素。后来加上root.host校验才算靠谱,这也是前面 2.2 节那个函数为什么要三段式判断的原因。
第三个坑:closed 模式给自己挖坑。有个项目图省事用了 closed,结果客户要换主题色,我连内部按钮都拿不到,只能重发一版组件。从那以后我坚持"open + 官方口子"原则,封装不等于锁死,留好样式接口的组件才是好组件。
最后再分享一个我的使用习惯:在设计组件时把可以定制的 CSS 变量列表写进注释里,算作组件文档的一部分。这样外部使用方不看源码也能知道有哪些"旋钮"可以拧,配合::part()出口,Shadow DOM 的隔离带来的不是麻烦,而是一份清晰的样式契约。