升级完 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(默认开启),会在后端走一套完整的处理流水线:
- 用
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;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 这两个专门为此设计的位置。