2026-03-09 20:22:56
在春节之后,我来到了马来西亚旅行。这是我出国旅行的第三个国家。在此之前,我曾经在日本呆了半年,在关西走了一些地方,回国后又花了几个月的时间走完了整个中国的所有省份和直辖市,所以某种程度上我的阈值确实提高了。因此,直到目前为止,马来西亚给我的体验,可以用一个字来形容:烂。
落地首先到了吉隆坡,基建水平还行,但室内导航很困难,而且售票机只收取小额纸币导致我一度没有零钱可用,直到后面办了一张 TNG 的卡,但这张卡在槟城时公交完全用不了,后面的其他地方几乎没有公共交通可用,所以是的,基本上是个坑。去看了那个大佛像和彩色楼梯,确实不错,至少值得一去。
吉隆坡标志性的大佛像。
彩色楼梯栏杆上的猴子。
登至台阶顶处往上看。
独立纪念碑,附近值得多拍几张,有巨大的清真寺。
顺便骂一句 Booking,收费方式极其混乱,我经常忘记一个预定到底有没有付钱,有些是到店付款,有些是线上支付,而且使用者没办法决定,真是糟透了。
抱团一日游简直太糟糕了,除了最早的森林徒步,后面凑数的都是什么臭鱼烂虾。我自己也走了两条热门的原始森林徒步路线,确实很原始,大部分都是石头、泥土和树根组成,但也仅此而已。
徒步起点,直接楼梯加绳子真的很劝退,我差点就放弃了。
山上云雾缭绕,山下的建筑却早已变成了水泥盒子。
第二天抱团走的森林徒步,看到了下面色彩斑驳的森林。
朝露与青苔,可能是森林徒步中的一种“小确幸”了。
金马伦高原有名的茶园。
还算幸运的拍到了纹胸花蜜鸟。
一日游最后一个佛寺景点实在让我蚌埠住了。
作为推荐的汽车订票软件 easybook,web/app 设计水平之烂,ux 体验之痛苦,我会说在国内那些毒瘤 app 都是少见的。尤其是支付部分,每次购买时都必须填写相同的信息,而没有自动保存选项。微信支付集成就更有趣了,在 app 上显示一个二维码让扫码支付,这是什么鬼才设计?即使如此,也无法正常支付,我怀疑是否有人测试过。
据说是华人聚居的城市,面朝海边吃着当地小吃确实还不错。但我本身对壁画和艺术并不太感兴趣,然后升旗山真的真的没什么,我的意思是和香港的太平山没什么区别。树冠步道也没有什么,远不及后面兰卡威的天空之桥。
天还没亮时拍摄的槟城。
日出。
树冠步道。
一只蝴蝶。
醒来的槟城。
市政府。
槟城海港。
电子化是依托答辩,除了 711 之类的连锁大部分还是要现金,有的地方看起来有扫码选项实际不能用,公交卡不能用来刷(滨城)公交是什么鬼?
截至目前为止唯一感觉还不错的城市,在海边拍日落还不错,天空之桥值得一看,旁边的七仙井瀑布也可以顺便取一下,那里可以泡水,我还看到了水巨蜥,不过如果要租摩托车需要摩托车证,这是一开始没有预料到的。
刚到就看到了日落,太幸运了。
天空之桥,真的非常棒。
大海的分层实在太漂亮了。
站在桥上往下看去。
山泉泡水,夏天这样很舒服。
只是需要小心“可爱”的邻居,这显然是它的地盘。
第二天早上骑着自行车就去看日出去了,这很棒,没有驾照也可以租个自行车。
还能去机场附近近距离拍大飞机降落。
亚庇的京那巴鲁山登顶需要专门的设备和门票,而下面的公园其实没有太多值得说的,东南亚的原始丛林远不及预期,原本我会以为类似于西北野生动物很常见的情况,也许确实如此,但可能都太隐蔽了。一些景点也真的太糊弄人了,例如亚庇的红树林据说可以看到长鼻猴和萤火虫,怎么说呢,确实如此。能看到,但也仅此而已了,萤火虫和预期完全不同,完全不是河面上大片的萤火虫,只是一棵树上聚集了一些萤火虫,也没什么大不了的。值得花 8 个小时和 RM190 吗?至少我觉得并不值得。
尚未降落时的云海。
山上到处都是雾,几乎什么都看不清。
复杂的树根结构。
第二天早晨雾气稍微散去之后。
休息一天后前往红树林,河旁边就有一条鳄鱼。
确实看到了长鼻猴,但只能说还好?
日落还算不错,萤火虫就别提了,只是一点点。
必须吐槽的是公共交通的水平之低,我的天呐,没想到现在还要在路边挥手随机拦车,真是见了鬼。这比西北那边的公共交通水平还低,很难有什么准确的预期。
来之前对马来西亚的大海和丛林比较感兴趣,可以说,后者令人大失所望。至于大海,传闻国外最糟糕的海岸也比国内的更好,就吾辈实际经历而言,滨城、兰卡威、亚庇 三者中只有兰卡威有点意思,其余都是什么鬼。
目前正在前往仙本那,也许仙本那的大海会改变我的想法,但我想先写下这种感觉。
2026-01-26 08:27:04
在之前的 7 篇博客中,我们依次了解了一些扩展开发中的基本概念,并且每一篇都附上了一个扩展示例。现在,我们终于要演示如何发布扩展了。下面我们将演示如何将之前做的自动冻结不活跃标签页的那个扩展发布到 Chrome Web Store 中,还记得吗?就是我们在 Browser Extension Dev - 04. Background Script 和 Browser Extension Dev - 05. 存储和配置 中作为示例的那个扩展。
注册完成后打开 https://chrome.google.com/webstore/devconsole/ 应该可以看到如下页面。

接下来,开始演示如何从构建到最终发布扩展。
首先,在项目中打开终端,然后运行 pnpm zip,应该会看到类似下面这样的输出,可以看到 Chrome 扩展已经被正常打包成 zip。
1 |
|
在 .output 目录下找到这个文件,记住这个路径。

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

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

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

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

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

PS: 如果你使用 mac,可以使用小工具 Window Resizer 来将窗口尺寸修改为指定大小。
参考 Chrome 官方发布文档 https://developer.chrome.com/docs/webstore/publish
切换到 Privacy 标签,可以看到有几个主要区域
Are you using remote code?,Chrome 禁止使用远程代码,某些库(如 zod)可能会不小心引入远程代码,需特别留意

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

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

至此,浏览器扩展开发的基础内容就介绍完了。后续可能会有一些进阶主题的番外篇,比如国际化、GitHub Actions 自动发布等。
如果还对发布 Safari 扩展并上架 App Store 感兴趣,可以查看我之前写的博客 转换 Chrome Extension 为 Safari 版本 和 发布 Safari 扩展到 iOS 应用商店。提醒一下,这非常复杂,且开发者账户无试用期,必须满足 1)有一台 macOS 电脑并且安装 Xcode 等开发工具 2)开通 App Store 开发者账户并支付 $99/年的费用。
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 的应用场景非常接近,都是显示一些当前网页相关的内容,但它也有一些独有的适用场景:
Content Script UI 则有其他几个优势
接下来,让我们接着之前的实现继续完善吧。
参考 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 页面,在 entrypoints/popup 下添加 index.html 和 main.ts
1 |
|
1 |
|
在浏览器中加载扩展之后,点击 action 可以看到弹窗出现了。

在实现通信部分之前,需要修改一下之前注入的 Inject Script,不再复制 Markdown 到剪切板,而是使用 return 返回给调用者。
1 |
|
下面开始实现 Popup 与 Background Script 的通信部分,由于 Chrome 原生的通信 API 使用起来非常痛苦,这里使用一个浅包装 @webext-core/messaging。
安装依赖
1 |
|
然后在 lib/messager.ts 中定义接口
1 |
|
然后在 Background Script 定义实现
1 |
|
最后在 Popup 中调用,出于简化考虑,这里直接使用 pre 渲染了 Markdown,我们将在下一步引入所见即所得的 Markdown 编辑器。
1 |
|

由于并未使用 react,所以这里直接使用一个 vanilla JS 实现的 markdown 编辑器 easymde。
还是先安装依赖。
1 |
|
然后在 Popup 中使用它。
1 |
|
现在就可以看到最终的效果了。

在这一章,我们介绍了 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
2026-01-21 10:57:21
在上一章 Browser Extension Dev - 05. Storage and Configuration 中,我们介绍了为扩展添加设置页面并使用 Storage API 保存和读取配置的功能。在这一章,我们将介绍按需注入脚本。这种方式完全不会拖慢网站运行速度,同时在 Chrome Web Store 安装时不会有任何安全警告提示。接下来我们将实现一个扩展:点击图标即可将网页主要内容复制为 Markdown。
我们之前已经接触过 Content Script 注入和 Background Script 监听扩展图标点击。虽然尚未介绍,但两者可以进行消息通信。
有了上面的背景知识,你可能会想要这样做:
这种方法有几个主要缺点:

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

这里涉及到的关键 API 是 scripting.executeScript,顾名思义,用于执行自定义脚本。
参考 Chrome 官方 activeTab 指南:https://developer.chrome.com/docs/extensions/develop/concepts/activeTab
接下来在后台脚本中实现监听和注入部分。
首先还是更新 wxt.config.ts,添加所需权限以及空的 action 字段。
1 |
|
然后添加一个用于测试的注入脚本。与 Content Script 不同,这类脚本在 WXT 中需要使用 defineUnlistedScript 声明。
1 |
|
然后在 background 中监听并注入它。
1 |
|
除了 files 参数,还可以通过 func/args 直接传递函数和参数,适用于简单场景。参考:https://developer.chrome.com/docs/extensions/reference/api/scripting#type-ScriptInjection
当我们打开 google.com 然后点击扩展图标时,却发现没有反应。查看扩展详情页面,可以看到一个错误。
1 |
|
和之前 Browser Extension Dev - 03. 注入 UI 时一样,需要在 manifest 中增加 web_accessible_resources 配置。
1 |
|
然后再试一次,可以看到脚本确实被注入并正确执行了。

⚠️ 局限性:如果你希望注入的脚本能持久化(刷新或重新进入页面后仍自动运行),这是行不通的,仍然需要正确声明
host_permissions权限来持久化注入 Content Script,即使使用 scripting API 仍然受到权限模型的限制。
接下来实现读取网页主要内容,转换为 Markdown,然后复制到剪贴板的功能。借助 npm 生态,实现起来非常简单。
首先安装需要的依赖
1 |
|
然后编写少量胶水代码即可完成。
1 |
|


在本章中,我们实现了一个按需注入脚本的扩展,它不会影响网页正常运行,只在用户触发时才执行代码,真正做到即插即用。在下一篇,我们将继续完善这个扩展,使用 Popup 弹窗直接预览和编辑从当前页面复制的 Markdown。
如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2
2026-01-16 17:14:40
在上一章 Browser Extension Dev - 04. Background Script 中,我介绍了 Background Script 的概念和使用场景,并实现了一个自动休眠不活跃标签页的扩展。在本章,我将介绍如何在扩展中存储数据和配置选项,并提供一个配置页面来访问它。
浏览器为扩展提供了 browser.storage API,允许存储 kv 数据,可以存储任何可以被结构化克隆的数据,通常而言对于扩展的配置功能足够了。除此之外,有时候还使用 localStorage(如果是 Content Script)或 indexeddb(简单的 kv 存储不足以满足需求时)来存储扩展的设置。
其中 browser.storage API 下有几个选项,它们的接口是一致的,只是存储的方式和行为有些不同
参考 Chrome 官方文档: https://developer.chrome.com/docs/extensions/reference/api/storage
配置页面是浏览器为扩展提供的一个专用页面,允许在单独的页面中调整扩展的选项,或者访问扩展提供的功能。下面是两种配置页面的使用方式
直接使用浏览器内嵌页面访问,布局紧凑,适合配置项较少的情况,也是官方推荐的默认方式。
或者在独立标签页中打开,有更大的空间展示完整配置甚至功能,但需要额外配置或编写代码才能让用户方便地访问。
在 WXT 中,可以在 options.html 中添加 meta 标签来修改它,参考 https://wxt.dev/guide/essentials/entrypoints.html#options
1 |
|
同时,也有两种方法可以访问扩展的配置页面

参考 Chrome 官方文档: https://developer.chrome.com/docs/extensions/develop/ui/options-page
在 WXT 中,需要在 entrypoints 目录下添加 options.html 或者 options/index.html 文件。
1 |
|
WXT options entrypoint 文档: https://wxt.dev/guide/essentials/entrypoints.html#options
效果:

创建 entrypoints/options/main.ts 并在 html 的 body 标签末尾引入。
1 |
|
1 |
|
打开配置页面测试,发现功能没有生效。右键打开开发者工具,在控制台中看到以下错误:
1 |
|
这是因为缺少 storage 权限。使用需要权限的 API 之前都必须先声明,修改 wxt.config.ts 添加权限:
1 |
|
现在,修改页面中的 Auto Sleep Interval 选项的值之后,刷新页面,可以看到值已经被持久化了。

不过,HTML 默认样式实在太丑了,让我们引入 tailwindcss 并添加一些样式。
安装依赖
1 |
|
更新配置并添加 tailwindcss 插件。
1 |
|
然后在 html 中引入 tailwindcss 美化一下。
1 |
|
现在,我们可以看到效果至少好看了一点。

将硬编码的 Timeout 改为从 storage 读取:
1 |
|
如果需要在修改配置后立刻触发重新检测,还可以使用 storage.onChanged API,由于上面已经监听了标签页切换时自动触发检测,所以下面这段代码只做演示。
1 |
|
目前为止,我们都使用 Chrome 默认的方法打开配置页面,例如上面提到的两种方法。但其实我们还可以将点击浏览器右上角的 action 图标绑定到打开配置页面的行为。
首先在 wxt.config.ts 的 manifest 中声明 action 选项,目前留空即可。
1 |
|
然后在 background script 中监听 browser.action.onClicked 事件
1 |
|
现在,只要点击 action 就能打开配置页面,更加方便快捷。
在这一篇中,主要介绍了添加配置页面以及使用 storage API。在下一篇中,将介绍按需向网页注入脚本,也将是目前为止唯一一个在 Chrome Web Store 安装扩展时不会有任何警告信息的扩展。
如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2
2026-01-14 23:32:29
在上一章 Browser Extension Dev - 03. 注入 UI 中,我介绍了如何向网页中注入自定义的 UI,同时还了解了如何使用 Shadow DOM、Tailwind CSS 和使用 npm 包。在本章,我将介绍 Background Script,这是扩展的核心元素之一。
首先,什么是 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 |
|
注意:Tabs API 本身提供了
lastAccessed字段查看标签页的最后访问时间,但这个字段在 Safari 浏览器并不支持,参考 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab#browser_compatibility
由于在之前的章节中已经说明过如何初始化扩展,这里不再赘述初始化过程。
1 |
|
1 |
|
首先打开 entrypoints/background.ts,可以看到 WXT 初始化的代码。
1 |
|
让我们在函数中注册我们的监听器
1 |
|
现在,使用 pnpm dev 启动开发模式,打开 chrome://extensions/,加载已解压的扩展并选择 .output/chrome-mv3-dev 目录,然后点击扩展的 service worker 链接打开 Background Script 的 DevTools Console 开始调试。


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

然后我们需要在 onActivated 事件中检查已记录的标签页中是否有长时间未访问的标签页,如果发现就自动冻结它。在此之前,需要更新 wxt.config.ts 添加 tabs 权限,这是使用 browser.tabs.query API 所必须的。
1 |
|
你可能注意到,下面查找标签页时包含了很多过滤条件,下面将会一一说明
1 |
|
将 Timeout 调整为 1ms,就可以方便的进行测试了。在切换标签页再切换回来之后,就能看到标签页自动刷新了,这意味着自动冻结功能确实生效了。

现在,我们实现了基本的标签页自动冻结功能,这个实现非常粗糙,还有很多问题没有处理,如果感兴趣,你可以自行尝试解决下面几个问题:
browser.runtime.onStartup)在下一章,我们将介绍配置相关的 API 和 options 页面来解决配置问题。
如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2
完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/04-background-script