优化: - 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
282 lines
16 KiB
Markdown
282 lines
16 KiB
Markdown
# 评论加载优化方案(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。
|