V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
461229187
V2EX  ›  程序员

下一代浏览器插件开发框架 WXT 入门指南

  •  2
     
  •   461229187 · 330 天前 · 2591 次点击
    这是一个创建于 330 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    如果你没有开发过浏览器插件,那么我建议你直接选择一款框架去开发,因为我们在开发一款 Chrome 插件时,是采用 HTML 、CSS 和 JavaScript 的传统方式开发,无法直接使用 vue 、react 等常用框架去编写 UI ,编译环境也需要自己手动去搭建,往往一些简单的插件,光是环境搭建比业务开发时间还要长。

    今天介绍的 WXT 是一个免费的开源浏览器插件开发框架,它致力于为开发者带来最好的开发体验和最快的开发速度,学习它可以为你的插件搭建一个坚实的基础,并为你节省大量的基础建设时间。

    特性

    • 支持所有浏览器,包括 Chrome 、Firefox 、Edge 、Safari 和一切基于 Chromium 的浏览器。
    • 一套代码支持 Manifest V2 和 V3 的插件。
    • 支持 HMR ,更新内容不再需要重新加载整个插件。
    • 入口点,manifest.json 是根据入口点的文件生成。
    • 默认使用 typescript 。
    • 自动导入,与 Nuxt 一样的自动导入功能,默认的接口无需导入即可使用。
    • 自动下载远程代码,Google Manifest V3 要求拓展程序不依赖远程代码。
    • 轻松使用任何带有 vite 插件的前端框架。
    • 内置包分析工具,方便优化,最小化扩展应用。

    官方提供了多个快速入门模板,方便生成你习惯的技术方案。 未来还会推出自动压缩、上传、发布。

    对比

    有经验的开发者,一定了解过另一款框架,Plasmo,截止文章编写日期,它已经有 7.7k star ,对比 WXT 不足 500 star 可谓是遥遥领先。那么本章节来说明一下为什么我们要选择 WXT?

    Plasmo 不足之处

    Plasmo 支持很多 WXT 的特性

    • 仅 React 、Vue 和 Svelte ,如果有其他框架使用者局限性就比较大了。
    • 不支持自动打开浏览器并安装,这步操作比较繁琐,极大的拉低了开发体验。
    • HMR 目前仅支持 React ,那么像我们使用其他框架的开发者,同样拉低了开发体验,并且更新内容时还会重新加载整个插件。

    WXT 不足之处

    • 未支持自动化发布。
    • 未支持消息传递。

    当然这些官方正在努力更新中,这两条对于开发体验影响不大,相信不久的将来就会把这些特性添加上去。

    前置知识

    如果你没有浏览器插件开发经验,这里提供了几个需要了解的名词含义,如果你已经了解,可以直接跳过本章节。

    Manifest

    Manifest(manifest.json) 是一个配置文件,包含插件的基础信息和功能。如果你不使用框架去开发,你需要了解一下。

    ⚠️ 使用 WXT 开发,可以忽略这一步,因为他会在构建时自动生成,Manifest V2 和 V3 是指 Chrome 扩展的清单文件的不同版本。

    Manifest V2

    V2 是旧版本清单文件格式。它是基于 JSON 格式的配置文件,用于描述扩展的名称、版本、权限、图标、页面注入等信息。Manifest V2 提供了一些基本的功能和 API ,如页面操作、消息传递和存储管理等。它是较早版本的 Chrome 扩展清单文件格式,目前仍然被广泛使用。

    Manifest V3

    V3 是新版本清单文件格式。它在 V2 的基础上进行了一些重大的改进和更新,引入了一些新的概念和 API ,如声明式的事件页、强制性的权限声明、更严格的内容脚本规则等。提高了扩展的性能、安全性和可维护性。

    入口文件

    在开发 Chrome 插件时,有 4 个入口文件,他们分别是:

    • background.js
    • content.js
    • injected.js
    • popup.html

    service_worker

    浏览器插件是基于事件的程序,事件是浏览器触发器,例如导航到新网页、移除书签或关闭标签页。我们可以在 service_worker 文件中监听这些后台的事件,然后做出响应。

    content_scripts

    content.js 的意思是内容脚本,运行于网页环境,使用标准文档对象模型 (DOM),通过它我们就可以获取或修改网页上的内容,这与平时开发网页的方式一致,在此基础上,还可以访问一些其他的 API ,主要是与插件的其他部分通讯的接口,它并不支持全部的 API 。

    injected.js

    可以注入 JavaScript 脚本到网页环境,注意这个是注入到整个网页中,content_scripts 只是特定的页面。

    popup.html

    弹窗,是一个非常常见的场景,当用户点击某个扩展程序的操作时,该扩展程序会显示一个弹出式窗口,用 popup.html 来写这个弹窗的 UI 。

    image.png

    安装

    看的多不如实际操作一下,所以我们从创建一个模板开始学习。

    执行命令:

    npx wxt@latest init <project-name>
    

    或者你安装了 pnpm:

    pnpx wxt@latest init <project-name>
    

    ⚠️ 这里建议使用 node(v18+)。

    脚本下载后会出现选择起始模板的选项,根据你喜欢的框架选择即可。

    image.png

    进入项目路径,安装依赖,虽然运行 npm run dev 即可自动打开浏览器并看到插件已经安装可用了。

    image.png

    这里我使用的 vue 模板。

    目录结构

    看一下目录结构:

    image.png

    • .output/ 构建的结果目录。
    • .vscode/ 和 .wxt 目录都是一些配置和类型,无需了解。
    • assets/ 可以存放资源,存储构建过程中将被 vite 处理。
    • public/ 同样的资源目录,会被原样复制到输出目录。
    • components/ 是存放 vue 通用组件的目录,这个大家都很熟悉了。
    • entrypoints/ 是最核心的路径,所有业务源码都在这里编写。
    • package.json 如果未配置 manifest ,那么插件 name 和 version 将会在这里取。
    • wxt.config.ts 最重要的配置文件,任何行为都需要在这里配置。

    配置

    首先打开 wxt.config.ts 发现里面有 vite 的配置,这代表着,不论你使用什么框架,都构建于 vite ,这也是为什么带有 vite 插件的框架就可以在 WXT 中使用的原因。

    WXT 提供了 defineConfig 方法,携带完全的 ts 类型说明,可以更加方便的去配置。这里我们讲几个比较重要的配置项:

    目录配置

    ⚠️ 本节建议直接跳过。

    如果官方提供的目录结构不是你喜欢的,你可以自行修改,但我不建议你这么做。

    • root 项目的根目录,默认值:process.cwd()
    • srcDir 所有源代码的位置,默认值:rootDir
    • entrypointsDir 包含所有入口点的文件夹,默认值 <srcDir>/entrypoints
    • publicDir 公共资源的文件夹,默认值: <srcDir>/public

    vite

    模板已经生成了 vue 的配置,如果你想改为 react ,或者移植过来的项目,可以这样配置:

    import { defineConfig } from 'wxt';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      vite: () => ({
        plugins: [react()],
      }),
    });
    

    当然 vite 的其他配置也在这里。

    manifest

    虽然 manifest 是根据源码自动生成,但是也可以自定义配置,直接在 wxt.config.ts 中的 manifest 字段中配置即可。

    ⚠️ permissions 配置是很重要的,不配置是没有权限使用的。

    manifestVersion

    可以明确规定 manifest 的版本,他的值为 2 或者 3 ,命令行 --mv2 或 --mv3 可以覆盖此选项。

    ⚠️ manifest v2 版本已经无法上架谷歌商店,这点值得注意。

    browser

    明确要构建的浏览器,他的值是任意字符串,默认是 chrome ,常用的还有 firefox 、edge 、safari 。


    其他的配置项请参考配置文档

    入口点

    在 WXT 中,入口点是通过将文件添加到 entrypoints/ 目录来定义的,也就是约定优于配置。通常一个目录下应该有这几个文件:

    <rootDir>
    └─ entrypoints/
       ├─ background.ts
       ├─ content.ts
       ├─ injected.ts
       └─ popup.html
    

    manifest.json 也会根据这个目录生成相应的配置:

    {
      "manifest_version": 3,
      "action": {
        "default_popup": "popup.html"
      },
      "background": {
        "service_worker": "background.js"
      },
      "content_scripts": [
        {
          "js": ["content-scripts/content.js"]
        }
      ]
    }
    

    background 、content 、popup 是 WXT 自动识别的特殊名称,他们才会自动被加入到 manifest 中,其他的文件也会被构建到插件中,但不会在 manifest 中定义,例如 injected.ts 会被输出到 .output/**/injected.js ,这类文件一般都通过 browser.runtime.getURL("/injected.js") 的方式访问。

    自动被识别的文件比较多,这里可以参考Entrypoints 文档

    扩展接口

    WXT 构建在 webextension-polyfill(Mozilla 出品) 之上,它使用标准 browser 全局变量,做过 chrome 插件的同学应该知道,全局变量是 chrome ,直接理解将 chrome 替换为 browser 即可,因为 WXT 不止是为了 Chrome 做插件。另外一定是该死的回调终于可以使用 async/await 代替了。

    由于支持自动导入,所以我们无需 import { browser } from 'wxt/browser',这里我们利用一个小 demo 了解一下他的用法,通过 onInstall 监听到插件被安装的事件,这时我们通过本地存储将事件保存下来:

    // background.ts
    export default defineBackground(() => {
      browser.runtime.onInstall.addEventListener(({ reason }) => {
        if (reason === 'install') {
          browser.storage.local.setItem({ installDate: Date.now() });
        }
      });
    });
    

    ⚠️ 注意 storage 需要添加到 manifest.permissions 中。

    存储

    上面的例子提到了将安装事件保存到本地存储,WXT 还提供了更精简的 API 用于存储:

    import { storage } from 'wxt/storage'; // 无需引用
    
    await storage.getItem('local:installDate');
    

    所有存储键都必须以其存储区域为前缀,支持 local:session:sync:managed:

    监听存储变化

    如果要对某个键单独设置监听,可以通过使用 storage.watch:

    const unwatch = storage.watch<number>('local:counter', (newCount, oldCount) => {
      // ...
    });
    
    unwatch(); // 取消监听
    

    对象存储

    同样也支持键值对的存储方式,使用 storage.setMetastorage.getMeta:

    await storage.setMeta('local:preference', { v: 2 });
    await storage.getMeta('local:preference');
    

    删除可以通过 storage.removeMeta:

    await storage.removeMeta('local:preference');
    await storage.removeMeta('local:preference', 'lastModified');
    await storage.removeMeta('local:preference', ['lastModified', 'v']);
    

    注意他们都是异步的。

    Content Script UI

    上文提到 Content Script 可以操作页面 DOM ,这意味着我们可以随意修改某个页面的 UI 。这里举个例子,我个人安装了 V2EX 的某个插件,看一下使用插件后的前后对比:

    使用前:

    image.png

    使用后:

    image.png

    可见界面已经天差地别,并且评论区的功能也变了,回复的评论改在了相应评论的后面,这就是插件带来的便利。

    三种实现方式

    与 popup 不同,Content Script UI 的实现方式比较复杂,所以 WXT 提供了三种模式去创建内容脚本 UI ,极大的降低了开发成本:

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

    他们都拥有各自的特性,需要按使用场景来使用。

    Integrated

    这种方式是将脚本和样式一块注入,这意味着页面上的内容和脚本 UI 内容互相是产生影响的。

    建议期望内容脚本 UI 与页面风格一致时使用。

    vue 示例:

    import { createApp } from 'vue';
    import App from './App.vue';
    
    export default defineContentScript({
      matches: ['<all_urls>'],
      main(ctx) {
        const ui = createIntegratedUi(ctx, {
          position: 'inline',
          anchor: '#anchor',
          onMount: (container) => {
            const app = createApp(App);
            app.mount(container);
            return app;
          },
          onRemove: (app) => {
            app.unmount();
          },
        });
        ui.mount();
      },
    });
    

    示例中使用了 createIntegratedUi 方法创建 UI ,这里说明一下几个参数的含义:

    • matches 匹配对应的 URL 去注入(不论哪种方式都需要填这个参数)。
    • anchor 是一个 CSS 选择器或函数,也就是将 UI 插入到页面的哪个位置。
    • 其他的就是一些事件了,创建和销毁,更多请参考文档。
    • position 表示注入方式,可选值有 inline 、overlay 、modal 。

    image.png

    Shadow Root

    如果你不想 CSS 互相影响,那么你可以选择这种模式。

    import './style.css'; // 注意要引入 CSS
    import { createApp } from 'vue';
    import App from './App.vue';
    
    export default defineContentScript({
      matches: ['<all_urls>'],
      cssInjectionMode: 'ui', // 注入模式
      async main(ctx) {
        const ui = await createShadowRootUi(ctx, {
          // 与上一个例子一致
        });
        ui.mount();
      },
    });
    

    参数 cssInjectionMode 是 CSS 注入模式的配置,它有 3 个可选参数:

    • ui 从 manifest 排除 CSS ,在调用 createShadowRootUi 时将 CSS 添加到 UI 。
    • manifest 使用 manifest CSS 下的样式。
    • manual 从 manifest 排除 CSS ,使用 browser.runtime.getURL 获取 CSS 。

    IFrame

    大家对它都很熟悉了,很多微前端框架都支持这种形式,因为它天生就对 CSS 和脚本隔离。

    WXT 提供了一个辅助函数 createIframeUi ,用来加载一个 HTML 页面:

    export default defineContentScript({
      matches: ['<all_urls>'],
      async main(ctx) {
        const ui = await createIframeUi(ctx, {
          page: '/example-iframe.html',
          // 其他配置一致
        });
        ui.mount();
      },
    });
    

    我个人不太喜欢这种模式,他唯一的优点是支持 HMR 。

    远程代码

    Google 对 Manifest V3 要求不能依赖远程代码,我们在使用谷歌分析这类工具时,可以采用这样的方式:

    import 'url:https://www.googletagmanager.com/gtag/js?id=G-XXXXXX';
    

    import + url: 的形式,WXT 会自动下载远程代码到本地。

    构建

    运行 npm run build 即可,默认构建了 Chrome 插件。

    如果运行 npm run build:firefox,则会构建 Firefox 插件,可以看到打包使用的 manifest v2:

    image.png

    这时默认打包的浏览器对应 Manifest 版本:

    浏览器 默认 Manifest 版本
    chrome 3
    firefox 2
    safari 2
    edge 3
    其他任何浏览器 3

    如果你想打包 Firefox 时使用 v3 版本,可以在命令后增加 --mv3 参数即可。

    构建 zip

    如果你是第一次向商店发布插件,需要先了解一下上传步骤,每个商店都需要上传 .zip 文件。

    庆幸的是 WXT 也提供了指令去做:

    wxt zip
    wxt zip -b firefox
    

    执行后 .zip 文件会出现在 .output

    我爱掘金插件实战

    接下来通过一个简单的例子:我爱掘金插件实战,来将 popup 、background 、content 三个东西串起来实践一下。有兴趣可以参考代码仓库

    实现效果,每隔 1 秒钟爱掘金一次,将页面上所有 .title 元素都替换成我爱掘金*次,弹窗也同样展示,数据存储在 storage ,刷新页面也不会让爱消失。

    效果展示:

    image.png

    配置

    首先先配置一下 package.jsonnamedescription,这步不重要。

    然后 wxt.config.ts 配置一下 manifest.permissions:

    export default defineConfig({
      manifest: {
        permissions: ["storage"],
      },
    });
    

    因为我们要存储爱了掘金多少次,所以要在这里获得存储权限。

    定时加爱

    每秒增加一次爱的话,我们可以在 background.ts 中去做。为什么不是在 content.ts 中呢?因为我们要替换页面上的元素时,如果存在多个的话,那么我们每秒就会爱掘金 N 次了。当然在 popup 里去写也没什么问题,但是我建议 popup 仅写 UI ,不要涉及业务逻辑。

    // background.ts
    export default defineBackground(() => {
      const count = storage.defineItem<number>("local:count", {
        defaultValue: 0,
      });
    
      setInterval(async () => {
        const _count = await count.getValue();
        console.log(_count);
        storage.setItem("local:count", _count + 1);
      }, 1000);
    });
    

    弹窗展示

    模板已经生成了 popup/App.vue ,我们直接修改这个文件即可:

    <!-- popup/App.vue -->
    <template>
      <div>
        我爱掘金{{ count }}次
      </div>
    </template>
    
    <script lang="ts" setup>
    import { onMounted, ref } from 'vue';
    
    const count = ref(0);
    
    onMounted(() => {
      setInterval(async () => {
        count.value = await storage.getItem<number>('local:count') || 0;
      }, 1000);
    });
    </script>
    

    让页面充满爱掘金

    Content Script 前面讲了不少,大家应该是轻车熟路。

    import { createApp } from "vue";
    import LoveJuejin from "@/components/LoveJuejin.vue";
    
    export default defineContentScript({
      matches: ["<all_urls>"],
      main(ctx) {
        const ui = createIntegratedUi(ctx, {
          position: "inline",
          anchor: "#juejin",
          onMount: (container) => {
            const app = createApp(LoveJuejin);
            app.mount(container);
            return app;
          },
          onRemove: (app) => {
            if (app) {
              app.unmount();
            }
          },
        });
        ui.mount();
      },
    });
    

    至此结束,运行一下体验吧。

    参考

    9 条回复    2024-08-05 17:28:27 +08:00
    SayHelloHi
        1
    SayHelloHi  
       330 天前
    消息传递的话 可以用这个凑合下

    https://webext-core.aklinker1.io/

    ---

    Plasmo 的文档体验比 WXT 好一点

    WXT 的文档 有时候搜索关键字 搜索结果出来一大堆 大部分都是 API 相关的

    想要找到答案 得非常有耐心 看 API 的注释

    ---

    就比如这个功能:如何禁用调试插件的浏览器自动启动

    在 WXT 文档搜索,没有搜索结果 但是如果有耐心 还是可以找到的:

    https://wxt.dev/api/wxt/interfaces/ExtensionRunnerConfig.html#disabled

    这个问题答案是在 issue 中找到的

    在 wxt.config.ts 中,添加如下字段即可禁用:
    runner: {
    disabled: true,
    },

    ---

    总体而言:

    WXT 开发 比 Plasmo 舒服 😁
    theprimone
        2
    theprimone  
       330 天前
    有趣,居然能看到 WXT 的主题 😏
    461229187
        3
    461229187  
    OP
       330 天前
    @SayHelloHi 越来越好
    461229187
        4
    461229187  
    OP
       330 天前
    如果觉得不错,能否去掘金给个赞
    https://juejin.cn/post/7329724409429917705
    Immortal
        5
    Immortal  
       330 天前
    // background.ts
    export default defineBackground(() => {
    browser.runtime.onInstall.addEventListener(({ reason }) => {
    if (reason === 'install') {
    browser.storage.local.setItem({ installDate: Date.now() });
    }
    });
    });

    browser.runtime.onInstall.addEventListener => 应该是 addListener 吧
    官方文档的 message 那块之前也是这个错误,我提了 pr 修正的
    Immortal
        6
    Immortal  
       330 天前
    最近也在高强度使用,小问题还是挺多,好处就是作者最近很勤快,修复的也比较积极
    zqjilove
        7
    zqjilove  
       313 天前
    这框架不适合我这新手,学习成本太高了。 体验了一下,还是放弃了。
    dedad558
        8
    dedad558  
       186 天前
    tomdddd
        9
    tomdddd  
       143 天前
    写的非常好,有没有文档可以进一步学习下
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3283 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 21ms · UTC 12:29 · PVG 20:29 · LAX 04:29 · JFK 07:29
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.