post

Hugo 个人博客项目技术规范与开发文档

博客建构

文章
目录

1. 文档目标与适用读者

本文档面向以下读者:

  • Hugo 较陌生,尤其不熟悉 Go Template(Go 模板语法) 的初级开发者
  • JavaScript DOM 操作、事件绑定与页面交互组织方式不熟悉的开发者
  • 需要在当前博客项目基础上进行修改、扩展和维护的开发者

本文档的核心目标有三个:

  1. 帮助读者建立对整个博客项目的系统架构认知
  2. 帮助读者理解当前项目中模板、内容、样式与脚本的协作关系
  3. 提供一套可复用的二次开发标准作业程序(SOP)

2. 系统架构与数据流

2.1 Hugo 的静态渲染机制

Hugo 是一个静态站点生成器(Static Site Generator)
所谓“静态”,是指最终输出的是一组已经生成好的 HTML、CSS、JavaScript 与静态资源文件,而不是运行时再由服务端动态拼装页面。

在本项目中,Hugo 的工作流程可以概括为以下四步:

  1. 读取 content/ 目录中的 Markdown 内容文件
  2. 读取 hugo.yaml 中的全局配置
  3. 根据内容类型和 URL 路由,匹配 layouts/ 中对应的模板
  4. 将 Markdown 渲染结果注入模板,输出到 public/ 目录

可以将该过程理解为:

Markdown 内容 + 站点配置 + Hugo 模板 = 最终静态页面

2.2 本项目中的实际数据流

以文章详情页为例,渲染链路如下:

content/posts/first-print.md
Hugo 读取 Front Matter(标题、日期、标签、分类等)
Hugo 匹配 layouts/posts/single.html
single.html 再调用多个 partial(局部模板)
Hugo 输出完整文章 HTML
浏览器加载页面后执行 site.js,补充交互行为

其中:

  • content/posts/first-print.md 提供数据
  • layouts/posts/single.html 提供页面结构
  • layouts/_partials/... 提供可复用组件
  • assets/js/*.js 提供运行时交互

2.3 动静分离逻辑

本项目采用典型的动静分离方式:

  • 静态部分由 Hugo 在构建阶段生成
  • 动态部分由 JavaScript 在浏览器中执行

二者职责边界如下:

Hugo 负责的内容

  • 页面 HTML 结构
  • 标题、正文、分类、标签、目录等内容注入
  • 资源链接生成
  • 页面级模板拼装
  • 根据内容类型输出不同页面

JavaScript 负责的内容

  • 目录高亮
  • 返回顶部按钮显示/隐藏
  • 点赞按钮状态变化
  • 本地存储状态保存

2.4 二者的衔接方式

Hugo 在模板中输出带有明确标识的 DOM 节点,JavaScript 再通过这些标识找到目标元素。

例如,目录组件模板:

<aside class="toc-card" data-toc>
  <h2>目录</h2>
  {{ .TableOfContents | safeHTML }}
</aside>

这里的 data-toc 是一个自定义数据属性
它的作用是给 JavaScript 提供稳定的挂载点。

对应的 JavaScript:

const toc = document.querySelector("[data-toc]");

这说明:

  • Hugo 负责输出 data-toc
  • JavaScript 负责查找 [data-toc] 并添加交互

这是一种推荐的协作模式,因为它避免了脚本对页面结构产生过强耦合。


3. 核心目录与文件规范

3.1 顶层目录职责总览

当前项目中与二次开发最相关的目录如下:

Blog_Hugo/
├─ archetypes/
├─ assets/
│  ├─ css/
│  └─ js/
├─ content/
├─ layouts/
│  ├─ _default/
│  ├─ _markup/
│  ├─ _partials/
│  ├─ posts/
│  ├─ essays/
│  ├─ library/
│  ├─ fictions/
│  └─ cards/
├─ static/
│  └─ img/
├─ hugo.yaml
└─ README.md

3.2 数据层:content/hugo.yaml

content/

content/ 是项目的内容数据层

这里存放的是 Markdown 文件,每个文件通常包含两部分:

  1. Front Matter:页头元数据
  2. 正文内容

示例:

---
title: "印刷感博客的第一层骨架"
date: 2026-03-14
description: "从固定宽度、纸面纹理到标题效果,先把阅读气质搭起来。"
subtitle: "把界面先做成一张会呼吸的纸"
categories: ["文章"]
tags: ["Hugo", "设计", "前端"]
---

## 为什么先做骨架

正文从这里开始。

这里的字段作用如下:

  • title:页面标题
  • date:发布日期
  • description:摘要或 SEO 描述
  • subtitle:文章副标题
  • categories:分类
  • tags:标签

hugo.yaml

hugo.yaml 是项目的全局配置中心

当前项目中它承担以下职责:

  • 配置站点标题、语言、基础 URL
  • 定义 taxonomies(标签、分类)
  • 定义导航菜单
  • 定义首页分区导航
  • 定义页脚链接
  • 定义 Giscus 评论开关
  • 定义 Markdown 目录生成范围

例如:

params:
  nav:
    - name: "首页"
      link: "/"
    - name: "文章"
      link: "/posts/"

这里的 params.nav 是一个自定义配置项,后续会在模板中通过 .Site.Params.nav 读取。

3.3 视图层:layouts/

layouts/ 是项目的视图层(UI 层),用于定义页面结构。

layouts/baseof.html

它是项目的基础布局模板
大多数页面都会继承它。

layouts/_default/

这里放的是 Hugo 的默认模板:

  • single.html:通用单页模板
  • list.html:通用列表页模板

当某个 section 没有提供更具体的模板时,Hugo 会回退到这里。

layouts/_partials/

这里放局部模板(partial),可以理解为“可复用页面组件”。

例如:

  • header.html:页头
  • footer.html:页脚
  • posts/toc.html:文章目录
  • posts/action-menu.html:文章操作区
  • components/tags.html:标签组件

layouts/posts/layouts/library/

这些目录是按内容分区(section)划分的专用模板目录

例如:

  • layouts/posts/single.html:文章详情页
  • layouts/library/single.html:书籍详情页
  • layouts/fictions/single.html:小说详情页

这说明本项目采用的是“按 section 拆分页面模板”的组织方式。

3.4 逻辑层:assets/js/

assets/js/ 是项目的前端逻辑层

当前包含:

  • toc.js:目录高亮逻辑
  • back2top.js:返回顶部逻辑
  • like.js:点赞逻辑

这些脚本不会直接单独引入,而是先在 layouts/_partials/scripts.html 中通过 Hugo Pipes 打包:

{{ $toc := resources.Get "js/toc.js" }}
{{ $back := resources.Get "js/back2top.js" }}
{{ $like := resources.Get "js/like.js" }}
{{ $bundle := slice $toc $back $like | resources.Concat "js/site.js" | minify | fingerprint }}
<script defer src="{{ $bundle.RelPermalink }}" integrity="{{ $bundle.Data.Integrity }}"></script>

其中:

  • Hugo Pipes:Hugo 的资源处理能力,可用于拼接、压缩、指纹摘要
  • Concat:将多个脚本合并成一个文件
  • minify:压缩代码
  • fingerprint:生成带摘要的文件名,用于缓存控制

3.5 样式层:assets/css/

assets/css/main.css 是当前项目的主样式文件。

它负责:

  • 全局 CSS 变量
  • 亮色/暗色模式
  • 页面排版
  • 卡片、目录、文章、书单等组件样式

3.6 静态资源层:static/

static/ 存放不会被 Hugo 再处理的静态文件。
例如当前项目中的 SVG 图标:

  • static/img/logo.svg
  • static/img/github.svg
  • static/img/book.svg
  • static/img/skeleton.svg

这些文件在构建时会原样复制到输出目录。


4. 视图层、逻辑层、数据层的职责边界

为了便于维护,应始终明确三层职责:

4.1 数据层

位置:

  • content/
  • hugo.yaml

职责:

  • 提供页面内容
  • 提供站点配置
  • 提供元数据

4.2 视图层

位置:

  • layouts/
  • layouts/_partials/

职责:

  • 决定页面结构
  • 控制不同页面的布局样式
  • 为 JavaScript 提供挂载节点

4.3 逻辑层

位置:

  • assets/js/

职责:

  • 处理浏览器行为
  • 响应用户交互
  • 维护轻量级前端状态

一个良好的判断标准是:

  • 如果是“页面上显示什么”,优先考虑 content/config
  • 如果是“页面如何排布”,优先考虑 layouts
  • 如果是“用户点击后发生什么”,优先考虑 JavaScript

5. 核心代码深度解析

本节以项目中最关键的模板和脚本为例,进行逐块解析。

5.1 基础模板:layouts/baseof.html

代码如下:

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>
    {{ partial "head.html" . }}
  </head>
  <body>
    <main class="site-main">
      <div class="page-shell">
        {{ block "main" . }}{{ end }}
      </div>
    </main>
    {{ partial "scripts.html" . }}
  </body>
</html>

5.1.1 Hugo 语法:. 的标准定义

在 Hugo 模板中,. 表示当前上下文对象

上下文对象会随着 rangewithpartial 等语法的进入而变化。

baseof.html 中,. 通常代表当前页面对象(Page)。

因此:

  • .Title:当前页面标题
  • .IsHome:当前页面是否为首页
  • .Site:站点级对象
  • .Site.Title:站点标题

5.1.2 Hugo 语法:{{ if }} 的标准定义

{{ if 条件 }}...{{ else }}...{{ end }} 用于条件分支控制。

当前代码:

<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>

业务含义是:

  • 如果当前页是首页,则标题只显示站点标题
  • 否则显示“页面标题 | 站点标题”

5.1.3 Hugo 语法:{{ partial }} 的标准定义

partial 用于引入局部模板,相当于模板组件复用。

语法形式:

{{ partial "模板路径" 上下文 }}

当前代码:

{{ partial "head.html" . }}

业务含义:

  • 引入 layouts/_partials/head.html
  • 并把当前页面上下文 . 传进去

5.1.4 Hugo 语法:{{ block }} 的标准定义

block 是 Hugo 模板继承机制的核心语法之一。

它的作用是:

  • 在基础模板中预留一个“可被子模板覆盖的区域”

当前代码:

{{ block "main" . }}{{ end }}

意思是:

  • 子模板可以定义 main
  • 最终这个区域会被子模板的内容替换

5.2 首页模板:layouts/home.html

代码如下:

{{ define "main" }}
  <div class="content-shell">
    {{ partial "header.html" . }}
    {{ partial "home-nav.html" . }}
    <section class="intro-card">
      <p>这是一个根据你提供的 Geek Death Project 设计提示,直接生成的 Hugo 项目起始版本。</p>
    </section>
    <section class="posts-feed" style="margin-top: 1.5rem;">
      {{ $pages := first 12 (where .Site.RegularPages "Section" "in" .Site.Params.mainSections) }}
      {{ range $pages }}
        {{ partial "posts/card.html" . }}
      {{ end }}
    </section>
    {{ partial "footer.html" . }}
  </div>
{{ end }}

5.2.1 Hugo 语法:{{ define }} 的标准定义

define 用于定义一个命名模板块,通常与 block 配合使用。

这里:

{{ define "main" }}

表示首页模板要填充 baseof.html 中的 main 区域。

5.2.2 Hugo 语法:变量定义 :=

当前代码:

{{ $pages := first 12 (where .Site.RegularPages "Section" "in" .Site.Params.mainSections) }}

这是 Hugo 模板中的变量声明语法。

  • $pages:局部变量名
  • :=:定义变量

5.2.3 Hugo 语法:where 的标准定义

where 用于在集合中过滤元素。

当前表达式:

where .Site.RegularPages "Section" "in" .Site.Params.mainSections

含义是:

  • 从站点所有常规页面 .Site.RegularPages
  • 取出 Section 属于 mainSections 的页面

这里的 mainSections 来自:

params:
  mainSections:
    - "posts"
    - "essays"
    - "fictions"
    - "library"
    - "cards"

5.2.4 Hugo 语法:range 的标准定义

range 用于遍历集合。

当前代码:

{{ range $pages }}
  {{ partial "posts/card.html" . }}
{{ end }}

业务含义:

  • 遍历 $pages 中的页面
  • 对每一项调用一次文章卡片组件

需要注意的是:进入 range 后,. 会被切换为当前遍历项
因此这里传入 partial. 已不再是首页本身,而是当前的某篇文章页面对象。


5.3 页头组件:layouts/_partials/header.html

代码如下:

<header class="site-header">
  <div class="site-brand">
    {{ partial "components/logo.html" (dict "ctx" . "class" "" "imageClass" "") }}
    <div class="brand-lockup">
      <a href="{{ "/" | relURL }}"><h1 class="brand-title xerox">{{ .Site.Title }}</h1></a>
      <p class="brand-subtitle">{{ .Site.Params.description }}</p>
    </div>
  </div>
  <nav class="site-nav" aria-label="主导航">
    {{ range .Site.Params.nav }}
      {{ $active := or (eq $.RelPermalink .link) (and (ne .link "/") (hasPrefix $.RelPermalink .link)) }}
      <a class="nav-link" href="{{ .link | relURL }}" {{ if $active }}aria-current="page"{{ end }}>{{ .name }}</a>
    {{ end }}
  </nav>
</header>

5.3.1 Hugo 语法:dict 的标准定义

dict 用于构造一个字典对象(键值对对象),常用于给 partial 传递多个参数。

当前代码:

{{ partial "components/logo.html" (dict "ctx" . "class" "" "imageClass" "") }}

表示传递给 logo.html 三个字段:

  • ctx
  • class
  • imageClass

这比只传单个 . 更灵活。

5.3.2 Hugo 语法:$. 的标准定义

rangewith 中,. 会变化。
此时如果想访问外层根上下文,通常使用 $

当前代码:

{{ $active := or (eq $.RelPermalink .link) (and (ne .link "/") (hasPrefix $.RelPermalink .link)) }}

此处有两个上下文:

  • .:当前导航项
  • $:外层页面上下文

业务解释:

  • .link 取的是当前导航配置项的链接
  • $.RelPermalink 取的是当前页面路径

因此该表达式用于判断当前导航项是否应显示为激活状态。

5.3.3 relURL 的作用

{{ .link | relURL }}

这里的 relURL 是 Hugo 的 URL 处理函数,用于生成相对站点根路径的安全链接。

其作用是避免直接硬编码路径带来的部署路径问题。


5.4 文章详情页:layouts/posts/single.html

代码如下:

{{ define "main" }}
  <article class="content-shell">
    {{ partial "header.html" . }}
    <header class="article-header">
      <div class="hero-badge">
        <img src="{{ .Site.Params.logo | relURL }}" alt="">
        <span>post</span>
      </div>
      <h1 class="article-title xerox">{{ .Title }}</h1>
      {{ with .Params.subtitle }}<p class="muted"><em>{{ . }}</em></p>{{ end }}
      {{ with .Description }}<p class="muted">{{ . }}</p>{{ end }}
      <div class="meta-strip">
        {{ partial "components/date.html" . }}
        {{ partial "components/category-pill.html" . }}
        {{ partial "components/tags.html" . }}
      </div>
    </header>
    {{ partial "posts/toc.html" . }}
    <div class="markdown-body">
      {{ .Content }}
    </div>
    {{ partial "posts/action-menu.html" . }}
    {{ partial "posts/now-read-this.html" . }}
    {{ partial "posts/comment.html" . }}
    {{ partial "components/back-to-top.html" . }}
    {{ partial "footer.html" . }}
  </article>
{{ end }}

5.4.1 Hugo 语法:with 的标准定义

with 用于“当某个值存在时才执行块内容”,同时会把 . 切换为该值本身。

例如:

{{ with .Params.subtitle }}<p>{{ . }}</p>{{ end }}

含义是:

  • 如果当前页面存在 subtitle
  • 就输出 <p>
  • 且块内部的 . 已经是 subtitle 字符串本身

同理:

{{ with .Description }}<p>{{ . }}</p>{{ end }}

表示仅当页面有描述时才渲染描述文本。

5.4.2 .Content 的作用

.Content 是 Hugo 页面对象最核心的字段之一。
它表示:

  • 当前 Markdown 正文经过 Hugo 渲染后的 HTML 内容

也就是说,content/posts/*.md 文件中的正文最终会被注入这里。

5.4.3 partial 串联的业务意义

文章页没有把所有 HTML 都写在一个文件中,而是拆成多个 partial:

  • components/date.html:日期
  • components/category-pill.html:分类
  • components/tags.html:标签
  • posts/toc.html:目录
  • posts/action-menu.html:点赞/分享/评论入口
  • posts/comment.html:评论区域
  • components/back-to-top.html:返回顶部按钮

优点如下:

  1. 单个模板文件不会过长
  2. 同类结构可以复用
  3. 后续修改范围更小,定位更清晰

5.5 目录组件:layouts/_partials/posts/toc.html

代码如下:

{{ if and .TableOfContents (ne .TableOfContents "<nav id=\"TableOfContents\"></nav>") }}
  <aside class="toc-card" data-toc>
    <h2>目录</h2>
    {{ .TableOfContents | safeHTML }}
  </aside>
{{ end }}

5.5.1 .TableOfContents 的含义

.TableOfContents 是 Hugo 自动生成的目录 HTML。

它基于 Markdown 正文中的标题层级生成,例如:

## 一级标题
### 二级标题

会被 Hugo 转换为一个嵌套 <nav><ul><li>...</li></ul></nav> 结构。

5.5.2 andne

  • and:逻辑与
  • ne:not equal,不等于

当前判断逻辑的业务意图是:

  • 如果目录存在
  • 且它不是一个空目录
  • 才渲染目录容器

5.5.3 safeHTML

safeHTML 用于告诉 Hugo:

  • 这段内容已经是可信 HTML
  • 不要再把它转义成普通文本

如果不加它,目录 HTML 可能会以纯文本形式显示,而不是被浏览器正确解析。

5.5.4 为什么加 data-toc

这里的 data-toc 是目录脚本的挂载点。

JavaScript 并不直接依赖 .toc-card 这种样式类,而是通过:

document.querySelector("[data-toc]")

进行查找。
这样做的好处是:

  • 样式类可以调整
  • 只要 data-toc 不变,脚本就仍然有效

5.6 目录脚本:assets/js/toc.js

代码如下:

document.addEventListener("DOMContentLoaded", () => {
  const toc = document.querySelector("[data-toc]");
  if (!toc) return;

  const links = [...toc.querySelectorAll("a[href^='#']")];
  const headings = links
    .map((link) => document.getElementById(link.getAttribute("href").slice(1)))
    .filter(Boolean);

  if (!headings.length) return;

  const activate = (id) => {
    links.forEach((link) => {
      const isActive = link.getAttribute("href") === `#${id}`;
      link.setAttribute("aria-current", isActive ? "true" : "false");
      link.parentElement?.classList.toggle("is-active", isActive);
    });
  };

  const observer = new IntersectionObserver(
    (entries) => {
      const visible = entries
        .filter((entry) => entry.isIntersecting)
        .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
      if (visible?.target?.id) activate(visible.target.id);
    },
    {
      rootMargin: "0px 0px -55% 0px",
      threshold: [0.1, 0.4, 0.7],
    }
  );

  headings.forEach((heading) => observer.observe(heading));
  activate(headings[0].id);
});

5.6.1 DOMContentLoaded 的标准定义

DOMContentLoaded 是浏览器事件,表示:

  • HTML 文档已完成解析
  • DOM 树已经建立
  • 但图片、样式等资源未必全部加载完成

之所以在这里执行逻辑,是为了避免脚本运行时目标 DOM 尚不存在。

5.6.2 querySelector 的标准定义

document.querySelector(selector) 用于查找第一个匹配 CSS 选择器的 DOM 元素

当前代码:

const toc = document.querySelector("[data-toc]");

表示查找带有 data-toc 属性的目录容器。

5.6.3 “脚本挂载到 Hugo 生成节点”的机制

这个脚本之所以能工作,是因为 Hugo 模板先生成了:

<aside data-toc>...</aside>

脚本再执行:

document.querySelector("[data-toc]");

二者形成稳定对应关系。
这正是“静态模板输出结构,脚本按约定接管行为”的典型协作方式。

5.6.4 链接与标题的映射过程

这段代码:

const links = [...toc.querySelectorAll("a[href^='#']")];

作用是:

  • 取出目录中所有跳转到页面内部锚点的链接

接着:

const headings = links
  .map((link) => document.getElementById(link.getAttribute("href").slice(1)))
  .filter(Boolean);

处理流程如下:

  1. 读取链接的 href,例如 #为什么先做骨架
  2. slice(1) 去掉开头的 #
  3. getElementById() 去页面正文中查找对应标题
  4. filter(Boolean) 去掉空值

因此,目录中的链接最终会和正文中的标题节点建立一一对应关系。

5.6.5 activate 函数的业务作用

const activate = (id) => {
  links.forEach((link) => {
    const isActive = link.getAttribute("href") === `#${id}`;
    link.setAttribute("aria-current", isActive ? "true" : "false");
    link.parentElement?.classList.toggle("is-active", isActive);
  });
};

这个函数负责:

  • 根据当前激活的标题 ID
  • 遍历所有目录链接
  • 给匹配项加上激活状态

其中:

  • aria-current:无障碍属性,表示“当前项”
  • classList.toggle("is-active", isActive):根据布尔值添加或移除类名

5.6.6 IntersectionObserver 的标准定义

IntersectionObserver 是浏览器提供的可见性观察 API。
它可以监控一个元素是否进入视口。

当前用法表示:

  • 监听所有正文标题
  • 哪个标题进入可视区域,就激活对应目录项

相比传统的 scroll + 手动计算坐标方式,它更清晰,也更节省性能。


5.7 操作区模板:layouts/_partials/posts/action-menu.html

代码如下:

<div class="action-menu">
  <button class="action-button" type="button" data-like-button data-page-id="{{ .File.UniqueID }}">
    <span>❤</span>
    <span data-like-count>0</span>
  </button>
  <a class="action-button" href="https://twitter.com/intent/tweet?text={{ .Title | urlquery }}&url={{ .Permalink | urlquery }}" target="_blank" rel="noopener noreferrer">分享</a>
  <a class="action-button" href="#comments">评论</a>
</div>

5.7.1 .File.UniqueID 的作用

.File.UniqueID 是 Hugo 提供的页面文件唯一标识。

这里用它作为点赞按钮的 data-page-id,意味着:

  • 每一篇文章都拥有自己的独立点赞状态键

5.7.2 urlquery 的作用

urlquery 会对字符串做 URL 编码。
当前用于构造分享链接中的标题与 URL 参数,避免特殊字符破坏链接结构。

5.7.3 为什么使用 data-like-buttondata-like-count

这些自定义属性是脚本的挂载点:

  • data-like-button:定位按钮
  • data-like-count:定位数量显示节点

这使得脚本可以准确绑定到 Hugo 输出的按钮结构上。


5.8 点赞脚本:assets/js/like.js

代码如下:

document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll("[data-like-button]").forEach((button) => {
    const pageId = button.getAttribute("data-page-id");
    const countNode = button.querySelector("[data-like-count]");
    if (!pageId || !countNode) return;

    const countKey = `geekdeath.like.count.${pageId}`;
    const likedKey = `geekdeath.like.liked.${pageId}`;
    const initial = Number(localStorage.getItem(countKey) || "0");
    const liked = localStorage.getItem(likedKey) === "1";

    countNode.textContent = String(initial);
    button.classList.toggle("is-liked", liked);

    button.addEventListener("click", () => {
      if (localStorage.getItem(likedKey) === "1") return;
      const next = Number(localStorage.getItem(countKey) || "0") + 1;
      localStorage.setItem(countKey, String(next));
      localStorage.setItem(likedKey, "1");
      countNode.textContent = String(next);
      button.classList.add("is-liked");
    });
  });
});

5.8.1 querySelectorAll 的标准定义

querySelectorAll(selector) 用于查找所有匹配的节点,返回一个节点集合。

当前代码:

document.querySelectorAll("[data-like-button]")

表示查找页面中所有点赞按钮。

5.8.2 状态管理的实现方式

该脚本使用 localStorage 做轻量级前端状态管理。

什么是 localStorage

localStorage 是浏览器本地存储机制,用于在用户浏览器中保存字符串数据。
数据在页面刷新后仍然保留。

当前项目定义了两个键:

const countKey = `geekdeath.like.count.${pageId}`;
const likedKey = `geekdeath.like.liked.${pageId}`;

含义如下:

  • countKey:保存当前页面点赞数
  • likedKey:保存当前页面是否已点赞

5.8.3 初始化逻辑

const initial = Number(localStorage.getItem(countKey) || "0");
const liked = localStorage.getItem(likedKey) === "1";

这表示:

  • 如果用户之前从未访问过,则点赞数默认为 0
  • 如果本地存储中保存了 "1",则视为已点赞

随后将状态同步到界面:

countNode.textContent = String(initial);
button.classList.toggle("is-liked", liked);

5.8.4 点击事件绑定

button.addEventListener("click", () => {

这是最标准的 DOM 事件绑定方式之一,表示:

  • 当按钮被点击时执行回调函数

5.8.5 防重复点赞逻辑

if (localStorage.getItem(likedKey) === "1") return;

这里的 return 表示提前结束函数,阻止重复累加。

5.8.6 为什么该逻辑“只在前端生效”

当前点赞系统是纯本地实现,没有后端接口,因此:

  • 每个浏览器独立记录
  • 换浏览器、清空存储后会失效
  • 它更适合作为交互演示,而非真实业务统计

如果后续要做真实点赞,应将该逻辑改为:

  1. 点击按钮
  2. 调用后端 API
  3. 后端返回新点赞数
  4. 再更新页面状态

5.9 返回顶部脚本:assets/js/back2top.js

代码如下:

document.addEventListener("DOMContentLoaded", () => {
  const wrapper = document.querySelector("[data-back-to-top]");
  const button = document.querySelector("[data-back-to-top-button]");
  if (!wrapper || !button) return;

  const toggle = () => {
    wrapper.classList.toggle("is-visible", window.scrollY > 900);
  };

  button.addEventListener("click", () => {
    window.scrollTo({ top: 0, behavior: "smooth" });
    localStorage.setItem("geekdeath.backToTop.seen", "1");
  });

  toggle();
  document.addEventListener("scroll", toggle, { passive: true });
});

5.9.1 DOM 挂载方式

脚本通过以下节点挂载:

  • [data-back-to-top]
  • [data-back-to-top-button]

对应模板位于:

<div class="back-to-top" data-back-to-top>
  <button type="button" data-back-to-top-button aria-label="返回顶部">
  </button>
</div>

5.9.2 window.scrollY

window.scrollY 表示当前页面垂直滚动距离。

当前逻辑:

wrapper.classList.toggle("is-visible", window.scrollY > 900);

意思是:

  • 当页面向下滚动超过 900px 时,显示返回顶部按钮

5.9.3 scrollTo

window.scrollTo({ top: 0, behavior: "smooth" });

表示平滑滚动回页面顶部。

5.9.4 passive: true

document.addEventListener("scroll", toggle, { passive: true });

这里的 passive: true 告诉浏览器:

  • 这个滚动监听不会调用 preventDefault()

这样浏览器可以更积极地优化滚动性能。


5.10 脚本打包入口:layouts/_partials/scripts.html

代码如下:

{{ $toc := resources.Get "js/toc.js" }}
{{ $back := resources.Get "js/back2top.js" }}
{{ $like := resources.Get "js/like.js" }}
{{ $bundle := slice $toc $back $like | resources.Concat "js/site.js" | minify | fingerprint }}
<script defer src="{{ $bundle.RelPermalink }}" integrity="{{ $bundle.Data.Integrity }}"></script>

5.10.1 为什么脚本统一从这里进入

这样做有三个优点:

  1. 页面模板不必分别引用多个脚本
  2. 构建时自动压缩与合并
  3. 后续新增脚本时只需要修改一个入口文件

5.10.2 defer 的作用

defer 表示脚本会:

  • 在 HTML 解析完成后执行
  • 且不阻塞页面初始解析

虽然脚本内部已经使用了 DOMContentLoaded,但保留 defer 仍然是合理的,它能进一步降低阻塞风险。


6. 二次开发标准作业程序(SOP)

本节给出两类最常见的二次开发操作。

6.1 场景一:新增一个页面模块

需求示例:新增“项目”页面模块 projects

步骤 1:创建内容目录

content/ 下新增:

content/projects/

并创建:

content/projects/_index.md
content/projects/my-first-project.md

示例:

---
title: "项目"
description: "项目列表"
---

步骤 2:创建模板

如果项目页需要专门布局,新增:

layouts/projects/single.html
layouts/projects/list.html

建议做法:

  1. 先复制现有最接近的模板
  2. 再局部修改结构

例如可以从:

  • layouts/posts/single.html
  • layouts/_default/list.html

开始改。

步骤 3:补全配置

如果需要出现在首页聚合流中,应修改:

params:
  mainSections:
    - "posts"
    - "essays"
    - "fictions"
    - "library"
    - "cards"
    - "projects"

如果需要出现在导航中,也应修改:

params:
  nav:
    - name: "项目"
      link: "/projects/"

步骤 4:决定是否需要专属 partial

若项目页有独立元信息区、标签区、卡片区,建议新增 partial,而不是把所有结构堆在一个文件中。

例如:

layouts/_partials/projects/project-meta.html
layouts/_partials/projects/project-card.html

步骤 5:本地调试

执行:

hugo server -D

检查以下内容:

  • 路由是否正确
  • 导航是否可点
  • 模板是否匹配成功
  • 页面布局是否符合预期

6.2 场景二:为某个元素新增交互事件

需求示例:给文章页新增“复制文章链接”按钮

步骤 1:先在模板中输出稳定挂载点

layouts/_partials/posts/action-menu.html 中加入:

<button
  class="action-button"
  type="button"
  data-copy-link-button
  data-copy-link-value="{{ .Permalink }}">
  复制链接
</button>

这里必须先定义清晰的挂载标识:

  • data-copy-link-button
  • data-copy-link-value

步骤 2:新增脚本文件

assets/js/ 下新增:

assets/js/copy-link.js

示例代码:

document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll("[data-copy-link-button]").forEach((button) => {
    const value = button.getAttribute("data-copy-link-value");
    if (!value) return;

    button.addEventListener("click", async () => {
      try {
        await navigator.clipboard.writeText(value);
        button.textContent = "已复制";
      } catch (error) {
        console.error("复制失败", error);
      }
    });
  });
});

步骤 3:把脚本加入打包入口

修改 layouts/_partials/scripts.html

{{ $copy := resources.Get "js/copy-link.js" }}
{{ $bundle := slice $toc $back $like $copy | resources.Concat "js/site.js" | minify | fingerprint }}

步骤 4:验证挂载关系

确认以下三件事:

  1. 页面 HTML 中确实输出了 data-copy-link-button
  2. 脚本确实被打包进了最终页面
  3. 点击后浏览器控制台没有报错

步骤 5:考虑失败分支

初学者很容易只写“成功逻辑”,忽略失败分支。
任何交互都应至少思考:

  • DOM 不存在怎么办
  • 属性值不存在怎么办
  • API 不支持怎么办
  • 操作失败怎么办

6.3 修改现有模板的推荐顺序

如果需要修改一个已有页面,建议遵循以下顺序:

  1. 先确定是哪个 section 的页面
  2. 确认 Hugo 实际命中的模板路径
  3. 确认该模板调用了哪些 partial
  4. 明确这次修改属于结构、样式还是交互
  5. 再进入对应目录修改

简化判断如下:

改内容 → content/
改布局 → layouts/
改样式 → assets/css/
改交互 → assets/js/
改全局配置 → hugo.yaml

7. 调试指南与常见异常排查

7.1 Hugo 模板改了但页面没有变化

可能原因

  1. 修改的不是实际命中的模板
  2. 浏览器缓存仍在使用旧资源
  3. 资源指纹未刷新时误判

排查步骤

  1. 先确认页面类型
  2. 检查是否存在更具体模板覆盖默认模板
  3. 重新执行:
hugo server -D --disableFastRender

--disableFastRender 可以关闭 Hugo 的快速增量渲染机制,在排查模板更新问题时很有帮助。


7.2 JavaScript 获取不到节点

典型现象

  • querySelector(...) 返回 null
  • 点击事件无效
  • 控制台报 Cannot read properties of null

常见原因

  1. Hugo 模板没有输出目标节点
  2. 选择器写错
  3. 脚本执行时机早于 DOM 就绪

当前项目中的防御方式

本项目大量使用:

if (!toc) return;
if (!wrapper || !button) return;

这是一种推荐的空节点防御式写法

排查建议

  1. 打开浏览器开发者工具
  2. 检查元素面板中是否真的存在目标节点
  3. 检查节点上的 data-* 属性是否拼写一致
  4. 检查脚本是否被成功打包并加载

7.3 脚本文件新增了,但页面中没有生效

常见原因

  1. 新脚本没有加入 scripts.html
  2. 脚本路径写错
  3. Hugo 资源管线未重新构建

排查重点

检查:

{{ $copy := resources.Get "js/copy-link.js" }}

中的路径是否真的存在于:

assets/js/copy-link.js

7.4 资源路径错误导致图片或图标不显示

常见原因

  1. 直接硬编码相对路径
  2. 静态资源文件不在 static/ 目录
  3. 部署时站点根路径不同

推荐方式

使用 Hugo 的 URL 处理函数:

{{ .Site.Params.logo | relURL }}

而不是直接写:

<img src="/img/logo.svg">

前者在子路径部署时更安全。


7.5 目录不显示

常见原因

  1. 正文没有 ##### 标题
  2. markup.tableOfContents 配置范围不包含当前标题层级
  3. .TableOfContents 生成了空结构

排查方式

检查 hugo.yaml

markup:
  tableOfContents:
    startLevel: 2
    endLevel: 3

这意味着:

  • 只有二级、三级标题会进入目录

如果正文全是一级标题,则目录为空。


7.6 点赞状态异常

当前实现特性

本项目点赞逻辑依赖浏览器本地存储,因此异常通常来自:

  • 浏览器清理缓存
  • 切换浏览器
  • 隐身模式
  • 手动清空 Local Storage

排查建议

打开浏览器开发者工具中的 Application / Storage 面板,检查是否存在如下键:

  • geekdeath.like.count.xxx
  • geekdeath.like.liked.xxx

7.7 部署后样式或脚本失效

常见原因

  1. 构建命令未执行成功
  2. 部署平台未正确上传 public/ 输出结果
  3. baseURL 与部署地址不一致

排查思路

  1. 本地先执行:
hugo
  1. 确认 public/ 中已生成页面
  2. 确认 HTML 中引用的 CSS 和 JS 路径可访问
  3. 若部署到子目录,重新审查 baseURL

8. 面向初级开发者的维护建议

8.1 先改结构,再改样式,最后加交互

推荐顺序:

  1. 先让 Hugo 模板输出正确 HTML
  2. 再补 CSS
  3. 最后编写 JavaScript

这是因为:

  • 如果结构未稳定,脚本挂载点就会反复变化
  • 如果 DOM 尚未确定,交互很容易写废

8.2 始终使用 data-* 作为脚本挂载点

不建议直接依赖纯样式类名,例如:

document.querySelector(".action-button")

更推荐:

document.querySelector("[data-copy-link-button]")

原因是样式类可能因视觉重构而变化,但交互挂载点应保持稳定。

8.3 一个 partial 只负责一个明确职责

例如:

  • tags.html 只负责标签显示
  • date.html 只负责日期显示

不要把过多业务拼进单个 partial,这会显著增加维护成本。

8.4 修改前先定位“数据来源”

当页面显示结果不正确时,先问自己:

  • 这是内容错了,还是模板错了?
  • 值来自 content/,还是来自 hugo.yaml
  • 是 Hugo 输出错了,还是 JS 改坏了?

这一步会决定你应该查哪个目录。


9. 建议的开发工作流

推荐采用以下开发流程:

9.1 日常开发

hugo server -D

9.2 当模板更新异常时

hugo server -D --disableFastRender

9.3 发布前检查

hugo

重点检查:

  • 构建是否报错
  • public/ 是否生成完整
  • 页面是否存在空白区域或控制台错误
  • 静态资源是否成功引用

10. 总结

本项目的整体结构可以概括为:

  • content/ 是数据来源
  • layouts/ 是页面结构来源
  • assets/css/ 是视觉样式来源
  • assets/js/ 是交互逻辑来源
  • hugo.yaml 是全局配置来源

理解本项目的关键,不在于记住所有 Hugo 语法,而在于建立以下认知:

  1. Hugo 先在构建阶段输出静态 DOM
  2. JavaScript 再在浏览器中接管需要交互的节点
  3. 模板与脚本之间通过稳定的 DOM 标识协作

一旦掌握这一点,后续无论是新增页面、调整导航、重构卡片结构,还是为页面增加新的交互行为,都会有清晰的落点和可重复的方法。

如果后续需要继续扩展,建议优先补充以下两类文档:

  1. 页面模板对照表:列出每个 section 的模板命中规则
  2. 组件接口文档:列出每个 partial 所需的输入上下文和输出结构

这会让项目在多人协作或长期维护时更稳定。

分享 评论

还要读

博客建构

兜兜转转,终于将它完善了一些。

安静界面的边界感

讨论如何在不制造视觉噪声的前提下,让界面保持清晰的边界。

评论

当前已预留评论区位置。需要接入 Giscus 时,在配置里打开 `params.giscus.enabled` 并补全仓库参数即可。