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
This commit is contained in:
zqlit committed 2026-10-04 15:00:52 +08:00
1 parent 9e972f2b61
commit 50a906e07c
14 files changed
+1018 -486

No files matched your search

+218 -19
View File
@@ -21,17 +21,94 @@
internal: { ic: '⚠️', title: '评论暂时加载不出来', desc: '服务出了点小状况,请稍后再试。' }
};
// ── /api/v2/conf 内联快照 ──────────────────────────────────────────
// 构建期把 conf 响应内联进页面(见 partials/artalk.html)。服务端这份 conf
// 只由 settings 表决定,唯一因人而异的字段是 imgUpload(imgUpload || admin),
// 所以匿名访客看到的 conf 人人都一样 —— 匿名请求直接本地合成 Response,
// 不发网络。Artalk 内部 conf 与评论列表是严格串行的,省掉这一跳等于首屏
// 少一整个跨境往返。
// 带 Authorization 的请求来自已登录用户(管理员拿到 imgUpload=true),属个性化
// 响应,放行真实网络。
var CONF_URI = /\/api\/v2\/conf(\?|$)/;
function hasAuthHeader(init, input) {
try {
var h = (init && init.headers) || null;
if (!h && input && typeof input !== 'string') h = input.headers || null;
if (!h) return false;
if (typeof h.get === 'function') return !!h.get('Authorization');
if (Array.isArray(h)) {
for (var i = 0; i < h.length; i++) {
if (String(h[i][0]).toLowerCase() === 'authorization') return !!h[i][1];
}
return false;
}
return !!(h.Authorization || h.authorization);
} catch (e) {
return true; // 判不准就当有凭据 → 放行真实请求(保守,宁可多一跳也不串号)
}
}
// ── 0) 去掉 GET/HEAD 上多余的 content-type:省掉一次 CORS 预检 ──────
// Artalk 给「所有」请求都带上 content-type: application/json,可 GET 根本没有 body。
// content-type 属于「非简单头」→ 浏览器必须先发一个 OPTIONS 预检(Access-Control-Request-Headers: content-type)。
// 预检按「完整 URL」缓存(含 query),而 comments 的 URL 带 page_key、每篇文章都不同,
// 于是每进一篇新文章都要重付一次预检 —— 跨域下这是实打实的整整一个 RTT,
// 而它正好卡在评论列表的关键路径上(预检 → 才发 GET)。
// GET/HEAD 没有 body,删掉这个头不改变任何语义,却能直接把预检砍掉。
function sansContentType(init) {
var out = {}, k;
for (k in init) out[k] = init[k];
var h = out.headers;
try {
if (h && typeof Headers === 'function' && h instanceof Headers) {
var nh = new Headers();
h.forEach(function (v, n) { if (n.toLowerCase() !== 'content-type') nh.append(n, v); });
out.headers = nh;
} else if (Array.isArray(h)) {
out.headers = h.filter(function (p) { return String(p[0]).toLowerCase() !== 'content-type'; });
} else if (h && typeof h === 'object') {
var nh2 = {};
for (k in h) { if (k.toLowerCase() !== 'content-type') nh2[k] = h[k]; }
out.headers = nh2;
}
} catch (e) {
return init; // headers 形态不认识就原样放行(宁可多一次预检,不能出错)
}
return out;
}
// ── 1) 记录 API 失败原因。只读不改:包装层原样返回上游响应,
// 失败时顺带 clone 一份读 body(clone 不影响 Artalk 自己读)。
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input) {
window.fetch = function (input, init) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
url = String(url);
var p = origFetch.apply(this, arguments);
if (url.indexOf('/api/v2/') === -1) return p;
// 学习 API 源(探测恢复用)
if (url.indexOf('/api/v2/') === -1) return origFetch.apply(this, arguments);
// 学习 API 源(探测恢复用)—— 拦快照之前先学,别因为少了一次网络请求而丢掉来源
if (!apiBase && url.indexOf('/api/v2/') > 0) apiBase = url.split('/api/v2/')[0];
// conf 快照命中:本地合成响应,完全不发网络
if (window.__artalkConfSnapshot && CONF_URI.test(url)
&& typeof Response === 'function' && !hasAuthHeader(init, input)) {
return Promise.resolve(new Response(
JSON.stringify(window.__artalkConfSnapshot),
{
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}
));
}
// GET/HEAD 且无 body:去掉 content-type,避免不必要的 CORS 预检
var fargs = arguments;
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
if ((method === 'GET' || method === 'HEAD') && !(init && init.body)) {
fargs = [input, sansContentType(init || {})];
}
var p = origFetch.apply(this, fargs);
return p.then(function (res) {
if (res && res.ok === false) {
try {
@@ -105,6 +182,7 @@
// 这里:编辑器整体置灰(内容原样可见,只是变灰)+ 编辑框上方插窄横幅
// 说明原因和恢复时间,并每 60s 用一次 1 行 D1 成本的探针(/captcha/status,
// 只读 1 行 settings)探测恢复,恢复后自动解除禁用并 toast,不用刷新页面。
// toast 统一走 Message.js(modules/toast.js 适配层),不再自绘浮层。
// conf 加载失败时 Artalk 渲染的是"空壳编辑器"(i18n 没加载):发送按钮
// 没有字、输入框没有占位符、工具栏按钮整排缺失。置灰态要保留完整外观,
@@ -130,22 +208,18 @@
var svcDown = false;
var downCode = '';
var downAt = 0; // 进入禁用态的时刻(用来判断这次"故障"是不是一闪而过)
var probeTimer = null;
var apiBase = ''; // 从拦截到的请求里学习 API 源(跨域探测需要绝对地址)
function svcToast(msg) {
var t = document.getElementById('atk-svc-toast');
if (!t) {
t = document.createElement('div');
t.id = 'atk-svc-toast';
t.style.cssText = 'position:fixed;left:50%;bottom:110px;transform:translateX(-50%);z-index:99999;' +
'padding:12px 24px;border-radius:999px;background:var(--theme-main,#07c160);color:#fff;' +
'font-size:14px;box-shadow:0 10px 32px rgba(0,0,0,.35);opacity:0;transition:opacity .35s,transform .35s;pointer-events:none';
document.body.appendChild(t);
// 统一通知组件(Message.js):Toast 在 core bundle 里先于本模块加载,
// 但 PJAX 极端时序下仍兜底判一次,避免 undefined 直接抛错
if (window.Toast && typeof window.Toast.info === 'function') {
window.Toast.info(msg, 3200);
} else {
try { console.log('[artalk]', msg); } catch (e) {}
}
t.textContent = msg;
requestAnimationFrame(function () { t.style.opacity = '1'; });
setTimeout(function () { t.style.opacity = '0'; }, 3200);
}
function ensureBar(code) {
@@ -185,8 +259,11 @@
function setSvcDown(code) {
downCode = code;
// 评论服务故障:列表不会返回了,别让骨架屏一直转(故障态有自己的横幅/卡片)
listSkeletonDone();
if (svcDown) { ensureBar(code); return; } // 已在禁用态,只刷新文案
svcDown = true;
downAt = Date.now();
ensureBar(code);
if (!probeTimer) {
probeTimer = setInterval(function () {
@@ -203,12 +280,26 @@
svcDown = false;
if (probeTimer) { clearInterval(probeTimer); probeTimer = null; }
removeBar();
svcToast('评论服务已恢复,可以继续评论了 ✓');
// 禁用态只持续了一瞬间就别报喜:读者眨个眼就"恢复"的故障,弹提示纯属噪音
// (真实故障从出现到恢复不会只有两三秒,所以门槛不会漏报)
if (downAt && Date.now() - downAt >= 3000) {
svcToast('评论服务已恢复,可以继续评论了 ✓');
}
downAt = 0;
}
function rewrite(layer) {
if (!layer) return;
// ── 不是评论列表的错误层,一律不碰 ──────────────────────────────
// Artalk 会把「编辑器插件资源加载失败」(表情包 / 图片等)的错误层也渲染成
// .atk-error-layer,挂在编辑器插件面板(.atk-plug-panel-wrap)里。它既不是
// 评论服务故障,也不该被改写成我们的故障卡片 —— 直接放行走 Artalk 原样式。
// 曾经没这道判断时:这类层的文本不在 .error-message 里 → raw 读成空串 →
// 兜底判成 internal → 因为 internal !== network 就置灰整个评论区,随后
// 数据正常返回又弹「评论服务已恢复」toast(读者视角:页面顿一下 + 莫名提示)。
if (layer.closest('.atk-plug-panel-wrap, .atk-editor-plug-emoticons')) return;
var raw = '';
var em = layer.querySelector('.error-message');
if (em) raw = (em.innerText || em.textContent || '').trim();
@@ -236,8 +327,10 @@
? fresh.msg.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::]\s*/, '')
: info.desc;
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅
if (code !== 'network') setSvcDown(code);
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅。
// 但「读不到任何可归因证据」时不下结论:raw 为空且结构化记录也没命中,
// 说明这是个我们识别不了的错误层,宁可只渲染卡片,也不要误置灰评论区。
if (code !== 'network' && (raw || (fresh && fresh.code))) setSvcDown(code);
// 原始「重试」节点:Vue 会整层重建,所以每次重新找;
// 若本层已重写过,就从我们自己卡片里把它取回来(移动 DOM 保留已绑事件)
@@ -501,8 +594,91 @@ window.loadScriptOnce = window.loadScriptOnce || function(src, globalName, callb
document.head.appendChild(script);
};
/* ── 评论列表骨架屏 ────────────────────────────────────────────────────
Artalk 初始化时会把 #Comments 整个清空(连服务端渲染出来的骨架屏一起清),
而评论列表要等一次跨境 /api/v2/comments 往返才有内容 —— 这段时间评论区是
一片空白(本地实测空档约 1.0~1.5s,跨境只会更久),读者视角就是"进来啥也没有"。
这里在 Artalk 挂载后往 .atk-list-body 里补一份骨架,list-loaded / 服务故障 /
12s 兜底时移除,把这段空档抹平。
延迟 120ms 才显示:命中缓存、极快返回的情况不会闪一下骨架。 */
var LIST_SKELETON_HTML =
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-80"></div>' +
'<div class="atk-loading-line width-60"></div>' +
'</div>' +
'</div>' +
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-60"></div>' +
'</div>' +
'</div>' +
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-80"></div>' +
'<div class="atk-loading-line width-40"></div>' +
'</div>' +
'</div>';
var listSkelTimer = null; // 延迟布防用的定时器
var listSkelSettled = false; // 本次导航的列表是否已经有结果(含失败)
function listSkeletonShow() {
var body = document.querySelector('#Comments .atk-list-body');
if (!body || body.querySelector('.atk-ys-skel')) return;
var skel = document.createElement('div');
skel.className = 'atk-ys-skel atk-loading-placeholder';
skel.setAttribute('aria-hidden', 'true');
skel.innerHTML = LIST_SKELETON_HTML;
body.insertBefore(skel, body.firstChild);
// 注意:Artalk 渲染评论时若整体重写 .atk-list-body 的 innerHTML,
// 骨架会随之被自然移除 —— 因此这里不做任何"占位"逻辑,移除是幂等的。
}
function listSkeletonHide() {
var skels = document.querySelectorAll('#Comments .atk-ys-skel');
for (var i = 0; i < skels.length; i++) {
if (skels[i].parentNode) skels[i].parentNode.removeChild(skels[i]);
}
}
/* 布防:Artalk 挂载后调用(可重复调用,只生效一次) */
function listSkeletonArm() {
if (listSkelSettled || listSkelTimer) return;
listSkelTimer = setTimeout(function () {
listSkelTimer = null;
if (listSkelSettled) return;
listSkeletonShow();
// 极端时序下 .atk-list-body 可能还没渲染出来:短轮询补一次
if (!document.querySelector('#Comments .atk-ys-skel')) {
var tries = 0;
var spin = setInterval(function () {
if (listSkelSettled || ++tries > 12) { clearInterval(spin); return; }
if (document.querySelector('#Comments .atk-list-body')) {
listSkeletonShow();
clearInterval(spin);
}
}, 100);
}
}, 120);
}
/* 撤防:列表有结果(成功/失败/超时)后调用 */
function listSkeletonDone() {
listSkelSettled = true;
if (listSkelTimer) { clearTimeout(listSkelTimer); listSkelTimer = null; }
listSkeletonHide();
}
window.initArtalk = function() {
if (!document.querySelector("#Comments")) return;
var mount = document.querySelector("#Comments");
if (!mount) return;
// Check if configuration exists
if (!window.artalkConfig) {
@@ -517,6 +693,17 @@ window.initArtalk = function() {
return;
}
// ── 幂等守卫:同一份 #Comments 只初始化一次 ──────────────────────────
// initArtalk 有多个调用入口(artalk.html 内联脚本 / main.js DOMContentLoaded /
// mypjax 的 script.onload 与 requestIdleCallback),不去重就会在同一页上创建
// 多个 Artalk 实例:实测每次 PJAX 进文章页发出 4× /api/v2/comments + 4×
// /pages/pv,列表还要等最慢那条响应才渲染(既拖首屏,又 4 倍消耗 D1 读额度)。
// 用「节点级」标记而不是全局标志:PJAX 换页会换掉 #Comments 节点,新节点无标记
// → 照常重新初始化,切页行为完全不受影响。
// 第二道判断(节点内已有 .atk-list-body)兜底 Artalk 若替换了挂载节点的情况。
if (mount.__atkInited || mount.querySelector('.atk-list-body')) return;
mount.__atkInited = true;
try {
// Logic for caching Artalk configuration to speed up rendering
// 1. Manual Refresh (F5): Request from server and save to localStorage
@@ -578,6 +765,14 @@ window.initArtalk = function() {
artalk.setDarkMode(currentTheme === 'dark');
}
// ── 评论列表骨架屏布防(见文件上方 listSkeleton* 注释) ──
listSkelSettled = false;
listSkeletonArm();
artalk.on('mounted', listSkeletonArm);
artalk.on('list-loaded', listSkeletonDone);
// 兜底:长时间没结果(严重慢网 / 列表接口异常)也别让骨架一直转
setTimeout(listSkeletonDone, 12000);
// ── 增强评论框字段属性 (兼容油猴自动填充脚本) ──
// 为 Artalk 输入框添加额外属性,确保第三方脚本(如博客网站留言评论信息自动填充)
// 能够通过 type/id/name/placeholder/class/aria-label 准确匹配
@@ -1183,5 +1378,9 @@ artalk.on('list-loaded', function() {
} catch (e) {
console.error('Artalk init failed:', e);
// 初始化失败要撤回幂等标记,否则后续入口的调用会被守卫拦掉、评论区再也起不来
mount.__atkInited = false;
listSkelSettled = true;
listSkeletonHide();
}
};