Skip to content

Content Scripts

要创建 Content Script,请参阅 入口点类型

上下文

Content Script 的 main 函数的第一个参数是它的"上下文"。

ts
// entrypoints/example.content.ts
export default defineContentScript({
  main(ctx) {},
});

该对象负责跟踪 Content Script 的上下文是否已"失效"。大多数浏览器在扩展被卸载、更新或禁用时,默认不会停止 Content Scripts。当这种情况发生时,Content Scripts 会开始报告以下错误:

plaintext
Error: Extension context invalidated.

ctx 对象提供了多个辅助方法,用于在上下文失效后停止异步代码的运行:

ts
ctx.addEventListener(...);
ctx.setTimeout(...);
ctx.setInterval(...);
ctx.requestAnimationFrame(...);
// 以及更多

你也可以手动检查上下文是否已失效:

ts
if (ctx.isValid) {
  // 执行某些操作
}
// 或者
if (ctx.isInvalid) {
  // 执行某些操作
}

CSS

在常规 Web 扩展中,Content Script 的 CSS 通常是一个单独的 CSS 文件,添加到 Manifest 中的 CSS 数组中:

json
{
  "content_scripts": [
    {
      "css": ["content/style.css"],
      "js": ["content/index.js"],
      "matches": ["*://*/*"]
    }
  ]
}

在 WXT 中,要为 Content Script 添加 CSS,只需在 JS 入口点中导入 CSS 文件,WXT 会自动将打包后的 CSS 输出添加到 css 数组中。

ts
// entrypoints/example.content/index.ts
import './style.css';

export default defineContentScript({
  // ...
});

要创建一个仅包含 CSS 文件的独立 Content Script:

  1. 创建 CSS 文件:entrypoints/example.content.css

  2. 使用 build:manifestGenerated 钩子将 Content Script 添加到 Manifest 中:

    ts
    export default defineConfig({
      hooks: {
        'build:manifestGenerated': (wxt, manifest) => {
          manifest.content_scripts ??= [];
          manifest.content_scripts.push({
            // 先构建一次扩展,查看 CSS 被写入的位置
            css: ['content-scripts/example.css'],
            matches: ['*://*/*'],
          });
        },
      },
    });

UI

WXT 提供了 3 个内置工具,用于在 Content Script 中向页面添加 UI:

每种方式都有各自的优缺点。

方式样式隔离事件隔离HMR使用页面上下文
Integrated
Shadow Root✅(默认关闭)
IFrame

Integrated

集成式 Content Script UI 会被注入到页面内容中。这意味着它们会受到该页面 CSS 的影响。

ts
// entrypoints/example-ui.content.ts
export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 向容器添加子元素
        const app = document.createElement('p');
        app.textContent = '...';
        container.append(app);
      },
    });

    // 调用 mount 将 UI 添加到 DOM
    ui.mount();
  },
});
ts
// entrypoints/example-ui.content/index.ts
import { createApp } from 'vue';
import App from './App.vue';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 创建应用并挂载到 UI 容器
        const app = createApp(App);
        app.mount(container);
        return app;
      },
      onRemove: (app) => {
        // 当 UI 被移除时卸载应用
        app.unmount();
      },
    });

    // 调用 mount 将 UI 添加到 DOM
    ui.mount();
  },
});
tsx
// entrypoints/example-ui.content/index.tsx
import ReactDOM from 'react-dom/client';
import App from './App.tsx';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 在 UI 容器上创建 root 并渲染组件
        const root = ReactDOM.createRoot(container);
        root.render(<App />);
        return root;
      },
      onRemove: (root) => {
        // 当 UI 被移除时卸载 root
        root.unmount();
      },
    });

    // 调用 mount 将 UI 添加到 DOM
    ui.mount();
  },
});
ts
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
import { mount, unmount } from 'svelte';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 在 UI 容器内创建 Svelte 应用
        return mount(App, { target: container });
      },
      onRemove: (app) => {
        // 当 UI 被移除时销毁应用
        unmount(app);
      },
    });

    // 调用 mount 将 UI 添加到 DOM
    ui.mount();
  },
});
tsx
// entrypoints/example-ui.content/index.ts
import { render } from 'solid-js/web';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 将应用渲染到 UI 容器
        const unmount = render(() => <div>...</div>, container);
        return unmount;
      },
      onRemove: (unmount) => {
        // 当 UI 被移除时卸载应用
        unmount();
      },
    });

    // 调用 mount 将 UI 添加到 DOM
    ui.mount();
  },
});

完整选项列表请参阅 API 参考

Shadow Root

在 Web 扩展中,你通常不希望 Content Script 的 CSS 影响页面,反之亦然。ShadowRoot API 是实现这一目标的理想选择。

WXT 的 createShadowRootUi 封装了所有 ShadowRoot 的设置工作,使创建样式与页面隔离的 UI 变得简单。它还支持可选的 isolateEvents 参数来进一步隔离用户交互。

使用 createShadowRootUi 的步骤如下:

  1. 在 Content Script 顶部导入你的 CSS 文件
  2. defineContentScript 中设置 cssInjectionMode: "ui"
  3. 使用 createShadowRootUi() 定义你的 UI
  4. 挂载 UI 使其对用户可见
ts
// 1. 导入样式
import './style.css';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. 设置 cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. 定义你的 UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      anchor: 'body',
      onMount(container) {
        // 定义 UI 在容器内的挂载方式
        const app = document.createElement('p');
        app.textContent = 'Hello world!';
        container.append(app);
      },
    });

    // 4. 挂载 UI
    ui.mount();
  },
});
ts
// 1. 导入样式
import './style.css';
import { createApp } from 'vue';
import App from './App.vue';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. 设置 cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. 定义你的 UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 定义 UI 在容器内的挂载方式
        const app = createApp(App);
        app.mount(container);
        return app;
      },
      onRemove: (app) => {
        // 当 UI 被移除时卸载应用
        app?.unmount();
      },
    });

    // 4. 挂载 UI
    ui.mount();
  },
});
tsx
// 1. 导入样式
import './style.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. 设置 cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. 定义你的 UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // Container 是 body,React 在 body 上创建 root 时会警告,所以创建一个包装 div
        const app = document.createElement('div');
        container.append(app);

        // 在 UI 容器上创建 root 并渲染组件
        const root = ReactDOM.createRoot(app);
        root.render(<App />);
        return root;
      },
      onRemove: (root) => {
        // 当 UI 被移除时卸载 root
        root?.unmount();
      },
    });

    // 4. 挂载 UI
    ui.mount();
  },
});
ts
// 1. 导入样式
import './style.css';
import App from './App.svelte';
import { mount, unmount } from 'svelte';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. 设置 cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. 定义你的 UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 在 UI 容器内创建 Svelte 应用
        return mount(App, { target: container });
      },
      onRemove: (app) => {
        // 当 UI 被移除时销毁应用
        unmount(app);
      },
    });

    // 4. 挂载 UI
    ui.mount();
  },
});
tsx
// 1. 导入样式
import './style.css';
import { render } from 'solid-js/web';

export default defineContentScript({
  matches: ['<all_urls>'],
  // 2. 设置 cssInjectionMode
  cssInjectionMode: 'ui',

  async main(ctx) {
    // 3. 定义你的 UI
    const ui = await createShadowRootUi(ctx, {
      name: 'example-ui',
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 将应用渲染到 UI 容器
        const unmount = render(() => <div>...</div>, container);
      },
      onRemove: (unmount) => {
        // 当 UI 被移除时卸载应用
        unmount?.();
      },
    });

    // 4. 挂载 UI
    ui.mount();
  },
});

完整选项列表请参阅 API 参考

完整示例:

IFrame

如果你不需要在与 Content Script 相同的框架中运行 UI,可以使用 IFrame 来承载你的 UI。由于 IFrame 只是加载一个 HTML 页面,支持 HMR

WXT 提供了一个辅助函数 createIframeUi,用于简化 IFrame 的设置。

  1. 创建一个将加载到 IFrame 中的 HTML 页面:

    html
    <!-- entrypoints/example-iframe.html -->
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Your Content Script IFrame</title>
      </head>
      <body>
        <!-- ... -->
      </body>
    </html>
  2. 将页面添加到 Manifest 的 web_accessible_resources 中:

    ts
    export default defineConfig({
      manifest: {
        web_accessible_resources: [
          {
            resources: ['example-iframe.html'],
            matches: [...],
          },
        ],
      },
    });
  3. 创建并挂载 IFrame:

    ts
    export default defineContentScript({
      matches: ['<all_urls>'],
    
      main(ctx) {
        // 定义 UI
        const ui = createIframeUi(ctx, {
          page: '/example-iframe.html',
          position: 'inline',
          anchor: 'body',
          onMount: (wrapper, iframe) => {
            // 为 iframe 添加样式,如宽度
            iframe.width = '123';
          },
        });
    
        // 向用户展示 UI
        ui.mount();
      },
    });

完整选项列表请参阅 API 参考

隔离世界 vs 主世界

默认情况下,所有 Content Scripts 都在隔离的上下文中运行,其中只有 DOM 与其所在的网页共享——即"隔离世界"。在 MV3 中,Chromium 引入了在"主世界"中运行 Content Scripts 的能力——在主世界中,不仅是 DOM,所有内容都对 Content Script 可用,就像脚本是由网页本身加载的一样。

你可以通过设置 world 选项来启用此功能:

ts
export default defineContentScript({
  world: 'MAIN',
});

然而,这种方式有几个明显的缺点:

  • 不支持 MV2
  • world: "MAIN" 仅被 Chromium 浏览器支持
  • 主世界 Content Scripts 无法访问扩展 API

WXT 建议使用其 injectScript 函数手动将脚本注入主世界。这可以解决上述缺点:

  • injectScript 同时支持 MV2 和 MV3
  • injectScript 支持所有浏览器
  • 拥有一个"父" Content Script 意味着你可以来回发送消息,从而可以访问扩展 API

要使用 injectScript,我们需要两个入口点,一个 Content Script 和一个未列出的脚本:

html
📂 entrypoints/
   📄 example.content.ts
   📄 example-main-world.ts
ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
  console.log('Hello from the main world');
});
ts
// entrypoints/example.content.ts
export default defineContentScript({
  matches: ['*://*/*'],
  async main() {
    console.log('Injecting script...');
    await injectScript('/example-main-world.js', {
      keepInDom: true,
    });
    console.log('Done!');
  },
});
json
export default defineConfig({
  manifest: {
    // ...
    web_accessible_resources: [
      {
        resources: ["example-main-world.js"],
        matches: ["*://*/*"],
      }
    ]
  }
});

injectScript 的工作原理是在页面上创建一个指向你脚本的 script 元素。这会将脚本加载到页面的上下文中,使其在主世界中运行。

injectScript 返回一个 Promise,当 Promise 被 resolve 时,表示脚本已被浏览器执行,你可以开始与其通信。

警告:run_at 注意事项

对于 MV3,injectScript 是同步的,注入的脚本将在与 Content Script 的 run_at 相同的时间被执行。

然而对于 MV2,injectScript 需要 fetch 脚本的文本内容并创建一个内联 <script> 块。这意味着对于 MV2,你的脚本是异步注入的,它不会在与 Content Script 的 run_at 相同的时间被执行。

可以在 script 元素添加到 DOM 之前使用 modifyScript 选项对其进行修改。这可以用于修改 script.async/script.defer、添加事件监听器,或通过 script.dataset 向脚本传递数据。示例如下:

ts
// entrypoints/example.content.ts
export default defineContentScript({
  matches: ['*://*/*'],
  async main() {
    await injectScript('/example-main-world.js', {
      modifyScript(script) {
        script.dataset['greeting'] = 'Hello there';
      },
    });
  },
});
ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
  console.log(document.currentScript?.dataset['greeting']);
});

injectScript 返回创建的 script 元素。它可以用于以自定义事件的形式向脚本发送消息。脚本可以通过 document.currentScript 为其添加事件监听器。以下是双向通信的示例:

ts
// entrypoints/example.content.ts
export default defineContentScript({
  matches: ['*://*/*'],
  async main() {
    const { script } = await injectScript('/example-main-world.js', {
      modifyScript(script) {
        // 在注入脚本加载之前添加监听器。
        script.addEventListener('from-injected-script', (event) => {
          if (event instanceof CustomEvent) {
            console.log(`${event.type}:`, event.detail);
          }
        });
      },
    });

    // 在注入脚本加载之后发送事件。
    script.dispatchEvent(
      new CustomEvent('from-content-script', {
        detail: 'General Kenobi',
      }),
    );
  },
});
ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
  const script = document.currentScript;

  script?.addEventListener('from-content-script', (event) => {
    if (event instanceof CustomEvent) {
      console.log(`${event.type}:`, event.detail);
    }
  });

  script?.dispatchEvent(
    new CustomEvent('from-injected-script', {
      detail: 'Hello there',
    }),
  );
});

挂载 UI 到动态元素

在许多情况下,你可能需要将 UI 挂载到页面初始加载时尚不存在的 DOM 元素上。要处理这种情况,可以使用 autoMount API 在目标元素动态出现时自动挂载 UI,并在元素消失时自动卸载。在 WXT 中,anchor 选项用于定位目标元素,根据其出现和移除实现自动挂载和卸载。

ts
export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      // 它会观察 anchor 元素
      anchor: '#your-target-dynamic-element',
      onMount: (container) => {
        // 向容器添加子元素
        const app = document.createElement('p');
        app.textContent = '...';
        container.append(app);
      },
    });

    // 调用 autoMount 来观察 anchor 元素的添加/移除。
    ui.autoMount();
  },
});

TIP

当调用 ui.remove 时,autoMount 也会停止。

完整选项列表请参阅 API 参考

处理 SPA

为 SPA(单页应用)和使用 HTML5 history 模式导航的网站编写 Content Scripts 是很困难的,因为 Content Scripts 仅在完整页面重载时运行。SPA 和利用 HTML5 history 模式的网站**在切换路径时不会执行完整重载**,因此你的 Content Script 不会在你期望的时机运行。

来看一个例子。假设你想在观看 YouTube 视频时添加一个 UI:

ts
export default defineContentScript({
  matches: ['*://*.youtube.com/watch*'],
  main(ctx) {
    console.log('YouTube content script loaded');

    mountUi(ctx);
  },
});

function mountUi(ctx: ContentScriptContext): void {
  // ...
}

你只会在重新加载观看页面或从其他网站直接导航到该页面时看到 "YouTube content script loaded"。

要解决这个问题,你需要手动监听路径变化,并在 URL 匹配你期望的模式时运行你的 Content Script。

ts
const watchPattern = new MatchPattern('*://*.youtube.com/watch*');

export default defineContentScript({
  matches: ['*://*.youtube.com/*'],
  main(ctx) {
    ctx.addEventListener(window, 'wxt:locationchange', ({ newUrl }) => {
      if (watchPattern.includes(newUrl)) mainWatch(ctx);
    });
  },
});

function mainWatch(ctx: ContentScriptContext) {
  mountUi(ctx);
}