MoreRSS

site iconrxliuli | 琉璃修改

Web 前端开发,在日本,喜欢自称「吾辈」让我很难受。。博客主要记录一些技术相关的东西
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

Browser Extension Dev - 08. 发布 Chrome Web Store

2026-01-26 08:27:04

前言

在之前的 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

2026-01-21 22:33:45

前言

在上一章 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. 按需注入脚本

2026-01-21 10:57:21

前言

在上一章 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. 存储和配置

2026-01-16 17:14:40

前言

在上一章 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

2026-01-14 23:32:29

前言

在上一章 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 Extra - 如何找到锁定滚动的元素

2026-01-06 20:44:07

背景

最近因为多邻国 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,可能永远不会有机会尝试。

相关资源