作为一个喜欢折腾博客细节的人,我一直觉得 Hugo PaperMod 主题的首页副标题太 “安静” 了 —— 静态文字放在那里,少了点互动感。于是今天给自己定了个小目标:给副标题加个「打字轮询动画」:文字逐字敲出、带闪烁光标,还能自动切换不同的句子。本以为是 “复制粘贴代码” 的小事,结果踩了好几个坑,好在最后完美实现。这篇就记录下整个过程,希望能帮到同样想折腾的朋友。

一、先看最终效果:我想要的是什么?

在动手前先明确需求,避免后续跑偏:

  1. 打字效果:文字不是一次性出来,而是像键盘敲击一样逐字显示;
  2. 闪烁光标:文字右侧跟着一个竖线光标,模仿真实打字体验;
  3. 自动轮询:打完一句后,逐字删除,再随机切换下一句(不重复);
  4. 不破坏主题:尽量不改主题核心文件,后续升级 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 层级极敏感,必须确保 quotesprofileMode 是 “兄弟” 关系,都在 params 下面。

第二步:改模板 —— 给副标题 “挂标识”

PaperMod 渲染首页 profile 的模板是 index_profile.html,需要给副标题加两个东西:

  • class="profile-subtitle":让 JS/CSS 能定位到这个元素;
  • data-quotes:把 config.yml 里的句子列表以 JSON 格式 “注入” 到元素里,供 JS 读取。

操作时要注意:不要直接改主题文件夹的模板(升级会覆盖),先复制到项目目录:

  1. themes/PaperMod/layouts/partials/index_profile.html 复制到 项目根目录/layouts/partials/index_profile.html(没有 partials 文件夹就手动建);
  2. 打开复制后的文件,找到原副标题代码(通常是 <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.jsassets 是 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")。

四、总结:折腾一天的收获

  1. Hugo 读配置全靠层级,quotesprofileMode 必须平级,差一层就报错;
  2. 改主题模板时,先复制到项目目录再改,避免升级主题时被覆盖;
  3. F12 控制台查 JS 错误,Elements 看元素是否存在,Network 看资源是否加载,遇到问题先看这三个地方;
  4. CSS 放头部,JS 放页脚,符合网页性能规范,体验更好。

现在打开博客,看着副标题逐字敲出,光标跟着闪烁,再自动切换句子,感觉之前的折腾都值了。如果你也想给 Hugo 博客加这个小动画,跟着上面的步骤来,应该能少踩很多坑~