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

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

写在前面:

本文首发于 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 请求正常发出并返回 :white_check_mark:
  • <script type="module"> 中的 JS 完全没有执行 :cross_mark:
  • #waline 容器尺寸为 908.812×0(宽度撑开了,但高度为 0,Waline 从未初始化):cross_mark:

根本原因: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;
  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 渲染 + 新版过滤器 :cross_mark:(被过滤) 看情况,不稳定 自定义 Body 直接注入 <body>,不走 README 过滤器 :white_check_mark: :white_check_mark: 自定义 Head 注入 <head>,标准 HTML 解析 :white_check_mark: :white_check_mark:

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

1 个帖子 - 1 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文