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

从 DevTools > Elements 中很容易就找到了弹窗元素的稳定选择器。
1 |
|
页面遮罩也很容易定位。
1 |
|
然后我就可以正常点击了,但我仍然无法自由滚动,而滚动条锁定又不在 HTML/body 上,所以这种情况下应该如何找到是哪个 HTML 元素导致的滚动条锁定呢?

想要解决这个问题需要一些简单的启发式的方法。首先,一般如何实现滚动条锁定?
通常只需要给 body 元素增加一些样式即可,例如
1 |
|
或者使用 position: fixed
1 |
|
按照这种思路,我们可以找到一个在无法滚动区域内的元素,然后递归向上查找,直到找到 overflow: hidden; 或者 position: fixed 的元素即可认为它就是阻止滚动的罪魁祸首,实现起来相当简单。
1 |
|
视频演示
现在,我们可以通过 CSS 恢复正常的滚动条了。
tip:
$0在 DevTools > Console 中代表在 DevTools > Elements 面板中选中的元素。
如果你也想尝试开发 Extension 或者 UserScript 来改善你的 Web 体验,不妨一试,JavaScript 提供了相当多 trick 的技巧,如果只是编写普通的 Web App,可能永远不会有机会尝试。
2026-01-02 19:11:27
你是否曾经对某个网页的功能感到不满?比如 Google 搜索页面上那个显眼的 AI Mode 按钮:

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

一般而言,浏览器扩展是一种修改用户浏览网页的方式,它赋予了网站使用者而不是开发者更多的权限。让有能力的用户可以自定义它们的网页浏览体验,现代浏览器提供了极其丰富的扩展 API,甚至能实现一些看起来需要 app 才能做到的事情,但它的基本出发点是让用户可以控制正在浏览的网页。
一些例子
1 |
|
现在,我们来创建一个基本的扩展,不使用任何包管理器、TypeScript、或者 Web 框架,只使用基本的 JavaScript 完成。在下一章节中,将会使用现代开发工具链(WXT)重写它,这里只是让我们对扩展的实际结构有个了解。作为例子,我们将会创建一个扩展来隐藏 google.com 中那个 AI Mode 按钮。

首先,创建一个 manifest.json,里面是一些基本信息,manifest_version 是一个固定值,代表使用扩展 API 的第三个主要版本。
1 |
|
然后我们需要创建一个 content-scripts/content.js 脚本(这个路径不是固定的,只是一般做法),里面实现在网页加载时自动隐藏 AI Mode 按钮。
基本思路也很简单
打开 google.com,然后打开 DevTools > Elements 可以看到 AI Mode 是一个 button 元素,但它看起来并没有可以作为稳定 CSS selector 的东西。

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

1 |
|
:has指的是如果元素包含指定 selector 的子元素,就匹配它。对于这个 selector 而言,这意味着只有包含特定 AI Mode svg 图标的 button 按钮才会被匹配到。
接下来创建 content.js 脚本,由于我们只想隐藏这个按钮,最简单的方法就是注入一个 css 样式,例如
1 |
|
接下来,我们还需要在 manifest.json 中声明这个内容脚本,告诉浏览器要在哪些网站注入,这里我们只需要在 google.com 注入,所以需要修改为
1 |
|
接下来,我们需要在 Chrome 中加载这个扩展。
首先,打开 chrome://extensions/,并启用 Developer Mode

然后,使用 Load unpacked 按钮选择扩展目录,就能加载扩展到浏览器了。
访问 google.com,可以看到 AI Mode 按钮确实不见了,但会闪一下出现然后才消失,这意味着注入的脚本时机不够早,在注入的脚本执行之前,AI Mode 按钮就已经显示了,然后脚本注入才隐藏了按钮,这是一个基本的时序问题。幸运的是,可以简单调整 manifest 配置解决这个问题。
1 |
|
然后在 chrome://extensions/ 重新加载扩展让修改生效。

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

这是因为注入脚本的时机非常早,甚至连 document.head 标签都还没有渲染,可以修改为在 document.documentElement 注入 style 标签。
1 |
|
再次刷新扩展然后访问 google.com 可以看到 AI Mode 按钮不见了,而且也不再出现闪烁的情况。

最后,这个扩展还缺少图标,我们将一张 128x128 的 png 图像放在 icon/128.png。
然后修改 manifest 添加 icons 设置即可。
1 |
|
再次刷新扩展,就可以看到扩展图标已经被正确加载了。

这就是第一个基本扩展。通过这个例子,我们了解了扩展开发的几个核心概念:
document_start、document_end 或 document_idle
你可能注意到,直接编写原生 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
2025-09-18 21:12:09
在为 Chrome 开发了一个扩展程序之后,接下来就是移植到其他浏览器中了,而 Firefox 一般认为是首要选择,它们都使用类似的 Browser Extension API 接口,所以这应该非常简单,对吧?
不,Firefox 有很多微妙的长期问题,如果你遇到了,可能会变得非常棘手,下面是一些吾辈在移植扩展到 Firefox 中已经发现的问题。
CSP 在 Firefox 扩展中很奇怪,与 Chrome 甚至 Safari 都非常不同,例如
Firefox Extension 不支持访问 localhost 导致 wxt 这种扩展开发框架无法支持 dev mode 热更新 [1],在 bugzilla 中提出的 issue 也已经有 2 年了 [2]。目前唯一的解决方法就是在 Chrome 中进行开发,然后构建在 Firefox 进行验证和测试。
Firefox 会根据网站本身的 CSP 来限制扩展注入的 Content Script(使用前朝的剑来斩本朝的官)[3]。这也涉及到一些已经存在 9 年的 bug [4],预计短期内不可能得到解决,幸运的是,可以使用 declarativeNetRequest 来禁用网站的 CSP 来绕过这个问题。
下面是一个基本的 rules.json 规则配置文件来删除特定网站的 Content-Security-Policy,当然,这会导致一些安全问题,但这也是对于业务层侵入最小的方法。
1 |
|
Firefox Extension Page 中使用 webworker 会出现错误,例如使用 esbuild-wasm 会出现以下 CSP 错误
1 |
|
即便已经在 manifest 中设置了 CSP
1 |
|
这同样与一个存在 9 年的 bug 有关,即便它已经被开发者关闭,但通过 blob URI 使用 webworker 仍然无法工作 [5]。
对于 esbuild-wasm 而言,它提供一个选项可以禁用 webworker,这可以解决该问题。
1 |
|
基本上 Firefox 就像 Apple 一样,要求所有扩展都必须进行公证和签名 [6],即使不打算发布给其他人使用,也必须提交到 AMO [7] 进行审核。如果你直接拖拽一个构建好的 zip 到 Firefox,就会出现 This add-on could not be installed because it is not been verified 的错误。

当你的扩展使用人数达到一定数量,AMO 每次审核都会变得异常缓慢,因为总是需要人工审核。并且审核人员并不总是优秀的浏览器扩展开发者,他们甚至可能不是 Web 开发者,糟糕的审核流程惹恼了一些非常知名的扩展开发者,让他们放弃了在 Firefox 发布扩展,例如

例如在使用 monaco-editor 时,ts 的 LSP 代码很大,直接导致了扩展被自动拒绝,而在 Chrome 中根本不会发生。

Firefox 曾经是一个优秀的浏览器,但这几年除了“碰瓷” Chrome 的名声与 Vue 类似之外,似乎没什么值得大惊小怪的。而且最近开始往浏览器中塞 AI 相关的功能,似乎总是在追逐闪闪发光的东西而不是真的去正视现有的问题。
2025-08-08 07:28:40
今年以来,吾辈开始发布一些 Safari 扩展程序到 AppStore 中,由于吾辈并不使用 iPhone,所以仅发布了 Mac 版本。而这个月吾辈开始实践全平台浏览器扩展的开发,即为所有主流的桌面浏览器(Chrome/Safari/Edge/Firefox)和所有支持扩展的移动端浏览器(Kiwi/Edge/Safari/Firefox)发布相同的插件,这让吾辈将发布 iOS Safari 扩展重新提上日程。
关于如何转换 Chrome 扩展为 Safari 扩展,请参考 转换 Chrome Extension 为 Safari 版本
在已经有一个 Mac Safari 扩展的情况下,为它发布到 iOS 版本理论上很简单。但发布之前,必须通过模拟器调试确保没有漏洞。
首先,在 Target 中选择 iOS 平台,然后选择一个模拟器,建议 iPhone 16 Pro,最后点击 Build 按钮。
其次,模拟器中的 iOS 扩展的封装 App 就会自动打开。
再其次,点击模拟器顶部工具栏的 Home 图标,返回桌面,打开 Settings > Apps > Safari > Extensions 中,即可看到刚刚 Build 的扩展。默认情况下它应该是 Disable 的,进入然后 Enable 即可。如果你无法找到刚刚 Build 的扩展,请参考下面的问题,就我而言,在排查问题的过程中 Claude 4.1 Opus 确实给了不错的提示,让吾辈意识到排查错误的方向和关键词是什么。

最后,点击 Home 回到桌面,找到 Safari 打开,你应该能在浏览器工具栏看到刚刚 Build 的扩展。
参考: Apple 官方视频 2022
在测试完成确认没有漏洞之后,就可以发布到 AppStore 了。
首先,在 Xcode 中选择 Product > Archive 进行打包。
其次,在弹窗中点击 Distribute App 按钮,接着选择 App Store Connect 作为分发渠道,最后点击 Distribute 按钮,你的 App 就会开始上传到 AppStore 了。

但请注意,上传完成之后并未发布,只是上传了一个构建包,还需要到 App Store Connect 添加版本信息、App 描述、截图等一系列常规信息,并提交审核才能最终发布。
如果没有报错但同时也不生效,那可能不是你的错,只能怪 Apple/Safari 的开发体验太糟。
如图

根据这个 社区 issue,可以知道是 18.2 的 bug,升级到 18.4 解决。

验证

如图

升级至最新版的 XCode 及虚拟机解决,就吾辈而言,是 XCode 16.4 及 iOS 18.4 的虚拟机。

验证方法是通过 XCode 创建一个全新的 Safari Extension 项目,然后 Build 并检查 Settings > Safari > Extensions 中是否能看到。
Apple 的官方文档几乎没什么用 https://developer.apple.com/documentation/safariservices/troubleshooting-your-safari-web-extension
但吾辈看到一个今年刚出的视频感觉很有帮助 https://www.youtube.com/watch?v=DZe7L70CDPc
一个很小的问题,CJK 输入法在 Mac 上输入的字符是 \u0020,即便输入的是中文的空格,但 keydown 事件中仍然识别为标准的 \u0020。
例如
1 |
|
而在 iOS Safari 上,输入中文空格后,在 beforeinput 事件中,e.data 是 \u3000。
1 |
|
所以针对 iOS Safari 必须小心处理输入相关的事件。
例如,在插件中使用了 OpenAI 的 API 实现部分功能,发布到国区就无法过审。Apple 声称根据 MIIT(工信部)的要求,所有 AI 相关的 App 都必须报备取得资质。如果是个人开发者,建议直接放弃国区。Fuck of MIIT。

截止目前为止,吾辈已经成功发布了两个全平台浏览器扩展,分别是
发布 Safari 扩展虽然有趣,却也让人意识到 Safari 扩展的开发体验有多么糟糕,吾辈在开发过程中踩了不少坑,也浪费了不少时间。
2025-06-28 17:35:11
六月初有个去新疆自驾的机会,于是便和新一开始了二刷新疆之旅。大致路线定的是南疆环线,由于距离霍尔果斯口岸很近,所以也顺便出国去哈萨克斯坦看了看。这次的旅行体验比上次报团要好得多,主要是单个地点好玩的话可以多玩会而不再卡时间了。
首先第一站前往了赛里木湖,中间由于路途太远,所以在精河县暂住一晚,第二天继续前往赛里木湖。
首先去游客中心买票,确认了自驾是每个人 140,人车合一而非车单独计算。
将这里称作天空之镜似乎很合适。
让吾辈大吃一惊的雪堆,在 6 月中旬这个季节。
远处的湖边有人正在拍“场照”?
从赛里木湖西侧的山坡木栈道上向下俯拍,可以看到赏心悦目的风景。
雨过天晴,很幸运的看到了双彩虹。不幸的是,吾辈没有拍好它。
第二天早上,早起前往点将台等待日出。
上午的湖水无比湛蓝。
还在湖边用石头堆起了一个玛尼堆。
接下来,由于赛里木湖附近就有一个陆上口岸,加之哈萨克斯坦又是免签国家,所以之后前往了口岸城市霍尔果斯,休息一晚后第二天出发前往扎尔肯特。
霍尔果斯当地并没有什么东西,旧口岸附近有一个观光塔。
闹市之中有一处欧式建筑。
暂住一晚后第二天前往哈萨克斯坦的扎尔肯特小镇,从新国门出去。
再经过两三个小时漫长的安检审核流程之后,终于到了扎尔肯特,由于有 3 个小时的时差,所以还能吃上午饭。第一次吃如此巨大的鸡肉卷。
在路上看到的一个广告牌,有点好奇这是否就是 Local Superman?
之后前往了旅馆,但由于哈萨克斯坦是旧苏联加盟国,所以西方软件在此地不好使,打车、住宿和支付都使用俄罗斯 Yandex 系列的软件,而不是常用的 Uber/Booking/GooglePay。由于没有自驾而且也无法打车,所以步行前往了最近的清真寺。这个清真寺似乎不太清真,杂糅了中国风?
这是小镇上最大的超市,大部分本地人都来这里采购东西,甚至被作为一日游的景点了。
在路上随处可见的管道中不知道有什么,自来水?或者是天然气?
回国之后到了伊宁,六星街没什么好玩的,喀赞其民俗旅游区也因为天气原因没有去看,所以直接略过,开始翻越天山的伊昭公路之旅。
山脉之间。
尽管已经快没有信号了,但海拔三千米仍然有人卖现杀羊肉烧烤。
这是沿山而筑的一条小路,虽然危险,但风景却也是极好的。
下山后快到昭苏时,开始看到牛和羊。
这个弧线看着真的太舒服了,如果没有人就更好了。
山上还牧民的马。
吃完午饭,开始前往了昭苏附近的一个小景点:玉湖。但它却着实带来了不少惊喜,首先,它的门票人和车分开计算,一辆车只会计算一次,所以人均只有 55,与赛里木湖(140)、那拉提(200)相比,实在太划算了。
只看湖水颜色有点像西藏的羊卓雍湖。
但真正带来惊喜的不是湖水,而是景区内部公路的长度,单程至少超过 45km(没能走到终点),而景区区间车只走 25km。在后面能看到雪山、草地和成群的牛羊,风景着实不错。
往回走时看到远方层层叠叠的山有一些透明的感觉。
在中途经过特克斯八卦城住下之后,前往了非常有名的天山花海,但却大失所望,里面的花海挺壮观,但从地面上非常难拍。
成片的薰衣草花海。
蜜蜂正在采蜜。
似乎已经到末期的绳子草。
在纪念品购买的地方看到介绍天山花海似乎是个农业庄园,只是兼具旅游的功能。
与赛里木湖一样,那拉提也是二刷。上次抱团来的时候体验极其糟糕,只是乘坐区间车快速走马观花看了空中草原(盛名之下,其实难副)。这次自驾进来,在 48 小时内一共进来了 3 次,空中草原的体验仍然一般般,但河谷草原末端登上天鹰台才真正看到很棒的风景。
不知道什么时候立的一座雕像。
空中草原,也就是说,在海拔很高的山上的一片草原。
来的时候还下着淅淅沥沥的小雨,天气比较糟糕。
曾经的牧民就住在这种小房子中,牛羊在外面吃草。
一条小溪从山间流下,也许正是来自雪莲谷。
终于,在经过上午去空中草原的失望之后。在河谷草原末端,登上天鹰台,便可以远眺整个那拉提。
山顶的小路两侧正卧着几只牛。
山上还放牧着一群马。
还看到一朵不知名的花。
之后就是独库公路,由于之前已经走过伊昭公路,也同样是翻山越岭,加之在那拉提镇不小心磕伤需要提前回去,所以并没能带来预期的体验。
路边随手一拍。
雪山草甸。
雪山之顶。
奇形怪石。
高山深壑。
途径山泉。
丹霞地貌。
天山峡谷。
这次旅行起于偶然,终于偶然。几乎盲目的进入新疆,完全没有计划。所有的住宿都是临时决定,所有的门票都是现场购买。这种旅行体验之前从未尝试过,但自驾旅行的话,似乎确实不需要完整的旅行计划。实在不行,睡在车上凑活一晚也不是不行。
2025-06-07 20:27:37
最近写了几个前后端都包含的应用,从最初的 Next.js 到后来的 SvelteKit,再到 Tanstack Router,终究不如熟悉的 Hono 框架那么好使。所有的 Web 元框架都在尝试将服务端加入到框架中,但没有一个做的足够好。例如 Cloudflare 上包含许多官方服务,作为一个服务端框架,Hono 的集成做的很棒,但 Web 元框架并非如此。
为什么 Web 元框架已经有服务端路由了,还要使用 Hono 呢?有几个原因
不管使用什么 Web 框架,Hono 的知识都是通用的。可以轻松的将 Hono 应用部署到任何地方,例如 Cloudflare、Vercel、Deno 等。而 Web 元框架。。。好吧,最好的说法是百花齐放。看几个例子
Next.js 声称在 React 组件中直接耦合数据库查询推荐的做法。
PHP:敢问今夕是何年?
1 |
|
好吧,其实它也有 Route Handlers,像是下面这样。是的,需要 export 不同的函数来处理不同的请求,而路径则取决于文件相对路径。想要快速搜索特定路径的 API?抱歉,你需要在文件系统中找找看。
1 |
|
SvelteKit 也是类似的。
1 |
|
Tanstack 据称从 tRPC 得到了灵感,嗯。。。
1 |
|
好吧,它们有什么共通之处?嗯,显然基本概念是类似的,但除此之外?生态系统全部没有共享,想要连接 KV?数据库?OAuth2 登录?抱歉,你需要找到适合 Web 元框架的方法。
而且对于 Cloudflare 来说,Hono 的集成度相当高,包括 KV/D1、R2、Pages 等。而且对于其他服务端需要的功能,例如数据库、登录、OAuth2 以及测试集成都做的非常棒。
这是一个直观的对比,可以明显看到不管是构建时间还是最终 bundle 产物的大小差异都非常明显。
SvelteKit minimal
Hono starter
现在,同时使用 Hono 和 Web 元框架,例如 SvelteKit,来开发一个应用。问题来了,谁在前面?也就是说,Hono 在前面并转发路由,还是 SvelteKit 在前面并转发路由?由于下面几个特征,Hono 在前面会更好
现在,终于到了实现的部分,下面是 Hono 作为入口,静态资源转发到 SvelteKit 的静态产物。最终部署到 Cloudflare Workers 上。
首先确定静态资源在哪儿,例如在 SvelteKit 中,它是由 @sveltejs/adapter-cloudflare 插件配置的。例如下面配置的是 dist 目录。
1 |
|
然后需要配置 wrangler.json 来将静态资源绑定到 ASSETS 上。例如下面配置的是 dist 目录。
1 |
|
最后在 hono 的入口文件中将找不到的路由全部转发到 SvelteKit 的静态资源就好了。
1 |
|
现在,就可以在编码时服务端使用 Hono 而客户端使用喜欢的 Web 元框架了。
1 |
|
说了这么多,这种模式的缺点是什么?
Web 全栈开发是一个流行的趋势,将 Web 的前端/服务端放在一起写看起来很有吸引力,但最终可能在一如既往的绕远路,就像 Next.js 终究活成了一个怪物。另外对于不需要动态渲染 UGC [9] 的网站而言,SSR 通常增加的复杂性可能是没有必要的。