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

289 天前
 461229187

前言

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

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

特性

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

对比

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

Plasmo 不足之处

Plasmo 支持很多 WXT 的特性

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 个入口文件,他们分别是:

service_worker

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

content_scripts

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

injected.js

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

popup.html

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

安装

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

执行命令:

npx wxt@latest init <project-name>

或者你安装了 pnpm:

pnpx wxt@latest init <project-name>

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

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

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

这里我使用的 vue 模板。

目录结构

看一下目录结构:

配置

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

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

目录配置

⚠️ 本节建议直接跳过。

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

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 的某个插件,看一下使用插件后的前后对比:

使用前:

使用后:

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

三种实现方式

与 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 ,这里说明一下几个参数的含义:

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 个可选参数:

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:

这时默认打包的浏览器对应 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 ,刷新页面也不会让爱消失。

效果展示:

配置

首先先配置一下 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();
  },
});

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

参考

2395 次点击
所在节点    程序员
9 条回复
SayHelloHi
289 天前
消息传递的话 可以用这个凑合下

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
289 天前
有趣,居然能看到 WXT 的主题 😏
461229187
289 天前
@SayHelloHi 越来越好
461229187
289 天前
如果觉得不错,能否去掘金给个赞
https://juejin.cn/post/7329724409429917705
Immortal
289 天前
// 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
289 天前
最近也在高强度使用,小问题还是挺多,好处就是作者最近很勤快,修复的也比较积极
zqjilove
272 天前
这框架不适合我这新手,学习成本太高了。 体验了一下,还是放弃了。
dedad558
145 天前
tomdddd
102 天前
写的非常好,有没有文档可以进一步学习下

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1013150

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX