前言
Shadow DOM是Web Components的核心技术之一,它允许开发者在主文档之外创建隔离的DOM树。通过attachShadow方法,开发者可以将Shadow DOM附加到宿主元素上,形成一个独立的子树,其内部的样式与主文档完全隔离。这种隔离性由浏览器内核实现,确保了组件的样式不会泄漏到外部,也不会被外部样式覆盖。
在修改博客样式时,发现自定义的样式在有个组件的弹窗界面不生效,即使写了!important也是如此,会被浏览器默认样式和“构造的样式表”覆盖。看了一下DOM结构,发现此组件的弹窗界面使用的是Shadow DOM,它相对独立,不容易受到外部样式的影响

那么要如何修改Shadow DOM内的元素样式呢?
1.最好的方法,自然是直接修改组件的源代码,修改其插入的Shadow DOM内容
这里我们讨论的是从外部修改的方法,我并不想去修改组件源码,再重新打包,这太麻烦了,我需要的是通过全局注入JS/CSS的方式实现无侵入式的修改,所以不考虑
2.使用CSS变量
在Shadow DOM中使用CSS变量,在全局样式中定义变量的值。需要Shadow DOM配合。组件并没有定义需要的CSS变量,不可行
<!-- 全局 -->
<style>
:root {
--primary-color: #0396ff;
}
</style>
<!-- Shadow DOM内部 -->
<style>
:host {
color: var(--primary-color);
}
</style>3.使用::part()伪元素
Shadow DOM提供了::part()和::theme()选择器,允许外部样式通过指定part属性对Shadow DOM内部元素进行样式调整。需要Shadow DOM配合。组件并没有定义需要的part属性,不可行
<!-- 全局 -->
<style>
host-element::part(title) {
color: red;
}
</style>
<!-- Shadow DOM内部(宿主元素为host-element) -->
<div part="title">This is a title.</div>
对于我们的需求,看起来这些方法都不太行,而且只能修改Shadow DOM中元素的样式。有没有一种优雅的方式,不需要看Shadow DOM的“脸色”,可以优雅的从外部修改Shadow DOM中的元素及其样式呢?如果Shadow DOM是以"open"模式创建的,我们可以使用宿主元素的 shadowRoot 属性访问Shadow DOM对象,然后就可以操作Shadow DOM内部的元素了
let shadowElement = document.querySelector('host-element').shadowRoot;
最佳实践
使用let shadowElement = document.querySelector('host-element').shadowRoot;获取到Shadow DOM对象后,我们就可以用JS对内部的元素进行各种修改了。
1.Shadow DOM在确定的时机创建/显示。例如这个组件的调用:<a href="javascript:;" onclick="LinkSubmitWidget.open()"...,只有点击了提交按钮,组件才会处理和显示Shadow DOM。这种情况我们可以监听这个按钮的点击事件,在组件处理完Shadow DOM后对其进行修改:
//注入html标签到元素内部
function injectElement(node, tag, id, content, prepend=false) {
if (node.querySelector(`#${id}`)) {
return;
}
let element = document.createElement(tag);
element.id = id;
element.innerHTML = content;
if (prepend) {
node.prepend(element);
} else {
node.append(element);
}
}
$(document).on('click', '.custom-btn-link', function() {
setTimeout(function() {
//核心代码开始
//宿主元素选择器,请修改
let host = 'link-submit-modal';
let hostElement = document.querySelector(host);
if (hostElement && hostElement.shadowRoot) {
//获取Shadow DOM对象
let shadowElement = hostElement.shadowRoot;
//要注入的标签的类型,请修改
let customElement_tag = 'style';
let customElement_id = 'custom-element-' + customElement_tag + '-' + host;
//要注入的标签的内容,请修改
let customElement_content = ':host{cursor:url(/upload/normal.png) 6 10,auto !important;}*,:before,:after{cursor:inherit !important;}label,select,option{cursor:url(/upload/normal.png) 6 10,auto !important;}a,button,summary,[role="button"]{cursor:url(/upload/pointer.png) 12 7,pointer !important;}input,textarea,[contenteditable="true"]{cursor:text !important;}';
injectElement(shadowElement, customElement_tag, customElement_id, customElement_content, true);
}
//核心代码结束
}, 0);
});可以看到,这次我们成功将自定义的style标签注入到了Shadow DOM内部,“战胜”了浏览器默认样式和“构造的样式表”

2.Shadow DOM是异步创建的,时机不确定。对于这种情况,我们可以使用MutationObserver监听元素变化,在元素出现后对其进行修改。以另一个组件为例(此组件在页面加载时就会异步创建Shadow DOM,存在延迟):
//Shadow DOM的创建可能存在延迟,确保安全调用
function initExec(element, func, wait) {
let check = null;
if (typeof wait === 'function') {
check = wait;
}
if (!check || check(element)) {
func(element);
return;
}
let i = 0;
const FRAME_MAX = 300;//大约5秒
let loop = () => {
if (check(element)) {
func(element);
} else if (element.isConnected && i < FRAME_MAX) {
i++;
requestAnimationFrame(loop);
}
};
requestAnimationFrame(loop);
}
//监听元素变化,在元素出现后执行处理函数
function observeElement(node, selector, func, once=false, wait=false) {
if (!node.querySelector) {
return;
}
let exist = node.querySelectorAll(selector);
if (exist.length > 0) {
exist.forEach(e => initExec(e, func, wait));
if (once) {
return;
}
}
/*
//全量扫描
let observer = new MutationObserver(function() {
let elements = node.querySelectorAll(selector);
if (elements.length > 0) {
elements.forEach(e => initExec(e, func, wait));
if (once) {
observer.disconnect();
}
}
});
*/
//只关注新增的节点
let observer = new MutationObserver(function(mutations) {
let isFound = false;
for (let mutation of mutations) {
for (let addedNode of mutation.addedNodes) {
if (addedNode.nodeType !== 1) {
continue;
}
if (addedNode.matches && addedNode.matches(selector)) {
initExec(addedNode, func, wait);
isFound = true;
}
if (addedNode.querySelector) {
addedNode.querySelectorAll(selector).forEach(e => {
initExec(e, func, wait);
isFound = true;
});
}
}
}
if (isFound && once) {
observer.disconnect();
}
});
observer.observe(node, {childList: true, subtree: true});
}
//具体处理函数,请自行修改
(function() {
function processShikiCode(element) {
if (!element || !element.shadowRoot || element._processedShikiCode) {
return;
}
let shadowElement = element.shadowRoot;
let shadowElementTag = element.tagName.toLowerCase();
let customElement_tag = 'style';
let customElement_id = 'custom-element-' + customElement_tag + '-' + shadowElementTag;
let customElement_content = [
':host{cursor:url(/upload/normal.png) 6 10,auto !important;}',
'*,:before,:after{cursor:inherit !important;}',
'label,select,option{cursor:url(/upload/normal.png) 6 10,auto !important;}',
'a,button,summary,[role="button"]{cursor:url(/upload/pointer.png) 12 7,pointer !important;}',
'input,textarea,[contenteditable="true"]{cursor:text !important;}',
':disabled{cursor:url(/upload/normal.png) 6 10,auto !important;}'
].join('');
injectElement(shadowElement, customElement_tag, customElement_id, customElement_content, true);
element._processedShikiCode = true;
let node = shadowElement;
let selector = 'shiki-code-simple-variant, shiki-code-mac-variant';
observeElement(node, selector, processShikiCodeVariant, true, (h) => !!h.shadowRoot);
}
function processShikiCodeVariant(element) {
if (!element || !element.shadowRoot || element._processedShikiCodeVariant) {
return;
}
let shadowElement = element.shadowRoot;
let shadowElementTag = element.tagName.toLowerCase();
let customElement_tag = 'style';
let customElement_id = 'custom-element-' + customElement_tag + '-' + shadowElementTag;
let customElement_content_list = [
':host{cursor:url(/upload/normal.png) 6 10,auto !important;}',
'*,:before,:after{cursor:inherit !important;}',
'label,select,option{cursor:url(/upload/normal.png) 6 10,auto !important;}',
'a,button,summary,[role="button"]{cursor:url(/upload/pointer.png) 12 7,pointer !important;}',
'input,textarea,[contenteditable="true"]{cursor:text !important;}',
':disabled{cursor:url(/upload/normal.png) 6 10,auto !important;}'
];
let customElement_content_list_simple = [
'div.absolute.top-1.right-2{left:50%!important;right:auto!important;transform:translateX(-50%)!important;opacity:1!important;}',
'button.opacity-0.z-2{opacity:1!important;}'
];
let customElement_content_list_mac = [
//
];
if (shadowElementTag === 'shiki-code-simple-variant') {
customElement_content_list = customElement_content_list.concat(customElement_content_list_simple);
}
if (shadowElementTag === 'shiki-code-mac-variant') {
customElement_content_list = customElement_content_list.concat(customElement_content_list_mac);
}
let customElement_content = customElement_content_list.join('');
injectElement(shadowElement, customElement_tag, customElement_id, customElement_content, true);
element._processedShikiCodeVariant = true;
}
//调用示例,observeElement(监听的节点,要查找的元素选择器,处理函数,是否只处理一次,等待条件函数)
let node = document.body;
let selector = 'shiki-code';
observeElement(node, selector, processShikiCode, false, (h) => !!h.shadowRoot);
})();
3.hook原生attachShadow方法。在其他组件调用它创建Shadow DOM时触发回调。暴露一个全局方法,根据规则调用对应的处理函数对创建的Shadow DOM进行修改。可用于Shadow DOM是closed模式的情况
此方案虽然优雅,但应注意以下问题:
①需要在组件加载、调用attachShadow之前完成hook(比如放在<head>里,或者加载组件JS的<script>标签之前)。不一定有效
②此方案也需要在调用全局方法时考虑异步加载的时序问题,需要尽早执行(组件加载前),否则可能会出现意外的结果
以下是示例代码:
//核心代码
//需要在组件加载前执行
(function() {
if (window.__shadowDOMInterceptor__) {
return;
}
window.__shadowDOMInterceptor__ = true;
const callbacks = [];
const originalAttachShadow = Element.prototype.attachShadow;
//劫持原生的attachShadow方法
Element.prototype.attachShadow = function(options) {
//强制改为"open"模式
if (options && options.mode === 'closed') {
options.mode = 'open';
}
const shadowRoot = originalAttachShadow.call(this, options);
callbacks.forEach(item => {
if (this.matches(item.selector)) {
item.callback(shadowRoot, this);
}
});
return shadowRoot;
};
//暴露全局方法
window.onShadowRootCreated = function(selector, callback) {
const isExist = callbacks.some(item => item.selector === selector && item.callback === callback);
if (!isExist) {
callbacks.push({selector, callback});
}
document.querySelectorAll(selector).forEach(e => {
if (e.shadowRoot) {
callback(e.shadowRoot, e);
}
});
};
})();
//调用示例,window.onShadowRootCreated(宿主元素选择器,回调处理函数);
//尽早执行(组件加载前),先注册监听
window.onShadowRootCreated('host-element', function(shadowElement, hostElement) {
const customElement = document.createElement("p");
customElement.id = "custom";
customElement.innerHTML = "我是通过全局onShadowRootCreated方法插入的文本";
shadowElement.appendChild(customElement);
});
注意
1.代码执行时机。Shadow DOM可能是异步创建的,可以增加延时、重试逻辑,或者监听调用函数的地方。对于复杂场景,建议使用MutationObserver监听元素变化,或直接hook attachShadow方法
2.Shadow DOM的模式。Shadow DOM必须是“open”模式,如果是"closed"模式,hostElement.shadowRoot方法返回null
对于"closed"模式,即#shadow-root (closed),可尝试以下方案:
①直接修改组件源代码,将attachShadow({mode:'closed'})改为attachShadow({mode:'open'})
②使用hook原生attachShadow方法的方案,在Shadow DOM创建时强制改为“open”模式
3.注入Shadow DOM的CSS样式,建议添加!important
4.如果存在多层Shadow DOM嵌套,需要从最外层依次获取,例如:
let host_1 = 'search-modal';
let hostElement_1 = document.querySelector(host_1);
if (hostElement_1 && hostElement_1.shadowRoot) {
let shadowElement_1 = hostElement_1.shadowRoot;
//do something(shadowElement_1)
let host_2 = 'search-form';
//核心代码
let hostElement_2 = shadowElement_1.querySelector(host_2);
if (hostElement_2 && hostElement_2.shadowRoot) {
let shadowElement_2 = hostElement_2.shadowRoot;
//do something(shadowElement_2)
}
}
结语
Shadow DOM并非神秘,而是需要明确进入它。它更多的是提供封装和隔离的能力,以避免受到页面外部元素的影响。使用hostElement.shadowRoot,我们也可以像操作普通DOM一样操作它
评论区