作为一个喜欢折腾博客细节的人,我一直觉得 Hugo PaperMod 主题的首页副标题太 “安静” 了 —— 静态文字放在那里,少了点互动感。于是今天给自己定了个小目标:给副标题加个「打字轮询动画」:文字逐字敲出、带闪烁光标,还能自动切换不同的句子。本以为是 “复制粘贴代码” 的小事,结果踩了好几个坑,好在最后完美实现。这篇就记录下整个过程,希望能帮到同样想折腾的朋友。
一、先看最终效果:我想要的是什么?
在动手前先明确需求,避免后续跑偏:
- 打字效果:文字不是一次性出来,而是像键盘敲击一样逐字显示;
- 闪烁光标:文字右侧跟着一个竖线光标,模仿真实打字体验;
- 自动轮询:打完一句后,逐字删除,再随机切换下一句(不重复);
- 不破坏主题:尽量不改主题核心文件,后续升级 PaperMod 不丢配置。
最终实现的效果是:首页 profile 区域,光标先闪,然后逐字 “敲” 出句子,停顿 2 秒后删光,再切换下一句,循环往复 —— 看起来像是博客在 “自言自语”,多了点灵动的感觉。
二、从零开始:分 5 步实现动画
整个过程围绕「配置→模板→逻辑→样式→加载」展开,每个步骤都有需要注意的细节,尤其是 Hugo 的模板语法和资源处理逻辑,踩坑也多在这上面。
第一步:配置文件埋 “伏笔”—— 集中管理要轮询的句子
首先要把想轮询的句子存在配置里,方便后续修改(不用改代码)。打开项目根目录的 config.yml
,在 params
下加两个关键配置:
params:
# 1. 启用首页 profile 模式(必须开,否则副标题不显示)
profileMode:
enabled: true # 核心开关,关了整个 profile 都没了
title: "我的博客" # 主标题不变
subtitle: "" # 副标题留空!后面让 JS 填内容
imageUrl: "img/avatar.jpg" # 头像路径(放 static/img 里)
imageWidth: 275
imageHeight: 275
# 2. 要轮询的句子列表(和 profileMode 平级!别嵌套进去)
quotes:
- "生命不是要超越别人,而是要超越自己。"
- "人生伟业的建立,不在能知,乃在能行。"
- "任何的限制,都是从自己内心开始的。"
- "含泪播种的人一定能含笑收获。"
这里踩了第一个坑:一开始把 quotes
嵌套进 profileMode
里(比如 params → profileMode → quotes
),结果后续 JS 根本读不到句子 ——Hugo 对 YAML 层级极敏感,必须确保 quotes
和 profileMode
是 “兄弟” 关系,都在 params
下面。
第二步:改模板 —— 给副标题 “挂标识”
PaperMod 渲染首页 profile 的模板是 index_profile.html
,需要给副标题加两个东西:
class="profile-subtitle"
:让 JS/CSS 能定位到这个元素;data-quotes
:把config.yml
里的句子列表以 JSON 格式 “注入” 到元素里,供 JS 读取。
操作时要注意:不要直接改主题文件夹的模板(升级会覆盖),先复制到项目目录:
- 把
themes/PaperMod/layouts/partials/index_profile.html
复制到项目根目录/layouts/partials/index_profile.html
(没有partials
文件夹就手动建); - 打开复制后的文件,找到原副标题代码(通常是
<span>{{ .subtitle | markdownify }}</span>
),替换成:
<!-- 副标题:加 class 供定位,加 data-quotes 传句子列表 -->
<span class="profile-subtitle"
data-quotes='{{ site.Params.quotes | jsonify | safeHTMLAttr }}'>
</span>
解释下这行代码:
site.Params.quotes
:读取配置里的句子列表;jsonify
:把列表转成 JSON 格式(JS 能解析);safeHTMLAttr
:防止 Hugo 转义引号(比如把"
变成"
),确保 JSON 格式正确。
第三步:写 JS 逻辑 —— 实现打字 + 轮询
这是核心步骤,要让 JS 完成 “读句子→打字→删除→切换” 的循环。在 项目根目录/assets/js/
下新建 typewriter.js
(assets
是 Hugo 处理资源的目录,比 static
更灵活):
// 等 DOM 加载完再执行,避免找不到元素
document.addEventListener("DOMContentLoaded", () => {
// 1. 找到副标题元素
const subtitle = document.querySelector(".profile-subtitle");
if (!subtitle) return; // 没找到元素就退出,避免报错
// 2. 读取 data-quotes 里的句子列表
const quotes = JSON.parse(subtitle.dataset.quotes || "[]");
if (quotes.length === 0) return; // 没句子也退出
// 3. 动画参数(可自己调速度)
const typingSpeed = 100; // 打字速度(毫秒/字)
const deletingSpeed = 50; // 删除速度
const pauseAfterTyping = 2000; // 打完停顿2秒
const pauseAfterDeleting = 500;// 删除完停顿0.5秒
// 4. 状态变量
let currentQuoteIndex = Math.floor(Math.random() * quotes.length); // 随机开始第一句
let currentText = ""; // 当前显示的文字
let charIndex = 0; // 当前处理的字符索引
let isDeleting = false; // 是否在删除状态
// 5. 核心打字函数
function typeWriter() {
const currentQuote = quotes[currentQuoteIndex];
if (isDeleting) {
// 删除阶段:逐字减少
currentText = currentQuote.substring(0, charIndex - 1);
charIndex--;
} else {
// 打字阶段:逐字增加
currentText = currentQuote.substring(0, charIndex + 1);
charIndex++;
}
// 更新副标题内容
subtitle.textContent = currentText;
// 切换状态/句子
if (!isDeleting && charIndex === currentQuote.length) {
// 打完一句,准备删除
isDeleting = true;
setTimeout(typeWriter, pauseAfterTyping);
} else if (isDeleting && charIndex === 0) {
// 删除完,切换下一句(避免重复)
isDeleting = false;
let newIndex;
do {
newIndex = Math.floor(Math.random() * quotes.length);
} while (newIndex === currentQuoteIndex);
currentQuoteIndex = newIndex;
setTimeout(typeWriter, pauseAfterDeleting);
} else {
// 继续打字/删除
setTimeout(typeWriter, isDeleting ? deletingSpeed : typingSpeed);
}
}
// 6. 启动动画
typeWriter();
});
这段代码的逻辑很直观:先读句子,然后循环 “打字→停顿→删除→换句子”,还加了随机开始和避免重复的逻辑,体验更自然。
第四步:写 CSS 样式 —— 让光标闪起来
光有文字打字还不够,得加个闪烁的光标。在 项目根目录/assets/css/extended/
下新建 custom.css
:
/* 副标题基础样式:确保文字可见 */
.profile-subtitle {
position: relative; /* 给光标定位用 */
display: inline-block; /* 避免光标换行 */
color: inherit !important; /* 继承父元素颜色,防止和背景同色 */
white-space: nowrap; /* 文字不换行 */
}
/* 光标样式:用伪元素生成竖线 */
.profile-subtitle::after {
content: "|"; /* 光标符号 */
position: absolute; /* 绝对定位,跟着文字走 */
right: -6px; /* 光标在文字右侧的距离 */
animation: blink 1s step-end infinite; /* 1秒闪烁一次 */
color: inherit; /* 光标颜色和文字一致 */
}
/* 光标闪烁动画 */
@keyframes blink {
from, to { opacity: 1; } /* 开始和结束显示 */
50% { opacity: 0; } /* 中间隐藏 */
}
这里用 ::after
伪元素生成光标,比直接写在 JS 里更灵活,样式也更容易调整 —— 想换光标颜色或速度,改 CSS 就行,不用动 JS。
第五步:加载资源 —— 让 JS 和 CSS 生效
Hugo 不会自动加载 assets
里的文件,必须手动配置。一开始我想改 baseof.html
(所有页面的根模板),但后来觉得不够 “优雅”,最终选择更规范的方式:
1. CSS 加载:放在 head.html
里
CSS 适合在头部加载,避免页面闪烁。把主题的 themes/PaperMod/layouts/partials/head.html
复制到 项目根目录/layouts/partials/head.html
,在 <head>
标签内的 “样式加载区”(比如最后一个 <link>
后面)加:
<!-- 加载光标样式 CSS -->
{{ $customCss := resources.Get "css/extended/custom.css" }}
{{ if $customCss }}
<link rel="stylesheet" href="{{ $customCss.RelPermalink }}">
{{ end }}
2. JS 加载:放在 footer.html
里
JS 放在页脚加载,避免阻塞页面渲染。把主题的 themes/PaperMod/layouts/partials/footer.html
复制到 项目根目录/layouts/partials/footer.html
,在 </footer>
标签后面加:
<!-- 加载打字动画 JS -->
{{ $typewriter := resources.Get "js/typewriter.js" }}
{{ if $typewriter }}
<script src="{{ $typewriter.RelPermalink }}"></script>
{{ end }}
这里又踩了个坑:一开始我把 JS 放在 static/js/
目录,还用 custom/js.html
加载,结果页面源代码里根本找不到脚本标签 —— 后来才知道 PaperMod 旧版本不默认加载 custom
目录的 partial,直接改 footer.html
才是 “万能方案”。
三、那些让人头大的坑:解决了才敢分享
折腾过程中遇到几个问题,差点放弃,最后靠调试工具和查文档解决了,记录下来帮大家避坑:
坑 1:只有光标,没有文字
现象:光标闪得很欢,但看不到打字的文字。
原因:文字颜色和背景色一致(我的博客是深色模式,文字默认白色,背景也是白色)。
解决:在 custom.css
里加 color: inherit !important
,让文字继承父元素颜色,确保可见。
坑 2:删了 CSS 引用,样式还生效
现象:我在 head.html
里删了 custom.css
的加载代码,结果光标样式还在。
原因:看了 head.html
源码才发现,主题用 resources.Match "css/extended/*.css"
批量合并了 css/extended
目录下的所有 CSS,custom.css
正好在这个目录里,被自动打包进总样式文件了。
解决:如果想彻底移除,要么把 custom.css
移到其他目录(比如 css/my-custom
),要么在合并逻辑里排除它(用 where "Name" "not in" "css/extended/custom.css"
)。
四、总结:折腾一天的收获
- Hugo 读配置全靠层级,
quotes
和profileMode
必须平级,差一层就报错; - 改主题模板时,先复制到项目目录再改,避免升级主题时被覆盖;
- F12 控制台查 JS 错误,Elements 看元素是否存在,Network 看资源是否加载,遇到问题先看这三个地方;
- CSS 放头部,JS 放页脚,符合网页性能规范,体验更好。
现在打开博客,看着副标题逐字敲出,光标跟着闪烁,再自动切换句子,感觉之前的折腾都值了。如果你也想给 Hugo 博客加这个小动画,跟着上面的步骤来,应该能少踩很多坑~
...