Hexo NexT 评论系统长期失效的排查与修复

这个博客最初在 Windows 上维护,后来迁移到 Linux。迁移之后,文章正文、静态资源和部署流程都能正常工作,唯独评论系统长期无法恢复。

问题最令人困惑的地方在于:NexT 配置文件里明明填写过 Gitalk、Twikoo 和 Utterances 的配置,生成过程也没有直接报告评论相关错误,但文章末尾始终是一片空白。由于故障恰好发生在系统迁移之后,很容易把原因归结为 Windows 与 Linux 的路径、换行符或 Node.js 环境差异。

最终排查表明,操作系统迁移只是时间上的巧合。真正的问题分为两层:

  1. 评论配置长期处于相互矛盾的“半启用”状态;
  2. 切换到 Utterances 后,NexT 的自定义加载事件又发生了脚本时序竞争。

后者才是“页面源码里明明有评论代码,浏览器里却什么都没有”的直接原因。

完成配置并重新生成后,检查 public/cycle.html,可以看到评论容器、Utterances 配置和加载脚本全部存在:

1
2
3
4
5
6
7
8
9
10
11
12
<div class="comments utterances-container"></div>

<script class="next-config" data-name="utterances" type="application/json">
{
"enable": true,
"repo": "liujunhong2/liujunhong2.github.io",
"issue_term": "pathname",
"theme": "github-light"
}
</script>

<script src="/js/third-party/comments/utterances.js" defer></script>

线上页面也包含完全相同的内容,https://utteranc.es/client.js 返回 HTTP 200。这说明:

  • Hexo 已经读取评论配置;
  • NexT 已经注入评论容器;
  • 修复后的静态文件已经部署;
  • Utterances 官方脚本可以访问。

如果只检查生成产物,似乎所有环节都正常。但“脚本标签存在”并不等于“脚本按照预期执行”,所以排查必须从构建阶段进入浏览器运行时。

使用浏览器运行时取证

通过无界面 Chrome 和 Chrome DevTools Protocol 读取实际页面状态,得到如下结果:

1
2
3
4
5
6
7
8
9
{
"readyState": "interactive",
"container": true,
"containerHTML": "",
"frames": [],
"scripts": [
"https://baoziwan.top/js/third-party/comments/utterances.js"
]
}

这段结果非常关键:

  • .utterances-container 确实存在;
  • 容器内部为空;
  • 页面没有创建任何 Utterances iframe;
  • 浏览器只看到了 NexT 的包装脚本,没有插入官方 client.js

因此问题不在 CSS,也不在 GitHub Issue 映射,更不是 iframe 被主题遮挡。加载流程根本没有走到创建 iframe 的阶段。

根因:defer 脚本和 page:loaded 事件发生竞争

NexT 的 Utterances 脚本并不立即加载评论,而是先监听一个主题自定义事件:

1
2
3
4
5
6
7
8
document.addEventListener('page:loaded', () => {
if (!CONFIG.page.comments) return;

NexT.utils.loadComments('.utterances-container')
.then(() => NexT.utils.getScript('https://utteranc.es/client.js', {
// Utterances attributes
}));
});

page:loaded 由 NexT 的 utils.js 触发。原逻辑会根据 document.readyState 决定立即派发事件,或者只等待一次状态变化:

1
2
3
4
5
6
7
8
9
const onPageLoaded = () => document.dispatchEvent(
new Event('page:loaded', { bubbles: true })
);

if (document.readyState === 'loading') {
document.addEventListener('readystatechange', onPageLoaded, { once: true });
} else {
onPageLoaded();
}

问题在于,utils.js 位于页面较前的位置,而 Utterances 包装脚本位于文末,两者都使用 defer。在某些加载顺序下会出现如下过程:

  1. HTML 解析接近结束,document.readyState 进入 interactive
  2. 前面的 utils.js 执行并立即派发 page:loaded
  3. 文末的 Utterances 脚本稍后才执行并注册监听器;
  4. 事件不会为后来注册的监听器自动重放;
  5. 评论容器永远保持空白。

如果前面还存在加载缓慢的第三方 defer 脚本,执行顺序会变得更加脆弱。这也解释了为什么问题看起来像是“有时正常、有时异常”,以及为什么仅凭静态 HTML 很难发现。

最终在 NexT 的 themes/next/layout/_third-party/comments/utterances.njk 模板中加入一个独立加载器。它会在浏览器解析到文末时立即执行,同时仍监听 page:loaded,用于兼容 PJAX 页面切换。

核心实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
(() => {
const loadUtterances = () => {
const container = document.querySelector('.utterances-container');
const configElement = document.querySelector(
'.next-config[data-name="utterances"]'
);

if (!container || !configElement || container.dataset.utterancesLoading) {
return;
}

let config;
try {
config = JSON.parse(configElement.textContent);
} catch (error) {
console.error('Failed to parse Utterances config.', error);
return;
}

if (!config.repo) return;

container.dataset.utterancesLoading = 'true';

const script = document.createElement('script');
script.src = 'https://utteranc.es/client.js';
script.async = true;
script.crossOrigin = 'anonymous';
script.setAttribute('repo', config.repo);
script.setAttribute('issue-term', config.issue_term || 'pathname');
script.setAttribute('theme', config.theme || 'github-light');

if (config.label) {
script.setAttribute('label', config.label);
}

container.appendChild(script);
};

document.addEventListener('page:loaded', loadUtterances);
loadUtterances();
})();

这个实现有几个重要设计点。

  1. 不再等待可能已经错过的事件

注册监听器后立即调用一次 loadUtterances()。即使 NexT 已经派发过 page:loaded,首次打开文章时仍会加载评论。

  1. 不依赖 NexT 的运行时对象

加载器直接读取页面中的 JSON 配置节点,不要求 CONFIGNexT.utils 或其他主题脚本已经完成初始化。因此,即使某个前置第三方脚本加载缓慢,评论也不会被它阻塞。

  1. 使用幂等保护避免重复加载

通过 data-utterances-loading 标记容器。立即调用和 page:loaded 事件即使先后到达,也只会插入一次 client.js

  1. 保留 PJAX 兼容性

首次打开首页时可能没有评论容器,但监听器仍然存在。通过 PJAX 进入文章后,新的 page:loaded 会再次检查容器并加载评论。

  1. 失败必须对用户可见

原故障最大的麻烦是“静默失败”:页面只留下一块空白。修复后的加载器给 script 添加了错误处理。如果官方脚本加载失败,会显示错误提示和 GitHub Issues 备用链接,而不是继续假装一切正常。

1
2
3
4
5
6
7
8
9
10
11
script.addEventListener('error', () => {
delete container.dataset.utterancesLoading;
container.textContent = '评论加载失败,请检查网络后刷新,或前往 GitHub Issues 留言:';

const link = document.createElement('a');
link.href = `https://github.com/${config.repo}/issues`;
link.textContent = config.repo;
link.target = '_blank';
link.rel = 'noopener';
container.appendChild(link);
});

这次故障从表面上看只是“文章末尾没有评论框”,实际横跨了 Hexo 数据模型、NexT 注入机制、GitHub App、第三方脚本加载以及浏览器事件时序。

最终有效的排查路径是:先消除配置矛盾,再确认静态生成结果,最后进入真实浏览器观察运行时 DOM。修复的关键也不是简单地再写一次 enable: true,而是让评论加载逻辑摆脱容易丢失的初始化事件,并为重复执行和外部失败做好保护。

从此,评论区终于不再是那个“配置里存在、源码里存在、页面上却不存在”的幽灵组件。