如果你没有开发过浏览器插件,那么我建议你直接选择一款框架去开发,因为我们在开发一款 Chrome 插件时,是采用 HTML 、CSS 和 JavaScript 的传统方式开发,无法直接使用 vue 、react 等常用框架去编写 UI ,编译环境也需要自己手动去搭建,往往一些简单的插件,光是环境搭建比业务开发时间还要长。
今天介绍的 WXT 是一个免费的开源浏览器插件开发框架,它致力于为开发者带来最好的开发体验和最快的开发速度,学习它可以为你的插件搭建一个坚实的基础,并为你节省大量的基础建设时间。
官方提供了多个快速入门模板,方便生成你习惯的技术方案。 未来还会推出自动压缩、上传、发布。
有经验的开发者,一定了解过另一款框架,Plasmo,截止文章编写日期,它已经有 7.7k star ,对比 WXT 不足 500 star 可谓是遥遥领先。那么本章节来说明一下为什么我们要选择 WXT?
Plasmo 支持很多 WXT 的特性
当然这些官方正在努力更新中,这两条对于开发体验影响不大,相信不久的将来就会把这些特性添加上去。
如果你没有浏览器插件开发经验,这里提供了几个需要了解的名词含义,如果你已经了解,可以直接跳过本章节。
Manifest(manifest.json) 是一个配置文件,包含插件的基础信息和功能。如果你不使用框架去开发,你需要了解一下。
⚠️ 使用 WXT 开发,可以忽略这一步,因为他会在构建时自动生成,Manifest V2 和 V3 是指 Chrome 扩展的清单文件的不同版本。
V2 是旧版本清单文件格式。它是基于 JSON 格式的配置文件,用于描述扩展的名称、版本、权限、图标、页面注入等信息。Manifest V2 提供了一些基本的功能和 API ,如页面操作、消息传递和存储管理等。它是较早版本的 Chrome 扩展清单文件格式,目前仍然被广泛使用。
V3 是新版本清单文件格式。它在 V2 的基础上进行了一些重大的改进和更新,引入了一些新的概念和 API ,如声明式的事件页、强制性的权限声明、更严格的内容脚本规则等。提高了扩展的性能、安全性和可维护性。
在开发 Chrome 插件时,有 4 个入口文件,他们分别是:
浏览器插件是基于事件的程序,事件是浏览器触发器,例如导航到新网页、移除书签或关闭标签页。我们可以在 service_worker 文件中监听这些后台的事件,然后做出响应。
content.js 的意思是内容脚本,运行于网页环境,使用标准文档对象模型 (DOM),通过它我们就可以获取或修改网页上的内容,这与平时开发网页的方式一致,在此基础上,还可以访问一些其他的 API ,主要是与插件的其他部分通讯的接口,它并不支持全部的 API 。
可以注入 JavaScript 脚本到网页环境,注意这个是注入到整个网页中,content_scripts 只是特定的页面。
弹窗,是一个非常常见的场景,当用户点击某个扩展程序的操作时,该扩展程序会显示一个弹出式窗口,用 popup.html 来写这个弹窗的 UI 。
看的多不如实际操作一下,所以我们从创建一个模板开始学习。
执行命令:
npx wxt@latest init <project-name>
或者你安装了 pnpm:
pnpx wxt@latest init <project-name>
⚠️ 这里建议使用 node(v18+)。
脚本下载后会出现选择起始模板的选项,根据你喜欢的框架选择即可。
进入项目路径,安装依赖,虽然运行 npm run dev
即可自动打开浏览器并看到插件已经安装可用了。
这里我使用的 vue 模板。
看一下目录结构:
.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
。模板已经生成了 vue 的配置,如果你想改为 react ,或者移植过来的项目,可以这样配置:
import { defineConfig } from 'wxt';
import react from '@vitejs/plugin-react';
export default defineConfig({
vite: () => ({
plugins: [react()],
}),
});
当然 vite 的其他配置也在这里。
虽然 manifest 是根据源码自动生成,但是也可以自定义配置,直接在 wxt.config.ts 中的 manifest 字段中配置即可。
⚠️ permissions 配置是很重要的,不配置是没有权限使用的。
可以明确规定 manifest 的版本,他的值为 2 或者 3 ,命令行 --mv2 或 --mv3 可以覆盖此选项。
⚠️ manifest v2 版本已经无法上架谷歌商店,这点值得注意。
明确要构建的浏览器,他的值是任意字符串,默认是 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.setMeta
和 storage.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 可以操作页面 DOM ,这意味着我们可以随意修改某个页面的 UI 。这里举个例子,我个人安装了 V2EX 的某个插件,看一下使用插件后的前后对比:
使用前:
使用后:
可见界面已经天差地别,并且评论区的功能也变了,回复的评论改在了相应评论的后面,这就是插件带来的便利。
与 popup 不同,Content Script UI 的实现方式比较复杂,所以 WXT 提供了三种模式去创建内容脚本 UI ,极大的降低了开发成本:
方法 | 样式隔离 | 事件隔离 | HMR | 使用页面上下文 |
---|---|---|---|---|
Integrated | ❌ 合并 | ❌ | ❌ | ✅ |
Shadow Root | ✅ | ✅ 默认关闭 | ❌ | ✅ |
IFrame | ✅ | ✅ | ✅ | ❌ |
他们都拥有各自的特性,需要按使用场景来使用。
这种方式是将脚本和样式一块注入,这意味着页面上的内容和脚本 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 。如果你不想 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 。大家对它都很熟悉了,很多微前端框架都支持这种形式,因为它天生就对 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
文件。
庆幸的是 WXT 也提供了指令去做:
wxt zip
wxt zip -b firefox
执行后 .zip
文件会出现在 .output
。
接下来通过一个简单的例子:我爱掘金插件实战,来将 popup 、background 、content 三个东西串起来实践一下。有兴趣可以参考代码仓库。
实现效果,每隔 1 秒钟爱掘金一次,将页面上所有 .title 元素都替换成我爱掘金*次
,弹窗也同样展示,数据存储在 storage ,刷新页面也不会让爱消失。
效果展示:
首先先配置一下 package.json
的 name
和 description
,这步不重要。
然后 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();
},
});
至此结束,运行一下体验吧。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.