优化: - 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
16 KiB
评论加载优化方案(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 缓存那条路径也没省掉请求,它省的是「重新渲染」,不是「重新请求」。)
做法(三段,缺一不可):
- 构建期抓快照 ——
.cnb.yml的「Hugo 构建」stage 里curl一次/api/v2/conf, 落到data/artalk_conf.json(Hugo 读成site.Data.artalk_conf)。 拉取失败就删文件降级(失败发生在if条件里,不触发set -e,绝不让它拖垮构建)。 该文件已 gitignore:每次构建现拉,避免陈旧配置被 git 固化。 - 模板内联 ——
partials/artalk.html输出window.__artalkConfSnapshot = {…}, 放在window.artalkConfig之前。</会被替换成<\/防</script>逃逸。 - 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()。(这也是为什么这项不能"顺手加个头"就完事。)
必须做对的四件事:
- 只缓存匿名公开视图。
listComments的结果依赖是否管理员(is_pending过滤、includeMarked)、name/email、scope=user、type=mine|pending|mentions、view_only_admin——这些一律不缓存,只缓存scope=page的默认视图。/conf同理(含imgUpload || admin)。 - 缓存 key 要带 origin。
corsHeaders()已经把Access-Control-Allow-Origin回显成请求方的 Origin(并设了Vary: Origin),缓存 key 里必须把 origin 编进去,否则 A 域名的响应会被回给 B 域名。 - 失效策略复用现有机制。项目里已有
cml:ver:<site>:<page>这个 KV 版本号,评论新增/删除/审核会bumpCountVersion()。直接把它编进缓存 key → 有评论变化时 key 自然变化,旧条目靠 TTL 自然过期,不用做 purge。 - 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,三处):
rewrite()开头直接跳过.atk-plug-panel-wrap / .atk-editor-plug-emoticons内的错误层 —— 既不改写文案,也不参与故障判定(这类层是资源加载失败,不是评论服务故障)。- 故障判定加证据要求:
if (code !== 'network' && (raw || (fresh && fresh.code)))—— 读不到可归因证据时不下结论,宁可只渲染卡片也不误置灰。 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 也消失)。
两条硬约束:
api.200181.xyz本身做不了——200181.xyz 的 NS 在 Cloudflare,免费版不支持按国家分线路解析。所以必须换成api.usj.cc这类「NS 在 DNSPod」的域名。- 代价是把一台自维护机器重新拉回关键路径——与 2026-10-04 刚完成的「0 台自维护机器」方向相反。
没有白吃的午餐:这一档能根治,但要把刚拆掉的东西装回去。
6. 不变量(别被顺手改坏)
SERVER_API_VERSION必须与博客里打包的 Artalk 客户端版本一致(当前 2.8.7),否则前端弹版本警告。listComments里countFromSql的「不用 JOIN users 就不 JOIN」是额度优化,别回退。- 任何新增缓存都必须先回答:这个响应会不会因为「谁在问」而不同? 会 → 不能缓存,或必须把身份编进 key。