Files
blog/评论加载优化方案.md
T
zqlit e3e3cce1a4 perf(comments): 评论加载提速 + 骨架屏 + 友链弹窗 pjax 修复 + toast 统一 Message.js
优化:
- initArtalk 加节点级幂等守卫(mount.__atkInited,失败回滚),
  消除 artalk.html/mypjax 多入口导致的 4× comments + 4× pv 重复请求
- fetch 包装层对无 body 的 GET/HEAD 去掉多余 content-type,
  消掉 Artalk 对 GET /comments 的 CORS 预检(按完整 URL 缓存,
  每篇文章都重付一次 RTT)。实测评论列表 +1150~1565ms → +626ms

修复:
- 评论列表骨架屏改注入 .atk-list-body(原骨架在 #Comments 内,
  被 Artalk.init 清空),list-loaded/故障/12s 兜底移除
- 友链申请弹窗抽成 modules/friendlink.js 进 page-only bundle
  (原内联脚本在 #pjax-container 外,pjax 后 flApplyOpenModal 未定义)
- toast 统一走本地化 Message.js/Qmsg 适配层(保留 window.Toast/showToast 旧 API)

文档:
- README / 架构总览 更新;新增 评论加载优化方案.md
2026-10-04 15:00:52 +08:00

16 KiB
Raw Blame History

评论加载优化方案(Cloudflare 免费版)

起因:读者侧「评论加载太慢」。 约束:Cloudflare 免费版,无中国节点;全程不引回自维护机器(除非你主动选第三档)。 状态:第一档 + 2-A 已实施(本地,待预览确认);2-B 待做,2-C 已由实测数据关闭。


0. 先厘清「慢」到底慢在哪一层

这一点决定每一项优化的真实价值,否则容易做一堆「看起来很快」的改动却没有体感。

层 链路 现状 谁能修
L1 跨境 读者浏览器 → CF 边缘机房 CF 免费版无中国节点,每次请求跨境 ≈ 250–400ms 只能靠少发请求(前端),或第三档反代
L2 回源 CF 边缘机房 → D1 主库 D1 主库在 WNAM(美国西),代码注释:「每次读都要跨太平洋」 边缘缓存(省掉这一跳)
L3 结构 CF 在中国大陆无节点 结构性,无解 只有第三档(境内反代)

关键结论:边缘缓存(第二档)治的是 L2 和额度,治不了 L1。 读者感受到的「慢」主要在 L1,所以降请求数比加缓存更直接。


1. 当前一次评论首屏的请求链(来自代码,非推测)

Artalk 客户端 libs/Artalk.js 的初始化是串行的——已从压缩源码确认:

// conf 请求(await)→ mounted → 之后才发评论列表
...getApi().conf.conf().catch(...)
... e.trigger("mounted"), e.conf.remoteConfModifier || e.fetch({offset:0})

即:conf 不回来,评论列表请求根本不会发出。所以首屏是严格串行的两跳:

浏览器 ──① GET /api/v2/conf ──────► CF 边缘 ──► D1     ≈ 1×L1 + 1×L2
       ──② GET /api/v2/comments ─► CF 边缘 ──► D1     ≈ 1×L1 + 1×L2(含多条查询)
       ──③ POST /api/v2/pages/pv ─► CF 边缘 ──► D1     (异步,不阻塞渲染)

页面加载时还会打 /captcha/status 或 /human/*(仅在需要时)。

L1 一次 ≈ 250–400ms(国内实测 CF 0.75s vs EdgeOne 0.25s),所以 ①→② 两跳 ≈ 0.5–0.8s 纯网络。把 ① 干掉,首屏网络时间直接减半。

已有的优化不动:window.friendLinks/friendFeeds 是 Hugo 构建期内联的(零运行时请求);Artalk 的 JS/CSS 走又拍云本地资源;conf 已有 localStorage 缓存(但只在 PJAX 第二次以上生效,首屏吃不到);/api/favicon 已有 KV 缓存 + max-age=86400;计数查询已有 KV 短缓存(10 分钟 + 版本号失效)。


2. ✅ 第一档(已实施):修 preconnect

问题:themes/Ying/layouts/partials/head.html 里给 API 域名预热了连接,但没带 crossorigin。

Artalk 发的是 CORS 跨域请求。浏览器把「带凭据的连接」和「匿名连接」分池缓存:不带 crossorigin 预热出来的是普通连接,CORS 请求无法复用它——等于白预热,还是要重付一次 DNS + TCP + TLS(约 2–3 个 RTT)。

改法:preconnect ... crossorigin + dns-prefetch 兜底(老浏览器不支持 preconnect);host 取 artalk.server 与 rssapi.base 去重后渲染,以后两处配置分家也不会漏。

验证:本地 hugo 构建产物确认(1270 个页面全部生效,两个 host 正确去重):

<link rel="preconnect" href="https://cravatar.cn">
<link rel="preconnect" href="https://api.200181.xyz" crossorigin>
<link rel="dns-prefetch" href="https://api.200181.xyz">

估算收益:首屏第一跳省下 ~2–3 个 RTT 的握手(≈200–400ms),且后续 ②③ 请求复用同一条连接,各自只花 1 个 RTT。


3. 📋 第二档评估

2-A. ✅ 首屏内联 conf(已实施)

⚠️ 先记一次认知纠正:本节原评估的方向是错的。

原方案写的是「前端首屏直接 useBackendConf: false 用内联配置起步,跳过 ①」。 读 libs/Artalk.js 压缩源码后确认做不到:

const { data: i } = await e.getApi().conf.conf()   // ← 无条件发请求
if (e.conf.useBackendConf) { ...merge frontend_conf... }  // ← 只管要不要「用后端覆盖前端」

useBackendConf 决定的是要不要采用后端的配置,与发不发这次请求无关。 所以内联配置在 Artalk 内部绕不过请求 ① —— 必须拦在 fetch 层。 (PJAX 缓存那条路径也没省掉请求,它省的是「重新渲染」,不是「重新请求」。)

做法(三段,缺一不可):

  1. 构建期抓快照 —— .cnb.yml 的「Hugo 构建」stage 里 curl 一次 /api/v2/conf, 落到 data/artalk_conf.json(Hugo 读成 site.Data.artalk_conf)。 拉取失败就删文件降级(失败发生在 if 条件里,不触发 set -e,绝不让它拖垮构建)。 该文件已 gitignore:每次构建现拉,避免陈旧配置被 git 固化。
  2. 模板内联 —— partials/artalk.html 输出 window.__artalkConfSnapshot = {…}, 放在 window.artalkConfig 之前。</ 会被替换成 <\/ 防 </script> 逃逸。
  3. fetch 拦截 —— modules/artalk.js 的包装层里命中 /api/v2/conf 时, 直接 new Response(JSON.stringify(快照)) 返回,完全不发网络。 (Artalk 的通用请求函数 w() 只检查 r.ok 就把 Response 原样返回,所以合成响应可无缝接管。)

为什么可以安全复用「一份固定响应」:服务端 getConf 是 {...DEFAULT_FRONTEND_CONF, ...settings.frontend_conf, imgUpload: imgUpload || admin} —— 除 imgUpload 外全部来自 settings 表,与访客无关。于是:

  • 匿名访客 → conf 人人相同 → 用快照 ✅
  • 已登录用户 → 管理员会拿到 imgUpload: true(个性化)→ 放行真实请求 判断方式:w() 在无 token 时会把 Authorization 头删掉,所以「该头存在」即「有凭据」; 判不准时保守放行(宁可多一跳,也不错配)。

实测验证(Chrome CDP 抓真实页面网络):

/api/v2/conf 请求数
改动前(对照:CDP 注入脚本吞掉快照变量的赋值) 2 次
改动后 0 次 ✅

页面探针同时确认 Artalk 完全正常:artalkInited: true、editorPresent: true、 listPresent: true、errText: "";且 conf 内容确实来自快照 (sendBtn:"评论一下" / nestMax:2 / locale:"zh-CN" / emoticons 全对); conf 之后的评论列表请求照常发出 —— 串行链没断。

成本:conf 响应 771B,内联进 123 个带评论区的页面(占页面体积 1.6%),全站内容一致。

结果
收益 首屏少一整跳跨境(≈250–400ms);实际比预期多省一倍,见下方「顺带发现」
风险 低。构建期失败自动降级;带凭据请求自动放行
代价 后台改评论配置后要等下次构建才生效(已有每日 9:00 定时构建兜底)

怎么回退:删掉 data/artalk_conf.json 再构建即可 —— 模板不输出快照,前端自动走原生远程请求,行为等同今天(无需改代码)。

2-B. 给读接口加边缘缓存(Cache API)

注意:Worker 的响应默认不进 Cloudflare 缓存。光加 Cache-Control 头是没有效果的,必须显式用 caches.default.put()/match()。(这也是为什么这项不能"顺手加个头"就完事。)

必须做对的四件事:

  1. 只缓存匿名公开视图。listComments 的结果依赖是否管理员(is_pending 过滤、includeMarked)、name/email、scope=user、type=mine|pending|mentions、view_only_admin——这些一律不缓存,只缓存 scope=page 的默认视图。/conf 同理(含 imgUpload || admin)。
  2. 缓存 key 要带 origin。corsHeaders() 已经把 Access-Control-Allow-Origin 回显成请求方的 Origin(并设了 Vary: Origin),缓存 key 里必须把 origin 编进去,否则 A 域名的响应会被回给 B 域名。
  3. 失效策略复用现有机制。项目里已有 cml:ver:<site>:<page> 这个 KV 版本号,评论新增/删除/审核会 bumpCountVersion()。直接把它编进缓存 key → 有评论变化时 key 自然变化,旧条目靠 TTL 自然过期,不用做 purge。
  4. TTL 取短:30–60s。评论不是毫秒级强一致场景。
评估
收益 D1 额度:命中时省掉「计数 + 根列表 + 子评论 + page + site」多条查询(单页几十~几百 rows_read)。这个是真金白银——代码注释里记着当年额度被打爆过
收益 延迟:命中时省掉 L2 那一跳(≈100–200ms)。但L1 那一跳仍然要付,所以体感提升有限
风险 低–中。要点全在「不可缓存哪些请求」上,漏一个就是串号/看到别人的数据
代价 评论提交后,同一机房的其他读者最多看到 TTL 秒的旧列表(可接受)

2-C. Smart Placement([placement] mode = "smart")

让 Worker 跑在离 D1 更近的机房,代价是可能离读者更远。

要不要开不用猜:你的 /api/v2/healthz 已经专门返回这两个字段了(当初就是为这个判断留的):

colo   —— Worker 实际执行机房
region —— D1 实际服务区域
  • 同区域 → 开了没收益,还可能变慢,别开
  • 不同区域 → 有意义,可以开

实测结果(2026-10-04,用户提供):

{"region":"WNAM","colo":"LAX"}

两者同为美西 → 结论:不开。Worker 已经在 D1 同区域执行,Smart Placement 带不来收益, 还可能把请求推到离读者更远的机房。本项关闭,不用再做。

2-D. 「加 HTTP 缓存头」——不是独立项

它只是 2-B 的实现细节(caches.default.put() 要求响应显式可缓存)。单独加头没有任何效果,不要当成一项来做。

顺带发现(本轮实测捡到的既有问题,与 2-A 独立)

① initArtalk() 被调用两次 → conf / comments / pv 全部发两遍。

根因是既有代码的重复触发:

partials/artalk.html 内联脚本 → initArtalk 尚未定义 → 注册 DOMContentLoaded 回调(兜底)
assets/js/main.js:70          → 也在 DOMContentLoaded 里调 window.initArtalk()
                              ↓
                    两个监听都会触发 → initArtalk 跑 2 次

CDP 实测(改动前):GET /api/v2/conf ×2、GET /api/v2/comments ×2、POST /api/v2/pages/pv ×2。 2-A 把 conf 那两次消掉了,但 comments 仍多付一次跨境,pv 仍是双倍计数 (浏览量统计偏高 100%,这是数据准确性问题,不只是性能)。

修法很轻:在 initArtalk 入口加幂等守卫(同一 pageKey 已初始化过就 return), PJAX 切页时 pageKey 变化自然放行。但改动落在 PJAX 路径上,建议单独一轮做 + 单独验证。

② head.html:59 有一个第三方统计脚本。

<script async src="https://019e3dec-3312-7873-862a-3f56ac99ea83.spst2.com/ustat.js"></script>

它不在主题自身模块里,是页面唯一的外部脚本,会把访问数据上报给第三方。 确认是不是你自己加的;若不是,建议连同这一行一起删掉。

③ ✅ 已修:非评论类错误层会让整个评论区「假故障」一次(顿一下 + 弹「已恢复」toast)。

现象:进文章页时评论区整体置灰、插「评论服务暂时不可用」横幅,约 1 秒后自己恢复并弹 评论服务已恢复,可以继续评论了 ✓ —— 读者看到的就是「顿一下 + 一条莫名其妙的提示」。

根因(CDP 时间线实测,非推测):

表情包 /emotion/OwO.json 加载失败(本地预览跨域;线上则可能是 CDN 抖动)
  → Artalk 在「编辑器插件面板」内渲染 .atk-error-layer,文本:
      Artalk Error / [表情] 加载失败: TypeError: Failed to fetch
  → rewrite() 只用 layer.querySelector('.error-message') 取文本 —— 而插件面板的错误层
    没有这个 class,取到空串
  → 空串不匹配任何特征词 → 兜底 code = 'internal'
  → if (code !== 'network') setSvcDown(code)   ← 只有 network 被豁免,internal 不豁免
  → 整个评论区置灰 + 插横幅(此时评论数据其实还没回来,也没失败)
  → 约 1s 后 Artalk 收掉错误层 / 评论数据正常返回 → setSvcUp() → 弹 toast

CDP 抓到的判定证据:错误卡片上的 data-raw = "|internal"(竖线左边就是空串 raw), 而错误层的真实 DOM 位置是 atk-editor-plug-emoticons < atk-plug-panel-wrap < atk-main-editor。

修复(themes/Ying/assets/js/modules/artalk.js,三处):

  1. rewrite() 开头直接跳过 .atk-plug-panel-wrap / .atk-editor-plug-emoticons 内的错误层 —— 既不改写文案,也不参与故障判定(这类层是资源加载失败,不是评论服务故障)。
  2. 故障判定加证据要求:if (code !== 'network' && (raw || (fresh && fresh.code))) —— 读不到可归因证据时不下结论,宁可只渲染卡片也不误置灰。
  3. setSvcUp() 加最短门槛:禁用态不足 3 秒不弹 toast(真实故障不会两三秒就恢复)。

验证:CDP 复跑,svc-down / 横幅 / toast 三项全部 0 次,评论数据仍 200; 另做三组回归:真实故障层仍置灰 ✅、无文本层不置灰 ✅、插件面板层完全放行 ✅。

⚠️ 线上本来不出现这个现象:线上页面在 usj.cc、表情包也在 usj.cc,同源不触发 CORS。 它只在本地预览(跨域)必现,线上要等 OwO.json 真出问题(CDN 抖动 / 404)才会踩到 —— 所以这算是一次「本地预览帮忙提前暴露了线上潜在缺陷」。

不建议做的

  • 改 Artalk 客户端把 ① ② 并行:要 fork 官方库,收益和 2-A 重叠,维护成本高。
  • 给 /pages/pv 加速:它已经是异步的,不阻塞渲染。

4. 建议的执行顺序

顺序 项目 收益 风险 状态
✅ 1 第一档 preconnect 首屏 −200~400ms 极低 已完成
✅ 2 2-A 首屏内联 conf conf 请求 2 次 → 0 次 低 已完成
✅ 3 2-C Smart Placement — 零 实测同区域 → 关闭
4 修「initArtalk 跑两次」 再省 1 跳,且修正 PV 双倍计数 中(涉及 PJAX) 建议做,单独一轮
5 2-B 读接口边缘缓存 保 D1 额度;命中 −100~200ms 中 推荐

做完 2 + 4 + 5,首屏从「2 跳跨境」变成「1 跳 + 命中即回」,D1 读量大幅下降,PV 计数恢复正确。

预期天花板:即使全部做完,L1(跨境那一跳)依然存在——免费版 CF 在国内就是没有节点。


5. 第三档(真正的解法,需要你权衡)

usj.cc 的 NS 在 DNSPod,可以对 api.usj.cc 做分线路解析:境内 → 境内机器反代 → CF;境外 → 直连 CF。这才治本(L1 也消失)。

两条硬约束:

  1. api.200181.xyz 本身做不了——200181.xyz 的 NS 在 Cloudflare,免费版不支持按国家分线路解析。所以必须换成 api.usj.cc 这类「NS 在 DNSPod」的域名。
  2. 代价是把一台自维护机器重新拉回关键路径——与 2026-10-04 刚完成的「0 台自维护机器」方向相反。

没有白吃的午餐:这一档能根治,但要把刚拆掉的东西装回去。


6. 不变量(别被顺手改坏)

  • SERVER_API_VERSION 必须与博客里打包的 Artalk 客户端版本一致(当前 2.8.7),否则前端弹版本警告。
  • listComments 里 countFromSql 的「不用 JOIN users 就不 JOIN」是额度优化,别回退。
  • 任何新增缓存都必须先回答:这个响应会不会因为「谁在问」而不同? 会 → 不能缓存,或必须把身份编进 key。