避免样式污染:Shadow DOM 在浏览器油猴脚本中的实战应用

目录
[隐藏]

0 前言

Violentmonkey(暴力猴)和 Tampermonkey(油猴)是两款流行的浏览器脚本管理器,它们的主要功能是帮助用户管理和运行用户脚本。其中 Tampermonkey 是闭源的,可能不适合对代码透明度有严格要求的用户。Violentmonkey 是 Tampermonkey 的开源实现,兼容 Tampermonkey 所有能力,遵循 MIT 协议,代码透明,适合对隐私和安全性有较高要求的用户。

在开发浏览器油猴脚本(UserScript)时,我们经常需要引入第三方 CSS 框架(如 Tailwind CSS)和 UI 库(如 SweetAlert2)来快速构建美观的界面。然而,这些样式库的全局特性往往会"污染"目标网站的样式,导致页面布局被意外修改的面目全非。

本文将分享如何通过 Shadow DOM 技术实现完美的样式隔离,让油猴脚本的 UI 组件既能享受现代 CSS 框架的便利,又不会影响目标网站的原有样式。

1 问题背景

1.1 样式污染的痛点

在开发一个媒体链接抓取工具的油猴脚本时,我遇到了这样的问题:

  1. Tailwind CSS 的全局重置:Tailwind 的 preflight 样式会重置全局样式,影响目标网站的布局
  2. SweetAlert2 的样式冲突:弹窗库的样式可能与网站的自定义样式产生冲突
  3. CSS 选择器污染:即使使用命名空间,也难以完全避免样式泄露

2 Shadow DOM:完美的环境隔离方案

2.1 什么是 Shadow DOM?

Shadow DOM 是 Web Components 标准的一部分,它提供了一个封装的 DOM 树,具有以下特性:

  • 样式隔离:Shadow DOM 内的样式不会泄露到外部,外部样式也不会影响 Shadow DOM
  • DOM 封装:Shadow DOM 内的元素对外部不可见(除非使用 mode: 'open'
  • 作用域隔离:JavaScript 作用域也被隔离

2.2 为什么选择 Shadow DOM?

相比其他方案,Shadow DOM 具有以下优势:

  1. 原生支持:现代浏览器都支持,无需 polyfill
  2. 完美隔离:真正的样式隔离,不是"伪隔离"
  3. 性能优秀:浏览器原生实现,性能开销小
  4. 维护简单:不需要复杂的命名空间管理

3 技术实现

3.1 创建 Shadow DOM 容器

首先,我们需要创建一个 Shadow DOM 容器来承载所有 UI 组件:

function createShadowHost() {
    if (shadowHost) return shadowRoot;

    // 创建宿主元素
    shadowHost = document.createElement('div');
    shadowHost.id = 'm3u8-capture-shadow-host';
    // 覆盖整个视口,但不阻止页面交互
    shadowHost.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9999;';
    document.body.appendChild(shadowHost);

    // 创建 Shadow DOM(open 模式允许外部访问)
    shadowRoot = shadowHost.attachShadow({ mode: 'open' });

    return shadowRoot;
}

关键点
- 使用 pointer-events: none 让宿主元素不阻止页面交互
- 子元素通过 pointer-events: auto 恢复交互能力
- mode: 'open' 允许外部 JavaScript 访问(调试方便)

3.2 在 Shadow DOM 内加载 CSS

接下来,我们需要将 Tailwind CSSSweetAlert2 的样式加载到 Shadow DOM 内:

// ==UserScript==
// @author       lzw
// @updateURL    https://raw.githubusercontent.com/lzwme/m3u8-dl/refs/heads/main/client/m3u8-capture.user.js
// @downloadURL  https://raw.githubusercontent.com/lzwme/m3u8-dl/refs/heads/main/client/m3u8-capture.user.js
// @match        *://*/*
// @grant        GM_addElement
// @resource     SwalJS   https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.js
// @resource     SwalCSS  https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.css
// @resource     TailwindCSS  https://s4.zstatic.net/ajax/libs/tailwindcss/2.2.19/tailwind.min.css
// ==/UserScript==

// 在 Shadow DOM 内加载 Tailwind CSS
addCssOrScript('TailwindCSS', shadowRoot, 'css');

// 在 Shadow DOM 内加载 SweetAlert2 CSS,并修改部分内容以适应 Shadow DOM
addCssOrScript(GM_getResourceText("SwalCSS").replace(':root{', `#m3u8-capture-container{`).replace(/body/g, ""), shadowRoot, 'css');

// 添加基础样式重置
const styleTextContent = `
    :host {
        all: initial;
        font-family: system-ui, -apple-system, sans-serif;
    }
    * { box-sizing: border-box; }
`;
addCssOrScript(styleTextContent, shadowRoot, 'css');

/** 使用 GM_addElement 创建 style 或 script 元素,避免 CSP 策略拦截问题 */
function addCssOrScript(key, parentEl = document.head, type = 'css') {
    // 如果 key 长度小于 50,则认为是资源文本,否则认为是字符串
    const textContent = key.length < 50 ? GM_getResourceText(key) : key;
    return GM_addElement(parentEl, type === 'css' ? 'style' : 'script', {
        type: type === 'css' ? 'text/css' : 'text/javascript',
        textContent: textContent
    });
}

关键点
- :host 选择器用于设置 Shadow DOM 宿主元素的样式
- all: initial 重置所有样式,确保不影响外部
- 通过 GM_getResourceTextGM_addElement 创建 style 或 script 元素并添加至 Shadow DOM 内,可以避免 CSP 拦截
- 对于 SweetAlert2,需要修改css样式内容以适应 Shadow DOM 环境

3.3 创建 UI 组件

所有 UI 组件都创建在 Shadow DOM 内:

function createUI() {
    const root = createShadowHost();

    const panel = document.createElement('div');
    panel.className = 'fixed w-[420px] bg-white border-2 border-blue-500 rounded-xl shadow-2xl';
    panel.style.cssText = 'position: fixed; pointer-events: auto; z-index: 1059;';

    // 将面板添加到 Shadow DOM
    root.appendChild(panel);
}

3.4 处理第三方库:SweetAlert2 的特殊处理

SweetAlert2 默认会在 document.body 上创建弹窗,我们需要让它支持 Shadow DOM。这里有两个步骤。

步骤一:修改并加载 JS 源码

通过 GM_addElement 和字符串替换,修改 SweetAlert2 的挂载目标:

function loadSwal() {
    let swalTarget = document.body;

    // 定义挂载容器函数
    globalThis.SetSwalTarget = (newTarget) => (swalTarget = newTarget);
    globalThis.GetSwalTarget = () => swalTarget;

    // 读取并修改 SweetAlert2 源码
    const SwalJS = GM_getResourceText("SwalJS")
        // 替换挂载目标
        .replace(/document\.body/g, "GetSwalTarget()");

    // 使用 GM_addElement 创建 script,避免 CSP 拦截
    addCssOrScript(SwalJS, document.head || document.documentElement, 'script').then(() => {
        // 创建 SweetAlert2 容器
        const swalContainer = document.createElement('div');
        shadowRoot.appendChild(swalContainer);

        // 设置 SweetAlert2 的目标容器
        SetSwalTarget(swalContainer);
        Swal = Swal.mixin({ target: swalContainer });
    })

}

关键点
- 使用 GM_addElement 而不是 eval,避免 CSP 安全策略拦截
- 通过字符串替换修改源码中的 document.body 为自定义函数
- 使用 Swal.mixin() 配置默认目标容器

步骤二:CSS 作用域处理

加载 Swal CSS 样式,并修改内容以兼容 CSS 作用域:

const SwalCSS = GM_getResourceText("SwalCSS").replace(/body/g, "").replace(':root{', `#m3u8-capture-container{`);
const swalStyle = document.createElement('style');
swalStyle.textContent = SwalCSS;
shadowRoot.appendChild(swalStyle);

4 技术难点与解决方案

4.1 事件穿透问题

问题:Shadow DOM 内的元素需要接收事件,但宿主元素设置了 pointer-events: none

解决方案

// 宿主元素:不阻止事件
shadowHost.style.pointerEvents = 'none';

// 子元素:恢复事件接收
panel.style.pointerEvents = 'auto';
swalContainer.style.pointerEvents = 'auto';

4.2 焦点事件处理

问题:SweetAlert2 弹窗需要接收焦点事件,但可能被 pointer-events: none 影响。

解决方案

#m3u8-capture-swal-container,
#m3u8-capture-swal-container * {
    pointer-events: auto !important;
}

4.3 CSP 安全策略

问题:使用 eval() 执行动态代码会被 CSP 拦截。

解决方案:使用 GM_addElement API:

// ❌ 会被 CSP 拦截
eval(SwalJS);

// ✅ 绕过 CSP 限制
GM_addElement(document.head, 'script', {
    type: 'text/javascript',
    textContent: SwalJS
});

4.4 样式作用域

问题:确保 Shadow DOM 内的样式不影响外部。

解决方案

:host {
    all: initial; /* 重置所有样式 */
    font-family: system-ui, -apple-system, sans-serif;
}

5 完整示例

以下是一个完整的 Shadow DOM 样式隔离实现:

// 创建 Shadow DOM 容器
function createShadowHost() {
    if (shadowHost) return shadowRoot;

    // 1. 创建宿主元素
    shadowHost = document.createElement('div');
    shadowHost.id = 'm3u8-capture-shadow-host';
    shadowHost.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9999;';
    document.body.appendChild(shadowHost);

    // 2. 创建 Shadow DOM
    shadowRoot = shadowHost.attachShadow({ mode: 'open' });

    // 3. 加载 CSS 到 Shadow DOM
    // 在 Shadow DOM 内加载 Tailwind CSS
    addCssOrScript('TailwindCSS', shadowRoot, 'css');
    // 在 Shadow DOM 内加载 SweetAlert2 CSS,并修改样式以适应 Shadow DOM
    addCssOrScript(GM_getResourceText("SwalCSS").replace(':root{', `#m3u8-capture-container{`).replace(/body/g, ""), shadowRoot, 'css');

    // 4. 添加基础样式
    const style = document.createElement('style');
    style.textContent = `
        :host {
            all: initial;
            font-family: system-ui, -apple-system, sans-serif;
        }
        * {
            box-sizing: border-box;
        }
    `;
    shadowRoot.appendChild(style);

    // 5. 创建第三方库容器
    const swalContainer = document.createElement('div');
    swalContainer.id = 'm3u8-capture-swal-container';
    swalContainer.style.pointerEvents = 'auto';
    shadowRoot.appendChild(swalContainer);

    return shadowRoot;
}

5.1 性能优化建议

  1. 延迟加载:CSS 文件可以延迟加载,减少初始加载时间
  2. 缓存优化:使用 CDN 并设置合适的缓存策略
  3. 按需加载:只在需要时创建 Shadow DOM
  4. 事件委托:在 Shadow DOM 内使用事件委托减少事件监听器

5.2 浏览器兼容性

Shadow DOM 在现代浏览器中支持良好:

  • Chrome/Edge: 53+
  • Firefox: 63+
  • Safari: 10.1+
  • Opera: 40+

对于不支持 Shadow DOM 的旧浏览器,可以考虑使用 polyfill(如 @webcomponents/shadydom),但通常油猴脚本的目标用户使用的是现代浏览器。

5.3 总结

通过 Shadow DOM 技术,我们实现了:

完美的样式隔离:Tailwind CSS 和 SweetAlert2 的样式完全不影响目标网站
灵活的组件封装:UI 组件可以自由使用现代 CSS 框架
良好的用户体验:不影响原网站的功能和样式
易于维护:不需要复杂的命名空间管理

Shadow DOM 不仅解决了样式污染问题,还为构建更复杂的油猴脚本 UI 提供了坚实的基础。如果你也在开发油猴脚本,不妨试试这个方案,相信会给你带来惊喜!

6 参考资源


作者简介:专注于前端技术和用户体验,喜欢分享实用的技术解决方案。

技术交流:欢迎在评论区分享你的 Shadow DOM 使用经验,或者提出任何问题!