阅读视图

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

国内(大陆)版小米 FCM 熄屏断连:Rootless 环境下的尝试与可能的解决方案

在去年的 11 月,我入手了一台新的国行版的小米 15 作为我的主力机。其实之前手上的 Redmi K70 Ultra 与这台小米 15 是同一年发布的产品,性能上也不相上下,但我在 Gap Year 期间在全国多地旅行,意识到没能将自己所见的夜景拍下是一件挺可惜的事,于是就想换一台拍照更好的手机。新发布的小米 17 在价格上正处于高位,且相对于 15 来说并没有太大的提升,所以我就直接入了小米 15。

入手以后,我按照自己的使用习惯打开了设置中的「谷歌基础服务」,安装好「Google Play」后便正常使用。但就在这几个月的使用过程中,我发现我的 fcm 推送——无论是 outlook 邮件通知,还是某外观形似纸飞机的即时通讯软件,他们的 fcm 推送好像自始至终都没有正常 work 过。它们的通知偶尔会在我解锁手机后突然冒出来,但更多的时候它们就是不见了。一直要到我手动打开对应的 app,才会收到之前积压的通知。

最近收到了海外院校的 offer,准备在 9 月份去读水硕了,所以就想在出国之前把这个问题解决掉。毕竟在国内生活的时候,大部分软件能通过 mipush 给我推送消息,这些依赖 fcm 推送的 app 并不是我使用频率最高的 app,我每天睡醒手动打开一下就能收到通知了,即使回复消息的及时性不太好也无伤大雅,真有急事的话我的家人朋友们也知道哪些方式能更快的 reach 我。但到了国外,fcm 推送就变得非常重要了,所以要尽快处理掉这个问题,要不然我就得考虑换手机了。

测试环境

  • 小米 15 国内版,16G + 512G,HyperOS 3.0.7.0.WOCCNXM,是截至本文撰写时最新的版本
  • 已开启「谷歌基础服务」,已安装「Google Play」
  • 连接 WIFI,且 WIFI 分流后的数据出口 IP 在🇺🇸洛杉矶
  • 未关闭 MIUI 优化

先把 FCM 跑起来

我需要先解决 FCM 在亮屏状态下也无法接收消息的问题。在「电话」界面的拨号盘输入 *#*#426#*#* 可以进入 FCM Diagnostics 界面,里面有一些关于 FCM 连接状态的日志输出。

拨号盘输入 FCM Diagnostics 界面

在这个界面中,我发现 FCM 的连接状态其实是正常的,日志里也没有什么明显的错误信息。于是我就开始怀疑是不是 HyperOS 的某些省电机制在后台干扰了 FCM 的正常工作。通过在小红书的一番搜索,我发现在设置界面打开需要 FCM 推送的 App 的「自启动」权限,并且把电池优化设置为「不优化」,似乎能解决这个问题。

App 设置界面

通过 IM 软件(用一个号给另一个号发消息)测试,我发现在亮屏状态下,FCM 可以在 App 后台被关闭的情况下正常接收消息了。

熄屏一分钟后 FCM 断连的问题

然而,当我把手机熄屏后静置一分钟后,FCM 就完全不工作了。无论是邮件通知还是 IM 消息,都无法通过 FCM 推送到手机上。只有在我点亮屏幕的一瞬间,之前积压的通知才会突然冒出来。再通过 FCM Diagnostics 界面查看,FCM 的连接状态要么是 disconnected,要么是刚刚 connected 几秒。这说明在锁屏状态下,FCM 的连接会被系统断开,导致无法接收消息。

PS: 我发现在充电状态下,即使手机锁屏,FCM 也能保持连接并正常接收消息。这就更加印证了是 HyperOS 的省电机制在干扰 FCM 的正常工作了。

可能的解决方案

在小红书和小绿书(酷安)一番搜索后,我找到了前人的一些尝试和解决方案:

1. 关闭 MIUI 优化

这是我最不喜欢的方案,因为 MIUI 优化确实是我喜欢 HyperOS (MIUI) 的一个重要原因。关闭 MIUI 优化后,我发现电池的剩余电量信息没法展示在状态栏电池图标内,一定会出现在电池图标右侧,这对于 HyperOS 出现超级岛(灵动岛)后的状态栏空间是一个极大的浪费。当然,还有一些别的功能也会受到影响,但这是我最在意的一个。

另外,在 HyperOS 3 的开发者模式中已经找不到「关闭 MIUI 优化」这个选项了,虽然有用户反馈说可以通过重置设置状态之类的手段来让这个选项重新出现,但我觉得这并不是一个很好的解决方案。

2. 冻结「电量和性能」应用或替换为国际版

在 HyperOS 2 上,有用户反馈对「电量和性能」这个系统应用动刀可以解决 FCM 熄屏断连的问题。我尝试使用 adb shell 来冻结,经过我的测试这并不是一个有用的方案,并且它可能会导致一些系统调度异常,并且不要进入超级省电模式,因为这个模式的 UI 就是「电量和性能」这个应用提供的!!!

adb 命令如下

adb shell pm uninstall --user 0 com.miui.powerkeeper

如果你想恢复的话,可以通过下面的命令重新安装这个应用:

adb shell cmd package install-existing com.miui.powerkeeper

还有人反馈说可以通过替换为国际版的「电量和性能」应用来解决这个问题,但我没找到 HyperOS 3 国际版的「电量和性能」应用的安装包,下载小米 15 海外版的完整 Rom 解包后,这个应用的 apk 也没法直接覆盖更新或者通过 adb 安装。

覆盖更新失败

3. 解锁 Bootloader 以后使用 fcmfix 等 Xposed 模块

这个方案就。。。算了吧,虽然我在小米社区有 5 级账号,但我没有这个精力去参加小米高考(听说还停办了),况且解锁 Bootloader 以后可能还会面临支付软件无法使用等一系列问题,如果要掩盖相关痕迹又要折腾一番,感觉得不偿失了。

4. 使用 HeartbeatFixerForGCM

该软件已在 Google Play 下架并且长期没有更新,经测试在 HyperOS 3 没法阻止 FCM 在锁屏状态下被系统断开连接。

5. 使用 Gboard 保活 FCM

虽然不知道是什么原理,但有用户反馈说安装 Gboard 键盘可以让 FCM 在锁屏状态下保持连接并正常接收消息了。我也试了一下,确实在安装了 Gboard 并将它设置为默认输入法后,FCM 在锁屏状态下确实能保持连接了。

不过这个方案也不是很完美,毕竟我并不喜欢 Gboard 的输入体验,所以我不太能接受这个方案。

一些个人的小尝试

尽管我对安卓开发没多少经验,只有在大二做课设的时候接触过,但 AI 时代赋予了我 vibe coding 的能力

尝试使用 vibe coding 修改源码并编译

所以我也让 AI 基于 HeartbeatFixerForGCM 的开源代码修改了一些保活 FCM 的逻辑,大概是下面这些思路:

  • 输入法保活: 延续 Gboard 的思路,利用输入法服务的常驻特性来保持 FCM 连接,能够保活 FCM,但丢失了输入法的输入能力,pass
  • 通知监听:NotificationListener 作为系统监听角色,提升常驻概率,未果,pass
  • 前台服务:常驻通知 + FGS 提升进程存活优先级,未果,pass
  • 无障碍保活: 无障碍服务同样作为系统监听角色,提升常驻概率,未果,pass
  • VPN 保活:通过 VPN 服务的常驻特性来保持 FCM 连接,不知道有没有效果,但确实给我手机整断网了,pass

总之,这些尝试都没有成功,FCM 在锁屏状态下依然会被系统断开连接。

好像找到一个可行的方案了?

就在我山重水复疑无路,准备物色下一台手机的时候,我看到一篇小红书笔记中提到,可以先卸载更新「Google Play 服务」,再重新更新到最新版的方案。帖主的解释是先把国内优化版的 Play 服务卸载掉,再从 Play Store 安装一个没有被国内优化过的版本,能解决这个问题了。

虽然没法在 Play Store 上直接找到「Google Play 服务」这个应用,但可以通过手机浏览器搜索 「Google Play Services」,点开 google.com 上的那个链接,就可以自动跳转到 Play Store 上的「Google Play 服务」应用界面。你也可以直接点这里

我也试了一下,确实在卸载更新「Google Play 服务」以后,FCM 在锁屏状态下就能保持连接了。虽然这个方案听起来有点玄学且我也无法窥见真正起作用的原理,但既然有效果了,我也就不纠结了。

但就当我以为问题解决了,在写这篇文章的时候,我尝试重启手机,结果发现问题又回来了。并且在重启后,重复上面的卸载更新「Google Play 服务」的步骤,问题依然没有解决,这让我很苦恼,于是开始回忆之前的操作步骤,但始终没让我再复现之前的状态了。。。

等等,好像有保底

就在我和一位资深玩机网友讨论这个问题的时候,他提出国内 OS 确实会在熄屏后为了省电而断开 fcm 的长连接,但仍然会保留定时检查的机制,这与我在测试过程中的部分孤例似乎是吻合的。这个定时检查的间隔时长比较长,据他推测在 10~20 分钟左右。我自己也进行了一轮测试,流程是这样的

  1. 熄屏一分钟,使用小号往主号发送消息,等待 6 分钟后主号收到消息,通过小米手环的振动通知我消息到了
  2. 立刻再使用小号往主号发送消息,等待主号第二次收到消息,记录两次消息的时间差。

因为时间间隔比较长,所以我只测试了一轮半,第一轮的时间差是 28 分钟,第二轮的时间差达到了 38 分钟。

得出结论:在熄屏状态下,虽然 FCM 的连接会被系统断开,但系统会每隔 30 分钟左右(不准确数据)自动唤醒一次 FCM 来检查是否有新的消息,如果有的话就会收到通知了。

结论

目前来说,要在国内版的小米手机上接收 FCM 推送,只能在使用 Gboard + 实时推送 / 定时检查机制的保底方案中二选一了。

前者通过 Gboard 输入法的常驻特性来保持 FCM 连接,能够在熄屏状态下实时接收消息,但需要牺牲输入体验;后者则是通过系统定时唤醒 FCM 来检查是否有新的消息,虽然不需要牺牲输入体验了,但可能会有超过半小时的延迟。

参见

🔲 ☆

我没法访问 dl.google.com —— 记一次 TUN 下的网络 debug

如果大家对目前中国大陆境内的网络环境足够了解,应该就会知道 dl.google.com 在很多情况下是可以直连访问的。比如,你可以通过 google.cn/chrome/?standalone=1 这个 URL 直接在境内的网络环境下下载 Chrome 的离线安装包,最终的下载域名就是 dl.google.com

我平常的使用习惯是 24 小时开启代理工具的 TUN,让所有流量先经过一张虚拟网卡,再根据分流规则自动判断要不要走代理。这个习惯大部分时候都挺省心的——直到最近我用 yay 滚 Arch 的时候,突然遇到了 dl.google.com 的 SSL 连接建立失败。

yay 更新失败

而且不止是 yay,我的浏览器也返回了相同的结果:

Firefox 访问失败

当时我的第一反应是:是不是我那套分流规则又抽风了?(毕竟不是我自己写的,出事先甩锅很合理。)

规则确实是直连,这锅甩不掉

我特意去查了分流规则,针对 SNI 为 dl.google.com 的流量是直连访问的。

Mihomo的分流规则

这就很奇怪了。按理说:

  • dl.google.com 本身在国内网络环境里经常是能直连的
  • 规则也明确写了 DIRECT

说实话,这个问题我之前也遇到过,但那时手上有优先级更高的事,就直接关掉代理工具绕过了它完成更新。好在我现在刚处理完手头事情,正处于无事可做的状态,于是决定认真把这个坑填了。

解析到海外 IP 了

我先把代理工具的 fake-ip 关掉,换成真实 IP 解析(避免再引入额外变量),然后用 curl -vv 去访问 dl.google.com 的下载链接,看看它到底要连到哪里去。

curl -vv 的访问结果

现在回头看我能很笃定地说:这里解析出来的这个 IP 来自 Google 的海外 CDN,而不是国内机房/国内可达的那一类。

image-20260131070318960

如果大家不清楚的话:dl.google.com 针对国内访客的 DNS 解析结果,很多时候会返回国内可达的 IP(否则你也没法在境内直连下载)。而这里返回的这个海外 IP 在我这条网络上是不可达的;再加上我在喵喵工具里给 dl.google.com 配的是直连,于是就变成了:

DNS 给了一个「海外 IP」

  • 规则要求 DIRECT = 直连到一个我连不上的地方 = TLS 握手失败

所以这并不是「直连规则没生效」,而更像是:规则生效得非常彻底,但 DNS 把我带沟里了。

谁在负责回答 dl.google.com?

Mihomo 内核目前的 DNS 配置项主要是下面四个:

  1. nameserver: 默认解析服务器(大部分域名都走这里)
  2. direct-nameserver: 直连域名的解析服务器(较新版本才有)
  3. proxy-server-nameserver: 节点域名解析(跟这次没啥关系)
  4. default-nameserver: 用来解析 DNS 配置里「域名形式」的 nameserver(也先不展开)

dl.google.com 被规则指定为直连域名,所以 Mihomo 理论上应该优先参考 direct-nameserver;如果没设置,就回落到 nameserver

而我当时的 nameserver 配置是:

  • https://dns.alidns.com/dns-query

我当时的直觉很简单:既然解析结果像是从海外 CDN 池里出来的,那就先验证一下是不是这条阿里 DNS(DoH)返回的就是海外 IP。

直接查阿里 DoH,确实回了海外池

阿里 DoH 提供了一个 JSON 查询接口,所以我直接用 curl 去请求:

curl -s 'https://dns.alidns.com/resolve?name=dl.google.com&type=A'

DoH 解析结果

返回的 IP 就是我之前遇到的那个海外 IP。到这一步我基本可以确认:至少在我当前这条网络出口下,阿里 DNS 对 dl.google.com 的解析结果就是“那一类”我访问不到的 IP。

这个问题需要两个条件同时成立(缺一不可)

写到这里必须强调一下:这事并不是「阿里 DNS 永远解析错」这么简单,我后来做了一圈对照,发现它其实很“苛刻”:

**只有在「移动宽带」+「阿里 DNS(包括 223.5.5.5 或 alidns 的 DoH)」这两个条件同时成立时,问题才可能稳定复现。**两个条件缺一不可。

更具体一点就是:

  • 换成电信/联通的宽带:用同样的阿里 DNS,dl.google.com 的解析结果通常就正常
  • 还是移动宽带,但不用阿里 DNS:解析结果也通常正常
  • 移动宽带 + 阿里 DNS:高概率拿到海外池,然后直连就炸

我也用 itdog 做了下全国解析测试,移动网络下的复现比例确实更高。

itdog 测试结果

为什么会这样?老实说我没有能力给一个“全网唯一真相”的解释,我只能说现象非常一致,而且足够让我下结论:问题不是 TUN 本身,而是 TUN 下我的 DNS 选择把 dl.google.com 导向了一个在移动网络里不可达的地址池。

我最后怎么解决的?

既然问题出在「移动宽带 + 阿里 DNS」这个组合上,那解决方式也就很朴素了:别让 dl.google.com 继续走阿里 DNS 解析。

可以配置 direct-nameserver 或者 nameserver-policy,可以配置 119.29.29.29 等其他公共 DNS,或者干脆把 DNS 解析交给家里的路由器。

direct-nameserver:
  - 192.168.8.1

nameserver-policy:
  "dl.google.com": [119.29.29.29]

这么搞完之后,yay 更新恢复正常,浏览器也能直连访问 dl.google.com

参见

🔲 ☆

Vercel 的缓存控制,你注意过吗?

Vercel 默认的缓存配置其实并不合理,但鲜有人注意。

先看效果

图一

图二

分析

测试方案

这两张图都是我博客在 PageSpeed Insights 上测得的,测试步骤如下:

  1. 部署
  2. 在 PageSpeed Insights 进行第一次测试
  3. 等待 120s,防止 PageSpeed Insights 拿之前的结果糊弄你
  4. 进行第二次测试,取第二次测试的结果

取第二次结果的目的是为了让 PageSpeed Insights 所命中的 Vercel CDN 节点完成回源,并将内容缓存在 CDN 节点上,这样第二次访问的时候就会直接从 CDN 的缓存中得到结果,不需要回源。

那我们看图一的测试结果,正常吗?针对首页的单个 html 加载时长达到了 450ms,看着不算慢,但其实细究下来是有问题的。

Vercel 采用的是 Amazon 提供的全球 CDN 网络,在我们的首次访问之后,CDN 节点应当该已经缓存了首页的内容,第二次访问的时候应该是直接从 CDN 节点的缓存中获取内容。

合理的时长是多久呢?

  • TCP 建立连接的三次握手,需要 1.5 个往返时延(RTT),再加上 TLS 1.3 握手的 1 个 RTT,共计 2.5 个 RTT。
  • HTML 文件大小 18KB,初始拥塞窗口(IW)10 MSS ≈ 1460 字节 ≈ 14.6KB,理论上应该可以在两个 RTT 内传输完毕。

共计 4.5 个 RTT。

PageSpeed Insights 测试时使用的节点大概率是在美国,Amazon CDN 在美国的节点覆盖非常广泛,单个 RTT 时长控制在 5ms 以内绰绰有余,所以理论加载时长应该在 22.5ms 左右。加上 DNS 解析时长(这个也不多,因为两分钟前有过一次访问,这次不是冷启动)和一些不可控的网络抖动,50ms 以内应该是完全没有问题的。

但实际测得的时长却高达 450ms,差了近 9 倍,这就很不合理了。

我们再来看图二的结果,单个 HTML 加载时长降到了 41ms,完全符合预期。

为什么会有这么大的差异呢?原因就在于 Vercel 对缓存控制的设置上。

Vercel 的缓存控制

在 Vercel 上部署的网站,默认情况下,Vercel 会对 HTML 文件设置如下的缓存控制头:

cache-control: public, max-age=0, must-revalidate

这个设置的含义是:

  • public:响应可以被任何缓存区缓存,包括浏览器和 CDN。
  • max-age=0: 响应的最大缓存时间为 0 秒,意味着响应一旦被缓存后立即过期。
  • must-revalidate:一旦响应过期,缓存必须向源服务器验证其有效性。

结合这三个指令,Vercel 实际上是告诉 CDN 节点:你可以缓存这个 HTML 文件,但每次在使用缓存之前都必须回源验证其有效性。由于 max-age=0,缓存一旦存储就立即过期,因此每次请求都会触发回源验证。

尽管 HTTP/1.1 和 HTTP/2 中的缓存验证通常使用条件请求(如文件的 ETag 或 Last-Modified 头)来节省传输流量,但这仍然需要与源服务器进行往返通信,从而增加了额外的延迟开销。

所以,在 Vercel 默认配置下,任何请求的响应都不会被 CDN 节点直接缓存,大致的流程如下:

sequenceDiagram
    participant Client
    participant CDN
    participant Vercel
    Client->>CDN: 请求 HTML 文件
    CDN->>Vercel: 条件请求验证缓存
    Vercel->>CDN: 返回最新的 HTML 文件或 304 Not Modified
    CDN->>Client: 返回 HTML 文件

解决方案

要解决这个问题,我们需要调整 Vercel 上的缓存控制设置,使得 HTML 文件能够被 CDN 节点缓存一段时间,而不需要每次都回源验证。

Vercel 允许我们在项目的根目录下创建一个 vercel.json 文件以对 Vercel 的部署行为进行一系列的配置,其中就包括 HTTP 的响应头的配置。

我的博客采用 Nuxt.js 框架构建,生成的构建产物大概分为两类:

  1. HTML 文件:这些文件的内容可能会频繁变化,不能设置过长的缓存时间;
  2. 静态资源文件:包括 JavaScript、CSS 等,这些文件的文件名通常带有 hash 值,可以设置较长的缓存时间甚至被标记为不可变(immutable)。

在部署流程上,我的博客在每次推送后会先 Github Actions 中构建成静态页面,再部署到 Vercel 上,所以我在我项目的 public 目录下创建了 vercel.json 文件(这样 vercel.json 文件就会在构建产物的根目录),内容如下:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=0, s-maxage=600, must-revalidate"
        }
      ]
    },
    {
      "source": "/(.*)\\.(css|js)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

这里我对所有的 CSS 和 JS 文件设置了 max-age=31536000, immutable,这样这些静态资源文件就可以被浏览器和 CDN 长时间缓存。而对于所有其他文件(主要是 HTML 文件),我设置了 max-age=0, s-maxage=600, must-revalidate,这样 HTML 文件就可以被 CDN 缓存 10 分钟,在这 10 分钟内的请求都可以直接从 CDN 节点的缓存中获取内容,而不需要回源验证。

这样一来,经过修改后的缓存控制设置,HTML 文件的请求流程变成了:

sequenceDiagram
    participant Client
    participant CDN
    Client->>CDN: 请求 HTML 文件
    CDN->>Client: 直接返回缓存的 HTML 文件

从而大大减少了请求的延迟,提高了页面加载速度。

其他

Vercel 所采用的架构并不是传统的 「源站 - CDN」 架构,而是更接近 「全球多区域存储 + CDN边缘缓存」 的架构,所以即使是回源请求,Vercel 也会尽量从离用户最近的存储节点获取内容,从而减少延迟。但这并不意味着回源请求的延迟可以忽略不计,尤其是在追求极致的加载速度时,合理的缓存控制仍然是非常重要的。

参见

🔲 ☆

小记 —— Caddy 在 Layer 4 上的流量代理实践

背景

在我的一台优化线路 vps 上,我的 443 端口要承担两个职责

  1. 作为我博客对中国大陆境内访客的服务提供者,同时承担 https 流量加解密和 static server 的职责
  2. 把某些特殊用途的流量特征通过一些手段伪装成某些知名、常见、且被广泛允许的站点的 https 流量 (没错,是 Reality)

因此,我需要一个能够在同一台服务器的同一端口上同时处理这两种职责的方案。

方案选择

其实我很早就知道 Nginx 的 stream 关键词可以实现 Layer 4 (即 TCP 字节流原样转发)下基于 SNI 识别实现的分流功能,但我其实一直是 Caddy 的忠实用户,写了不少 Caddy 相关的博文。因此,尽管 Nginx 在不久前已经支持了 ACME v2,但 Caddyfile 的简洁和易用性依然让我更倾向于使用 Caddy 来实现这个功能。

经过一番查阅,Caddy 最新版本(v2.10)并不支持 Layer 4 的流量代理功能,但有一个名为 caddy-l4 的社区模块可以实现这个功能,在 Github 上有 1.5k stars,且最近也有更新维护,于是我决定尝试使用这个模块来实现我的需求。

安装

尽管 Caddy 官方提供的 APT 源中的 Caddy 版本并不包含 caddy-l4 模块,但我仍然建议先通过 APT 安装 Caddy 的基础版本,然后再通过 Caddy 官方提供的在线构建页面选择需要的模块来生成自定义的 Caddy 二进制文件,下载后替换掉系统中的 Caddy 可执行文件。这样做的好处是可以方便地完成 systemd 服务的配置。但注意关闭 Caddy 的 APT 源,以免后续自动更新覆盖掉自定义编译的版本。

后续更新可以通过

caddy upgrade

命令来完成,caddy 会自动列出当前二进制文件所包含的模块,并自动触发官网的在线构建来生成新的二进制文件并进行替换,只需手动重启 systemd 服务即可完成更新。

如果 Caddy 官方提供的在线构建失败(最近挺不稳定的),可以参考文档使用 xcaddy 在本地编译 Caddy:

xcaddy build --with github.com/mholt/caddy-l4

配置

这是我先前博客站点的 Caddyfile 配置:

zhul.in {
    root * /var/www/zhul.in

    encode zstd gzip
    file_server

    handle_errors {
            rewrite * /404.html
            file_server
    }
}

www.zhul.in {
    redir https://zhul.in{uri}
}

zhul.in 和 www.zhul.in 都占用了 80 和 443 端口,因此需要把这两个站点的 443 端口的监听改到其他端口,把 443 端口交给 caddy-l4 来处理。

修改后的 Caddyfile 如下:

http://zhul.in:80, https://zhul.in:8443 {
    root * /var/www/zhul.in

    encode zstd gzip
    file_server

    handle_errors {
            rewrite * /404.html
            file_server
    }
}

http://www.zhul.in:80, https://www.zhul.in:8443 {
    redir https://zhul.in{uri}
}

随后,添加 caddy-l4 的配置:

{
    layer4 {
        :443 {
            @zhulin tls sni zhul.in www.zhul.in
            route @zhulin {
                proxy 127.0.0.1:8443
            }

            @proxy tls sni osxapps.itunes.apple.com
            route @proxy {
                proxy 127.0.0.1:20443
            }
        }
    }
}

这里的写法还挺简单的,首先在 layer4 块中监听 443 端口,然后通过 @name tls sni domain 的方式定义基于 SNI 的匹配规则,随后通过 route @name 定义匹配到该规则时的处理方式,这里使用 proxy ip:port 来实现流量的转发。

由于我的妙妙流量伪装成了 Apple 的 itunes 流量,因此在上面的配置中的 SNI 特征是 osxapps.itunes.apple.com,这些流量会被转发到本地的 20443 端口,由另一个奇妙服务来处理。

caddy-l4 还提供了一些其他的匹配方式和处理方式,具体可以参考他们在 Github 中给到的 examples

完成配置后,重启 Caddy 服务:

sudo systemctl restart caddy

参见

🔲 ☆

你的域名后缀拖慢你的网站速度了嘛?——再谈 DNS 冷启动

上一篇博客中,我提到过一个核心观点——对于流量少、访客的地理位置不集中的小型站点,DNS 冷启动不是偶发的“意外”,而是一种被动的“常态”。

对于大多数站长而言,自己的站点流量不是一时半刻就能提上去的,因此我们的访客大概率都要走完一遍完整的 DNS 解析过程。上一篇博客中我提到过更改为距离访客物理位置更近的权威 DNS 服务器来提升速度,但 TLD(域名后缀)的 Nameservers 是我们无法改变的,也就是下图中红色背景的那一段解析过程。

sequenceDiagram
    autonumber
    participant User as 用户/浏览器
    participant Local as 本地DNS<br>递归解析器
    participant Root as 根域名服务器
    participant TLD as 顶级域服务器<br>(TLD Server)
    participant Auth as 权威DNS服务器

    Note over User,Auth: DNS 递归查询完整流程

    User->>Local: 查询域名 www.example.com
    Note over Local: 检查缓存 (MISS)

    Local->>Root: 查询 .com 的 TLD 服务器
    Root-->>Local: 返回 .com TLD 服务器地址

    %% --- 重点高亮区域开始 ---
    rect rgb(255, 235, 235)
        Note right of Local: ⚠️ 本文核心讨论区域 <br> (TLD 解析时延)
        Local->>TLD: 查询 example.com 的权威服务器

        Note left of TLD: 这里的物理距离与 Anycast 能力<br>决定了是否存在数百毫秒的延迟

        TLD-->>Local: 返回 example.com 的权威服务器地址
    end
    %% --- 重点高亮区域结束 ---

    Local->>Auth: 查询 www.example.com 的 A 记录
    Auth-->>Local: 返回 IP 地址 (e.g., 1.1.1.1)

    Note over Local: 缓存结果 (TTL)

    Local-->>User: 返回最终 IP 地址

    User->>Auth: 建立 TCP 连接 / HTTP 请求

所以,如果你还没有购买域名,但想要像个 geeker 一样追求极致的首屏加载(哪怕你并没有多少访客),你该选择哪个 TLD 呢?

简单测试

一个简单的方法是,直接去 ping TLD 的 nameserver,看看访客所请求的公共 DNS 服务器在这一段解析中所花费的时常。

以我的域名 zhul.in 为例,在 Linux 下,可以通过 dig 命令拿到 in 这个 TLD 的 Nameserver

dig NS in.

in 的 TLD Nameservers

随后可以挑选任何一个 Nameserver(公共 DNS 服务器其实有一套基于历史性能的选择策略),直接去 ping 这个域名

距离 TLD Nameserver 的延迟

我这里的网络环境是杭州移动,如果我在我的局域网开一台 DNS 递归服务器,这个结果就是在上面那张时序图中红色部分所需要时长的最小值(DNS 服务器还需要额外的时长去处理请求)。

借助一些网站提供的多个地点 ping 延迟测试,我们可以推测这个 TLD 在全球哪些国家或地区部署了 Anycast(泛播)节点,下图为 iplark.com 提供的结果。

in 的 TLD Nameserver 在全球范围内的 ping 值

可以推测,in 的 TLD Nameserver 起码在日本、香港、美国、加拿大、欧洲、澳大利亚、巴西、印度、南非等多地部署了 Anycast 节点,而在中国大陆境内的延迟较高。


作为对比,我们可以通过同样的方法再看看 cn 域名的 TLD Nameserver 的 Anycast 节点。

cn 的 TLD Nameserver 在全球范围内的 ping 值

经过 itdog.cn 的测试,推测 cn 域名的 TLD Nameserver 可能仅在北京有节点。

更进一步的的实验方案

上面的测试方法只是一个简易的判断方法,在现实中会有很多的外部因素影响 DNS 冷启动的解析时长:

  • 公共 DNS 服务器和 TLD Nameserver 之间存在 peer,他们的通信非常快
  • TLD Nameserver 的性能差,需要额外的几十 ms 去处理你的请求
  • TLD 的几个 Nameserver 有快慢之分,而你选用的公共 DNS 服务器能根据历史数据选择较快的那个
  • ...

所以,我们需要有一个基于真实的 DNS 解析请求的测试方案

对于 DNS 冷启动相关的测试一直以来存在一个困境——公共 DNS 服务器不归我们管,我们无法登陆上去手动清除它的缓存,因此所有的测试都只有第一次结果才可能有效,后续的请求会直接打到缓存上。但这一次我们测试的是公共 DNS 服务器到 TLD Nameserver 这一段的延迟,在 Gemini 的提醒下,我意识到可以在不同地区测试公共 DNS 对随机的、不存在的域名的解析时长,这能够反应不同 TLD 之间的差异。

所以,测试代码在下面,你可以使用常见的 Linux 使用 bash 执行这段代码,需要确保装有 dig 和 shasum 命令,并且推荐使用 screen / tmux 等工具挂在后台,因为整个测试过程可能会持续十几分钟。如果你所采用的网络环境在中国大陆境内,我建议你把代码中的公共 DNS 服务器换成 223.5.5.5 / 119.29.29.29 ,应该会更符合境内访客的使用环境。

#!/bin/bash

# ================= 配置区域 =================
# CSV 文件名
OUTPUT_FILE="dns_benchmark_results.csv"

# DNS 服务器
DNS_SERVER="8.8.8.8"

# 待测试的 TLD 列表
# 包含:全球通用(com), 国别(cn, de), 热门技术(io, xyz), 以及可能较慢的后缀
TLDS_TO_TEST=("com" "net" "org" "cn" "in" "de" "cc" "site" "ai" "io" "xyz" "top")

# 每个 TLD 测试次数
SAMPLES=1000

# 每次查询间隔 (秒),防止被 DNS 服务器判定为攻击
# 1000次 * 0.1s = 100秒/TLD,总耗时约 15-20 分钟
SLEEP_INTERVAL=0.1
# ===========================================

# 初始化 CSV 文件头
echo "TLD,Domain,QueryTime_ms,Status,Timestamp" > "$OUTPUT_FILE"

echo "============================================="
echo "   DNS TLD Latency Benchmark Tool"
echo "   Target DNS: $DNS_SERVER"
echo "   Samples per TLD: $SAMPLES"
echo "   Output File: $OUTPUT_FILE"
echo "============================================="
echo ""

# 定义进度条函数
function show_progress {
    # 参数: $1=当前进度, $2=总数, $3=当前TLD, $4=当前平均耗时
    let _progress=(${1}*100/${2})
    let _done=(${_progress}*4)/10
    let _left=40-$_done

    # 构建填充字符串
    _fill=$(printf "%${_done}s")
    _empty=$(printf "%${_left}s")

    # \r 让光标回到行首,实现刷新效果
    printf "\rProgress [${_fill// /#}${_empty// /-}] ${_progress}%% - Testing .${3} (Avg: ${4}ms) "
}

# 主循环
for tld in "${TLDS_TO_TEST[@]}"; do
    # 统计变量初始化
    total_time_accum=0
    valid_count=0

    for (( i=1; i<=${SAMPLES}; i++ )); do
        # 1. 生成随机域名 (防止缓存命中)
        # 使用 date +%N (纳秒) 确保足够随机,兼容 Linux/macOS
        RAND_PART=$(date +%s%N | shasum | head -c 10)
        DOMAIN="test-${RAND_PART}.${tld}"
        TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")

        # 2. 执行查询
        # +tries=1 +time=2: 尝试1次,超时2秒,避免脚本卡死
        result=$(dig @${DNS_SERVER} ${DOMAIN} A +noall +stats +time=2 +tries=1)

        # 提取时间 (Query time: 12 msec)
        query_time=$(echo "$result" | grep "Query time" | awk '{print $4}')
        # 提取状态 (status: NXDOMAIN, NOERROR, etc.)
        status=$(echo "$result" | grep "status:" | awk '{print $6}' | tr -d ',')

        # 3. 数据清洗与记录
        if [[ -n "$query_time" && "$query_time" =~ ^[0-9]+$ ]]; then
            # 写入 CSV
            echo "${tld},${DOMAIN},${query_time},${status},${TIMESTAMP}" >> "$OUTPUT_FILE"

            # 更新统计
            total_time_accum=$((total_time_accum + query_time))
            valid_count=$((valid_count + 1))
            current_avg=$((total_time_accum / valid_count))
        else
            # 记录失败/超时
            echo "${tld},${DOMAIN},-1,TIMEOUT,${TIMESTAMP}" >> "$OUTPUT_FILE"
            current_avg="N/A"
        fi

        # 4. 显示进度条
        show_progress $i $SAMPLES $tld $current_avg

        sleep $SLEEP_INTERVAL
    done

    # 每个 TLD 完成后换行
    echo ""
    echo "✅ Completed .${tld} | Final Avg: ${current_avg} ms"
    echo "---------------------------------------------"
done

echo "🎉 All Done! Results saved to $OUTPUT_FILE"

测试结果

免责声明:以下测试结果仅供参考,不构成任何购买推荐,且仅代表测试当日(2025.11.24)的网络情况,后续不会进行跟进。DNS 冷启动对于大型站点几乎没有影响,仅小站需要关注。本次测试中,所有境内检测点使用 223.5.5.5 作为 DNS 服务器,境外检测点使用 8.8.8.8。

测试点/延迟(ms) .com .net .org .cn .in .de .cc .site .ai .io .xyz .top
🇨🇳 上海腾讯云 438 429 470 30 535 353 476 454 367 485 444 43
🇨🇳 北京腾讯云 425 443 469 17 350 420 466 647 582 461 559 9
🇭🇰 香港 Yxvm 75 75 363 227 6 11 61 6 33 126 5 7
🇨🇳 彰化(台湾) Hinet 90 87 128 213 59 38 76 37 73 94 36 47
🇯🇵 大阪 Vmiss 20 19 244 309 15 24 17 35 19 65 37 90
🇸🇬 新加坡 Wap 6 9 139 398 6 10 7 17 7 110 17 66
🇺🇸 洛杉矶 ColoCrossing 7 7 307 137 4 64 5 62 5 49 47 231
🇩🇪 杜塞尔多夫 WIIT AG 16 17 288 82 75 15 14 24 66 73 24 306
🇦🇺 悉尼 Oracle 33 31 12 338 7 13 121 7 10 9 7 191

通过上面的数据,我们可以看到 .cn 和 .top 是所有测试的域名后缀中在中国大陆境内解析速度最快的,但选择 .cn 和 .top 意味着你需要牺牲其他地区访客的解析速度。而像 .com、.net、.org 这些通用的域名后缀在全球绝大部分地区表现良好,而在中国大陆境内的解析速度则相对较慢,因为他们没有在大陆境内部署 Anycast 节点。在 DNS 冷启动的场景下(如果你的站点访客少,那几乎每次访问都是冷启动),首屏加载时间会因此增加 500ms 甚至更多。

经 v2ex 的网友 Showfom 提醒,GoDaddy 作为注册局掌握的部分 TLD 的 Nameserver 同样在中国大路境内拥有 Anycast 节点,比如 .one、.tv、.moe 等。另, Amazon Registry Services 旗下的 .you 域名经我测试也有境内的 Anycast 节点。其他域名后缀可自行测试。

你可以点击这里下载完整的测试结果 CSV 文件进行进一步的分析。

🔲 ☆

DNS 冷启动:小型站点的“西西弗斯之石”

当我们谈论网站性能时,我们通常关注前端渲染、资源懒加载、服务器响应时间(TTFB)等。然而,在用户浏览器真正开始请求内容之前,有一个至关重要却鲜少在性能优化方面被提及的部分—— DNS 解析。对于默默无闻的小型站点而言,“DNS Cache Miss”(缓存未命中)或我称之为“DNS 冷启动”,会成为绕不过去的性能瓶颈,也就是本文标题所提到的“西西弗斯之石”。

神话的隐喻:DNS 解析的漫长旅程

要理解这块“石头”的重量,我们必须重温 DNS 解析的完整路径。这并非一次简单的查找,而是一场跨越全球的接力赛:

  1. 起点:公共 DNS 服务器 — 用户发出请求,公共 DNS 服务器尝试在缓存中寻找答案。
  2. 首次“推石”:根服务器 — 缓存缺失(Cache Miss),公共 DNS 服务器被引向全球 13 组根服务器。
  3. 第二程:TLD 服务器 — 根服务器指向特定后缀(如 .com)的顶级域名服务器。
  4. 第三程:权威服务器 — TLD 服务器指向网站域名最终的“管家”——权威 DNS 服务器。
  5. 终点: 权威服务器返回最终的 IP 地址,再由公共 DNS 服务器返回给用户。
sequenceDiagram
    participant User as 用户/浏览器
    participant Local as 本地DNS<br>递归解析器
    participant Root as 根域名服务器
    participant TLD as 顶级域服务器<br>(.com, .org等)
    participant Auth as 权威DNS服务器

    Note over User,Auth: DNS递归查询完整流程

    User->>Local: 1. 查询域名<br>www.example.com
    Note over Local: 检查缓存<br>未找到记录

    Local->>Root: 2. 查询 .com 的TLD服务器
    Root-->>Local: 3. 返回 .com TLD服务器地址

    Local->>TLD: 4. 查询 example.com 的权威服务器
    TLD-->>Local: 5. 返回 example.com 的权威服务器地址

    Local->>Auth: 6. 查询 www.example.com 的A记录
    Auth-->>Local: 7. 返回 IP地址 (e.g., 1.1.1.1)

    Note over Local: 缓存结果<br>(根据TTL设置)

    Local-->>User: 8. 返回最终IP地址

    Note over User,Auth: 后续流程
    User->>Auth: 9. 使用IP地址建立TCP连接<br>开始HTTP请求

对于首次长时间未访问的请求,这个过程意味着至少 4 次网络往返(RTT),而在涉及到 CNAME 等情况时则会更多。对于那些拥有完美缓存的大型网站来说,这块石头可能已被别人推到了山顶;但对小型站点,它总是在山脚等待它的西西弗斯。

多重世界:Anycast 的镜像迷宫

“既然 DNS 冷启动的代价如此之高,那我能否使用脚本定时访问自己的网站,提前让公共 DNS 缓存预热起来呢?”——这是我曾经设想的解题思路。

然而,这一思路在现代互联网的 Anycast(泛播)架构下,往往徒劳无功。

Anycast 的核心理念是:同一个 IP 地址在全球多个节点同时存在,用户请求会被路由到“距离最近”或“网络路径最优”的节点。

这意味着,Google DNS (8.8.8.8) 、Cloudflare DNS (1.1.1.1)、阿里 DNS (223.5.5.5)、腾讯 DNS (119.29.29.29) 等公共 DNS 服务器背后并不是一台中心化的服务器,而是一组分布在世界各地、动态路由的节点集群。

于是问题出现了:

  • 我在上海运行的预热脚本,也许命中了 223.5.5.5 的上海节点;
  • 但来自北京的访问者,却会被路由到 223.5.5.5 的北京节点;
  • 这两个节点的缓存,彼此独立、互不共享。

从站长的视角来看,DNS 缓存不再是一个可预测的实体,而是分裂成一片片地理隔离、随时可变的“镜像迷宫”。

每个访客都在不同的山脚下推着自己的那块石头,仿佛世界上有成千上万个西西弗斯,孤独地在各自的路径上前行。

不可控的缓存与「冷启动的常态化」

这也解释了为什么即便一个小型网站有规律地被脚本访问,仍可能在真实访客那里出现明显的 DNS 延迟。因为「预热」只是局部生效 —— 它温暖的是某一个任播节点的缓存,而不是整个网络的全貌。而当 TTL 到期或缓存被公共 DNS 服务器采用 LRU 等算法清理时,这份温度也会悄然散去。

从宏观上看,这让“小流量站点”陷入了某种宿命循环:

  1. 因访问量低,缓存不易命中;
  2. 因缓存不命中,解析耗时高;
  3. 因解析耗时高,首屏性能差,用户更少访问;
  4. 因用户更少访问,缓存更难命中。

冷启动不再是偶发的“意外”,而是一种被动的“常态”。

我们能否让石头变轻?—— 减缓冷启动影响的策略

西西弗斯的困境看似无解,但我们并非完全无能为力。虽然无法彻底消除 DNS 冷启动,但通过一系列策略,我们可以显著减轻这块石头的重量,缩短它每次滚落后被推上山顶的时间。

权衡的艺术:调整 DNS TTL (Time-To-Live)

TTL(生存时间)是 DNS 记录中的一个关键值,它告知递归解析器(如公共 DNS、本地缓存)可以将一条解析记录缓存多久,尽管他们可能会被 LRU 算法淘汰。

拉长 TTL 可以有效提高缓存的命中率,减少 DNS 冷启动的情况,尽可能让西西弗斯之石保留在山顶上。

但拉长 TTL 是以牺牲灵活性作为代价的:如果你因为某些原因需要更换域名做对应的 IP 地址,过长的 TTL 可能会导致访客在很长一段时间内取得的都是已经失效的 IP 地址。

选择更快的“信使”:使用合适的权威 DNS 服务器

DNS 解析的最后一公里——从公共 DNS 服务器到你的权威 DNS 服务器——的耗时同样至关重要。如果你的域名所采用的 Nameserver 服务响应缓慢、全球节点稀少、又或者距离访客所请求的公共 DNS 服务器距离太远,那么即使用户的公共 DNS 节点就在身边,整个解析链条依然会被这最后一环拖慢。

如果我正在写的是一篇英文博客,那么我只需要说把 Nameserver 换成 Cloudflare、Google 等一线大厂就完事了。这些大厂提供免费的权威 DNS 托管业务,且在全球各地拥有大量节点,在这方面是非常专业且值得信赖的。

但我现在正在使用简体中文,根据我的博客统计数据,我的读者大多来自中国大陆,他们的站点访客大多也来自中国大陆,他们请求的公共 DNS 服务器大概率也都部署在中国大陆,而 Cloudflare/Google Cloud DNS 完全没有权威 DNS 服务器的中国大陆节点,这会拖慢速度。所以如果你的访客主要来自中国大陆境内,或许可以试试阿里云或者 Dnspod,他们主要的权威 DNS 服务器节点都在中国大陆境内,这在理论上可以减少公共 DNS 服务器与 权威 DNS 服务器之间的通信时长。

结语:推石头的人

DNS 冷启动的问题,从未有完美的解决方案。它像是互联网架构中注定存在的一段“延迟的诗意”——每个访问者都从自己的网络拓扑出发,沿着看不见的路径,一步步推着那块属于自己的石头,直到抵达你的服务器山顶,换得屏幕上第一个像素的亮起。

对小型站点而言,这或许是命运的重量;但理解它、优化它、监测它,便是我们在这条漫长上坡路上,为石头磨出更光滑的棱角。

参见

🔲 ☆

HTTP/2 Server Push 已事实性“死亡”,我很怀念它

我最近一阵子在重构我的博客,恰巧之前一阵子准备秋招的时候背八股时看到了 HTTP/2 的服务端推送,于是便尝试在部署阶段为我的博客配置好 HTTP/2 的服务端推送,试图以此来进一步优化首屏加载速度。

HTTP/2 服务端推送为什么能提升首屏加载速度

如下图,在传统的 HTTP/1.1 中,浏览器会先下载 index.html 并完成第一轮解析,然后再从解析出的数据中拿到 css/js 资源的 url,再进行第二轮请求,在 tcp/tls 连接建立后最小需要两个 RTT 才能取回完整渲染页面所需的资源。

sequenceDiagram
    participant Browser
    participant Server

    Browser->>Server: GET /index.html
    Server-->>Browser: 200 OK + HTML
    Browser->>Server: GET /style.css
    Browser->>Server: GET /app.js
    Server-->>Browser: 200 OK + CSS
    Server-->>Browser: 200 OK + JS

    Note over Browser: 浏览器必须等 HTML 下载并解析后<br/>才能发起后续请求,增加往返延迟 (RTT)

而在 HTTP/2 的设想中,流程则是像下面这张图一样。当浏览器请求 index.html 时,服务端可以顺带将 css/js 资源一起推送给客户端,这样在 tcp/tls 连接建立后最小只需要一个 RTT 就可以将页面渲染所需的资源取回。

sequenceDiagram
    participant Browser
    participant Server

    Browser->>Server: GET /index.html
    Server-->>Browser: 200 OK + HTML
    Server-->>Browser: PUSH_PROMISE /style.css
    Server-->>Browser: PUSH_PROMISE /app.js
    Server-->>Browser: (推送) style.css + app.js 内容

    Note over Browser: 浏览器收到资源前置推送<br/>减少请求轮次与首屏延迟

为了在 HTTP/1.1 中尽可能减少后续的请求,前端开发者尝试了非常多的优化手段,正如 Sukka 在《静态资源递送优化:HTTP/2 和 Server Push》一文中所讲:

关键资源、关键渲染路径、关键请求链的概念诞生已久,异步加载资源的概念可谓是老生常谈:懒加载图片、视频、iframe,乃至懒加载 CSS、JS、DOM,懒执行函数。但是,关键资源递送的思路却依然没有多少改变。

HTTP/2 的 Server Push 创造了新的资源递送思路,CSS/JS 等资源不用随着 html 一起递送也能在一个 RTT 内被传送到客户端,而这一部分资源可以被浏览器缓存起来,不被 html 那较短的 TTL 所限制。

初步方案

既然理清了 HTTP/2 服务端推送的优势,于是准备着手优化。我的博客是纯静态的,通过 DNS 进行境内外分流:境内流量会访问到 DMIT 一台带有 cmin2/9929 网络优化的 vps 上,通过 caddy 提供服务;境外流量则是直接打到 vercel,借助 Amazon 的 CDN 为全球网络提供边缘加速。网络架构大概是下面这个样子:

graph TD
    A[博客访客] --> B[发起DNS解析请求]
    B --> C[DNSPod 服务]
    C -->|境内访客:智能分流| D[网络优化 VPS]
    C -->|境外访客:智能分流| E[Vercel 平台]
    D --> F[Caddy]
    F --> G[返回博客内容给境内访客]
    E --> H[返回博客内容给境外访客]

Caddy 可以通过 http.handlers.push 模块实现 HTTP/2 的服务端推送,在 Caddyfile 中我们可以编写简单的推送逻辑,这没问题;vercel 平台则没有给开发者提供 HTTP/2 服务端推送的配置项,但好在我是静态博客,对平台依赖性不强,考虑迁移到 Cloudflare Workers,五年前就有开发者实现了

客户端的支持情况

历史上主流浏览器引擎(Chrome/Chromium、Firefox、Edge、Safari)曾普遍支持服务器推送技术。

2020 年 11 月,谷歌宣布计划在其 Chrome 浏览器的 HTTP/2 及 gQUIC(后发展为HTTP/3)实现中移除服务器推送功能

2022 年 10 月,谷歌宣布计划从Chrome浏览器中移除服务器推送功能,指出该扩展在实际应用中性能不佳、使用率低且存在更优替代方案。Chrome 106 成为首个默认禁用服务器推送的版本。

2024 年 10 月 29 日,Mozilla 发布了 Firefox 132版本,因“与多个网站存在兼容性问题”移除了对HTTP/2服务器推送功能的支持。

至此,主流浏览器对 HTTP/2 服务端推送(Server Push)的支持已全部终结。从最初被视为“减少往返延迟、优化首屏加载”的创新特性,到最终被全面弃用,HTTP/2 推送的生命周期不过短短数年,成为 Web 性能优化历史上的一次重要实验。

替代方案

1. HTTP 103 Early Hints

103 Early Hints 是对服务端推送最直接的“继任者”。它是一个信息性的 HTTP 状态码 (Informational Response),允许服务器在生成完整的 HTML 响应(例如,状态码为 200 OK)之前,先发送一个带有 Link 头部的“早期提示”响应。

这个 Link 头部可以告诉浏览器:“嘿,我还在准备主菜(HTML),但你可以先去把配菜(CSS、JS)准备好”。这样,浏览器就能利用服务器的“思考时间”提前开始下载关键资源或预热到所需源的连接,从而显著缩短首屏渲染时间。

与服务端推送的对比:

  • 决策权在客户端:Early Hints 只是“提示”,浏览器可以根据自身缓存情况、网络状况等因素决定是否采纳该提示。这就解决了服务端推送最大的痛点——服务器无法知晓客户端缓存而导致推送冗余资源。
  • 兼容性更好:它是一种更轻量、更易于中间代理服务器理解和传递的机制。

103 Early Hints 对动态博客很有意义,在后端进行计算之前先把需要的资源通过 103 响应告知浏览器,让浏览器先取回其他资源,再等待后端返回最终的 html;而对于我这种产物都是预构建好的静态博客,完全没有任何意义,网关有那个发 103 响应的闲工夫完全可以把 html 直接发过去了。

2. 资源提示(Resource Hints): Preload & Prefetch

早在服务端推送被弃用前,通过 <link>标签实现的资源提示就已经是前端性能优化的常用手段。它们将资源加载的提示声明在 HTML 中,由浏览器主导整个过程。

  • <link rel="preload">: 用于告诉浏览器当前页面必定会用到的资源,请以高优先级立即开始加载,但加载后不执行。比如,隐藏在 CSS 深处的字体文件或由 JS 动态加载的首屏图片。通过 Preload,可以确保这些关键资源能尽早被发现和下载,避免渲染阻塞。
  • <link rel="prefetch">: 用于告诉浏览器用户在未来可能访问的页面或用到的资源,请在浏览器空闲时以低优先级在后台下载。例如,在文章列表页 prefetch 用户最可能点击的文章页面的资源,从而实现近乎“秒开”的跳转体验。

Preload 和 Prefetch 将资源加载的控制权完全交给了开发者和浏览器,通过声明式的方式精细化管理资源加载的优先级和时机,是目前最成熟、应用最广泛的资源预加载方案,但仍然逃不过 2 RTT 的魔咒

尾声:写给一个理想主义者的挽歌

写到最后,我终究是没能为我的博客配上 HTTP/2 服务端推送。

HTTP/2 Server Push 已事实性“死亡”,我很怀念它。

在一个理想模型里,当浏览器请求 HTML 时,服务器顺手将渲染所需的 CSS 和 JS 一并推来,将原本至少两次的往返(RTT)干脆利落地压缩为一次。这是一个如此直接、如此漂亮的解决方案,几乎是前端工程师面对首屏渲染延迟问题时梦寐以求的“银弹”。它背后蕴含的是一种雄心勃勃的魄力:试图由服务端一次性地、彻底地解决“关键请求链”的延迟问题。

但 Web 的世界终究不是一个理想的实验室。它充满了缓存、重复访问的用户、以及形形色色的网络环境。

服务端推送最大的魅力,在于它的“主动”,而它最大的遗憾,也恰恰源于这份“主动”。它无法知晓浏览器缓存中是否早已静静躺着那个它正准备满腔热情推送的 style.css 文件。为了那一小部分首次访问用户的极致体验,却可能要以浪费更多再次访问用户的宝贵带宽为代价。

Web 的演进最终选择了一条更稳妥、更具协作精神的道路。它将决策权交还给了最了解情况的浏览器,整个交互从服务器的“我推送给你”,变成了服务器的“我建议你拿”,再由浏览器自己定夺。这或许不够浪漫,不够极致,但它更普适,也更健壮。

所以,我依然会怀念那个雄心勃勃的 Server Push。它代表了一种对极致性能的纯粹追求,一种美好的技术理想主义。尽管它已悄然淡出历史舞台,但它所指向的那个关于“速度”的梦想,早已被 103 Early Hints 和 preload 以一种更成熟、更懂得权衡的方式继承了下来。

参见

🔲 ☆

【翻译】GLWTPL——祝你好运开源许可证

说实话,当我第一次看见 GLWTPL( Good Luck With That Public License ) 的时候,我差点把嘴里的饭给喷出来了,这是一个非常有意思的开源许可证。原文请直接戳原仓库 -> https://github.com/me-shaon/GLWTPL

如果你对你的代码有这样的感觉:

当我写下这段代码的时候,只有上帝和我知道我在写什么。
现在只有上帝知道了。

那不如来考虑一下将这份开源许可证添加到你的项目中!

并且,祝你未来的自己、人类同胞、外星人或人工智能机器人(可以编码并会毁灭人类)——实际上是任何敢于参与你的项目的人好运。

good-luck.gif

当然,它还有一个脏话版本。干杯!

可能的使用场景

  • 你写了一些你并不为此自豪的代码,但你想要将它开源。
  • 你想要将你写的代码“放生”,但不想为此负任何责任。
  • “无论如何我都已经写完了”,并且你没有时间/意图对你的代码进行修复、修改或改进。
  • 想要将自己参加的黑客马拉松/代码竞赛的代码打造成一个爆火的仓库?该使用什么开源许可证?这就是为你量身打造的开源许可证!
  • 你的大学课设或科研工作与这份许可证是天作之合。

一些翻译版本

本译文翻译于 2023 年 11 月 12 日,日后大概率也不会对本文进行任何改进,故也采用 GLWTPL 向所有人授权。


此协议的英文原版:

               GLWT(Good Luck With That) Public License
                 Copyright (c) Everyone, except Author

Everyone is permitted to copy, distribute, modify, merge, sell, publish,
sublicense or whatever they want with this software but at their OWN RISK.

                            Preamble

The author has absolutely no clue what the code in this project does.
It might just work or not, there is no third option.


                GOOD LUCK WITH THAT PUBLIC LICENSE
   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION, AND MODIFICATION

  0. You just DO WHATEVER YOU WANT TO as long as you NEVER LEAVE A
TRACE TO TRACK THE AUTHOR of the original product to blame for or hold
responsible.

IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

Good luck and Godspeed.

此协议在 Github 上的中文翻译版本:

GLWT(Good Luck With That,祝你好运)公共许可证
版权所有© 每个人,除了作者

任何人都被允许复制、分发、修改、合并、销售、出版、再授权或
任何其它操作,但风险自负。

作者对这个项目中的代码一无所知。
代码处于可用或不可用状态,没有第三种情况。


                祝你好运公共许可证
            复制、分发和修改的条款和条件

0 :在不导致作者被指责或承担责任的情况下,你可以做任何你想
要做的事情。

无论是在合同行为、侵权行为或其它因使用本软件产生的情形,作
者不对任何索赔、损害承担责任。

祖宗保佑。
🔲 ☆

为什么我不推荐Manjaro

说起Linux发行版,很多人都会去推荐Manjaro给新手使用,原因很简单——安装简单、有庞大的AUR和ArchlinuxCN提供软件、有丰富的ArchWiki以供新手查阅。那么,为什么大多数Archlinux用户(包括我)始终不推荐Manjaro作为自己使用的发行版呢。


首先来了解一下两款Linux发行版

Archlinux

Archlinux是一款滚动发行版,所有的软件全部都基于上游最新的源代码进行编译,源内也仅仅保留最新版本,是最为激进的发行版之一,甚至或许没有之一。

Manjaro

Manjaro是一款基于Archlinux的滚动发行版,部分软件同样基于上游源代码编译,同时也有部分软件包直接从Archlinux源内直接拿二进制包。与Archlinux不同的是,Manjaro大部分软件更新相比Archlinux会滞后一个星期,一些比较重要的软件甚至会滞后两个星期以上(比如Python3.9就滞后了19天)以保证稳定性。(虽然我目前观察下来这个稳定性就是出现Bug和修复Bug都比Archlinux慢一个礼拜)


接下来就是正文

Archlinux 和 Manjaro 都不适合Linux小白

Archlinux和Manjaro都是激进的滚动发行版,作为一个滚动发行版都会有滚坏的风险,这就要求用户有一定的Linux使用基础,能够多关注更新动态,在系统罢工后有修复系统的能力,因此我不会给小白推荐Archlinux/Manjaro这样的发行版(虽说能够用纯cli界面安装Archlinux的用户其实已经有一定的水平了)。

ArchWiki 不是 ManjaroWiki

Manjaro官方为了最大限度地降低用户的使用门槛,为用户打造了一套开箱即用的环境,这听起来很好。

但是Manjaro官方为了降低用户使用门槛,不得不替用户去做一些选择,写上一些默认配置,在必要的地方对系统进行魔改。因此,ArchWiki上面的解决方案并非在Manjaro上能够100%适用,因此不要指望在系统使用过程中ArchWiki能够解决你所有的问题,有相当一部分问题你需要去查阅纯英文版的ManjaroWiki。

AUR(Archlinux User Repository)&ArchlinuxCN 并不是为 Manjaro 准备的

AUR和ArchlinuxCN源都是Archlinux用户为Archlinux打包的常用软件,因此所有的软件都是选择Archlinux最新的软件作为依赖来编译/打包的。上文中我们提到过,Manjaro源内的软件会滞后更新。因此AUR和ArchlinuxCN内一些对于依赖版本要求比较苛刻的软件会在Manjaro这个更新比较落后的发行版上不工作。

我们甚至还有一张表情包来调侃这种情况的

我知道这听起来会有些荒唐,不过我可以举出一个就发生在不久之前的生动的例子。

Archlinux在2020年10月17日将grpc从1.30更新到了1.32,qv2ray开发者反应迅速,在几个小时内直接更新了基于grpc-1.32的qv2ray,接着是仍然在使用grpc-1.30的Manjaro用户的一片哀嚎。。。

解决方法有很多,比如临时使用Archlinux源把grpc更新到1.32、通过AppImage安装qv2ray等等,但是如你所见,Manjaro用户使用AUR&ArchlinuxCN确实容易出现问题。

附:AUR上需要下载源码的自己编译的包不会碰到依赖的版本问题,但是仍然有部分情况下PKGBUILD会直接因为依赖版本号被写死而编译出错。而ArchlinuxCN清一色是编译好的二进制包,所以Manjaro用户使用ArchlinuxCN相比AUR出问题的几率更加大一点。

此外,他们延迟两周,并不是在测试 Arch 包打包本身的质量,而是在测试他们拿来 Arch 的包和他们自己乱改的核心包之间的兼容性。以下内容来自于一位 Archlinux Trusted User

manjaro 這個分三個 channel 延遲兩週的做法,原因出於兩點他們處理打包方面非常存疑的做法

  1. 他們想要自己打包一部分非常核心的包,包括 glibc 內核 驅動 systemd
  2. 他們不想重新打整個發行版所有包,想直接從 Arch 拿二進制來用。

這兩個做法單獨只做一個沒啥事,放一起做就很容易導致他們自己打包的核心包破壞了二進制兼容,以至於他們從 Arch 拿的二進制包壞掉。所以他們延遲兩週,並不是在測試 Arch 包打包本身的質量,而是在測試他們拿來 Arch 的包和他們自己亂改的核心包之間的兼容性。Arch 本身有一套機制保證 Arch 打包放出來的時候是測試好相互兼容的,被他們替換掉幾個核心包之後就不一定兼容了,他們也沒有渠道涉足 Arch 內部打包機制,從 Arch 組織內部了解什麼時候放出包之類的信息。綜合這些情況,對他們來說合理的做法就是延遲一陣子讓他們自己的人測試一下。

所以作為證據你看他們的打包者開發者很少會向 Arch 上游反饋測試打包遇到的問題…因為 manjaro unstable 和 manjaro testing 會遇到的問題大部分都是他們自己造成的問題而不是 Arch 的問題。

要是他們誠實地把這個情況傳達給他們用戶的話我不責怪他們。Arch整個滾動發布的生態也不利於下游發行版。Debian 這種上游打包時可以約定版本兼容性的範圍,可以鎖 abi ,Arch 打包本身就不考慮這些,作為Arch下游就的確很難操作。我反感 manjaro 的點在於他們把這種難看的做法宣傳成他們的優勢,還為了這個看起來是優勢故意去抹黑 Arch 作為上游的打包質量…做法就很難看了。

—— farseerfc

Manjaro 没有 Archive 源

Archlinux拥有一个archive源,通过Archive源,你可以将你的系统滚到任何一天的状态,比如在你不知道更新了什么滚炸了以后,你可以用Archive源回滚到三天前的状态,等bug修复完以后再用回正常的Archlinux源。况且,这个Archive源在国内拥有tunabfsu两个镜像源(虽然这两个镜像源并不是完整的镜像,而是每隔7天镜像一次),不会存在访问速度过慢的状况。有名的downgrade软件也是基于Archive源使用的。而Manjaro?很遗憾,没有。

写在最后

如果你有一定的Linux基础,阅读了我上面的科普以后仍然要去使用Manjaro也没有关系,但是记得遵守以下几点以确保你在Arch社区不会被打死

  • 谨慎使用AUR和ArchlinuxCN
  • 使用AUR和ArchlinuxCN时遇到问题请不要反馈
  • 在Arch社区提问时请提前说明自己在使用Manjaro
  • 不要根据Manjaro的使用经验随意编辑ArchWiki
🔲 ☆

UOS到底有没有Secure Boot签名/UOS引导怎么修复

以下内容来自2020年12月22日晚上的大佬对话,非本人原创。

吃瓜群众:

话说UOS到底有没有Secure boot签名啊

某dalao:

用的是ubuntu的

吃瓜群众:

哪来的签名?

某dalao:

这就不得不讲到另一个槽点了s

吃瓜群众:

ubuntu给他们签?

某dalao:

不不不,用的是ubuntu签好名的那个binary

然后ubuntu的那个binary会在EFI分区的ubuntu目录找配置

于是他们在安装器里写了个逻辑

把deepin目录的内容复制一份到ubuntu目录

(而不是patch grub包,或者写在grub包的postinst之类的地方)

后果是用户只要搞坏了引导

用网上任何教程都恢复不了

因为没人会教你建一个ubuntu目录,然后把deepin目录的内容复制进去)

如果不做这一步,任你怎么grub-install啊,update-grub啊,引导就还是坏的

🔲 ☆

No Hello

Don't Just Say "Hello" in Chat.

别在向别人问问题的时候问“在吗?”

英文原版请查阅这里,此处是我个人的翻译版。

2010-07-19 12:32:12 你: 在吗?
2010-07-19 12:32:15 我: 在的。
## 我就这这里静静的等待你打字描述自己的问题
2010-07-19 12:34:01 你: 我正在进行 [莫些事情] 然后我正尝试 [等等。。。]
2010-07-19 12:35:21 我: 这样啊,你应该 [我的回答]

这就像是你在和某人打电话,你接起电话说了一声:”喂?“,然后放下手机打开免提等待着对方的提问,这很低效

请使用如下格式:

2010-07-19 12:32:12 你: 你好,我正在进行 [某些事情] 然后我正尝试 [等等。。。]
2010-07-19 12:33:32 我: [我的回答]

这样做的原因是:你可以更快速地获得你想要的答案,而不是让对方在那边傻傻地等待你以龟速打字。

你的潜意识里试图不打断对方的回应,得到对方的回复以后再回答以显示你的礼貌,正如你在给别人打电话时那样。但是,网络聊天并不是打电话,通常情况下,打字要比说话慢得多。你的行为不是在彰显自己的礼貌,而是在浪费对方的时间。

其他的用语比如“你好,你在吗?”,“老王,问你个很简单的问题。”,“你有空吗?”都是很愚蠢的行为,在网络聊天中直接问问题就好。

如果你觉得直接问问题不礼貌,你可以采用以下的格式:

2010-07-19 12:32:12 你: 你好,如果你不介意的话我想问个问题,我正在进行 [莫些事情] 然后我正尝试 [等等。。。]

这样提问的另一个好处是:你的提问题同时具有即时性和留言性。如果对方不在,而你在对方上线之前就离开了,他们仍然可以回答您的问题,而不仅仅是盯着你发的“在吗”并为你究竟想要问什么问题而好奇。

(如果你使用的聊天软件支持查看对方的在线状态或者对方是否看到消息,你要做好被对方忽视的准备)

🔲 ☆

git笔记

git自动填入账号密码

打开终端,输入

git config --global credential.helper store

此时,我们就已经开启了git账号密码的本地储存,在下一次push时只要输入账号密码就可以一劳永逸了。

git设置默认的commit编辑器

git config --global core.editor $editor_name

Ps: $editor_name指的是你选用的编辑器,一般为nano、vim等

pick一个仓库中连续的几个commit

git cherry-pick <commit1_id>..^<cimmitn_id>

Ps: <commit1_id>和<commitn_id>分别指第一个你想要pick的commit_id和最后一个你想要pick的commit_id

pick失败时如何撤销此次pick

git cherry-pick --abort

踩坑记录

发生背景:

clone了一个内核仓库,大概是1.4G左右的大小,在github新建了一个repository,打算push上去,报错如下

[zhullyb@Archlinux sdm845]$ git push -u origin master
Enumerating objects: 5724101, done.
Counting objects: 100% (5724101/5724101), done.
Delta compression using up to 4 threads
Compressing objects: 100% (983226/983226), done.
Writing objects: 100% (5724101/5724101), 1.34 GiB | 2.46 MiB/s, done.
Total 5724101 (delta 4693465), reused 5723950 (delta 4693375), pack-reused 0
error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: INTERNAL_ERROR (err 2)
send-pack: unexpected disconnect while reading sideband packet
fatal: the remote end hung up unexpectedly
Everything up-to-date

搜索互联网,最终使用的解决方案

git config http.version HTTP/1.1		#原文中加了--global,不过我就临时遇到这种情况,不考虑加

最终应该可以使用如下命令设置回来

git config http.version HTTP/2
🔲 ☆

repo笔记

清除同步过程中产生的不完整碎片文件

源码路径/.repo下搜索tmp_pack 将搜索结果中出现的所有文件全部删除

以下命令仅供参考

rm -rf */*/*/*/objects/pack/tmp_pack_*

repo自动同步

下载脚本
echo #!/bin/bash
echo "======start repo sync======"
repo sync  --force-sync --current-branch --no-tags --no-clone-bundle --optimized-fetch --prune -j$(nproc --all)
while [ $? == 1 ]; do
echo "======sync failed, re-sync again======"
sleep 3
repo sync  --force-sync --current-branch --no-tags --no-clone-bundle --optimized-fetch --prune -j$(nproc --all)
done
> repo.sh

授予运行权限

chmod a+x repo.sh
运行脚本
bash repo.sh
🔲 ☆

Nuxt Content v3 中数组字段的筛选困境与性能优化

Nuxt Content 是 Nuxt 生态中用于处理 Markdown、YAML 等内容的强大模块。最近,我在使用 Nuxt v4 + Nuxt Content v3 重构博客(原为 Hexo)时,遇到了一个棘手的问题:v3 版本的默认查询 API 并未直接提供对数组字段进行“包含”($contains)操作的支持。

例如,这是我的正在写的这篇博客的 Front Matter:

---
title: Nuxt Content v3 中数组字段的筛选困境
date: 2025-10-20 21:52:59
sticky:
tags:
- Nuxt
- Nuxt Content
- JavaScript
---

我的目标是创建一个 Tag 页面,列出所有包含特定 Tag(例如 'Nuxt')的文章。

v2 的便捷与 v3 的限制

在 Nuxt Content v2 中,数据基于文件系统存储,查询方式是对文件内容的抽象,模拟了类似 MongoDB 的 JSON 文档查询语法。我们可以轻松地使用 $contains 方法获取所有包含 “Nuxt” 标签的文章:

const tag = decodeURIComponent(route.params.tag as string)

const articles = await queryContent('posts')
  .where({ tags: { $contains: tag } })  // ✅ v2 中的 MongoDB Style 查询
  .find()

但在使用 Nuxt Content v3 的 queryCollection API 时,我们很自然地会尝试使用 .where() 方法进行筛选:

const tag = decodeURIComponent(route.params.tag as string)

const { data } = await useAsyncData(`tag-${tag}`, () =>
    queryCollection('posts')
        .where(tag, 'in', 'tags')  // ❌ 这样会报错,因为第一次参数必须是字段名
        .order('date', 'DESC')
        .select('title', 'date', 'path', 'tags')
        .all()
)

遗憾的是,这样是行不通的。.where() 的方法签名要求字段名必须作为首个参数传入:where(field: keyof Collection | string, operator: SqlOperator, value?: unknown)

由于 Nuxt Content v3 底层采用 SQLite 作为本地数据库,所有查询都必须遵循类 SQL 语法。如果设计时未提供针对数组字段的内置操作符(例如 $contains 的 SQL 等价形式),最终的解决方案往往会显得比较“别扭”。

初版实现:牺牲性能的“全量拉取”

本着“尽快重构,后续优化”的思路,我写出了以下代码:

// 初版实现:全量拉取后使用 JS 筛选
const allPosts = (
    await useAsyncData(`tag-${route.params.tag}`, () =>
        queryCollection('posts')
            .order('date', 'DESC')
            .select('title', 'date', 'path', 'tags')
            .all()
    )
).data as Ref<Post[]>

const Posts = computed(() => {
    return allPosts.value.filter(post =>
        typeof post.tags?.map === 'function'
            ? post.tags?.includes(decodeURIComponent(route.params.tag as string))
            : false
    )
})

这种方法虽然满足了需求,但也带来了明显的性能代价:_payload.json 文件体积的膨胀。

在 Nuxt 项目中,_payload.json 用于存储 useAsyncData 的结果等动态数据。在全量拉取的方案下,每一个 Tag 页面 都会加载包含所有文章信息的 _payload.json,造成数据冗余。很多 Tag 页面仅需一两篇文章的数据,却被迫加载了全部文章信息,严重影响了性能。

tags 目录占据了 2.9MiB,是所有目录中最大的

_payload.json

讨巧方案:利用 SQLite 的存储特性进行优化

为了减少 useAsyncData 返回的查询结果,我查阅了 Nuxt Content 的 GitHub Discussions,发现在 v3.alpha.8 版本时就有人提出了一种“巧妙”的解决方案

由于 Nuxt Content v3 使用 SQLite 数据库,原本在 Front Matter 中定义的 tags 数组(通过 z.array() 定义)最终会以 JSON 字符串的形式存储在数据库中(具体格式可在 .nuxt/content/sql_dump.txt 文件中查看)。

sql_dump.txt

这意味着我们可以利用 SQLite 的字符串操作特性,通过 LIKE 动词配合通配符来完成数组包含的筛选,本质上是查询 JSON 字符串是否包含特定子串:

const tag = decodeURIComponent(route.params.tag as string)

const { data } = await useAsyncData(`tag-${route.params.tag}`, () =>
    queryCollection('posts')
        .where('tags', 'LIKE', `%"${tag}"%`)
        .order('date', 'DESC')
        .select('title', 'date', 'path', 'tags')
        .all()
)

下面是优化后重新生成的文件占用,体积减小还是非常显著的

  • tags 目录体积: 2.9MiB -> 1.4MiB
  • 单个 _payload.json 的体积: 23.1KiB -> 1.01 KiB

通过这种方法,我们成功将查询逻辑下推到了数据库层,避免了不必要的全量数据传输,显著降低了单个目录中 _payload.json 的体积,实现了性能优化。

tags 目录体积下降

_payload.json

参见

queryCollection - Nuxt Content

How do you query z.array() fields (e.g. tags) in the latest nuxt-content module (v3.alpha.8) · nuxt/content · Discussion #2955

🔲 ☆

后 OCSP 时代,浏览器如何应对证书吊销新挑战

2023 年 8 月,CA/Browser Forum 通过了一项投票——不再强制要求 Let’s Encrypt 等公开信任的 CA 设立 OCSP Server

2024 年 7 月,Let's Encrypt 发布博客,披露其计划关闭 OCSP Server

同年 12 月,Let's Encrypt 发布其关闭 OCSP Server 的时间计划表,大致情况如下:

  • 2025 年 1 月 30 日 - Let’s Encrypt 不再接受新的包含 OCSP Must-Staple 扩展的证书签发请求,除非你的账号先前申请过此类证书
  • 2025 年 5 月 7 日 - Let's Encrypt 新签发的证书将加入 CRL URLs,不再包含 OCSP URLs,并且所有新的包含 OCSP Must-Staple 扩展的证书签发请求都将被拒绝
  • 2025 年 8 月 6 日 - Let's Encrypt 关闭 OCSP 服务器

Let's Encrypt 是全世界最大的免费 SSL 证书颁发机构,而这一举动标志着我们已逐渐步入后 OCSP 时代。

OCSP 的困境:性能与隐私的权衡

Let's Encrypt 这一举动的背后,是人们对 OCSP(在线证书状态协议)长久以来累积的不满。OCSP 作为一种实时查询证书有效性的方式,最初的设想很美好:当浏览器访问一个网站时,它可以向 CA(证书颁发机构) 的 OCSP 服务器发送一个简短的请求,询问该证书是否仍然有效。这似乎比下载一个巨大的 CRL(证书吊销列表) 要高效得多。

然而,OCSP 在实际应用中暴露出众多缺陷:

首先是性能问题。尽管单个请求很小,但当数百万用户同时访问网站时,OCSP 服务器需要处理海量的实时查询。这不仅给 CA 带来了巨大的服务器压力,也增加了用户访问网站的延迟。如果 OCSP 服务器响应缓慢甚至宕机,浏览器可能会因为无法确认证书状态而中断连接,或者为了用户体验而不得不“睁一只眼闭一只眼”,这都削弱了 OCSP 的安全性。

更严重的是隐私问题。每一次 OCSP 查询,都相当于向 CA 报告了用户的访问行为。这意味着 CA 能够知道某个用户在何时访问了哪个网站。虽然 OCSP 查询本身不包含个人身份信息,但将这些信息与 IP 地址等数据结合起来,CA 完全可以建立起用户的浏览习惯画像。对于重视隐私的用户和开发者来说,这种“无声的监视”是不可接受的。即使 CA 故意不保留这些信息,地区法律也可能强制 CA 收集这些信息。

再者,OCSP 还存在设计上的安全缺陷。由于担心连接超时影响用户体验,浏览器通常默认采用 soft-fail 机制:一旦无法连接 OCSP 服务器,便会选择放行而非阻断连接。攻击者恰恰可以利用这一点,通过阻断客户端与 OCSP 服务器之间的通信,使查询始终超时,从而轻松绕过证书状态验证。

OCSP 装订 (OCSP stapling)

基于上面这些缺陷,我们有了 OCSP 装订 (OCSP stapling) 方案,这在我去年的博客里讲过,欢迎回顾

强制 OCSP 装订 (OCSP Must-Staple)

OCSP Must-Staple 是一个在 ssl 证书申请时的拓展项,该扩展会告知浏览器:若在证书中识别到此扩展,则不得向证书颁发机构发送查询请求,而应在握手阶段获取装订式副本。若未能获得有效副本,浏览器应拒绝连接。

这项功能赋予了浏览器开发者 hard-fail 的勇气,但在 OCSP 淡出历史之前,Let's Encrypt 似乎是唯一支持这一拓展的主流 CA,并且这项功能并没有得到广泛使用。

~~本来不想介绍这项功能的(因为根本没人用),但考虑到这东西快入土了,还是给它在中文互联网中立个碑,~~更多信息参考 Let's Encrypt 的博客

Chromium 的方案:弱水三千只取一瓢

OCSP 的隐私和性能问题并非秘密,浏览器厂商们早就开始了各自的探索。2012 年,Chrome 默认禁用了 CRLs、OCSP 检查,转向自行设计的证书校验机制。

众所周知,吊销列表可以非常庞大。如果浏览器需要下载和解析一个完整的全球吊销列表,那将是一场性能灾难(Mozilla 团队在今年的博客中提到,从 3000 个活跃的 CRL 下载的文件大小将达到 300MB)。Chromium 团队通过分析历史数据发现,大多数被吊销的证书属于少数高风险类别,例如证书颁发机构(CA)本身被攻破、或者某些大型网站的证书被吊销。基于此洞察,CRLSets 采取了以下策略:

  1. 分层吊销:Chromium 不会下载所有被吊销的证书信息,而是由 Google 团队维护一个精简的、包含“最重要”吊销信息的列表。这个列表会定期更新并通过 Chrome 浏览器更新推送给用户。
  2. 精简高效:这个列表体积非常小,目前大概只有 600KB。它包含了那些一旦被滥用就会造成大规模安全事故的证书,例如 CA 的中间证书、或者一些知名网站(如 Google、Facebook)的证书。
  3. 牺牲部分安全性:这种方案的缺点也很明显——它无法覆盖所有的证书吊销情况。对于一个普通网站的证书被吊销,CRLSets 大概率无法检测到。根据 Mozilla 今年的博客所说,CRLSets 只包含了 1%~2% 的未过期的被吊销证书信息。

虽然 CRLSets 是一种“不完美”的解决方案,但它在性能和可用性之间找到了一个平衡点。它确保了用户在访问主流网站时的基础安全,同时避免了 OCSP 带来的性能和隐私开销。对于 Chromium 而言,与其追求一个在现实中难以完美实现的 OCSP 方案,不如集中精力解决最紧迫的安全威胁。

Firefox 的方案:从 CRLs 到 CRLite

与 Chromium 的“只取一瓢”策略不同,Firefox 的开发者们一直在寻找一种既能保证全面性,又能解决性能问题的方案。

为了解决这个问题,Mozilla 提出了一个创新的方案:CRLite。CRLite 的设计理念是通过哈希函数和布隆过滤器等数据结构,将庞大的证书吊销列表压缩成一个小巧、可下载且易于本地验证的格式

CRLite 的工作原理可以简单概括为:

  1. 数据压缩:CA 定期生成其全部吊销证书的列表。
  2. 服务器处理:Mozilla 的服务器会收集这些列表,并使用加密哈希函数和布隆过滤器等技术,将所有吊销证书的信息编码成一个非常紧凑的数据结构。
  3. 客户端验证:浏览器下载这个压缩文件,当访问网站时,只需本地对证书进行哈希计算,然后查询这个本地文件,就能快速判断该证书是否已被吊销。

与 CRLSets 相比,CRLite 的优势在于它能够实现对所有吊销证书的全面覆盖,同时保持极小的体积。更重要的是,它完全在本地完成验证,这意味着浏览器无需向任何第三方服务器发送请求,从而彻底解决了 OCSP 的隐私问题。

Firefox 当前的策略为每 12 小时对 CRLite 数据进行一次增量更新,每日的下载数据大约为 300KB;每 45 天进行一次全量的快照同步,下载数据约为 4MB。

Mozilla 开放了他们的数据看板,你可以在这里找到近期的 CRLite 数据大小:https://yardstick.mozilla.org/dashboard/snapshot/c1WZrxGkNxdm9oZp7xVvGUEFJCELfApN

自 2025 年 4 月 1 日发布的 Firefox Desktop 137 版本起,Firefox 开始逐步以 CRLite 替换 OCSP 校验;同年 8 月 19 日,Firefox Desktop 142 针对 DV 证书正式弃用 OCSP 检验。

CRLite 已经成为 Firefox 未来证书吊销验证的核心方案,它代表了对性能、隐私和安全性的全面追求。

后 OCSP 时代的展望

随着 Let's Encrypt 等主要 CA 关闭 OCSP 服务,OCSP 的时代正在加速落幕。我们可以看到,浏览器厂商们已经开始各自探索更高效、更安全的替代方案。

  • Chromium 凭借其 CRLSets 方案,在性能和关键安全保障之间取得了务实的平衡。
  • Firefox 则通过 CRLite 这一技术创新,试图在全面性、隐私和性能三者之间找到最佳的解决方案。

这些方案的共同点是:将证书吊销验证从实时在线查询(OCSP)转变为本地化验证,从而规避了 OCSP 固有的性能瓶颈和隐私风险。

未来,证书吊销的生态系统将不再依赖单一的、中心化的 OCSP 服务器。取而代之的是,一个更加多元、分布式和智能化的新时代正在到来。OCSP 这一技术可能逐渐被淘汰,但它所试图解决的“证书吊销”这一核心安全问题,将永远是浏览器和网络安全社区关注的重点。

参见

🔲 ⭐

初试 Github Action Self-hosted Runner,想说爱你不容易

在今年八月的时候,我这边所在的一个 Github Organization 在私有项目开发阶段频繁触发 CI,耗尽了 Github 为免费计划 (Free Plan) 提供的每月 2000 分钟 Action 额度(所有私有仓库共享,公有仓库不计)。大致看了下,CI 流设置得是合理的,那么就要另寻他法看看有没有办法去提供更宽裕的资源,因此也就盯上了文章标题中所提到的 Github Action Self-hosted Runner

对于这个 Self-hosted Runner,与 Github 官方提供的 runner 相比,主要有以下几个优势

  • 针对私有仓库,拥有无限制的 Action 运行时长
  • 可以自行搭配更强大的硬件计算能力和内存
  • 可以接入内网环境,方便与内网/局域网设备通信

配置安装

由于不清楚需要的网络环境,我这次测试直接选用了一台闲置的香港 vps,4核4G + 80G 硬盘 + 1Gbps 大口子的配置,除了硬盘读写稍微拉胯一些,别的地方可以说是拉满了。

Self-hosted Runner 的配置本身是相当直接和清晰的,照着官方提供的方案基本没什么问题。

三个主流平台都有,如果好好加以利用,应该可以涵盖包括 iPhone 应用打包等一系列的需求。

在观察一下我这边拿到手的 2.328.0 版本的 runner 安装文件压缩包的体积在 220MB 左右,内置了 node20 和 node24 各两个版本的运行环境。

在执行完 config.sh 后,当前目录下就会多出一个 svc.sh,可以帮助利用这东西来调用 systemd 实现进程守护之类的需求。

再次刷新网页,就可以看到 Self-hosted Runner 处于已经上线的状态了

指定 Action 采用自己的 Runner

这一步很简单,只需在原 Action 的 yml 文件中改变 runs-on 字段即可

jobs:
  run:
+    runs-on: self-hosted
-    runs-on: ubuntu-latest

实测

当我满心欢喜地将 CI 流程从 Github 官方的 runner 切换到自托管的 runner 后,问题很快就浮现了,而这也正是我“爱不起来”的主要原因。问题集中体现在我习以为常的 setup-python 这一由 Github 官方维护的 Github Action Flow 中,提示 3.12 版本没找到。

在 Github 官方提供的虚拟环境中,这些 Action 会为我们准备好指定版本的开发环境。例如,uses: actions/setup-python 加上 with: python-version: '3.12' 就会自动在环境中安装并配置好 Python 3.12.x。我对此已经习以为常,认为这是一个“开箱即用”的功能。但在 Self-hosted Runner 上,情况略有些不同。setup-python 在文档中指出

Python distributions are only available for the same environments that GitHub Actions hosted environments are available for. If you are using an unsupported version of Ubuntu such as 19.04 or another Linux distribution such as Fedora, setup-python may not work.

setup-python 这个 Action 只支持 Github Action 所采用的同款操作系统,而我 VPS 的 Debian 不受支持,因此有这个误报,同时也给我的 Debian 判了死刑。

症结所在:对 Self-hosted Runner 的误解

我潜意识里认为,Self-hosted Runner 仅仅是将计算成本从 Github 服务器转移到了本地,而 actions/setup-python 这种官方标准动作,理应会像 Github-hosted Runner 中那样,优雅地为我下载、安装、并配置好我需要的一切。然而,Self-hosted Runner 的本质只是从 Github 接收任务,并在当前的操作系统环境中执行指令,并不保证和 Github 官方提供的 Runner 的运行环境一致。

Self-hosted Runner 不是一个开箱即用的“服务”,而是一个需要你亲自管理的“基础设施”。你需要负责服务器的安装、配置、安全更新、依赖管理、磁盘清理等一系列运维工作。它更适合那些对 CI/CD 有更高阶需求的团队或个人:比如 CI/CD 消费大户、需要特定硬件(如 ARM、GPU)进行构建的团队、或者 CI 流程深度依赖内部网络资源的企业。对于像我这样只是愿意拿出更多的本地计算资源来获取更多 Action 运行时长的普通开发者而言,它带来的运维心智负担,似乎是有一点重了。

🔲 ☆

DNS 解析延迟毁了我的图床优化

去年夏天,我花了不少时间搭建博客图床,核心目标是分地区解析 DNS,让国内外访客都能快速加载图片。技术方案看起来完美无缺,直到最近群友反馈首次访问时图片加载很慢,我才发现问题所在。

955 毫秒的 DNS 解析时长! 这个数字让我大吃一惊。访客点开博客后,光是确定图片服务器位置就要等将近一秒,这完全抵消了 CDN 优化的效果。

为什么之前没发现?

主要是 DNS 缓存的"功劳"。它会为后续访问记住解析结果,让我的本地测试和复访测试看起来都很正常。直到用户反馈,结合最近准备秋招复习的 DNS 解析流程(递归查询、权威查询、根域名、顶级域名等),我才定位到问题:首次访问时的 DNS 解析延迟

DNS 解析流程分析

让我们看看访客访问 static.031130.xyz 时,DNS 是如何工作的:

sequenceDiagram
    participant User as 访客浏览器
    participant Local as 本地 DNS
    participant CF as Cloudflare<br/>(国外权威)
    participant DP as DNSPod<br/>(国内权威)
    participant CDN as CDN 节点

    User->>Local: 请求 static.031130.xyz
    Local->>CF: 查询 031130.xyz 权威
    Note over Local,CF: 跨国查询,延迟高
    CF->>Local: CNAME: cdn-cname.zhul.in
    Local->>DP: 查询 zhul.in 权威
    DP->>Local: CNAME: small-storage-cdn.b0.aicdn.com
    Local->>DP: 查询 aicdn.com
    DP->>Local: CNAME: nm.aicdn.com
    Local->>DP: 查询最终 IP
    DP->>Local: 返回 CDN IP
    Local->>User: 返回解析结果
    User->>CDN: 连接并下载图片

问题就在这里:前两步查询指向了国外的 Cloudflare 权威服务器。对于国内用户,虽然最终解析到的 CDN 节点是国内的,但跨国 DNS 查询就足以拖垮首次访问体验。那 955ms 的延迟,基本都耗在与国外 DNS 服务器的通信上了。

优化方案

针对这个问题,我采取了三个措施:

1. DNS 预取

在博客 HTML 的 <head> 中添加:

<link rel="dns-prefetch" href="//static.031130.xyz">

这样浏览器在渲染页面时就会提前解析图床域名,等真正需要加载图片时,DNS 结果可能已经准备好了。

2. 延长 TTL

static.031130.xyz 的 CNAME 记录 TTL 值调大(从几分钟延长到几小时甚至一天)。这样本地 DNS 服务器会缓存更久,后续用户可以直接使用缓存结果,省掉权威查询。

3. 迁移权威 DNS(核心)

031130.xyz 域名的权威 DNS 服务器从 Cloudflare 迁移到国内的 DNSPod:

graph TB
    subgraph "优化前"
        A1[访客] --> B1[本地 DNS]
        B1 --> C1[Cloudflare 权威<br/>国外]
        C1 --> D1[DNSPod 权威<br/>国内]
        D1 --> E1[CDN 节点]
        style C1 fill:#ffcccc
    end
graph TB
    subgraph "优化后"
        A2[访客] --> B2[本地 DNS]
        B2 --> D2[DNSPod 权威<br/>国内]
        D2 --> E2[CDN 节点]
        style D2 fill:#ccffcc
    end

迁移后的好处:

  • 递归 DNS 查询 031130.xyz 时,直接找到国内的 DNSPod,响应快
  • DNSPod 直接返回 static.031130.xyz -> small-storage-cdn.b0.aicdn.com,无需中间跳转
  • 整个 DNS 解析链路在国内完成,首次访问延迟大幅降低

优化效果

虽然 DNS 缓存给测试带来了困难,但迁移权威 DNS + 调整 TTL + 添加预取后,首次访问的 DNS 解析时间降到了可接受的范围。

经验教训

  1. DNS 位置很重要:涉及多地优化时,权威 DNS 的地理位置对首次访问延迟影响很大。优先使用国内权威服务器。

  2. 首次访问是关键:虽然缓存能帮助后续访问,但首次访问体验直接影响用户印象。善用 dns-prefetch 和合理的 TTL 设置。

  3. 监控和反馈重要:本地测试环境往往有缓存加持,真实的首次访问体验需要通过监控和用户反馈来发现。

重要提醒:警惕 CNAME 拉平

如果你需要分地区解析来让访客连接到最近的 CDN 节点,务必避开 CNAME Flattening(CNAME 拉平)

什么是 CNAME 拉平?

权威 DNS 服务器(如 Cloudflare)看到 CNAME 记录后,会主动查询目标域名的最终 IP 地址,然后直接返回 IP 而不是 CNAME。

为什么会出问题?

分地区解析(GeoDNS)在权威 DNS 服务器层面实现。当权威服务器执行 CNAME 拉平时,它会在自己的位置查询目标域名的 IP。如果权威 DNS 在美国,它获取的 IP 就是美国最优节点,然后把这个 IP 返回给所有地区的查询者,包括中国用户。这样,你为中国用户配置的国内 CDN IP 策略就完全失效了。

graph LR
    subgraph "启用 CNAME 拉平的问题"
        A[中国用户] --> B[Cloudflare 权威<br/>美国节点]
        B --> C[查询目标 CNAME]
        C --> D[返回美国 CDN IP]
        D --> A
        style D fill:#ffcccc
    end

正确做法

老实使用 CNAME 指向另一个支持 GeoDNS 的域名(如 static.031130.xyz -> cdn-cname.zhul.in,后者在 DNSPod 上做分地区解析),才能保证分流策略正确执行。

如果需要分地区解析功能,不要在相关域名上启用 CNAME Flattening(或 ALIAS、ANAME 等类似功能)。

🔲 ☆

Vue Markdown 渲染优化实战(下):告别 DOM 操作,拥抱 AST 与函数式渲染

上回回顾:当 morphdom 遇上 Vue

上一篇文章中,我们经历了一场 Markdown 渲染的性能优化之旅。从最原始的 v-html 全量刷新,到按块更新,最终我们请出了 morphdom 这个“终极武器”。它通过直接比对和操作真实 DOM,以最小的代价更新视图,完美解决了实时渲染中的性能瓶颈和交互状态丢失问题。

然而,一个根本性问题始终存在:在 Vue 的地盘里,绕过 Vue 的虚拟 DOM (Virtual DOM) 和 Diff 算法,直接用一个第三方库去“动刀”真实 DOM,总感觉有些“旁门左道”。这就像在一个精密的自动化工厂里,引入了一个老师傅拿着锤子和扳手进行手动修补。虽然活干得漂亮,但总觉得破坏了原有的工作流,不够“Vue”。

那么,有没有一种更优雅、更“原生”的方式,让我们既能享受精准更新的快感,又能完全融入 Vue 的生态体系呢?

带着这个问题,我询问了前端群里的伙伴们。

如果就要做一个渲染器,你这个思路不是最佳实践。每次更新时,你都生成全量的虚拟 HTML,然后再对 HTML 做减法来优化性能。然而,每次更新的增量部分是明确的,为什么不直接用这部分增量去做加法?增量部分通过 markdown-it 的库无法直接获取,但更好的做法是在这一步进行改造:先解析 Markdown 的结构,再利用 Vue 的动态渲染能力生成 DOM。这样,DOM 的复用就可以借助 Vue 自身的能力来实现。—— j10c

可以用 unified 结合 remark-parse 插件,将 markdown 字符串解析为 ast,然后根据 ast 使用 render func 进行渲染即可。—— bii & nekomeowww

新思路:从“字符串转换”到“结构化渲染”

我们之前的方案,无论是 v-html 还是 morphdom,其核心思路都是:

Markdown 字符串 -> markdown-it -> HTML 字符串 -> 浏览器/morphdom -> DOM

这条链路的问题在于,从 HTML 字符串 这一步开始,我们就丢失了 Markdown 的原始结构信息。我们得到的是一堆非结构化的文本,Vue 无法理解其内在逻辑,只能将其囫囵吞下。

而新的思路则是将流程改造为:

Markdown 字符串 -> AST (抽象语法树) -> Vue VNodes (虚拟节点) -> Vue -> DOM

什么是 AST?

AST (Abstract Syntax Tree) ,即抽象语法树,是源代码或标记语言的结构化表示。它将一长串的文本,解析成一个层级分明的树状对象。对于 Markdown 来说,一个一级标题会变成一个 type: 'heading', depth: 1 的节点,一个段落会变成一个 type: 'paragraph' 的节点,而段落里的文字,则是 paragraph 节点的 children

一旦我们将 Markdown 转换成 AST,就相当于拥有了整个文档的“结构图纸”。我们不再是面对一堆模糊的 HTML 字符串,而是面对一个清晰、可编程的 JavaScript 对象。

我们的新工具:unified 与 remark

为了实现 Markdown -> AST 的转换,我们引入 unified 生态。

  • unified: 一个强大的内容处理引擎。你可以把它想象成一条流水线,原始文本是原料,通过添加不同的“插件”来对它进行解析、转换和序列化。
  • remark-parse: 一个 unified 插件,专门负责将 Markdown 文本解析成 AST(具体来说是 mdast 格式)。

第一步:将 Markdown 解析为 AST

首先,我们需要安装相关依赖:

npm install unified remark-parse

然后,我们可以轻松地将 Markdown 字符串转换为 AST:

import { unified } from 'unified'
import remarkParse from 'remark-parse'

const markdownContent = '# Hello, AST!\n\nThis is a paragraph.'

// 创建一个处理器实例
const processor = unified().use(remarkParse)

// 解析 Markdown 内容
const ast = processor.parse(markdownContent)

console.log(JSON.stringify(ast, null, 2))

运行以上代码,我们将得到一个如下所示的 JSON 对象,这就是我们梦寐以求的 AST:

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "Hello, AST!",
          "position": { ... }
        }
      ],
      "position": { ... }
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "This is a paragraph.",
          "position": { ... }
        }
      ],
      "position": { ... }
    }
  ],
  "position": { ... }
}

第二步:从 AST 到 Vue VNodes

拿到了 AST,下一步就是将这个“结构图纸”真正地“施工”成用户可见的界面。在 Vue 的世界里,描述 UI 的蓝图就是虚拟节点 (VNode),而 h() 函数(即 hyperscript)就是创建 VNode 的画笔。

我们的任务是编写一个渲染函数,它能够递归地遍历 AST,并为每一种节点类型(heading, paragraph, text 等)生成对应的 VNode。

下面是一个简单的渲染函数实现:

function renderAst(node) {
  if (!node) return null
  switch (node.type) {
    case 'root':
      return h('div', {}, node.children.map(renderAst))
    case 'paragraph':
      return h('p', {}, node.children.map(renderAst))
    case 'text':
      return node.value
    case 'emphasis':
      return h('em', {}, node.children.map(renderAst))
    case 'strong':
      return h('strong', {}, node.children.map(renderAst))
    case 'inlineCode':
      return h('code', {}, node.value)
    case 'heading':
      return h('h' + node.depth, {}, node.children.map(renderAst))
    case 'code':
      return h('pre', {}, [h('code', {}, node.value)])
    case 'list':
      return h(node.ordered ? 'ol' : 'ul', {}, node.children.map(renderAst))
    case 'listItem':
      return h('li', {}, node.children.map(renderAst))
    case 'thematicBreak':
      return h('hr')
    case 'blockquote':
      return h('blockquote', {}, node.children.map(renderAst))
    case 'link':
      return h('a', { href: node.url, target: '_blank' }, node.children.map(renderAst))
    default:
      // 其它未实现类型
      return h('span', { }, `[${node.type}]`)
  }
}

第三步:封装 Vue 组件

整合上述逻辑,我们可以构建一个 Vue 组件。鉴于直接生成 VNode 的特性,采用函数式组件或显式 render 函数最为适宜。

<template>
  <component :is="VNodeTree" />
</template>

<script setup>
import { computed, h, shallowRef, watchEffect } from 'vue'
import { unified } from 'unified'
import remarkParse from 'remark-parse'

const props = defineProps({
  mdText: {
    type: String,
    default: ''
  }
})

const ast = shallowRef(null)
const parser = unified().use(remarkParse)

watchEffect(() => {
  ast.value = parser.parse(props.mdText)
})

// AST 渲染函数 (同上文 renderAst 函数)
function renderAst(node) { ... }

const VNodeTree = computed(() => renderAst(ast.value))

</script>

现在就可以像使用普通组件一样使用它了:

<template>
  <MarkdownRenderer :mdText="markdownContent" />
</template>

<script setup>
import { ref } from 'vue'
import MarkdownRenderer from './MarkdownRenderer.vue'

const markdownContent = ref('# Hello Vue\n\nThis is rendered via AST!')
</script>

AST 方案的巨大优势

切换到 AST 赛道后,我们获得了前所未有的超能力:

  1. 原生集成,性能卓越:我们不再需要 v-html 的暴力刷新,也不再需要 morphdom 这样的“外援”。所有更新都交由 Vue 自己的 Diff 算法处理,这不仅性能极高,而且完全符合 Vue 的设计哲学,是真正的“自己人”。
  2. 高度灵活性与可扩展性:AST 作为可编程的 JavaScript 对象,为定制化处理提供了坚实基础:
    • 元素替换:可将原生元素(如 <h2>)无缝替换为自定义 Vue 组件(如 <FancyHeading>),仅在 renderAst 函数中调整对应 case 逻辑即可。
    • 逻辑注入:可便捷地为外部链接 <a> 添加 target="_blank"rel="noopener noreferrer" 属性,或为图片 <img> 包裹懒加载组件,此类操作在 AST 层面易于实现。
    • 生态集成:充分利用 unified 丰富的插件生态(如 remark-gfm 支持 GFM 语法,remark-prism 实现代码高亮),仅需在处理器链中引入相应插件(.use(pluginName))。
  3. 关注点分离:解析逻辑(remark)、渲染逻辑(renderAst)和业务逻辑(Vue 组件)被清晰地分离开来,代码结构更清晰,维护性更强。
  4. 类型安全与可预测性:相较于操作字符串或原始 HTML,基于结构化 AST 的渲染逻辑更易于进行类型校验与逻辑推理。

结论:从功能实现到架构优化的演进

回顾优化历程:

  • v-html:实现简单,但存在性能与安全性隐患。
  • 分块更新:缓解了部分性能问题,但方案存在局限性。
  • morphdom:有效提升了性能与用户体验,但与 Vue 核心机制存在隔阂。
  • AST + 函数式渲染:回归 Vue 原生范式,提供了性能、灵活性、可维护性俱佳的终极解决方案。

通过采用 AST,我们不仅解决了具体的技术挑战,更重要的是实现了思维范式的转变——从面向结果(HTML 字符串)的编程,转向面向过程与结构(AST)的编程。这使我们能够深入内容本质,从而实现对渲染流程的精确控制。

本次从“全量刷新”到“结构化渲染”的优化实践,不仅是一次性能提升的技术过程,更是一次深入理解现代前端工程化思想的系统性探索。最终实现的 Markdown 渲染方案,在性能、功能性与架构优雅性上均达到了较高水准。

❌