阅读视图

发现新文章,点击刷新页面。
🔲 ☆

Browser Extension Dev Extra - User Script 介绍

前言

在此之前的基础教程系列中,为了演示创建了多个 Chrome 扩展程序,它们通常都比较简单。所以在本章,我将说明扩展之外另一种允许用户修改网页的标准:User Script,它的基本思路非常简单,向用户浏览的网站注入自定义的一个脚本片段。但与扩展不同,使用 User Script 通常需要安装一个脚本管理器,例如

通过这类脚本管理器,可以从网站上直接安装其他人创建的 User Script,也可以自己创建需要的脚本。

User Script 结构

首先是结构,User Script 虽然只是一个 JavaScript 文本,但同样分为 Manifest 和 Code 两部分,前者使用注释完成,后者则通常包含在闭包函数中。下面这一个非常简单的示例脚本(由 Tampermonkey 创建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ==UserScript==
// @name New Userscript
// @namespace http://tampermonkey.net/
// @version 2026-04-09
// @description try to take over the world!
// @author You
// @match https://*/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// ==/UserScript==

;(function () {
'use strict'

// Your code here...
})()

可以看到,开头的注释以 // ==UserScript== 开始和 // ==/UserScript== 结尾,中间每一行的注释都以 @ 开头代表脚本的元数据,例如 @name/@version/@match/@grant 之类的,最接近官方文档的地方可能是 https://www.tampermonkey.net/documentation.php。你可能注意到 icon 是一个 datauri 图标。是的,由于只有一个 JavaScript 脚本,所以无法放置 icon 文件之类的。

而下面的代码使用了一个看上去有点奇怪的写法,这是在 ES6 之前为了避免全局变量污染而遗留下来的历史包袱。例如如果在 devtools console 中输入两次下面这个代码,将会出现不同的打印结果

1
2
3
4
console.log(a) // undefined
var a = 1
console.log(a) // 1
var a = 1

如果使用闭包包裹,则两次执行的变量都是局部的,不会互相影响。

1
2
3
4
5
6
7
8
9
10
;(function () {
'use strict'
console.log(a) // undefined
var a = 1
})()
;(function () {
'use strict'
console.log(a) // undefined
var a = 1
})()

重写之前的扩展

对于之前 Browser Extension Dev - 01. 介绍基本概念 创建的那个隐藏 Google 搜索主页上 AI Mode 按钮的扩展,完全可以使用 User Script 重写它。

之前的 manifest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"manifest_version": 3,
"name": "Hide AI Mode on Google Search",
"version": "0.0.1",
"description": "Hide the AI Mode button on Google Search pages.",
"content_scripts": [
{
"matches": ["https://www.google.com/"],
"js": ["content-scripts/content.js"],
"run_at": "document_start"
}
],
"icons": {
"128": "icon/128.png"
}
}

转换成 User Script 就变成了

1
2
3
4
5
6
7
8
9
10
11
// ==UserScript==
// @name Hide AI Mode on Google Search
// @namespace https://rxliuli.com/
// @version 0.0.1
// @description Hide the AI Mode button on Google Search pages.
// @author rxliuli
// @match https://www.google.com/
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com
// @run-at document-start
// @grant none
// ==/UserScript==

然后将之前的 content-scripts/content.js 追加到闭包函数中。

1
2
3
4
5
6
7
8
;(function () {
'use strict'

const style = document.createElement('style')
style.textContent =
'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }'
document.documentElement.appendChild(style)
})()

在 Tampermonkey 中安装脚本

现在,我们可以在 Tampermonkey 脚本管理器中安装脚本了。

首先从 Chrome Web Store 中安装 Tampermonkey https://chromewebstore.google.com/detail/dhdgffkkebhmkfjojejmpbldmpobfkfo

1775697649183.jpg

安装之后还必须启用 Developer Mode 并且在 Tampermonkey 扩展的设置中允许 User Scripts。

1775697452427.jpg

然后打开 chrome-extension://dhdgffkkebhmkfjojejmpbldmpobfkfo/options.html#nav=new-user-script+editor 创建一个新的脚本

1775697776386.jpg

最后,将上面的脚本粘贴进去,然后保存。

1775697819185.jpg

现在,可以看到 AI Mode 搜索按钮确实被隐藏起来了。

1775697832818.jpg

使用 GM API

上面我们完成了一个最简单的脚本,但完全没有涉及到 UserScript 提供的独特的 GM API。目前而言,Tampermonkey 包含了相当多的 API https://www.tampermonkey.net/documentation.php

1775694780786.jpg

最常见的可能是 GM_setValue/GM_getValue,它们是扩展向脚本提供的 KV 存储 API。这里使用 GM_registerMenuCommand 注册一个菜单项来演示如何在脚本内部启用和禁用。

首先仍然需要使用 // @grant 声明权限

1
2
3
4
5
6
7
8
9
10
11
12
13
// ==UserScript==
// @name Hide AI Mode on Google Search
// @namespace https://rxliuli.com/
// @version 0.0.1
// @description Hide the AI Mode button on Google Search pages.
// @author rxliuli
// @match https://www.google.com/
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com
// @run-at document-start
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// ==/UserScript==

然后根据设置动态注册菜单项,并且在启用时自动隐藏 AI Mode 按钮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
;(function () {
'use strict'

let menuId
if (GM_getValue('visible')) {
menuId = GM_registerMenuCommand('Hide AI Mode', toggle)
} else {
menuId = GM_registerMenuCommand('Show AI Mode', toggle)
hide()
}

function toggle() {
if (GM_getValue('visible')) {
hide()
GM_registerMenuCommand('Show AI Mode', toggle, { id: menuId })
GM_setValue('visible', false)
} else {
show()
GM_registerMenuCommand('Hide AI Mode', toggle, { id: menuId })
GM_setValue('visible', true)
}
}

function hide() {
const style = document.createElement('style')
style.id = 'hide-ai-mode-on-google-search'
style.textContent =
'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }'
document.documentElement.appendChild(style)
}
function show() {
document.getElementById('hide-ai-mode-on-google-search')?.remove()
}
})()

现在打开 Tampermonkey 的 Popup 的弹窗就可以看到注册的菜单项了。

1775697581036.jpg

总结

总体来说,如果你需要快速向网页注入一些脚本(这本质上就是用户脚本的定义),你可以优先使用 UserScript。如果你需要更强大的功能,比如操作标签页、运行后台定时任务,或者需要包含大型资源文件(例如 wasm,直接将 base64 嵌入内容脚本会很乱),那么你可以选择扩展。

UserScript 有几个显著的优势:

  1. 没有审核,这意味着你可以为 YouTube 添加视频下载功能,而谷歌禁止所有试图这样做的扩展程序,甚至本地下载工具 FDM 也收到了投诉。https://www.wilderssecurity.com/threads/free-download-manager-removes-support-for-youtube-downloads.441430/
  2. 更广泛的可用性,扩展在移动浏览器中难以使用,只有少数浏览器支持扩展,而支持用户脚本的浏览器数量要更多,例如 Firefox Android、Kiwi Browser、Safari + Userscripts app 等
  3. 没有数量限制,这可能是许多人不知道的,Chrome 网上应用店限制一个账户最多提交 20 个扩展程序,这是一个不常见的限制,但它确实存在。https://developer.chrome.com/docs/webstore/publish#:~:text=Note%3A%20You%20cannot%20have%20more%20than%2020%20extensions%20published%20on%20the%20Chrome%20Web%20Store
  4. 风险相对更低,扩展拥有的能力远超 UserScript,但这也意味着风险更高,例如加密货币领域因恶意扩展损失数百万美元的例子屡见不鲜。https://thehackernews.com/2025/12/trust-wallet-chrome-extension-bug.html

除了拥有更强大的 API 之外,扩展还有几个优势

  1. 可发现性,如果你希望被更多人看到,选择扩展可能是更好的选择。扩展有官方的 Chrome Web Store 或 App Store,而 User Script 没有什么“官方”商店,Userscript.Zone 是一个好的搜索工具,但并不负责审核。
  2. 可货币化,扩展尽管成功货币化的例子并不多(相比于 App/Web),但仍然存在。很难说服别人为一个脚本付费,尽管有点愚蠢,但脚本小子的刻板印象也存在
  3. UserScript 需求更加“下沉”和“长尾”,相对而言,User Script 的创建门槛足够低,并且缺乏审核或规范,所以你能找到各种形形色色的脚本和开发者,容易被灰黑产滥用
  4. 资源限制更高,例如通过嵌入 wasm/onnx 可以运行一些客户端 AI 模型,例如实现网页内容过滤或是图片识别等等,拓宽了 User Script 难以触及的可能性
🔲 ☆

Browser Extension Dev - 08. 发布 Chrome Web Store

前言

在之前的 7 篇博客中,我们依次了解了一些扩展开发中的基本概念,并且每一篇都附上了一个扩展示例。现在,我们终于要演示如何发布扩展了。下面我们将演示如何将之前做的自动冻结不活跃标签页的那个扩展发布到 Chrome Web Store 中,还记得吗?就是我们在 Browser Extension Dev - 04. Background ScriptBrowser Extension Dev - 05. 存储和配置 中作为示例的那个扩展。

步骤

准备 Chrome Web Store 发布账户

  1. 首先按照官方文档注册开发者账户,需要一个 Google 账户并且支付一次性注册费用 $5 即可完成。参考:https://developer.chrome.com/docs/webstore/register
  2. 然后继续完成设置,主要是设置开发者账户名称,以及验证邮箱,没什么太复杂的。参考 https://developer.chrome.com/docs/webstore/set-up-account

注册完成后打开 https://chrome.google.com/webstore/devconsole/ 应该可以看到如下页面。

1769515955075.jpg

构建扩展包

接下来,开始演示如何从构建到最终发布扩展。

首先,在项目中打开终端,然后运行 pnpm zip,应该会看到类似下面这样的输出,可以看到 Chrome 扩展已经被正常打包成 zip。

1
2
3
4
ℹ Zipping extension...
✔ Zipped extension in 12 ms
└─ .output/05-storage-and-configuration-0.0.0-chrome.zip 13.37 kB
Σ Total size: 13.37 kB

在 .output 目录下找到这个文件,记住这个路径。

1769517095997.jpg

上传到 Chrome Web Store

然后打开 https://chrome.google.com/webstore/devconsole/ 并点击右上角的 New Item 按钮。

1769517209416.jpg

选择刚刚找到的 zip 文件上传,此时遇到了一个错误,提示 The manifest has an invalid version: 0.0.0. Please format the version as defined,也就是版本号不能为 0.0.0

1769517322845.jpg

使用 pnpm version patch 将版本号增加到 0.0.1,然后重新运行 pnpm zip 构建并上传,即可看到扩展发布管理页面。

1769517483216.jpg

配置商店展示信息 (Store Listing)

其中,对于发布而言,最重要的两个标签页是 Store listing 和 Privacy。前者用于配置扩展在 Chrome Web Store 中的展示信息,例如简介、分类、图标和截图等等,后者则是权限使用说明和隐私政策链接。
对于这个扩展而言,选择分类为 Productivity > Tools,语言选择 English。

1769649613093.jpg

然后从 public/icon 目录选择 128.png 图标作为在商店显示的扩展图标。要截取精确 1280x800 像素的截图可能有点麻烦,但可以直接使用 https://squoosh.app 来调整截图的大小,使用 Resize 功能调整截图尺寸到 1280x800 就好了。

1769518272363.jpg

PS: 如果你使用 mac,可以使用小工具 Window Resizer 来将窗口尺寸修改为指定大小。
参考 Chrome 官方发布文档 https://developer.chrome.com/docs/webstore/publish

配置隐私政策 (Privacy)

切换到 Privacy 标签,可以看到有几个主要区域

  • Single purpose:单一用途说明,简单来说就是用一两句话说清楚扩展是做什么的
  • Permission justification:权限使用说明,注意最后的 Are you using remote code?,Chrome 禁止使用远程代码,某些库(如 zod)可能会不小心引入远程代码,需特别留意
  • Data usage:数据收集说明,选择扩展收集了什么数据,如果扩展是本地运行的,那么不需要选择任何选项
  • Privacy policy:隐私政策链接,这是唯一需要外部托管的内容,可以在个人域名上托管它,如果是开源的,也可以直接放上 GitHub 相关文件的链接。这是一个示例:https://rxliuli.com/webstore/privacy/

1769649228557.jpg
1769649249668.jpg

提交审核

按下 Save draft 按钮之后,如果 Submit for review 按钮可用,那就说明可以提交扩展进行审核了。

1769649204651.jpg

提交审核后,将会进入审核队列,通常需要几天甚至更长时间进行初次审核,所以还请耐心等待,某些使用高风险权限(例如向所有网站注入脚本)或者针对高风险网站(当然,吾辈是在说 YouTube)的扩展可能需要等待更长时间。

1769649344613.jpg

总结

至此,浏览器扩展开发的基础内容就介绍完了。后续可能会有一些进阶主题的番外篇,比如国际化、GitHub Actions 自动发布等。

如果还对发布 Safari 扩展并上架 App Store 感兴趣,可以查看我之前写的博客 转换 Chrome Extension 为 Safari 版本发布 Safari 扩展到 iOS 应用商店。提醒一下,这非常复杂,且开发者账户无试用期,必须满足 1)有一台 macOS 电脑并且安装 Xcode 等开发工具 2)开通 App Store 开发者账户并支付 $99/年的费用。

🔲 ☆

Browser Extension Dev - 07. Popup UI

前言

在上一章 Browser Extension Dev - 06. Inject Script on Demand 中,我们介绍了按需为网页注入脚本执行自定义的功能,还实现了一个简单的复制网页主要内容为 Markdown 的扩展。在这一章中,我们将继续实现一个 Popup 弹窗,用于显示页面主要内容转换得到的 Markdown,并支持在复制之前进行预览和编辑。

首先,需要明确 Popup 是什么?
之前我们已经接触过 Content Script 注入网页的 UI 和 Options 配置页面。Popup 类似于 Options 页面,独立运行,但权限相比 Background/Options 更加受限。通常而言,它和 Content Script UI 的应用场景非常接近,都是显示一些当前网页相关的内容,但它也有一些独有的适用场景:

  • 安全与隔离,网站无法以任何方式主动访问 Popup UI,它们完全由浏览器的不同线程/进程进行隔离。Content Script 注入的任何 UI 都有可能被网页检测出来,这就是网页能够检测是否使用了广告拦截器的原因之一。
  • 不受普通网站影响,例如一个定时刷新的扩展,可以自动刷新当前页面,我们肯定不希望每次刷新网页之后都重新注入并显示操控面板。
  • 无需内存清理,注入 Content Script 很难完全清理内存,这在普通网页不会出现问题,但在 SPA 网页可能会导致问题,复杂的(换句话说,使用了很多 npm 包的)JavaScript 代码真的到处都是内存泄漏。而 Popup 在关闭后就彻底销毁了,下次会再次重建。
  • 可以在特权页面打开,例如 https://chromewebstore.google.com/,所有扩展的 Content Script UI 都会在这个网站禁用,但可以打开 popup 并且获得当前标签页的 URL,这在特定场景很有用,例如用于下载扩展 zip 文件的工具

Content Script UI 则有其他几个优势

  • 更大的 UI 区域:Popup UI 受限于面板宽度,无法制作全屏面板
  • 更容易与网站本身高度集成,例如需要添加符合网站外观的按钮时
  • 更容易控制和修改网站本身,例如希望拦截网络、监听并修改 DOM、或者拦截脚本的特定代码执行之类的 – Popup 可以结合 Background Script 注入脚本做到,但没有那么灵活

接下来,让我们接着之前的实现继续完善吧。

参考 Chrome 官方文档 https://developer.chrome.com/docs/extensions/develop/ui/add-popup

思考

现在面临一个问题:如何在 Popup 中获取页面的内容?
答案是无法直接获取,需要通过 Background Script 中转,大致流程如下:

Popup → Background → executeScript(inject.js) → 返回 markdown → Popup 显示

但是等等,scripting.executeScript 可以有返回值吗?当然可以,它支持同步和异步返回值,但返回值必须是可结构化克隆的。

参考 Chrome scripting API 关于 Promise 返回值的官方文档 https://developer.chrome.com/docs/extensions/reference/api/scripting#promises

实现

添加 popup 页面

首先添加一个 popup 页面,在 entrypoints/popup 下添加 index.html 和 main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- entrypoints/popup/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popup</title>
<style>
html,
body,
#root {
margin: 0;
padding: 0;
width: 600px;
height: 800px;
font-size: 16px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
1
2
3
4
5
6
7
// entrypoints/popup/main.ts
const root = document.getElementById('root')!

root.innerHTML = `
<h1>Popup UI</h1>
<p>This is a placeholder for the popup UI.</p>
`

在浏览器中加载扩展之后,点击 action 可以看到弹窗出现了。

1769333824214.jpg

修改注入的脚本 Inject Script

在实现通信部分之前,需要修改一下之前注入的 Inject Script,不再复制 Markdown 到剪切板,而是使用 return 返回给调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Readability } from '@mozilla/readability'
import TurndownService from 'turndown'

export default defineUnlistedScript(async () => {
const service = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
})
const reader = new Readability(document.cloneNode(true) as Document)
const article = reader.parse()
if (article && article.title && article.content) {
return service.turndown(article.content)
// const markdown = service.turndown(article.content)
// await navigator.clipboard.writeText(`# ${article.title}\n\n${markdown}`)
// alert('Article copied as Markdown!')
} else {
return null
// alert('Failed to parse the article.')
}
})

实现 Popup 与 Background Script 通信

下面开始实现 Popup 与 Background Script 的通信部分,由于 Chrome 原生的通信 API 使用起来非常痛苦,这里使用一个浅包装 @webext-core/messaging

安装依赖

1
pnpm i @webext-core/messaging

然后在 lib/messager.ts 中定义接口

1
2
3
4
5
6
// lib/messager.ts
import { defineExtensionMessaging } from '@webext-core/messaging'

export const messager = defineExtensionMessaging<{
getMarkdown: () => string | null
}>()

然后在 Background Script 定义实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
messager.onMessage('getMarkdown', async (ev) => {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
})
if (!tabs[0].id) {
throw new Error('No active tab found')
}
const result = await browser.scripting.executeScript({
target: { tabId: tabs[0].id },
files: ['/inject.js'] as PublicPath[],
}) // 执行脚本并获取返回值
return result[0].result as string | null
})

最后在 Popup 中调用,出于简化考虑,这里直接使用 pre 渲染了 Markdown,我们将在下一步引入所见即所得的 Markdown 编辑器。

1
2
3
4
5
6
7
import { messager } from '@/lib/messager'

const root = document.getElementById('root')!
const md = await messager.sendMessage('getMarkdown')
const pre = document.createElement('pre')
pre.textContent = md as string
root.appendChild(pre)

1769333889436.jpg

添加 markdown 编辑器

由于并未使用 react,所以这里直接使用一个 vanilla JS 实现的 markdown 编辑器 easymde

还是先安装依赖。

1
pnpm i easymde

然后在 Popup 中使用它。

1
2
3
4
5
6
7
8
9
10
11
12
import 'easymde/dist/easymde.min.css'
import { messager } from '@/lib/messager'
import EasyMDE from 'easymde'

const root = document.getElementById('root')!
const textarea = document.createElement('textarea')
root.appendChild(textarea)
const md = await messager.sendMessage('getMarkdown')
new EasyMDE({
element: textarea,
initialValue: md as string,
})

现在就可以看到最终的效果了。

1769333846824.jpg

总结

在这一章,我们介绍了 Popup 的应用场景、Popup 与 Background Script 的通信、以及从网页获取数据的功能与实现。在下一章,我们终于要发布插件了,我将演示如何将插件发布到 Chrome Web Store,以便让其他人也能使用开发的扩展。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/07-popup-ui

🔲 ☆

Browser Extension Dev - 06. 按需注入脚本

前言

在上一章 Browser Extension Dev - 05. Storage and Configuration 中,我们介绍了为扩展添加设置页面并使用 Storage API 保存和读取配置的功能。在这一章,我们将介绍按需注入脚本。这种方式完全不会拖慢网站运行速度,同时在 Chrome Web Store 安装时不会有任何安全警告提示。接下来我们将实现一个扩展:点击图标即可将网页主要内容复制为 Markdown。

思考

我们之前已经接触过 Content Script 注入和 Background Script 监听扩展图标点击。虽然尚未介绍,但两者可以进行消息通信。

有了上面的背景知识,你可能会想要这样做:

  1. 为所有网页注入 Content Script 并监听后台消息
  2. 点击扩展图标时在 Background Script 通知 Content Script
  3. 在 Content Script 中执行具体逻辑

这种方法有几个主要缺点:

  1. 默认为所有网页注入 Content Script 不仅会拖慢网站速度,还可能导致风险,因为注入过程对用户完全无感知,后续扩展更新可能引入漏洞
  2. 权限要求极高,安装扩展时会提示安全警告,说明这个扩展要求读取和修改所有用户访问的网站

1768995880017.jpg

而对于需要明确动作触发的场景,其实有更简单的实现方式:

  1. 点击扩展图标时在 Background Script 中向当前网页注入一段脚本
  2. 在脚本中执行具体逻辑

这样,需要的权限就从 ['<all_urls>'] 变成了 ['activeTab', 'scripting'],虽然权限数量变多了,但风险反而更低,必须由用户触发才能执行代码,所以安装扩展时不会有任何警告。例如:

1768996707257.jpg

这里涉及到的关键 API 是 scripting.executeScript,顾名思义,用于执行自定义脚本。

参考 Chrome 官方 activeTab 指南:https://developer.chrome.com/docs/extensions/develop/concepts/activeTab

实现

在扩展图标点击时注入脚本

接下来在后台脚本中实现监听和注入部分。
首先还是更新 wxt.config.ts,添加所需权限以及空的 action 字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineConfig } from 'wxt'

export default defineConfig({
manifestVersion: 3,
manifest: {
name: 'Copy As Markdown',
description: 'Copy page content as Markdown',
permissions: ['activeTab', 'scripting'],
action: {},
},
webExt: {
disabled: true,
},
})

然后添加一个用于测试的注入脚本。与 Content Script 不同,这类脚本在 WXT 中需要使用 defineUnlistedScript 声明。

1
2
3
4
// entrypoints/inject.ts
export default defineUnlistedScript(() => {
alert('Injected script executed!')
})

然后在 background 中监听并注入它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { PublicPath } from 'wxt/browser'

export default defineBackground(() => {
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
await browser.scripting.executeScript({
target: { tabId: tab.id },
// 这里的 /inject.js 是指构建之后的文件,如果你使用 pnpm build,就可以在 .output/chrome-mv3 中看到 inject.js 了
// 注:一开始这里可能会报 ts 类型错误,pnpm dev/build 启动之后 wxt 会正确扫描 entrypoints 并生成类型定义
files: ['/inject.js'] as PublicPath[],
})
}
})
})

除了 files 参数,还可以通过 func/args 直接传递函数和参数,适用于简单场景。参考:https://developer.chrome.com/docs/extensions/reference/api/scripting#type-ScriptInjection

当我们打开 google.com 然后点击扩展图标时,却发现没有反应。查看扩展详情页面,可以看到一个错误。

1
Uncaught (in promise) Error: Could not load file: 'inject.js'.

和之前 Browser Extension Dev - 03. 注入 UI 时一样,需要在 manifest 中增加 web_accessible_resources 配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { defineConfig } from 'wxt'

export default defineConfig({
manifestVersion: 3,
manifest: {
name: 'Copy As Markdown',
description: 'Copy page content as Markdown',
permissions: ['activeTab', 'scripting'],
action: {},
web_accessible_resources: [
{
resources: ['inject.js'],
matches: ['<all_urls>'],
},
],
},
webExt: {
disabled: true,
},
})

然后再试一次,可以看到脚本确实被注入并正确执行了。

1768998702041.jpg

⚠️ 局限性:如果你希望注入的脚本能持久化(刷新或重新进入页面后仍自动运行),这是行不通的,仍然需要正确声明 host_permissions 权限来持久化注入 Content Script,即使使用 scripting API 仍然受到权限模型的限制。

在注入脚本中实现功能

接下来实现读取网页主要内容,转换为 Markdown,然后复制到剪贴板的功能。借助 npm 生态,实现起来非常简单。

首先安装需要的依赖

1
2
pnpm i @mozilla/readability turndown
pnpm i -D @types/turndown

然后编写少量胶水代码即可完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Readability } from '@mozilla/readability'
import TurndownService from 'turndown'

export default defineUnlistedScript(async () => {
const service = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
})
const reader = new Readability(document.cloneNode(true) as Document) // 深度复制避免影响到原网页
const article = reader.parse() // 解析主要内容
if (article && article.title && article.content) {
const markdown = service.turndown(article.content) // 转换 HTML 为 Markdown
await navigator.clipboard.writeText(`# ${article.title}\n\n${markdown}`) // 复制
alert('Article copied as Markdown!')
} else {
alert('Failed to parse the article.')
}
})

1768999375770.jpg
1769003419253.jpg

总结

在本章中,我们实现了一个按需注入脚本的扩展,它不会影响网页正常运行,只在用户触发时才执行代码,真正做到即插即用。在下一篇,我们将继续完善这个扩展,使用 Popup 弹窗直接预览和编辑从当前页面复制的 Markdown。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/06-inject-script-on-demand

🔲 ☆

Browser Extension Dev - 05. 存储和配置

前言

在上一章 Browser Extension Dev - 04. Background Script 中,我介绍了 Background Script 的概念和使用场景,并实现了一个自动休眠不活跃标签页的扩展。在本章,我将介绍如何在扩展中存储数据和配置选项,并提供一个配置页面来访问它。

Storage API(概念)

浏览器为扩展提供了 browser.storage API,允许存储 kv 数据,可以存储任何可以被结构化克隆的数据,通常而言对于扩展的配置功能足够了。除此之外,有时候还使用 localStorage(如果是 Content Script)或 indexeddb(简单的 kv 存储不足以满足需求时)来存储扩展的设置。
其中 browser.storage API 下有几个选项,它们的接口是一致的,只是存储的方式和行为有些不同

  • storage.local: 本地持久化存储
  • storage.sync: 在不同设备之间同步(有严重的局限性,仅限登录相同账号的相同浏览器,即便如此,Safari 也不支持同步)
  • storage.session: 临时存储在内存中,不会持久化,浏览器关闭重启即消失
  • storage.managed: 企业环境使用,通常扩展开发者完全不必关心

参考 Chrome 官方文档: https://developer.chrome.com/docs/extensions/reference/api/storage

配置页面(概念)

配置页面是浏览器为扩展提供的一个专用页面,允许在单独的页面中调整扩展的选项,或者访问扩展提供的功能。下面是两种配置页面的使用方式

直接使用浏览器内嵌页面访问,布局紧凑,适合配置项较少的情况,也是官方推荐的默认方式。
1768912374888.jpg

或者在独立标签页中打开,有更大的空间展示完整配置甚至功能,但需要额外配置或编写代码才能让用户方便地访问。
1768912393419.jpg

在 WXT 中,可以在 options.html 中添加 meta 标签来修改它,参考 https://wxt.dev/guide/essentials/entrypoints.html#options

1
<meta name="manifest.open_in_tab" content="true|false" />

同时,也有两种方法可以访问扩展的配置页面

  1. 点击扩展的 More Options > Options 来打开
  2. 进入扩展的详情页面,然后查找 Extension options 按钮

1768912061851.jpg

参考 Chrome 官方文档: https://developer.chrome.com/docs/extensions/develop/ui/options-page

实现

基础配置页面

在 WXT 中,需要在 entrypoints 目录下添加 options.html 或者 options/index.html 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- entrypoints/options/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options</title>
</head>
<body>
<div>
<label for="autoSleepInterval">Auto Sleep Interval (minutes):</label>
<input
type="number"
id="autoSleepInterval"
name="autoSleepInterval"
min="1"
value="30"
/>
</div>
</body>
</html>

WXT options entrypoint 文档: https://wxt.dev/guide/essentials/entrypoints.html#options

效果:

1768912342663.jpg

添加 storage 权限并实现持久化

创建 entrypoints/options/main.ts 并在 html 的 body 标签末尾引入。

1
<script type="module" src="./main.ts"></script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// entrypoints/options/main.ts
async function main() {
const input = document.querySelector<HTMLInputElement>('#autoSleepInterval')!
input.value =
(
await browser.storage.local.get<{ autoSleepInterval?: number }>(
'autoSleepInterval',
)
).autoSleepInterval?.toString() ?? '30' // 读取保存的设置,如果找不到则使用默认值 30min
input.addEventListener('input', async (ev) => {
const value = (ev.target as HTMLInputElement).valueAsNumber
// 每次修改设置时都写入 storage.local,不使用 change 事件是为了避免修改之后立刻刷新页面,有可能接收不到事件
await browser.storage.local.set({ autoSleepInterval: value })
})
}

main()

打开配置页面测试,发现功能没有生效。右键打开开发者工具,在控制台中看到以下错误:

1
2
3
Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'local')
at main (main.ts:3:3)
at main.ts:15:1

这是因为缺少 storage 权限。使用需要权限的 API 之前都必须先声明,修改 wxt.config.ts 添加权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineConfig } from 'wxt'

export default defineConfig({
manifestVersion: 3,
manifest: {
name: 'Auto Sleep Tabs',
description:
'Automatically puts inactive tabs to sleep to save memory and CPU.',
permissions: ['tabs', 'storage'], // new
},
webExt: {
disabled: true,
},
})

现在,修改页面中的 Auto Sleep Interval 选项的值之后,刷新页面,可以看到值已经被持久化了。

1768958959853.jpg

美化(tailwindcss)

不过,HTML 默认样式实在太丑了,让我们引入 tailwindcss 并添加一些样式。

安装依赖

1
pnpm install tailwindcss @tailwindcss/vite

更新配置并添加 tailwindcss 插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { defineConfig } from 'wxt'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
manifestVersion: 3,
manifest: {
name: 'Auto Sleep Tabs',
description:
'Automatically puts inactive tabs to sleep to save memory and CPU.',
permissions: ['tabs', 'storage'],
},
vite: () => ({
plugins: [tailwindcss()], // new
}),
webExt: {
disabled: true,
},
})

参考 https://tailwindcss.com/docs/installation/using-vite

然后在 html 中引入 tailwindcss 美化一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options</title>
<style>
@import 'tailwindcss';
</style>
</head>
<body>
<div class="p-6">
<h1 class="text-xl font-semibold text-gray-800">Settings</h1>
<div class="space-x-4">
<label for="autoSleepInterval" class="text-gray-700">
Auto Sleep Interval (minutes):
</label>
<input
type="number"
id="autoSleepInterval"
name="autoSleepInterval"
min="1"
value="30"
class="w-24 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</body>
</html>

现在,我们可以看到效果至少好看了一点。

1768959164182.jpg

在 background 中读取配置

将硬编码的 Timeout 改为从 storage 读取:

1
2
3
4
5
6
7
8
9
// const Timeout = 30 * 60 * 1000
const Timeout =
((
await browser.storage.local.get<{ autoSleepInterval?: number }>(
'autoSleepInterval',
)
).autoSleepInterval ?? 30) *
60 *
1000

如果需要在修改配置后立刻触发重新检测,还可以使用 storage.onChanged API,由于上面已经监听了标签页切换时自动触发检测,所以下面这段代码只做演示。

1
2
3
4
5
6
7
8
9
browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && changes.autoSleepInterval) {
console.log(
'autoSleepInterval changed to',
changes.autoSleepInterval.newValue,
)
autoDiscardTabs()
}
})

自定义 action 打开配置页面

目前为止,我们都使用 Chrome 默认的方法打开配置页面,例如上面提到的两种方法。但其实我们还可以将点击浏览器右上角的 action 图标绑定到打开配置页面的行为。

首先在 wxt.config.ts 的 manifest 中声明 action 选项,目前留空即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineConfig } from 'wxt'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
manifestVersion: 3,
manifest: {
name: 'Auto Sleep Tabs',
description:
'Automatically puts inactive tabs to sleep to save memory and CPU.',
permissions: ['tabs', 'storage'],
action: {}, // new
},
vite: () => ({
plugins: [tailwindcss()],
}),
webExt: {
disabled: true,
},
})

然后在 background script 中监听 browser.action.onClicked 事件

1
2
3
browser.action.onClicked.addListener(async () => {
await browser.runtime.openOptionsPage()
})

现在,只要点击 action 就能打开配置页面,更加方便快捷。

总结

在这一篇中,主要介绍了添加配置页面以及使用 storage API。在下一篇中,将介绍按需向网页注入脚本,也将是目前为止唯一一个在 Chrome Web Store 安装扩展时不会有任何警告信息的扩展。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/05-storage-and-configuration

🔲 ☆

Browser Extension Dev - 04. Background Script

前言

在上一章 Browser Extension Dev - 03. 注入 UI 中,我介绍了如何向网页中注入自定义的 UI,同时还了解了如何使用 Shadow DOM、Tailwind CSS 和使用 npm 包。在本章,我将介绍 Background Script,这是扩展的核心元素之一。

首先,什么是 Background Script?

顾名思义,这是扩展可以在后台运行的脚本,与注入到网页的脚本有所不同,它有几个显著的特点:

  1. 可以访问所有扩展 API,在扩展的其他部分,例如 Content Script,可以访问的扩展 API 极其受限,例如无法访问 tabs API 来获取当前浏览器所有打开的标签页。
  2. 全局唯一,对于 Content Script 而言,在多个标签页中可能会被注入多次,但 Background Script 始终保持唯一,它不会同时存在多个。
  3. 按需启动,在 Manifest V3 之后,Background Script 更改为基于事件的模型,也就是说,没有事件传入时(例如,扩展可以监听新标签页的打开事件),它会自动休眠节省资源
  4. 无法使用 DOM API,这点不太明显,虽然 Background Script 确实在浏览器环境,可以使用有限的 Web API,但无法访问 DOM,尽管确实有几个替代选项(jsdom/Offscreen)
  5. 可以和扩展其他部分通信,Background Script 能与 Content Script、Popup Page 等部分通信,但其他部分之间却不能直接通信,所以需要 Background Script 中转

参考:
Background 简介:https://developer.chrome.com/docs/extensions/develop/concepts/service-workers(Manifest V3 后 Chrome 官方改名为 extension service workers,但通常还是习惯称呼为 Background Script)
Manifest V3 简介:https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3

上面就是 Background Script 的几个关键点,接下来我将实现一个自动休眠不活跃标签页的扩展来演示它,下面是将会涉及到的 Tabs API,参考 https://developer.chrome.com/docs/extensions/reference/api/tabs

思考

首先,我们该如何定义不活跃的标签页?

从简单的角度来说,一个长时间没有访问的标签页就是不活跃的,例如最后一次访问标签页还是 30 分钟之前,那么应该可以认为是不活跃的了。如何知道标签页的最后访问时间呢?这就需要监听标签页相关的事件了,下面是涉及到的三个基本事件:

onCreated => 将标签页信息添加到扩展记录中
onRemoved => 从扩展记录的标签页列表中移除
onActivated => 更新标签页的最后访问时间

在长时间不活跃之后,我们可以自动冻结它,浏览器允许在不关闭标签页的情况下自动从内存中驱逐,再次访问时会自动重新加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Extension starts


Initialize existing tabs


┌─────────────────────────────────────────────────┐
│ Wait for events │◄──────┐
└─────────────────────────────────────────────────┘ │
│ │
├─── onCreated ──► Record new tab time ───────────►│
│ │
├─── onRemoved ──► Remove tab record ─────────────►│
│ │
└─── onActivated ──► Update access time │
│ │
▼ │
Check all tabs │
│ │
▼ │
Over 30 minutes? │
│ │ │
Yes │ │ No │
▼ └──────────────────►│
Freeze tab │
│ │
└─────────────────────────────┘

注意:Tabs API 本身提供了 lastAccessed 字段查看标签页的最后访问时间,但这个字段在 Safari 浏览器并不支持,参考 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab#browser_compatibility

实现

由于在之前的章节中已经说明过如何初始化扩展,这里不再赘述初始化过程。

1
2
# init project
pnpm dlx wxt@latest init 04-background-script --template vanilla --pm pnpm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// wxt.config.ts
import { defineConfig } from 'wxt'

export default defineConfig({
manifestVersion: 3,
manifest: {
name: 'Auto Sleep Tabs',
description:
'Automatically puts inactive tabs to sleep to save memory and CPU.',
},
webExt: {
disabled: true,
},
})

监听标签页事件

首先打开 entrypoints/background.ts,可以看到 WXT 初始化的代码。

1
2
3
export default defineBackground(() => {
console.log('Hello background!', { id: browser.runtime.id })
})

让我们在函数中注册我们的监听器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default defineBackground(() => {
console.log('Hello background!', { id: browser.runtime.id })

const lastAccessed = new Map<number, number>()

browser.tabs.onCreated.addListener((tab) => {
if (!tab.id) {
return
}
lastAccessed.set(tab.id, Date.now())
})
browser.tabs.onRemoved.addListener((tabId) => {
lastAccessed.delete(tabId)
})
browser.tabs.onActivated.addListener((activeInfo) => {
lastAccessed.set(activeInfo.tabId, Date.now())
console.log('Tab activated:', activeInfo.tabId)
})
})

现在,使用 pnpm dev 启动开发模式,打开 chrome://extensions/,加载已解压的扩展并选择 .output/chrome-mv3-dev 目录,然后点击扩展的 service worker 链接打开 Background Script 的 DevTools Console 开始调试。

1768548027488.jpg
1768548056610.jpg

当我们添加一个新标签页时可以看到 Tab activated: 1207047510 相关的日志。

1768548143569.jpg

识别不活跃的标签页并自动冻结

然后我们需要在 onActivated 事件中检查已记录的标签页中是否有长时间未访问的标签页,如果发现就自动冻结它。在此之前,需要更新 wxt.config.ts 添加 tabs 权限,这是使用 browser.tabs.query API 所必须的。

1
2
3
4
5
6
7
8
9
import { defineConfig } from 'wxt'

export default defineConfig({
manifest: {
// other config...
permissions: ['tabs'],
},
// other config...
})

你可能注意到,下面查找标签页时包含了很多过滤条件,下面将会一一说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
browser.tabs.onActivated.addListener((activeInfo) => {
lastAccessed.set(activeInfo.tabId, Date.now())
console.log('Tab activated:', activeInfo.tabId)
autoDiscardTabs()
})
async function autoDiscardTabs() {
const Timeout = 30 * 60 * 1000 // 30 minutes
const tabs = (await browser.tabs.query({})).filter(
(tab) =>
tab.id && // 只查找包含 id 的普通标签页,某些特殊标签页可能不包含 id,例如浏览器调试窗口之类的
!tab.pinned && // 如果是固定标签页,则忽略
!tab.active && // 如果标签页还活跃,也就是说,一直呆在一个标签页里
!tab.audible && // 如果正在播放音视频,则忽略
!tab.frozen && // 如果已经被 Chrome 内置机制冻结了,则忽略
!tab.discarded && // 如果已经被手动冻结了,则忽略
lastAccessed.has(tab.id) && // 如果没有记录过这个标签页,则忽略
Date.now() - lastAccessed.get(tab.id)! > Timeout, // 如果最后访问时间距离现在已经超过 30 分钟,则认为满足条件
)
for (const tab of tabs) {
// 注意:discard 可能会失败,例如标签页正在被使用或已被关闭
await browser.tabs.discard(tab.id!)
console.log('Tab auto-discarded:', tab.id, tab.title)
}
}

将 Timeout 调整为 1ms,就可以方便的进行测试了。在切换标签页再切换回来之后,就能看到标签页自动刷新了,这意味着自动冻结功能确实生效了。

1768549353576.jpg

总结

现在,我们实现了基本的标签页自动冻结功能,这个实现非常粗糙,还有很多问题没有处理,如果感兴趣,你可以自行尝试解决下面几个问题:

  1. 如何让用户手动配置自动冻结时间,避免默认值不符合用户需求
  2. 如何解决用户长时间未使用浏览器,不触发任何事件导致无法休眠的问题
  3. 如何处理扩展启动时已存在的标签页(提示:browser.runtime.onStartup

在下一章,我们将介绍配置相关的 API 和 options 页面来解决配置问题。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/04-background-script

🔲 ☆

Browser Extension Dev - 03. 注入 UI

前言

在此之前,通过 Browser Extension Dev - 02. 使用 WXT 我们已经了解了扩展的基本结构和 WXT 的使用,下面我们将进一步演示如何在网页中注入复杂的 UI,使用 React、Tailwind CSS、shadcn、或任何需要的 npm 包是怎么做的。

你可能有几个问题

  1. 如果网站也使用了 Tailwind CSS,扩展重复使用不会导致样式冲突吗?
  2. React 之类的现代 Web 框架如何注入到现有的网站中?

下面我将通过实现一个 Youtube 视频截图扩展来进行演示。

1768305726842.jpg

思考

首先,需要明确期望的扩展 UI & UX 是什么样的

  1. 扩展的图标自动注入到视频右下角的工具栏中
  2. 点击图标自动截取视频当前帧为图片
  3. 自动复制图片到剪切板并自动下载

这里可能的问题是什么?

实现

首先,使用 WXT 来初始化一个使用 react 的模版项目。

1
pnpm dlx wxt@latest init 03-inject-ui --template react --pm pnpm

并为 wxt.config.ts 添加一些基本配置。其中 @wxt-dev/module-react 是 WXT 对 React 的集成支持,让我们可以直接在 Content Script 中使用 React。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineConfig } from 'wxt'

export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifestVersion: 3,
manifest: {
name: 'Youtube Video Screenshot',
description: 'Take screenshots of YouTube videos easily.',
},
webExt: {
disabled: true,
},
})

然后删除模版项目 entrypoints 目录下的所有内容。

1
rm -rf ./entrypoints/*

安装 Tailwind CSS 和 shadcn 组件

首先来 Tailwind CSS 和 shadcn,由于是在扩展程序中,所以 shadcn 的自动安装命令基本都不可用,需要手动安装并配置,参考 https://ui.shadcn.com/docs/installation/manual

安装 Tailwind CSS 和 shadcn 需要的依赖

1
2
pnpm i -D tailwindcss @tailwindcss/vite
pnpm i clsx tailwind-merge tw-animate-css class-variance-authority

更新 tsconfig.json,添加 baseUrl 和 paths 字段

1
2
3
4
5
6
7
8
9
10
11
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}

更新 wxt.config.ts,增加 Tailwind CSS 插件

1
2
3
4
5
6
7
8
9
import { defineConfig } from 'wxt'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
// other config...
vite: () => ({
plugins: [tailwindcss() as any],
}),
})

添加 css 全局样式文件,需要注意,WXT 在 Shadow DOM 模式下会使用 all: initial !important;
重置所有样式,所以这里需要显式指定 html 的高度,否则按钮可能无法正确显示。参考 https://wxt.dev/api/reference/wxt/utils/content-script-ui/shadow-root/type-aliases/ShadowRootContentScriptUiOptions.html#inheritstyles

entrypoints/content/styles.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@import 'tailwindcss';
@import 'tw-animate-css';

@custom-variant dark (&:is(.dark *));

:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}

.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}

@layer base {
* {
@apply border-border outline-ring/50;
}
html {
height: 40px;
}
body {
height: 100%;
display: flex;
align-items: center;
}
}

添加 shadcn 需要的辅助函数 lib/utils.ts

1
2
3
4
5
6
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

然后添加 shadcn 安装的组件的配置文件 components.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "entrypoints/content/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

现在,尝试使用 shadcn 命令安装一个 button 组件,如果你能看到类似下面这样的输出,就说明你的配置完全正确,可以进行下一步了。

1
2
3
4
5
$ pnpm dlx shadcn@latest add button --yes
✔ Checking registry.
✔ Installing dependencies.
✔ Created 1 file:
- components/ui/button.tsx

在 Content Script 中使用 shadcn

这是很多人转不过来弯的地方,但 shadcn 最终也只是 JavaScript 和 CSS,而扩展程序允许我们注入这些,所以,我们确实可以使用它。

第一步是创建 entrypoints/content/index.tsx 入口文件

1
2
3
4
export default defineContentScript({
matches: ['https://www.youtube.com/*'],
main() {},
})

然后我们使用 Shadow Root 模式注入 UI,这能很好的隔离样式,网站的 CSS 不会影响扩展,反之亦然,参考 https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM,这解决了我们最初提到的第一个问题(样式冲突)。

而 React 作为 JavaScript 库,会在构建时被打包到 .output/chrome-mv3/content-scripts/content.js 中,随 Content Script 一起注入页面。换句话说:Content Script 能运行任何 JavaScript,所以我们能用任何 JavaScript 框架 —— React、Vue、Svelte 都可以,这就解决了第二个问题。可以在 WXT 官方文档中看到多个 Web 框架的使用示例 https://wxt.dev/guide/essentials/content-scripts.html#shadow-root

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import './styles.css' // 引入 css,会在 bundle 时自动处理
import { Button } from '@/components/ui/button'
import { createRoot } from 'react-dom/client'

function App() {
return (
<div
className={
'fixed z-[999999] top-0 left-0 w-full py-2 bg-white text-center'
}
>
<Button>Click me</Button>
</div>
)
}

export default defineContentScript({
matches: ['https://www.youtube.com/*'],
cssInjectionMode: 'ui', // 这里的配置要求 WXT 将 CSS 动态注入到页面中
async main(ctx) {
// 这里使用了 Shadow Root 模式注入 UI,意味着能够正确地避免我们的 Tailwind CSS "污染"到网页本身
const ui = await createShadowRootUi(ctx, {
name: 'inject-ui-app',
position: 'inline',
anchor: 'body',
onMount: (container) => {
const root = createRoot(container)
root.render(<App />)
return root
},
onRemove: (root) => {
root?.unmount()
},
})

ui.mount()
},
})

可以看到,我们确实成功在顶部注入了一个按钮,在 Devtools > Elements 中也可以看到注入的 inject-ui-app Shadow DOM 元素。

1768358577940.jpg

接下来我们需要找到适合注入图标的位置,然后注入 UI。

Content Script 不同的 UI 注入模式参考 https://wxt.dev/guide/essentials/content-scripts.html#ui

注入按钮到工具栏

首先我们需要找到在哪里注入合适。由于希望在右下角的工具栏中增加额外的图标,所以可以先找到找到容器元素,具体来说,就是 #movie_player .ytp-right-controls-left

1768351993485.jpg

然后修改之前的 Content Script 脚本,修改 App 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
function onTakeScreenshot() {
alert('Take screenshot!')
}
return (
<Button
className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'}
onClick={onTakeScreenshot}
>
<img
src={browser.runtime.getURL('/icon/32.png')} // 这里直接使用扩展图标作为操作 icon
alt={'icon'}
className={'w-[20px] h-[20px]'}
/>
</Button>
)
}

在创建 Shadow DOM 容器时使用 anchor 指定放置的位置,并使用 insertBefore 添加到工具栏的最左边。指定的容器元素有可能不存在,但 WXT 会正确在容器元素出现和消失时自动挂载和卸载 UI,参考 https://wxt.dev/guide/essentials/content-scripts.html#mounting-ui-to-dynamic-element

1
2
3
4
5
6
7
const ui = await createShadowRootUi(ctx, {
anchor: '#movie_player .ytp-right-controls-left', // 注入的容器元素
append(anchor, ui) {
anchor.insertBefore(ui, anchor.firstChild) // 添加到最左边
},
// other code...
})

现在,我们应该可以在右下角看到我们注入的 icon 了。但实际上,只得到了一个错误,提示无法加载这个资源。

1
Denying load of chrome-extension://aheclehodijmphbifdolliophgjiagof/icon/32.png. Resources must be listed in the web_accessible_resources manifest key in order to be loaded by pages outside the extension.

1768352597267.jpg

实际上还需要在 wxt.config.ts 中配置 web_accessible_resources,允许网页访问扩展的资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
export default defineConfig({
// other config...
manifest: {
// other config...
web_accessible_resources: [
{
resources: ['icon/*'],
matches: ['https://www.youtube.com/*'],
},
],
},
// other config...
})

然后,我们就能在网页中看到注入的 icon 按钮了,点击就会弹出 Take screenshot!

1768353258840.jpg

参考 Chrome 官方文档 https://developer.chrome.com/docs/extensions/reference/manifest/web-accessible-resources

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import './styles.css'
import { Button } from '@/components/ui/button'
import { createRoot } from 'react-dom/client'

function App() {
function onTakeScreenshot() {
alert('Take screenshot!')
}
return (
<Button
className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'}
onClick={onTakeScreenshot}
>
<img
src={browser.runtime.getURL('/icon/32.png')}
alt={'icon'}
className={'w-[20px] h-[20px]'}
/>
</Button>
)
}

export default defineContentScript({
matches: ['https://www.youtube.com/*'],
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: 'inject-ui-app',
position: 'inline',
anchor: '#movie_player .ytp-right-controls-left',
append(anchor, ui) {
anchor.insertBefore(ui, anchor.firstChild)
},
onMount: (container) => {
const root = createRoot(container)
root.render(<App />)
return root
},
onRemove: (root) => {
root?.unmount()
},
})

ui.mount()
},
})

可以看到,已经成功注入了一个图标按钮到工具栏。

1768320393726.jpg

实现截图功能

现在,我们要实现核心的截图功能。首先我们需要找到 video 元素,可以在 #movie_player video 看到。

1768353496838.jpg

然后需要使用 canvas 来截取视频的一帧,其中看起来最神奇的代码莫过于 ctx.drawImage(video, 0, 0),将一个视频传入了画布,但实际上这是支持的,参考 drawImage 文档 https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage#image,可以看到其中不仅允许 HTMLImageElement(图片),还允许 HTMLVideoElement(视频)甚至 HTMLCanvasElement(其他画布)。

1
2
3
4
5
6
7
8
const video = document.querySelector('#movie_player video')
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0)
const blob = await new Promise((r) => canvas.toBlob(r, 'image/png', 1))
blob

在 Devtools > Console 中运行这段代码,可以看到我们确实得到了一个 image Blob。

1768354207025.jpg

复制到剪切板并下载

现在我们有了一个 Blob,想要复制到剪切板很简单

1
2
const data = [new ClipboardItem({ [blob.type]: blob })]
await navigator.clipboard.write(data)

如果直接粘贴到 Devtools > Console 执行,会出现错误,这是因为访问剪切板需要在用户操作触发时才能使用,参考 https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API#security_considerations。不过没关系,稍后我们会把这些代码都连接到一起放到 icon 的点击事件里。

1
2
Uncaught NotAllowedError: Failed to execute 'write' on 'Clipboard': Document is not focused.
at <anonymous>:2:27

1768355046936.jpg

接下来实现图片保存,这里我们直接使用 file-saver 来保存,为了生成使用日期的文件名,还需要使用 dayjs

首先安装依赖

1
2
pnpm i file-saver dayjs
pnpm i -D @types/file-saver

然后只需要使用 saveAs 方法即可下载 Blob。

1
2
3
4
5
6
7
import { saveAs } from 'file-saver'
import dayjs from 'dayjs'

const filename = `Youtube-Screenshot_${dayjs().format(
'YYYY-MM-DD_HH-mm-ss',
)}.png` // 会得到类似 Youtube-Screenshot_2026-01-14_09-49-06.png 这样的文件名
saveAs(blob, filename)

连接起来

现在,修改 App 组件,在 onTakeScreenshot 函数中执行上面的代码。这样,当我们点击时就能看到图片确实复制到了剪切板,并且触发了下载。

1768356044728.jpg

App 组件完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import './styles.css'
import { Button } from '@/components/ui/button'
import saveAs from 'file-saver'
import dayjs from 'dayjs'

function App() {
// 这里有可能出现错误,例如 video 元素不存在、剪切板权限被拒绝、下载失败等等,但这里暂时忽略
async function onTakeScreenshot() {
const video = document.querySelector(
'#movie_player video',
) as HTMLVideoElement
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(video, 0, 0)
const blob = (await new Promise<Blob | null>((r) =>
canvas.toBlob(r, 'image/png', 1),
))!

const data = [new ClipboardItem({ [blob.type]: blob })]
await navigator.clipboard.write(data)

const filename = `Youtube-Screenshot_${dayjs().format(
'YYYY-MM-DD_HH-mm-ss',
)}.png` // 会得到类似 Youtube-Screenshot_2026-01-14_09-49-06.png 这样的文件名
saveAs(blob, filename)
}
return (
<Button
className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'}
onClick={onTakeScreenshot}
>
<img
src={browser.runtime.getURL('/icon/32.png')}
alt={'icon'}
className={'w-[20px] h-[20px]'}
/>
</Button>
)
}

总结

现在,我们完成了 Youtube 视频截图的扩展,仍然有许多边缘情况没有处理,例如

  • 在截图复制到剪切板时可能出现异常情况
  • 一开始视频未播放时无法获取到视频帧
  • 广告播放时的如何正确处理

但关键功能已经实现。而且使用了 React、Tailwind CSS、shadcn 和一些 npm 包,你觉得怎么样?在下一章中,我们将介绍 Background Script,它允许访问所有扩展 API,但同时无法访问 DOM API,我们将演示只有它能做到的事情。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/03-inject-ui

🔲 ☆

Browser Extension Dev - 02. 使用 WXT

什么是 WXT?

1767495559810.jpg

在第一章 Browser Extension Dev - 1. 介绍基本概念 里面,我使用了原始的 JavaScript 实现了一个简单的 Chrome 扩展。现在,我将使用 WXT 重写扩展。那么问题是:什么是 WXT?
简单来说,WXT 是用于浏览器扩展的开发框架,就像 Vite 是用于 Web 开发的流行框架一样。实际上,WXT 是基于 Vite 实现的,所以它也可以使用 Vite 插件的生态系统。在我看来,这是个非常棒的决定。

WXT 解决了什么问题?

那么,它能解决什么问题呢?

  1. 支持使用 TypeScript、Npm、React 等现代前端开发工具 – 对于熟悉现代 Web 工具链的开发者而言会感到宾至如归,不喜欢 React,也可以使用 Svelte,它不限制 UI 层框架
  2. 跨浏览器构建,主要的浏览器有 Chrome、Firefox 和 Safari – 是的,它对于跨浏览器扩展开发非常有用,打包多个 dist 轻而易举,从一开始提供的 API 就考虑到了跨浏览器构建
  3. 支持热更新,对于所有主流 Web 框架都已支持 – 你可能注意到之前每次修改扩展后都需要手动去浏览器刷新一下扩展加载修改,WXT 让这变得不再必要
  4. 提供了相对标准化的 message 通信、content script UI 注入方法 – 想要让注入的复杂 UI 和网页原本的 UI 互不干扰是个有挑战性的事情

如果想了解更详细的功能对比,可以参考官方文档:https://wxt.dev/guide/resources/compare.html

初始化项目

首先,让我们初始化一个项目,由于不涉及 UI 部分,所以只需要使用 vanilla 模版就好了。

1
2
3
pnpm dlx wxt@latest init 02-use-wxt --template vanilla --pm pnpm
cd 02-use-wxt
pnpm i

项目结构说明

现在我们得到一系列的目录和文件,让我们依次了解

1
2
3
4
5
6
7
wxt.config.ts # 扩展入口文件,定义 manifest 和构建流程,但通常不需要改动
entrypoints # 定义不同的入口点文件,例如 content script 或者 background 之类的,会自动写入到输出扩展的 manifest 中
public # 公共目录,会被原样复制到最终输出扩展的目录中,里面包含 icon 目录,用于放置扩展图标,会自动写入到输出扩展的 manifest 中
assets # 需要 bundle 的媒体资源目录,可以暂时忽略
components # 组件目录,通常放一些通用组件,例如 shadcn 之类的,可以暂时忽略
package.json
tsconfig.json

使用以下命令开发和构建

开发与构建流程

使用 pnpm dev 启动开发模式,输出目录在 .output/chrome-mv3-dev,在 Chrome 中需要加载这个目录作为扩展目录,而不是项目根目录。不过 WXT 会自动启动一个 Chrome 进程并自动加载扩展,所以可以不需要手动加载扩展。

1767442253914.jpg

1767442925701.jpg

但如果你不希望自动打开 Chrome 窗口,也可以配置 wxt.config.ts 来禁用这个行为。对于调试需要登录的网站而言,这也是必要的。

1
2
3
4
5
6
7
import { defineConfig } from 'wxt'

export default defineConfig({
webExt: {
disabled: true,
},
})

对于不熟悉现代前端工具链的人而言,开发和构建代码是分离的,不像早期那样编写的 JavaScript 就是运行在用户设备上的 JavaScript,使用 WXT 开发扩展也是一样的。

构建

使用 pnpm build 启动构建模式,输出目录在 .output/chrome-mv3,通常只有在需要调试 Firefox 或 Safari 版本时才需要使用构建后的扩展。

1767442265022.jpg

使用 pnpm zip 可以打包扩展的 zip 文件,对于 Firefox 还会有一个额外的 source 文件(Firefox AMO 要求提交扩展必须包含源码),这在提交到 Chrome Web Store 时才需要,这里先提一下。

1767442327565.jpg

实现扩展功能

设置 Manifest

在使用 WXT 之后,manifest 有许多部分都不再需要,它们通常都变成“约定配置”,不再需要手动处理。例如下面是之前实现扩展的 manifest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"manifest_version": 3,
"name": "Hide AI Mode on Google Search",
"version": "0.0.1",
"description": "Hide the AI Mode button on Google Search pages.",
"content_scripts": [
{
"matches": ["https://www.google.com/"],
"js": ["content-scripts/content.js"],
"run_at": "document_start"
}
],
"icons": {
"128": "icon/128.png"
}
}
  1. manifest_version 会自动推导,Chrome/Safari 使用 v3,而 Firefox 默认使用 v2,不过我仍然建议统一使用 v3,以避免一些边缘情况需要兼容
  2. name/version/description 可以在 package.json 中配置,它会自动合并到输出目录中的 manifest.json。由于 package.json 的 name 字段有大小写限制,而且可能包含包名,所以我仍然建议在 wxt.config.ts 中配置 name/description 字段
  3. content_scripts/icons 字段完全不需要,它们会从 entrypoints 和 public/icon 目录自动推导出来

所以更新后的 wxt.config.ts 是

1
2
3
4
5
6
7
8
9
10
11
import { defineConfig } from 'wxt'

export default defineConfig({
manifestVersion: 3,
manifest: {
name: 'Hide AI Mode on Google Search',
version: '0.0.1',
description: 'Hide the AI Mode button on Google Search pages.',
},
// other config...
})

实现 Content Script

首先,还是让我们先清理一下无关文件。

1
rm -r ./assets ./components ./entrypoints/background.ts ./entrypoints/popup ./public/wxt.svg

接下来打开 entryponits/content.ts 文件,可以看到初始内容如下

1
2
3
4
5
6
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {
console.log('Hello content.')
},
})

而这里就是有趣的部分,defineContentScript 实际上定义了 manifest 和对应实际执行的脚本,这就是为什么上面在 wxt.config.ts 中省略 content_scripts 字段的原因,不过这也合理,将相关的代码和配置放在一起。将之前扩展的代码和配置修改过来之后变成:

1
2
3
4
5
6
7
8
9
10
export default defineContentScript({
matches: ['https://www.google.com/'],
runAt: 'document_start',
main() {
const style = document.createElement('style')
style.textContent =
'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }'
document.documentElement.appendChild(style)
},
})

作为对比,之前的 content script 是放在 manifest.json 和 content-scripts/content.js 两个文件中完成。

1
2
3
4
5
6
7
8
9
{
"content_scripts": [
{
"matches": ["https://www.google.com/"],
"js": ["content-scripts/content.js"],
"run_at": "document_start"
}
]
}
1
2
3
4
5
// content-scripts/content.js
const style = document.createElement('style')
style.textContent =
'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }'
document.documentElement.appendChild(style)

可以看到 WXT 的 content script 仍然是非常直观的,只是将下划线字段重命名为驼峰字段了(run_at => runAt)。在我们更新 content script 之后,直接打开 google.com 可以看到扩展已经热更新为了我们修改后的代码。扩展效果

1767490889790.jpg

注意:你可能注意到闪烁问题又出现了,这是因为 wxt 为了实现热更新加载机制,content script 是动态注入的,这导致 run_at: document_start 设置在开发模式下不太有用,但无需担心,在构建后它的工作正常。参考:https://github.com/wxt-dev/wxt/issues/357
提示:在开发过程中,如果需要查看 content script 的 console.log 输出,需要在网页 google.com(而不是扩展页面)打开 Devtools > Console 查看。

添加 icon

接下来,我们来添加扩展 icon。在 WXT 中,只需要将图标放到 ./public/icon/ 目录就好了,创建项目后可以看到自动创建了一些不同尺寸的图标,这是用于在显示时自动选择合适的图标,你可以选择使用任何工具来生成合适尺寸的图标,这里使用 ImageMagick 作为演示。

1
2
3
4
5
6
cd ./public
magick logo.png -resize 16x16 ./icon/16.png
magick logo.png -resize 32x32 ./icon/32.png
magick logo.png -resize 48x48 ./icon/48.png
magick logo.png -resize 96x96 ./icon/96.png
magick logo.png -resize 128x128 ./icon/128.png

如果更喜欢可视化工具,也可以使用 https://squoosh.app/ 来调整图片尺寸。或者使用 WXT 官方的 icon 模块 https://wxt.dev/auto-icons 来自动生成。

回到浏览器扩展管理页面,可以看到 icon 已经被正确识别了。

1767492255149.jpg

总结

现在,我们完成了第一个使用 WXT 实现的扩展就完成了,你觉得怎么样?在下一章中,我们将使用现代 Web 框架和 npm 包,为网页注入 UI 并实现更复杂的功能。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/02-use-wxt

参考

WXT 官网 https://wxt.dev/

🔲 ☆

Browser Extension Dev Extra - 如何找到锁定滚动的元素

背景

最近因为多邻国 App 的 Bug,我正在考虑切换到 Web 版本。但没想到移动端 Web 版本体验那么差,每次打开首页都会弹出 App 下载推广,这真的太烦人了。我第一时间就想去写个 UserScript 彻底删除它,但没想到还碰到一些有趣的问题。

1767703519864.jpg

分析

从 DevTools > Elements 中很容易就找到了弹窗元素的稳定选择器。

1
#overlays:has([href*="adj.st"])

页面遮罩也很容易定位。

1
[data-test="drawer-backdrop"]

然后我就可以正常点击了,但我仍然无法自由滚动,而滚动条锁定又不在 HTML/body 上,所以这种情况下应该如何找到是哪个 HTML 元素导致的滚动条锁定呢?

1767713512009.jpg

解决

想要解决这个问题需要一些简单的启发式的方法。首先,一般如何实现滚动条锁定?
通常只需要给 body 元素增加一些样式即可,例如

1
2
3
body {
overflow: hidden;
}

或者使用 position: fixed

1
2
3
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollPosition}px`
document.body.style.width = '100%'

按照这种思路,我们可以找到一个在无法滚动区域内的元素,然后递归向上查找,直到找到 overflow: hidden; 或者 position: fixed 的元素即可认为它就是阻止滚动的罪魁祸首,实现起来相当简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function findScrollBlocker(element) {
const style = getComputedStyle(element)
// 检查当前元素是否设置了阻止滚动的样式
if (style.overflow === 'hidden' || style.position === 'fixed') {
return element
}
// 继续检查父元素
if (element.parentElement && element !== element.parentElement) {
return findScrollBlocker(element.parentElement)
}
return null
}

// 在 DevTools > Console 中调用,传入当前选中的元素
findScrollBlocker($0)
// 找到锁定元素后,可以通过以下方式恢复滚动
// const blocker = findScrollBlocker($0)
// if (blocker) blocker.style.overflow = 'auto'

视频演示

现在,我们可以通过 CSS 恢复正常的滚动条了。

tip: $0 在 DevTools > Console 中代表在 DevTools > Elements 面板中选中的元素。

总结

如果你也想尝试开发 Extension 或者 UserScript 来改善你的 Web 体验,不妨一试,JavaScript 提供了相当多 trick 的技巧,如果只是编写普通的 Web App,可能永远不会有机会尝试。

相关资源

🔲 ☆

Browser Extension Dev - 01. 介绍基本概念

你是否曾经对某个网页的功能感到不满?比如 Google 搜索页面上那个显眼的 AI Mode 按钮:

1767430067384.jpg

通过浏览器扩展,你可以让它彻底消失:

1767430033953.jpg

什么是浏览器扩展?

一般而言,浏览器扩展是一种修改用户浏览网页的方式,它赋予了网站使用者而不是开发者更多的权限。让有能力的用户可以自定义它们的网页浏览体验,现代浏览器提供了极其丰富的扩展 API,甚至能实现一些看起来需要 app 才能做到的事情,但它的基本出发点是让用户可以控制正在浏览的网页。

一些例子

  • 隐藏 Google AI 相关功能
  • 根据规则自定义网页重定向
  • 解除网页上的复制粘贴限制
  • 在浏览器后台运行定时任务

基本结构

1
2
3
4
5
6
manifest.json # 扩展入口文件
icon/*.png # 扩展图标
content-scripts/content.js # 注入到网页的脚本,可选
background.js # 后台脚本,可选
options.html # 配置页面,可选
popup.html # 点击扩展 icon 的弹出窗口,可选

创建 Manifest

现在,我们来创建一个基本的扩展,不使用任何包管理器、TypeScript、或者 Web 框架,只使用基本的 JavaScript 完成。在下一章节中,将会使用现代开发工具链(WXT)重写它,这里只是让我们对扩展的实际结构有个了解。作为例子,我们将会创建一个扩展来隐藏 google.com 中那个 AI Mode 按钮。

1767430067384.jpg

首先,创建一个 manifest.json,里面是一些基本信息,manifest_version 是一个固定值,代表使用扩展 API 的第三个主要版本。

1
2
3
4
5
6
{
"manifest_version": 3,
"name": "Hide AI Mode on Google Search",
"version": "0.0.1",
"description": "Hide the AI Mode button on Google Search pages."
}

实现 Content Script

然后我们需要创建一个 content-scripts/content.js 脚本(这个路径不是固定的,只是一般做法),里面实现在网页加载时自动隐藏 AI Mode 按钮。
基本思路也很简单

  1. 找到 google.com 中的 AI Mode 按钮的 CSS selector
  2. 在 google.com 加载时注入一个 js 脚本,自动隐藏它

分析获得 CSS selector

打开 google.com,然后打开 DevTools > Elements 可以看到 AI Mode 是一个 button 元素,但它看起来并没有可以作为稳定 CSS selector 的东西。

1767430093347.jpg

对于现代网站而言,class 通常被构建工具压缩的连亲妈都不认识了,所以需要一些跳脱的方法,例如 button 中包含的 SVG 似乎很适合作为一个选择器(网站上的 icon 改动频率并不高)。这样,借助 :has 子选择器和属性选择器,我们就可以组合出一个稳定的 selector 了。

1767430118046.jpg

1
button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"])

:has 指的是如果元素包含指定 selector 的子元素,就匹配它。对于这个 selector 而言,这意味着只有包含特定 AI Mode svg 图标的 button 按钮才会被匹配到。

实现 content script

接下来创建 content.js 脚本,由于我们只想隐藏这个按钮,最简单的方法就是注入一个 css 样式,例如

1
2
3
4
const style = document.createElement('style')
style.textContent =
'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }'
document.head.appendChild(style)

在 Manifest 中声明

接下来,我们还需要在 manifest.json 中声明这个内容脚本,告诉浏览器要在哪些网站注入,这里我们只需要在 google.com 注入,所以需要修改为

1
2
3
4
5
6
7
8
9
{
// before config...
"content_scripts": [
{
"matches": ["https://www.google.com/"], // 只匹配首页,因为 AI Mode 按钮仅在首页出现
"js": ["content-scripts/content.js"]
}
]
}

调试扩展

接下来,我们需要在 Chrome 中加载这个扩展。

首先,打开 chrome://extensions/,并启用 Developer Mode

1767429435907.jpg

然后,使用 Load unpacked 按钮选择扩展目录,就能加载扩展到浏览器了。

访问 google.com,可以看到 AI Mode 按钮确实不见了,但会闪一下出现然后才消失,这意味着注入的脚本时机不够早,在注入的脚本执行之前,AI Mode 按钮就已经显示了,然后脚本注入才隐藏了按钮,这是一个基本的时序问题。幸运的是,可以简单调整 manifest 配置解决这个问题。

1
2
3
4
5
6
7
8
9
{
"content_scripts": [
{
"matches": ["https://www.google.com/"],
"js": ["content-scripts/content.js"],
"run_at": "document_start" // 在网页刚开始加载时就注入脚本,参考 https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts#world-timings
}
]
}

然后在 chrome://extensions/ 重新加载扩展让修改生效。

1767429741359.jpg

接着就看到了一个错误 Uncaught TypeError: Cannot read properties of null (reading 'appendChild')

1767429844440.jpg

这是因为注入脚本的时机非常早,甚至连 document.head 标签都还没有渲染,可以修改为在 document.documentElement 注入 style 标签。

1
2
3
4
const style = document.createElement('style')
style.textContent =
'button:has([d="M15.65 11.58c.18-.5.27-1.03.31-1.58h-2c-.1 1.03-.51 1.93-1.27 2.69-.88.87-1.94 1.31-3.19 1.31C7.03 14 5 12.07 5 9.5 5 7.03 6.93 5 9.5 5c.46 0 .89.08 1.3.2l1.56-1.56C11.5 3.22 10.55 3 9.5 3 5.85 3 3 5.85 3 9.5S5.85 16 9.5 16c.56 0 2.26-.06 3.8-1.3l6.3 6.3 1.4-1.4-6.3-6.3c.4-.5.72-1.08.95-1.72z"]) { display: none; }'
document.documentElement.appendChild(style) // document.documentElement 代表页面的根元素,即 `<html></html>` 标签。

再次刷新扩展然后访问 google.com 可以看到 AI Mode 按钮不见了,而且也不再出现闪烁的情况。

1767430033953.jpg

添加图标

最后,这个扩展还缺少图标,我们将一张 128x128 的 png 图像放在 icon/128.png。
然后修改 manifest 添加 icons 设置即可。

1
2
3
4
5
{
"icons": {
"128": "icon/128.png"
}
}

再次刷新扩展,就可以看到扩展图标已经被正确加载了。

1767430968353.jpg

总结

这就是第一个基本扩展。通过这个例子,我们了解了扩展开发的几个核心概念:

  • Manifest 文件定义了扩展的基本信息和需要哪些权限
  • Content Script 可以注入到网页中,直接操作 DOM
  • 脚本注入时机会影响实际效果,需要根据场景选择 document_startdocument_enddocument_idle
  • 扩展的调试流程和普通网页开发类似,都可以在 DevTools 中查看错误

你可能注意到,直接编写原生 JavaScript 并手动管理文件有些繁琐。在下一章中,我们将使用 WXT 这个现代开发工具重构这个扩展,体验 TypeScript、热重载等特性。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/01-basic

参考

Google Chrome 扩展开发文档 https://developer.chrome.com/docs/extensions

❌