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

282 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 评论加载优化方案(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` 的初始化是**串行**的——已从压缩源码确认:
```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 正确去重):
```html
<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` 压缩源码后确认**做不到**:
>
> ```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,用户提供)**:
```json
{"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` 有一个第三方统计脚本。**
```html
<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。