
很多人第一次接触 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 typedate的日期面板、select的下拉列表内部都藏着 user agent shadow DOM。你之所以感觉这些控件顽固不化本质上是因为它们内部节点根本不归你页面的 CSS 管。1.2 几个绕不开的名词host、root、shadow tree先记住四个词后面所有代码都靠它们沟通shadow host挂在普通 DOM 里的宿主元素比如一个自定义标签my-cardshadow rootshadow 树的根节点也是样式和 DOM 的边界相当于门卫shadow treeshadow root 下面长出来的所有节点light DOM宿主元素原来的子节点通过slot能投射进 shadow 内去渲染。创建一个 shadow root 非常简单const host document.querySelector(#host); const shadow host.attachShadow({ mode: open });这里的mode决定了外部能不能直接访问。open模式下外部可以用host.shadowRoot拿到 shadow rootclosed模式下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()返回documentShadow 树里的元素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 树的 deepQueryAllopen 模式只能逐层手动取 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 模式的 shadowRootclosed 模式在外部拿不到引用这是设计使然。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 partbtn 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绑给 shadowRootconst 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 langzh-CN head meta charsetUTF-8 titleripple-btn 组件/title /head body ripple-btn idprimary确定/ripple-btn ripple-btn iddanger删除/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 partbtn 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三个变量做主题钩子按钮上挂了partbtn外部定制就有两套入口了。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 DOM6.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 的隔离带来的不是麻烦而是一份清晰的样式契约。