MoreRSS

site iconrxliuli | 琉璃修改

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

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

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,可能永远不会有机会尝试。

相关资源

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

2026-01-02 19:11:27

你是否曾经对某个网页的功能感到不满?比如 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

Chrome =&gt; Firefox 扩展移植的那些坑

2025-09-18 21:12:09

背景

在为 Chrome 开发了一个扩展程序之后,接下来就是移植到其他浏览器中了,而 Firefox 一般认为是首要选择,它们都使用类似的 Browser Extension API 接口,所以这应该非常简单,对吧?
不,Firefox 有很多微妙的长期问题,如果你遇到了,可能会变得非常棘手,下面是一些吾辈在移植扩展到 Firefox 中已经发现的问题。

CSP 问题

CSP 在 Firefox 扩展中很奇怪,与 Chrome 甚至 Safari 都非常不同,例如

Firefox Extension 不支持访问 localhost

Firefox Extension 不支持访问 localhost 导致 wxt 这种扩展开发框架无法支持 dev mode 热更新 [1],在 bugzilla 中提出的 issue 也已经有 2 年了 [2]。目前唯一的解决方法就是在 Chrome 中进行开发,然后构建在 Firefox 进行验证和测试。

Firefox 会根据网站本身的 CSP 来限制扩展注入的 Content Script

Firefox 会根据网站本身的 CSP 来限制扩展注入的 Content Script(使用前朝的剑来斩本朝的官)[3]。这也涉及到一些已经存在 9 年的 bug [4],预计短期内不可能得到解决,幸运的是,可以使用 declarativeNetRequest 来禁用网站的 CSP 来绕过这个问题。

下面是一个基本的 rules.json 规则配置文件来删除特定网站的 Content-Security-Policy,当然,这会导致一些安全问题,但这也是对于业务层侵入最小的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[
{
"id": 1,
"condition": {
"urlFilter": "https://example.com/**",
"excludedResourceTypes": []
},
"action": {
"type": "modifyHeaders",
"responseHeaders": [
{
"header": "content-security-policy",
"operation": "remove"
}
]
}
}
]

Firefox Extension Page 中使用 wasm 会出现错误

Firefox Extension Page 中使用 webworker 会出现错误,例如使用 esbuild-wasm 会出现以下 CSP 错误

1
Content-Security-Policy: The page’s settings blocked a worker script (worker-src) at blob:moz-extension://708674c8-9b11-450a-9552-c0e679d39d8e/0dff485f-4f32-4d1a-a109-8ca61a3037a2 from being executed because it violates the following directive: “script-src 'self' 'wasm-unsafe-eval'

即便已经在 manifest 中设置了 CSP

1
2
3
4
5
{
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
}
}

这同样与一个存在 9 年的 bug 有关,即便它已经被开发者关闭,但通过 blob URI 使用 webworker 仍然无法工作 [5]
对于 esbuild-wasm 而言,它提供一个选项可以禁用 webworker,这可以解决该问题。

1
2
3
4
5
await initialize({
wasmURL: wasmUrl,
// Firefox Extension Page CSP disable blob worker
worker: import.meta.env.CHROME,
})

无法安装未签名扩展

基本上 Firefox 就像 Apple 一样,要求所有扩展都必须进行公证和签名 [6],即使不打算发布给其他人使用,也必须提交到 AMO [7] 进行审核。如果你直接拖拽一个构建好的 zip 到 Firefox,就会出现 This add-on could not be installed because it is not been verified 的错误。

1758208110800.jpg

AMO 审核问题

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

  • uBlock Origin Lite,Chrome 版本用户超过 11M [11]
  • Enhancer for YouTube,Chrome 版本用户超过 1M [12]

1758542096165.jpg

AMO 限制 JavaScript 代码尺寸

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

1758660585954.jpg

结语

Firefox 曾经是一个优秀的浏览器,但这几年除了“碰瓷” Chrome 的名声与 Vue 类似之外,似乎没什么值得大惊小怪的。而且最近开始往浏览器中塞 AI 相关的功能,似乎总是在追逐闪闪发光的东西而不是真的去正视现有的问题。

发布 Safari 扩展到 iOS 应用商店

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 按钮。
1755089514895.jpg

其次,模拟器中的 iOS 扩展的封装 App 就会自动打开。
Simulator Screenshot - iPhone 16 Pro - 2025-08-13 at 20.52.34.jpg

再其次,点击模拟器顶部工具栏的 Home 图标,返回桌面,打开 Settings > Apps > Safari > Extensions 中,即可看到刚刚 Build 的扩展。默认情况下它应该是 Disable 的,进入然后 Enable 即可。如果你无法找到刚刚 Build 的扩展,请参考下面的问题,就我而言,在排查问题的过程中 Claude 4.1 Opus 确实给了不错的提示,让吾辈意识到排查错误的方向和关键词是什么。
1755089694981.jpg
1755089768574.jpg

最后,点击 Home 回到桌面,找到 Safari 打开,你应该能在浏览器工具栏看到刚刚 Build 的扩展。
1755090127320.jpg

参考: Apple 官方视频 2022

发布到 AppStore

在测试完成确认没有漏洞之后,就可以发布到 AppStore 了。

首先,在 Xcode 中选择 Product > Archive 进行打包。
1755090217839.jpg

其次,在弹窗中点击 Distribute App 按钮,接着选择 App Store Connect 作为分发渠道,最后点击 Distribute 按钮,你的 App 就会开始上传到 AppStore 了。
1755090347344.jpg
1755090341984.jpg

但请注意,上传完成之后并未发布,只是上传了一个构建包,还需要到 App Store Connect 添加版本信息、App 描述、截图等一系列常规信息,并提交审核才能最终发布。
1755090522509.jpg

问题

如果没有报错但同时也不生效,那可能不是你的错,只能怪 Apple/Safari 的开发体验太糟。

iOS 里面的 Settings > Apps 中看不到任何 App

如图

Snipaste\_2025-08-08\_07-23-20.jpg

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

Snipaste\_2025-08-08\_07-31-46.jpg

验证

Snipaste\_2025-08-08\_07-32-07.jpg

在 Settings > Safari > Extensions 中始终看不到开发的扩展

如图

Snipaste\_2025-08-08\_08-01-05.jpg

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

Snipaste\_2025-08-08\_08-43-45.jpg

验证方法是通过 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 输入法输入的空格不是 \u0020,而是 \u3000

一个很小的问题,CJK 输入法在 Mac 上输入的字符是 \u0020,即便输入的是中文的空格,但 keydown 事件中仍然识别为标准的 \u0020
例如

1
2
3
document.addEventListener('keydown', (e) => {
console.log(e.key) // 输出 ' '
})

而在 iOS Safari 上,输入中文空格后,在 beforeinput 事件中,e.data 是 \u3000

1
2
3
document.addEventListener('beforeinput', (e) => {
console.log(e.data) // 输出 ' '
})

所以针对 iOS Safari 必须小心处理输入相关的事件。

如果发布区域包含国区并存在 LLM 相关功能,则需要额外注意

例如,在插件中使用了 OpenAI 的 API 实现部分功能,发布到国区就无法过审。Apple 声称根据 MIIT(工信部)的要求,所有 AI 相关的 App 都必须报备取得资质。如果是个人开发者,建议直接放弃国区。Fuck of MIIT。

1755090943068.jpg

总结

截止目前为止,吾辈已经成功发布了两个全平台浏览器扩展,分别是

发布 Safari 扩展虽然有趣,却也让人意识到 Safari 扩展的开发体验有多么糟糕,吾辈在开发过程中踩了不少坑,也浪费了不少时间。

再游新疆 -- 自驾

2025-06-28 17:35:11

前言

六月初有个去新疆自驾的机会,于是便和新一开始了二刷新疆之旅。大致路线定的是南疆环线,由于距离霍尔果斯口岸很近,所以也顺便出国去哈萨克斯坦看了看。这次的旅行体验比上次报团要好得多,主要是单个地点好玩的话可以多玩会而不再卡时间了。

cover

赛里木湖

首先第一站前往了赛里木湖,中间由于路途太远,所以在精河县暂住一晚,第二天继续前往赛里木湖。

首先去游客中心买票,确认了自驾是每个人 140,人车合一而非车单独计算。
赛里木湖 1

将这里称作天空之镜似乎很合适。
赛里木湖 2

让吾辈大吃一惊的雪堆,在 6 月中旬这个季节。
赛里木湖 3

远处的湖边有人正在拍“场照”?
赛里木湖 4

从赛里木湖西侧的山坡木栈道上向下俯拍,可以看到赏心悦目的风景。
赛里木湖 5

雨过天晴,很幸运的看到了双彩虹。不幸的是,吾辈没有拍好它。
赛里木湖 6

第二天早上,早起前往点将台等待日出。
赛里木湖 7
赛里木湖 8
赛里木湖 9

上午的湖水无比湛蓝。
赛里木湖 10
赛里木湖 11

还在湖边用石头堆起了一个玛尼堆。

赛里木湖 12

霍尔果斯/扎尔肯特

接下来,由于赛里木湖附近就有一个陆上口岸,加之哈萨克斯坦又是免签国家,所以之后前往了口岸城市霍尔果斯,休息一晚后第二天出发前往扎尔肯特。

霍尔果斯当地并没有什么东西,旧口岸附近有一个观光塔。
霍尔果斯 1

闹市之中有一处欧式建筑。
霍尔果斯 2

暂住一晚后第二天前往哈萨克斯坦的扎尔肯特小镇,从新国门出去。
霍尔果斯 3

再经过两三个小时漫长的安检审核流程之后,终于到了扎尔肯特,由于有 3 个小时的时差,所以还能吃上午饭。第一次吃如此巨大的鸡肉卷。
扎尔肯特 1

在路上看到的一个广告牌,有点好奇这是否就是 Local Superman?
扎尔肯特 2

之后前往了旅馆,但由于哈萨克斯坦是旧苏联加盟国,所以西方软件在此地不好使,打车、住宿和支付都使用俄罗斯 Yandex 系列的软件,而不是常用的 Uber/Booking/GooglePay。由于没有自驾而且也无法打车,所以步行前往了最近的清真寺。这个清真寺似乎不太清真,杂糅了中国风?
扎尔肯特 3
扎尔肯特 4

这是小镇上最大的超市,大部分本地人都来这里采购东西,甚至被作为一日游的景点了。
扎尔肯特 5

在路上随处可见的管道中不知道有什么,自来水?或者是天然气?
扎尔肯特 6

伊昭公路

回国之后到了伊宁,六星街没什么好玩的,喀赞其民俗旅游区也因为天气原因没有去看,所以直接略过,开始翻越天山的伊昭公路之旅。

山脉之间。
伊昭公路 1
伊昭公路 2

尽管已经快没有信号了,但海拔三千米仍然有人卖现杀羊肉烧烤。
伊昭公路 3

这是沿山而筑的一条小路,虽然危险,但风景却也是极好的。
伊昭公路 4

下山后快到昭苏时,开始看到牛和羊。
伊昭公路 5

这个弧线看着真的太舒服了,如果没有人就更好了。
伊昭公路 6

山上还牧民的马。
伊昭公路 7

昭苏玉湖

吃完午饭,开始前往了昭苏附近的一个小景点:玉湖。但它却着实带来了不少惊喜,首先,它的门票人和车分开计算,一辆车只会计算一次,所以人均只有 55,与赛里木湖(140)、那拉提(200)相比,实在太划算了。

只看湖水颜色有点像西藏的羊卓雍湖。
昭苏玉湖 1
昭苏玉湖 2
昭苏玉湖 3

但真正带来惊喜的不是湖水,而是景区内部公路的长度,单程至少超过 45km(没能走到终点),而景区区间车只走 25km。在后面能看到雪山、草地和成群的牛羊,风景着实不错。
昭苏玉湖 4
昭苏玉湖 5
昭苏玉湖 6

往回走时看到远方层层叠叠的山有一些透明的感觉。
昭苏玉湖 7

天山花海

在中途经过特克斯八卦城住下之后,前往了非常有名的天山花海,但却大失所望,里面的花海挺壮观,但从地面上非常难拍。

成片的薰衣草花海。
天山花海 1

蜜蜂正在采蜜。
天山花海 2
天山花海 3

似乎已经到末期的绳子草。
天山花海 4

在纪念品购买的地方看到介绍天山花海似乎是个农业庄园,只是兼具旅游的功能。
天山花海 4

那拉提

与赛里木湖一样,那拉提也是二刷。上次抱团来的时候体验极其糟糕,只是乘坐区间车快速走马观花看了空中草原(盛名之下,其实难副)。这次自驾进来,在 48 小时内一共进来了 3 次,空中草原的体验仍然一般般,但河谷草原末端登上天鹰台才真正看到很棒的风景。

不知道什么时候立的一座雕像。
那拉提 1

空中草原,也就是说,在海拔很高的山上的一片草原。
那拉提 2

来的时候还下着淅淅沥沥的小雨,天气比较糟糕。
那拉提 3

曾经的牧民就住在这种小房子中,牛羊在外面吃草。
那拉提 4
那拉提 5

一条小溪从山间流下,也许正是来自雪莲谷。
那拉提 6

终于,在经过上午去空中草原的失望之后。在河谷草原末端,登上天鹰台,便可以远眺整个那拉提。
那拉提 7
那拉提 8

山顶的小路两侧正卧着几只牛。
那拉提 9

山上还放牧着一群马。
那拉提 10

还看到一朵不知名的花。
那拉提 11

独库公路

之后就是独库公路,由于之前已经走过伊昭公路,也同样是翻山越岭,加之在那拉提镇不小心磕伤需要提前回去,所以并没能带来预期的体验。

独库公路 1

路边随手一拍。
独库公路 2

雪山草甸。
独库公路 3
独库公路 4

雪山之顶。
独库公路 5

奇形怪石。
独库公路 6

高山深壑。
独库公路 7

途径山泉。
独库公路 8

丹霞地貌。
独库公路 9
独库公路 10

天山峡谷。
独库公路 11

总结

这次旅行起于偶然,终于偶然。几乎盲目的进入新疆,完全没有计划。所有的住宿都是临时决定,所有的门票都是现场购买。这种旅行体验之前从未尝试过,但自驾旅行的话,似乎确实不需要完整的旅行计划。实在不行,睡在车上凑活一晚也不是不行。

实践: 使用 Hono 开发全栈应用

2025-06-07 20:27:37

场景

最近写了几个前后端都包含的应用,从最初的 Next.js 到后来的 SvelteKit,再到 Tanstack Router,终究不如熟悉的 Hono 框架那么好使。所有的 Web 元框架都在尝试将服务端加入到框架中,但没有一个做的足够好。例如 Cloudflare 上包含许多官方服务,作为一个服务端框架,Hono 的集成做的很棒,但 Web 元框架并非如此。

为什么使用 Hono

为什么 Web 元框架已经有服务端路由了,还要使用 Hono 呢?有几个原因

  • 抽象不一:每个元框架都有不同的语法和规则,例如 Next.js Server Components [1]、SvelteKit Server routing [2]、或者 TanStack Server Functions [3]
  • 功能残缺:处理简单的 JSON API?没问题。复杂的 API 结合 Cloudflare 多个服务?很困难。
  • 尺寸很大:元框架的 bundle size 非常庞大,即便以小巧著称的 SvelteKit 也有 132kb,而 Hono 构建后只有 18kb.

抽象不一

不管使用什么 Web 框架,Hono 的知识都是通用的。可以轻松的将 Hono 应用部署到任何地方,例如 Cloudflare、Vercel、Deno 等。而 Web 元框架。。。好吧,最好的说法是百花齐放。看几个例子

Next.js 声称在 React 组件中直接耦合数据库查询推荐的做法。

PHP:敢问今夕是何年?

1
2
3
4
5
6
7
8
9
10
11
12
import { db, posts } from '@/lib/db'

export default async function Page() {
const allPosts = await db.select().from(posts)
return (
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}

好吧,其实它也有 Route Handlers,像是下面这样。是的,需要 export 不同的函数来处理不同的请求,而路径则取决于文件相对路径。想要快速搜索特定路径的 API?抱歉,你需要在文件系统中找找看。

1
2
3
export async function GET() {
return Response.json({ message: 'Hello World' })
}

SvelteKit 也是类似的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = ({ url }) => {
const min = Number(url.searchParams.get('min') ?? '0')
const max = Number(url.searchParams.get('max') ?? '1')

const d = max - min

if (isNaN(d) || d < 0) {
error(400, 'min and max must be numbers, and min must be less than max')
}

const random = min + Math.random() * d

return new Response(String(random))
}

Tanstack 据称从 tRPC 得到了灵感,嗯。。。

1
2
3
4
5
6
// routes/hello.ts
export const ServerRoute = createServerFileRoute().methods({
GET: async ({ request }) => {
return new Response('Hello, World!')
},
})

好吧,它们有什么共通之处?嗯,显然基本概念是类似的,但除此之外?生态系统全部没有共享,想要连接 KV?数据库?OAuth2 登录?抱歉,你需要找到适合 Web 元框架的方法。

功能残缺

而且对于 Cloudflare 来说,Hono 的集成度相当高,包括 KV/D1、R2、Pages 等。而且对于其他服务端需要的功能,例如数据库、登录、OAuth2 以及测试集成都做的非常棒。

  • 数据库:对 D1/Postgresql 支持的都很好(不过推荐使用 Drizzle 而非 Prisma)[4]
  • 登录:支持 JWT 中间件,使用起来非常简单 [5]
  • OAuth2: 官方的 OAuth Providers [6] 比 Auth.js 和 Better Auth 更简单,也更容易理解和调试,它的黑盒部分较少,不关心数据如何存储
  • 测试:全面拥抱 vitest [7],某知名框架至今仍然优先支持 jest

尺寸很大

这是一个直观的对比,可以明显看到不管是构建时间还是最终 bundle 产物的大小差异都非常明显。

SvelteKit minimal
1753979669566.jpg

Hono starter
1753981327766.jpg

实现

谁在前面?

现在,同时使用 Hono 和 Web 元框架,例如 SvelteKit,来开发一个应用。问题来了,谁在前面?也就是说,Hono 在前面并转发路由,还是 SvelteKit 在前面并转发路由?由于下面几个特征,Hono 在前面会更好

  1. Hono 的代码更少,启动更快
  2. 元框架可能会有一些意外的行为,例如自动序列化所有 Response [8]
  3. 如果没有 SSR(例如 SPA/SSG),那么元框架根本不会有服务端代码

Hono 作为入口

现在,终于到了实现的部分,下面是 Hono 作为入口,静态资源转发到 SvelteKit 的静态产物。最终部署到 Cloudflare Workers 上。

首先确定静态资源在哪儿,例如在 SvelteKit 中,它是由 @sveltejs/adapter-cloudflare 插件配置的。例如下面配置的是 dist 目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// packages/client/svelte.config.js
const config = {
// other config...
kit: {
adapter: adapter({
pages: 'dist',
assets: 'dist',
fallback: undefined,
precompress: false,
strict: true,
}),
},
}

export default config

然后需要配置 wrangler.json 来将静态资源绑定到 ASSETS 上。例如下面配置的是 dist 目录。

1
2
3
4
5
6
7
8
9
10
// packages/server/wrangler.json
{
"name": "sveltekit-to-hono-demo",
"main": "src/index.ts",
"compatibility_date": "2025-01-24",
"assets": {
"directory": "../client/dist",
"binding": "ASSETS"
}
}

最后在 hono 的入口文件中将找不到的路由全部转发到 SvelteKit 的静态资源就好了。

1
2
3
4
5
6
7
8
9
// packages/server/src/index.ts
import { Hono } from 'hono'

const app = new Hono<{ Bindings: Env }>()

app.get('/api/ping', (c) => c.text('pong'))
app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw))

export default app

现在,就可以在编码时服务端使用 Hono 而客户端使用喜欢的 Web 元框架了。

1
2
cd packages/client && pnpm build
cd ../server && pnpm wrangler dev --port 8787

缺点

说了这么多,这种模式的缺点是什么?

  • Hono 在前面时如果 SSR 需要调用服务端 API,不能在内部转换为函数调用,而是必须经过外部绕一圈请求回来。
  • 没有 Web 元框架提供的类型安全,当然这是一个可以解决的问题,例如 Trpc 或 OpenAPI 等。
  • 一般需要拆分为 monorepo 多模块,即 packages/server 和 packages/client,可能会增加一些复杂性
  • 如果仍然需要 SSR,那么还需要在 Hono 中拦截 404 请求并调用 Web 元框架构建出来的 server/index.js 动态执行

总结

Web 全栈开发是一个流行的趋势,将 Web 的前端/服务端放在一起写看起来很有吸引力,但最终可能在一如既往的绕远路,就像 Next.js 终究活成了一个怪物。另外对于不需要动态渲染 UGC [9] 的网站而言,SSR 通常增加的复杂性可能是没有必要的。