1795 字
9 分钟
OpenList 升级到 4.2.2 后 Waline 评论失效?根本原因和完整修复方案

升级完 OpenList 的那一刻,我以为只是常规的 bugfix 更新,结果打开主页一看——评论区消失了。

CSS 好端端的,Waline 的样式文件请求返回 200,但 <div id="waline"> 里空空如也,高度是一个令人绝望的 908.812×0

折腾了一段时间之后,把问题锁定在了 v4.2.2 的一个 Breaking Change 上。这篇文章就把整个来龙去脉记录下来,顺带把修复方案整合一下,希望踩过同样坑的朋友少花点时间。

问题复现#

根据 Waline快速上手中的方案,在旧版本(≤ 4.1.10)中仅需在主页的元数据 README 里写一段 HTML,CSS 和 JS 全部内联:

<!-- Waline 容器 -->
<link rel="stylesheet" href="https://unpkg.com/@waline/client@v3/dist/waline.css" />
<div id="waline-comment" style="margin: 20px auto; max-width: 960px;">
<h2 style="text-align:center">- 评论 Comments -</h2>
<div id="waline"></div>
14 collapsed lines
</div>
<!-- Waline JS 初始化 -->
<script type="module">
import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js';
init({
el: '#waline',
serverURL: 'https://comments.example.com',
emoji: false,
comment: true,
search: false,
path: window.location.pathname,
});
</script>

升级到 4.2.2 之后,这段代码的表现变成:

  • <link> 的 CSS 请求正常发出并返回 ✅
  • <script type="module"> 中的 JS 完全没有执行
  • #waline 容器尺寸为 908.812×0(宽度撑开了,但高度为 0,Waline 从未初始化)❌

根本原因:PR #2346 把过滤逻辑从后端搬到了前端#

v4.2.2 的 Release Notes 里有两条 Breaking Changes,其中一条就是:

settings: Move FilterReadMeScripts to frontend - by @xrgzs in #2346

这条改动对应 commit a5ba6a0,看一下实际 diff 就很清楚了。

旧版后端做了什么#

旧版的 server/handles/down.go 在代理 .md 文件时,如果 FilterReadMeScripts 开关为 true(默认开启),会在后端走一套完整的处理流水线:

  1. goldmark 把 Markdown 源文件渲染成 HTML
  2. bluemonday 的 UGC Policy 对 HTML 进行 sanitize(消毒)
  3. 把清洗过的 HTML 以 text/html 形式返回给前端

bluemonday 的 UGC Policy 会剥除 <script><link> 等危险标签,这是它的设计初衷。但这套流水线只在直接代理 .md 文件时(走 /d/ 路径)才触发,主页展示 README 走的是 /api/fs/get API 接口,后端直接原样返回 Markdown 内容,不经过 bluemonday。

所以旧版真正起作用的机制在前端:前端把 README 内容渲染为 HTML 注入到页面后,会对其中的 <script> 标签做一次克隆重新 append 的处理,让它们实际执行。<script type="module"> 就是通过这个机制被触发的。

新版改动:32 行代码的删除#

commit a5ba6a0down.go整体删除了那 31 行后端清洗逻辑,同时移除了对 goldmarkbluemonday 两个依赖库的引用,并在 setting.go 里给 FilterReadMeScripts 的配置加上了注释 // frontend,意思是:这个开关的实际执行逻辑已经全部交给前端负责了。

这是一次架构上的合理重构——把视图层的过滤放在前端做,后端减负,逻辑更清晰。

但问题在于:前端新版本对 README 内容里 <script> 标签的处理比之前更严格,type="module" 形式的脚本会被直接过滤掉,不再执行

这就解释了所有现象:

现象原因
CSS 正常加载<link> 标签通过了前端的过滤,或走了不同处理路径
JS 完全不执行<script type="module"> 被前端新过滤器丢弃
#waline 高度为 0容器 div 存在,但 Waline 从未初始化,内容为空

顺便一提,<script type="module"> 在被 innerHTML 动态插入 DOM 时,浏览器本身也不会自动执行——这是浏览器的安全规范,ES Module 的 import 语义只在顶层脚本上下文有效。所以即使前端没过滤,不经过特殊处理的 module script 也跑不起来。旧版前端的克隆 append 机制恰好绕过了这个限制,新版则没有。

完整修复方案#

修复思路是把代码分散到三个不同的注入点,各司其职,完全绕开 README 的过滤器:

第一步:自定义头部(Custom Head)—— 放 CSS#

进入 设置 → 全局 → 自定义头部,填入:

<link rel="stylesheet" href="https://unpkg.com/@waline/client@v3/dist/waline.css">

<head> 里的 <link> 走标准 HTML 解析路径,完全不经过任何 README 过滤器,稳定可靠。

第二步:自定义 Body(Custom Body)—— 放 JS 初始化逻辑#

进入 设置 → 全局 → 自定义 Body,填入以下完整代码:

<!-- 引入 UMD 版本的 Waline(挂载到 window.Waline 全局变量) -->
<script src="https://unpkg.com/@waline/client@v3/dist/waline.umd.js"></script>
<script>
(function() {
let walineInstance = null;
49 collapsed lines
let initTimer = null;
function doInitWaline() {
const walineEl = document.querySelector('#waline');
if (!walineEl || typeof Waline === 'undefined') return;
// 容器内已有内容,说明 Waline 正常挂载着,无需重复初始化
if (walineEl.children.length > 0) return;
// 有旧实例先销毁,防止内存泄漏
if (walineInstance && typeof walineInstance.destroy === 'function') {
try { walineInstance.destroy(); } catch(e) {}
walineInstance = null;
}
walineInstance = Waline.init({
el: '#waline',
serverURL: 'https://comments.example.xyz', // 替换为你的 Waline 服务地址
emoji: false,
comment: true,
search: false,
path: window.location.pathname,
});
}
// 防抖:SPA 路由切换时 DOM 频繁变动,延迟 300ms 等待稳定
function debouncedInit() {
clearTimeout(initTimer);
initTimer = setTimeout(doInitWaline, 300);
}
// 1. 页面首次加载
doInitWaline();
// 2. 监听 DOM 变化(SPA 框架动态重建 DOM 时触发)
const observer = new MutationObserver(debouncedInit);
observer.observe(document.body, { childList: true, subtree: true });
// 3. 拦截 History API(前进/后退/框架内部路由跳转)
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function() {
originalPushState.apply(this, arguments);
debouncedInit();
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
debouncedInit();
};
window.addEventListener('popstate', debouncedInit);
})();
</script>

为什么改用 UMD 而不是 ESM?

原来的 import { init } from '...waline.js' 是 ES Module 写法,有两个问题:一是动态插入的 <script type="module"> 浏览器不执行;二是前端过滤器会丢弃它。改用 waline.umd.js 后,脚本加载后直接把 Waline 挂到 window 上,后续代码用 Waline.init() 调用,完全是普通的全局变量方式,没有任何模块化限制。

为什么需要 MutationObserver 和 History API 拦截?

OpenList 是基于 SolidJS 的 SPA(单页应用)。当你从别的目录切换回主页时,整个 DOM 树会被 SolidJS 销毁并重建,之前初始化的 Waline 实例也随之消失。如果只在页面加载时初始化一次,路由切换回来后评论区就空了。

MutationObserver 监听 document.body 的子树变化,每次 DOM 重建后都会触发重新初始化;history.pushState/replaceState 的拦截则覆盖了框架内部路由跳转的场景;300ms 防抖避免 DOM 频繁变动时重复触发。

第三步:主页元数据 README —— 只保留容器 div#

回到主页元数据,把 <script><link> 全部删掉,只保留 HTML 容器:

<div id="waline-comment" style="margin: 20px auto; max-width: 960px;">
<h2 style="text-align:center">- 评论 Comments -</h2>
<div id="waline"></div>
</div>

纯粹的 <div> 不含任何可执行内容,不会被任何过滤器动到。JS 找到这个 #waline 容器后负责挂载,CSS 从 <head> 里来,三方各归其位。

三种注入点的本质区别#

理解这三个位置的差异,以后遇到类似问题就不会懵了:

注入点处理机制能跑 <script>能跑 <link>
主页元数据 README前端 Markdown 渲染 + 新版过滤器❌(被过滤)看情况,不稳定
自定义 Body直接注入 <body>,不走 README 过滤器
自定义 Head注入 <head>,标准 HTML 解析

README 的设计初衷是内容展示,过滤脚本是合理的安全考量。需要执行 JS 的逻辑就应该放到 Custom Body/Head 这两个专门为此设计的位置。

OpenList 升级到 4.2.2 后 Waline 评论失效?根本原因和完整修复方案
https://006lp.de/posts/openlist-waline-comment-fix/
作者
Hotaru
发布于
2026-06-08
许可协议
CC BY-NC-ND 4.0