WXT浏览器插件开发中文教程(17)----内容脚本详解

前言

大家好,我是倔强青铜三 。是一名热情的软件工程师,我热衷于分享和传播IT技术,致力于通过我的知识和技能推动技术交流与创新,欢迎关注我,微信公众号:倔强青铜三。欢迎点赞、收藏、关注,一键三连!!!

内容脚本

要创建内容脚本,请参阅 入口点类型

上下文

内容脚本的 main 函数的第一个参数是其"上下文"。

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

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

javascript 复制代码
Error: Extension context invalidated.

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

ts 复制代码
ctx.addEventListener(...);
ctx.setTimeout(...);
ctx.setInterval(...);
ctx.requestAnimationFrame(...);
// 以及更多

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

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

CSS

在常规的 Web 扩展中,内容脚本的 CSS 通常是单独的 CSS 文件,添加到清单中的 CSS 数组:

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

在 WXT 中,要为内容脚本添加 CSS,只需在 JS 入口文件中导入 CSS 文件,WXT 将自动将打包后的 CSS 输出添加到 css 数组。

ts 复制代码
// entrypoints/example.content/index.ts
import './style.css';
export default defineContentScript({
  // ...
});

要创建一个仅包含 CSS 文件的独立内容脚本:

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

  2. 使用 build:manifestGenerated 钩子将内容脚本添加到清单中:

    wxt.config.ts

    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 种内置工具,用于从内容脚本向页面添加 UI:

  • 集成 - createIntegratedUi
  • [Shadow Root](#Shadow Root "#shadow-root") - createShadowRootUi
  • IFrame - createIframeUi

每种方法都有其自身的优缺点。

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

集成

集成内容脚本 UI 会注入到页面内容的旁边。这意味着它们会受到该页面 CSS 的影响。 以下是将内容脚本生成的UI插入页面中的示例:

原生JavaScript将UI插入页面的方式

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();
  },
});

Vue将UI插入页面的方式

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();
  },
});

React将UI插入页面的方式

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 容器上创建根并渲染组件
        const root = ReactDOM.createRoot(container);
        root.render(<App />);
        return root;
      },
      onRemove: (root) => {
        // 在 UI 移除时卸载根
        root.unmount();
      },
    });
    // 调用 mount 将 UI 添加到 DOM
    ui.mount();
  },
});

Svelte将UI插入页面的方式

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 应用
        mount(App, {
          target: container,
        });
      },
      onRemove: (app) => {
        // 在 UI 移除时销毁应用
        unmount(app);
      },
    });
    // 调用 mount 将 UI 添加到 DOM
    ui.mount();
  },
});

Solid将UI插入页面的方式

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 [​](#Shadow Root "#shadow-root")

在 Web 扩展中,通常不希望内容脚本的 CSS 影响页面,反之亦然。ShadowRoot API 非常适合这种情况。

WXT 的 createShadowRootUi 抽象化了所有 ShadowRoot 的设置,便于创建样式与页面隔离的 UI。它还支持可选的 isolateEvents 参数,以进一步隔离用户交互。

要使用 createShadowRootUi,请按照以下步骤操作:

  1. 在内容脚本顶部导入 CSS 文件
  2. defineContentScript 中设置 cssInjectionMode: "ui"
  3. 使用 createShadowRootUi() 定义您的 UI
  4. 挂载 UI 使其对用户可见

原生JavaScript导入CSS方式

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();
  },
});

Vue导入CSS方式

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();
  },
});

React导入CSS方式

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) => {
        // 容器是 body,React 在 body 上创建根时会发出警告,因此创建一个包装 div
        const app = document.createElement('div');
        container.append(app);
        // 在 UI 容器上创建根并渲染组件
        const root = ReactDOM.createRoot(app);
        root.render(<App />);
        return root;
      },
      onRemove: (root) => {
        // 在 UI 移除时卸载根
        root?.unmount();
      },
    });
    // 4. 挂载 UI
    ui.mount();
  },
});

Svelte导入CSS方式

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 应用
        mount(App, {
          target: container,
        });
      },
      onRemove: () => {
        // 在 UI 移除时销毁应用
        unmount(app);
      },
    });
    // 4. 挂载 UI
    ui.mount();
  },
});

Solid导入CSS方式

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

如果您不需要在与内容脚本相同的框架中运行您的 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. 将页面添加到清单的 web_accessible_resources 中:

    wxt.config.ts

    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 参考

隔离世界与主世界

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

您可以通过设置 world 选项为内容脚本启用此功能:

ts 复制代码
export default defineContentScript({
  world: 'MAIN',
});

然而,这种方法有几个显著的缺点:

  • 不支持 MV2
  • world: "MAIN" 仅由 Chromium 浏览器支持
  • 主世界内容脚本无法访问扩展程序 API

相反,WXT 建议使用 injectScript 函数手动将脚本注入到主世界中。这将解决前面提到的缺点。

  • injectScript 支持 MV2 和 MV3
  • injectScript 支持所有浏览器
  • 有一个"父"内容脚本意味着您可以发送消息来回通信,从而可以访问扩展程序 API

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

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!');
  },
});
JavaScript 复制代码
export default defineConfig({
  manifest: {
    // ...
    web_accessible_resources: [
      {
        resources: ["example-main-world.js"],
        matches: ["*://*/*"],
      }
    ]
  }
});

injectScript 通过在页面上创建一个指向您的脚本的 script 元素来工作。这会将脚本加载到页面上下文中,从而使其在主世界中运行。

injectScript 返回一个承诺,当它被解析时,意味着脚本已被浏览器评估,您可以开始与它通信。

警告:run_at 注意事项

对于 MV3,injectScript 是同步的,注入的脚本将与内容脚本的 run_at 同时评估。

然而对于 MV2,injectScript 必须 fetch 脚本的文本内容并创建一个内联 <script> 块。这意味着对于 MV2,您的脚本是异步注入的,它不会与内容脚本的 run_at 同时评估。

将 UI 挂载到动态元素 (#将 UI 挂载到动态元素)

在许多情况下,您可能需要将 UI 挂载到在网页最初加载时不存在的 DOM 元素上。为此,请使用 autoMount API 自动在目标元素动态出现时挂载 UI,并在元素消失时卸载它。在 WXT 中,anchor 选项用于定位元素,根据其出现和移除自动进行挂载和卸载。

ts 复制代码
export default defineContentScript({
  matches: ['<all_urls>'],
  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      // 它观察锚点
      anchor: '#your-target-dynamic-element',
      onMount: (container) => {
        // 将子元素附加到容器
        const app = document.createElement('p');
        app.textContent = '...';
        container.append(app);
      },
    });
    // 调用 autoMount 观察锚点元素的添加/移除。
    ui.autoMount();
  },
});

提示

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

有关完整选项列表,请参阅 API 参考

处理单页应用

为单页应用 (SPA) 和使用 HTML5 历史模式进行导航的网站编写内容脚本较为困难,因为内容脚本仅在全页重新加载时运行。SPA 和利用 HTML5 历史模式的网站在更改路径时不会进行全页重新加载,因此您的内容脚本不会在您期望的时候运行。

让我们来看一个示例。假设您希望在 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 匹配您期望的值时运行您的内容脚本。

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);
}

最后感谢阅读!欢迎关注我,微信公众号倔强青铜三。欢迎点赞收藏关注,一键三连!!!

相关推荐
mldong6 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排6 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60617 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角8 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!8 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi10 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马11 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu11 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss