写在前面:
本文首发于 Asahi Blog,可能会出现用词不当、不分主次、逻辑不明等各种错误,以及入机式发言,希望各位佬友批评指正。
升级完 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>
</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(默认开启),会在后端走一套完整的处理流水线:
- 用
goldmark把 Markdown 源文件渲染成 HTML - 用
bluemonday的 UGC Policy 对 HTML 进行 sanitize(消毒) - 把清洗过的 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 a5ba6a0 从 down.go 里整体删除了那 31 行后端清洗逻辑,同时移除了对 goldmark 和 bluemonday 两个依赖库的引用,并在 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;
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>,不走 README 过滤器
<head>,标准 HTML 解析
README 的设计初衷是内容展示,过滤脚本是合理的安全考量。需要执行 JS 的逻辑就应该放到 Custom Body/Head 这两个专门为此设计的位置。
1 个帖子 - 1 位参与者