一个差点把我逼疯的 Hugo 坑:LoveIt 里 music 与 typeit 为什么会时灵时不灵
码艺轩|一次从混乱、误判到钉死根因的完整排查记录

很多坑不会一下把人打倒,它只是让你在错误方向上反复消耗。
你以为是目录问题,以为是 shortcode 问题,以为是缓存问题,结果真正动了手脚的,是那行你最不容易起疑的初始化代码。
这篇文章,是一次技术排坑,也是一次把混乱重新理顺的记录。
有些问题,不是不会解。
是它先把你拖进混乱里,再一点点消耗你的判断。
而这一次,我差点就被 Hugo 和 LoveIt 这个坑,磨到怀疑自己。
很多坑不会一下把人打倒,它只是让你在错误方向上反复消耗。
你以为是目录问题,以为是 shortcode 问题,以为是缓存问题,结果真正动了手脚的,是那行你最不容易起疑的初始化代码。
这篇文章,是一次技术排坑,也是一次把混乱重新理顺的记录。
这不是一个普通的小问题
最近在折腾梦行志博客时,我遇到了一个非常恶心、也非常拧巴的问题。
我在文章里用了 music 和 typeit 这两个 shortcode,正文源码里明明已经输出了 <meting-js>,也已经有 .typeit 容器,可页面就是不生效。播放器不出来,打字动画不启动,像是活了一半,又像是死了一半。
最折磨人的地方不在于它报错,而在于它不痛不痒地坏着。它不给你一个明确的错误信息,不给你一个清晰的失败信号,只是让你不断怀疑:是不是自己又写错了?是不是某个路径又配歪了?是不是 Hugo 又抽风了?
更烦的是,它还不是全站都坏,而是“有的文章正常,有的文章不正常”。这种问题最磨人。因为只要不是全坏,你就总会忍不住去怀疑是不是自己某一篇文章写法不一样,某一个目录结构不一样,某一个 front matter 参数不一样。
而我当时最先怀疑的,就是目录。
因为我测试出来的规律很诡异:
hiddenFromHomePage: true时,所有目录下都正常。hiddenFromHomePage: false时,只有Code Art Studio目录下的文章正常,别的目录下大量失效。
看到这里,人本能就会往“目录差异”“分类模板差异”“某个 section 特殊处理”这些方向猜。说实话,我当时也是这么想的。
但后来我才明白:这整个现象里,目录只是烟雾,真正的火根本不在那里。

先别乱猜,先看结果
我后来强迫自己停下来,不再继续凭感觉猜,而是回到最原始也最有效的一步:直接看最终 HTML 输出。
很多问题,只有当你肯老老实实去看“最后到底生成了什么”,你才会发现,自己前面那些猜测到底有多偏。
我把“生效页”和“失效页”的源码一点点对比之后,发现一个很关键的事实:
shortcode 其实已经执行了
失效页里并不是完全没有内容。
它照样有:
<meting-js auto="https://music.163.com/#/playlist?id=12472829847" theme="#448aff"></meting-js>
<div class="typeit"><h4 id="id-1"></h4></div>这一步一下就排除了很多错误方向。
因为如果正文里连 <meting-js> 都没有,说明是 music shortcode 没跑;如果连 .typeit 容器都没有,说明是 typeit shortcode 没跑。可现在容器都在,这就说明:
- shortcode 本身被识别了;
- shortcode 本身也执行了;
- 它们确实把容器写进了正文;
- 问题不在“执行没执行”,而在“执行完以后发生了什么”。
这一步特别关键。它让我把问题从“shortcode 不生效”修正成了“shortcode 只生效了一半”。
也就是:人到了,饭没上来。
真正缺的,不是容器,而是资源
继续往下对比后,我很快发现真正缺失的东西是什么。
生效页底部会正常加载这些资源:
<link rel="stylesheet" href="/lib/aplayer/APlayer.min.css">
<link rel="stylesheet" href="/lib/aplayer/dark.min.css">
<script src="/lib/aplayer/APlayer.min.js"></script>
<script src="/lib/meting/Meting.min.js"></script>
<script src="/lib/typeit/index.umd.js"></script>并且,生效页的 window.config 里还能看到对应的初始化数据:
window.config = {
typeit: {
speed: 100,
cursorSpeed: 1000,
cursorChar: "|",
duration: -1,
data: { ... }
}
}而失效页呢?
- 有
<meting-js>,但没有 APlayer 和 Meting 的 CSS/JS; - 有
.typeit容器,但没有 TypeIt 的 JS; window.config里也没有typeit和data这些初始化字段。
这时候,方向终于开始清晰了。
问题根本不在于 shortcode 没有输出,而在于:
页面后面的资源注入环节,没有拿到 shortcode 写入的状态。
也就是说,问题不是发生在正文阶段,而是发生在正文之后。

LoveIt 的设计,其实早就把坑埋好了
等我开始看 LoveIt 的 shortcode 和 partial 以后,这个问题才真正有了骨架。
LoveIt 对 music 和 typeit 的处理,不是“执行 shortcode 时,顺手就把所有依赖资源全都输出掉”。它走的是两阶段机制。
第一阶段:shortcode 只负责登记需求
music.html 大概是这样:
<meting-js auto="{{ .Get `auto` }}" theme="{{ $theme }}"></meting-js>
{{- .Page.Scratch.SetInMap "this" "music" true -}}也就是说,它做两件事:
- 输出一个
<meting-js>容器; - 往
.Page.Scratch的this里写入music = true。
typeit.html 也是类似套路:
- 先输出
.typeit容器; - 再把 TypeIt 的分组数据写进
.Page.Scratch的this.typeitMap。
第二阶段:assets.html 统一加载依赖
真正决定要不要加载 APlayer、Meting、TypeIt 的,是 assets.html:
{{- if (.Scratch.Get "this").music -}}
{{- /* 加载 APlayer / Meting */ -}}
{{- end -}}
{{- with (.Scratch.Get "this").typeitMap -}}
{{- /* 加载 TypeIt 并写入 window.config */ -}}
{{- end -}}看到这里,其实已经能感觉到问题的关键了。
这整套机制成立的前提只有一个:
shortcode 写进
.Scratch的状态,在assets.html读取它的时候,必须还在。
只要中间有人把 this 清空了,整个系统就会出现一种非常诡异的“半生效”状态:
- 容器还在;
- 资源没了;
- 页面看上去像 shortcode 失效;
- 但本质上,是状态流断了。
真正的变量,不是目录,而是首页
这时候我重新回头看 hiddenFromHomePage 这个线索,才发现它根本不是一个无关紧要的小参数。
它的意义不是“文章显示不显示在首页”这么简单。对这次问题来说,它真正改变的是:
这篇文章会不会先参与首页渲染,再参与 single 页面渲染。
也就是说,真正的变量不是目录,而是:
- 这篇文章是否进入了首页列表;
- 它是否因此多走了一条渲染路径;
- 在这条额外的路径里,页面级状态有没有被破坏。
这一下,我之前关于“目录特殊”“section 特殊”“某个分类模板不同”的那些猜测,基本全都可以丢掉了。
它们不是完全没关系,而是根本不在核心上。
真正的核心,是:
这篇文章在 Hugo 的渲染链里,被处理了几次;而这几次之间,共享状态有没有被错误清空。
我也试过先强制渲染 .Content,结果还是没用
在还没找到根因之前,我一度怀疑是不是 assets.html 读 .Scratch 的时机太早了,于是尝试过一个看起来很合理的修法。
我在 assets.html 顶部加了这样一行:
{{- $noop := .Content -}}想法很直接:
- 先把正文强制渲染一遍;
- shortcode 先执行;
.Scratch先写入;- 后面再读取
this.music和this.typeitMap。
按理说,如果问题只是“读取太早”,这一招应该能修好。
结果呢?
完全没有效果。
这一步失败之后,我反而彻底意识到:
问题不是“没写进去”,而是“写进去以后,又被谁抹掉了”。
有时候排查就是这样。你以为自己是在修问题,结果其实是在一步一步缩小嫌疑范围。虽然表面看没修好,但实际上你已经离根因更近了。
真正的元凶,藏在最像没问题的地方
后来继续往上游翻,终于看到了那个真正把我绊住的东西:init.html。
在这个初始化模板的最后,有这么两行:
{{- .Scratch.Set "params" $params -}}
{{- .Scratch.Set "this" dict -}}问题就出在第二行:
{{- .Scratch.Set "this" dict -}}它看起来特别普通。普通到你第一眼几乎不会觉得它有问题。甚至你还会本能地觉得,这不就是初始化一个字典吗?再正常不过。
可真正的坑,就埋在“看起来再正常不过”里。
这不是初始化,这是重置
这行代码的真实含义不是:
- 如果
this不存在,就初始化一个空字典。
而是:
- 不管
this里原来有没有数据; - 不管前面 shortcode 往里面写了什么;
- 只要这里跑一次,就无条件用一个新的空字典把它覆盖掉。
也就是说,前面所有写进去的:
music = truetypeitMap = {...}styleArr = [...]scriptArr = [...]
都可能在这里被一把清空。

看到这里,我前面所有的困惑一下就全接上了。
整个问题终于完全解释通了
把前面的线索全部串起来以后,这个坑的运行轨迹就非常清楚了。
当 hiddenFromHomePage: false
也就是文章会出现在首页时,流程大概是这样:
首页开始渲染;
init.html被调用;执行:
{{- .Scratch.Set "this" dict -}}这时候
this = {}。首页渲染这篇文章的摘要或相关内容,触发
.Content;music.html执行,写入:this.music = truetypeit.html执行,写入:this.typeitMap = {...}首页渲染完成;
然后开始生成这篇文章自己的 single 页面;
init.html又被调用一次;它再次执行:
{{- .Scratch.Set "this" dict -}}- 这一行把首页阶段 shortcode 写进去的状态全部抹平;
- 等到
assets.html再去读取:
(.Scratch.Get "this").music
(.Scratch.Get "this").typeitMap读到的就是空。
- 最终结果就是:
- 容器还在;
- 资源不在;
window.config缺字段;- 页面看起来像 shortcode 失效;
- 实际上是共享状态在中间被清空了。
当 hiddenFromHomePage: true
也就是文章不参与首页渲染时,整个过程就只走 single 页面这一条路径:
init.html初始化一次;.Content渲染一次;- shortcode 写入状态;
assets.html成功读取状态;- 依赖资源全部正常注入。
所以这个时候就一切正常。
到这一步为止,这个问题才算真正从“诡异现象”变成了“清晰因果链”。
最终修复,只有很小一刀
说到底,这个问题的根因不复杂。真正复杂的,是你在找到它之前,会被它带着绕很多圈。
最后真正的修复,只需要把原来的:
{{- .Scratch.Set "params" $params -}}
{{- .Scratch.Set "this" dict -}}改成:
{{- .Scratch.Set "params" $params -}}
{{- if not (.Scratch.Get "this") -}}
{{- .Scratch.Set "this" dict -}}
{{- end -}}这个改动到底做了什么
逻辑其实很朴素:
- 如果
this还不存在,那说明现在确实是第一次初始化,可以安全创建; - 如果
this已经存在,那说明前面某个阶段可能已经写过东西了; - 这时候绝不能再覆盖它。
也就是说,我们不是不初始化,而是:
只允许它第一次初始化,不允许它在后面的渲染路径里反复“洗掉现场”。
这就是整个修复的本质。
修复后的完整 init.html
下面是我最后实际可用的版本:
{{- .Scratch.Set "version" "1.0.0" -}}
{{- $params := .Params | merge .Site.Params.page -}}
{{- if eq hugo.Environment "production" -}}
{{- $cdn := .Site.Params.cdn -}}
{{- with $cdn.data -}}
{{- $cdnData := printf "data/cdn/%v" . | resources.Get | transform.Unmarshal -}}
{{- $cdn = dict "simpleIconsPrefix" $cdnData.prefix.simpleIcons -}}
{{- $prefix := $cdnData.prefix.libFiles | default "" -}}
{{- range $key, $value := $cdnData.libFiles -}}
{{- $cdn = printf "%v%v" $prefix $value | dict $key | merge $cdn -}}
{{- end -}}
{{- end -}}
{{- .Scratch.Set "cdn" $cdn -}}
{{- .Scratch.Set "fingerprint" .Site.Params.fingerprint -}}
{{- .Scratch.Set "analytics" .Site.Params.analytics -}}
{{- .Scratch.Set "comment" $params.comment -}}
{{- if eq .Params.comment true -}}
{{- .Scratch.Set "comment" .Site.Params.comment -}}
{{- else if eq .Params.comment false -}}
{{- .Scratch.Set "comment" dict -}}
{{- end -}}
{{- else if eq .Site .Sites.Default -}}
{{- warnf "Current environment is not "production". The "comment system", "CDN" and "fingerprint" will be disabled.
" -}}
{{- warnf "当前运行环境不是 "production". "评论系统", "CDN" 和 "fingerprint" 不会启用.
" -}}
{{- end -}}
{{- .Scratch.Set "params" $params -}}
{{- if not (.Scratch.Get "this") -}}
{{- .Scratch.Set "this" dict -}}
{{- end -}}
{{- partial "plugin/compatibility.html" . -}}建议把这个文件放在项目根目录:
layouts/_partials/init.html不要直接魔改主题目录。因为主题一旦更新,你之前的修改很容易被覆盖掉。
这次复盘,真正值钱的不是“修好了”
说实话,最后改的代码很少。
但这次排查对我真正有价值的,不是“又解决了一个技术问题”,而是我再一次看清了:很多坑之所以折磨人,不是因为它有多难,而是因为它太像别的问题了。
它先让你以为是目录问题; 再让你以为是 shortcode 问题; 再让你以为是资源路径问题; 再让你以为是 Hugo 缓存问题; 最后才让你发现,真正的问题居然藏在初始化模板那句你最不容易起疑的代码里。
这和很多现实里的困局其实很像。
真正把人拖住的,往往不是最凶的那一下,而是那些“不致命、但足够让你持续内耗”的东西。它们不一下子把你打倒,而是让你在错误方向上反复消耗,慢慢怀疑自己,慢慢乱掉节奏。
如果这次我一直跟着表象跑,我可能还会继续怀疑:
- 是不是哪个目录没配对;
- 是不是哪个 front matter 少了一个字段;
- 是不是哪个模板分支走偏了;
- 是不是某个 CDN 资源偶发失效;
- 是不是浏览器缓存发神经。
可一旦回到“看最终输出、看状态流、看是谁真正改了状态”这条线,很多迷雾就会自己散掉。
写在最后
这篇文章写的是一个 Hugo + LoveIt 的坑,但对我来说,它也不只是一个技术复盘。
它更像是一次提醒:
当你已经被一个问题困住很久的时候,最重要的不是赶紧乱改,而是先让自己稳下来。先去看它到底发生了什么,再去看是谁真正改变了结果,最后再动手。
很多局不是破不了,而是人在急的时候,会先把自己弄乱。
这次也一样。
前面那一大圈怀疑和误判,其实都不值钱。真正值钱的,是最后把那条状态链看清楚以后,我知道自己以后再碰到类似问题,应该优先往哪里看了。
这比修好一个 bug 更重要。
因为 bug 会过去,但这次逼出来的判断力,会留下来。

又填平一个差点把我逼疯的 Hugo 坑。
表面上看像目录异常、像 shortcode 失效、像缓存抽风,最后真正的元凶,却只是 LoveIt 里 init.html 那一行最不起眼的代码。
我把整个排查过程、因果链和修复方案都完整写下来了。如果你也在用 Hugo + LoveIt,尤其碰到过 music / typeit 容器在、资源却不加载的情况,这篇应该能帮你少走很多弯路。
–全文完–

“同频之人,终会相遇;同行之路,终有光亮。”
梦行志

