七两
一个带有印刷感、归档感与阅读氛围的 Hugo 博客。
Hugo 个人博客项目技术规范与开发文档
博客建构
目录
1. 文档目标与适用读者
本文档面向以下读者:
- 对 Hugo 较陌生,尤其不熟悉 Go Template(Go 模板语法) 的初级开发者
- 对 JavaScript DOM 操作、事件绑定与页面交互组织方式不熟悉的开发者
- 需要在当前博客项目基础上进行修改、扩展和维护的开发者
本文档的核心目标有三个:
- 帮助读者建立对整个博客项目的系统架构认知
- 帮助读者理解当前项目中模板、内容、样式与脚本的协作关系
- 提供一套可复用的二次开发标准作业程序(SOP)
2. 系统架构与数据流
2.1 Hugo 的静态渲染机制
Hugo 是一个静态站点生成器(Static Site Generator)。
所谓“静态”,是指最终输出的是一组已经生成好的 HTML、CSS、JavaScript 与静态资源文件,而不是运行时再由服务端动态拼装页面。
在本项目中,Hugo 的工作流程可以概括为以下四步:
- 读取
content/目录中的 Markdown 内容文件 - 读取
hugo.yaml中的全局配置 - 根据内容类型和 URL 路由,匹配
layouts/中对应的模板 - 将 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 文件,每个文件通常包含两部分:
- Front Matter:页头元数据
- 正文内容
示例:
---
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.svgstatic/img/github.svgstatic/img/book.svgstatic/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 模板中,. 表示当前上下文对象。
上下文对象会随着 range、with、partial 等语法的进入而变化。
在 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 三个字段:
ctxclassimageClass
这比只传单个 . 更灵活。
5.3.2 Hugo 语法:$. 的标准定义
在 range 或 with 中,. 会变化。
此时如果想访问外层根上下文,通常使用 $。
当前代码:
{{ $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:返回顶部按钮
优点如下:
- 单个模板文件不会过长
- 同类结构可以复用
- 后续修改范围更小,定位更清晰
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 and 与 ne
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);
处理流程如下:
- 读取链接的
href,例如#为什么先做骨架 - 用
slice(1)去掉开头的# - 用
getElementById()去页面正文中查找对应标题 - 用
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-button 与 data-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 为什么该逻辑“只在前端生效”
当前点赞系统是纯本地实现,没有后端接口,因此:
- 每个浏览器独立记录
- 换浏览器、清空存储后会失效
- 它更适合作为交互演示,而非真实业务统计
如果后续要做真实点赞,应将该逻辑改为:
- 点击按钮
- 调用后端 API
- 后端返回新点赞数
- 再更新页面状态
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 为什么脚本统一从这里进入
这样做有三个优点:
- 页面模板不必分别引用多个脚本
- 构建时自动压缩与合并
- 后续新增脚本时只需要修改一个入口文件
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
建议做法:
- 先复制现有最接近的模板
- 再局部修改结构
例如可以从:
layouts/posts/single.htmllayouts/_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-buttondata-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:验证挂载关系
确认以下三件事:
- 页面 HTML 中确实输出了
data-copy-link-button - 脚本确实被打包进了最终页面
- 点击后浏览器控制台没有报错
步骤 5:考虑失败分支
初学者很容易只写“成功逻辑”,忽略失败分支。
任何交互都应至少思考:
- DOM 不存在怎么办
- 属性值不存在怎么办
- API 不支持怎么办
- 操作失败怎么办
6.3 修改现有模板的推荐顺序
如果需要修改一个已有页面,建议遵循以下顺序:
- 先确定是哪个 section 的页面
- 确认 Hugo 实际命中的模板路径
- 确认该模板调用了哪些 partial
- 明确这次修改属于结构、样式还是交互
- 再进入对应目录修改
简化判断如下:
改内容 → content/
改布局 → layouts/
改样式 → assets/css/
改交互 → assets/js/
改全局配置 → hugo.yaml
7. 调试指南与常见异常排查
7.1 Hugo 模板改了但页面没有变化
可能原因
- 修改的不是实际命中的模板
- 浏览器缓存仍在使用旧资源
- 资源指纹未刷新时误判
排查步骤
- 先确认页面类型
- 检查是否存在更具体模板覆盖默认模板
- 重新执行:
hugo server -D --disableFastRender
--disableFastRender 可以关闭 Hugo 的快速增量渲染机制,在排查模板更新问题时很有帮助。
7.2 JavaScript 获取不到节点
典型现象
querySelector(...)返回null- 点击事件无效
- 控制台报
Cannot read properties of null
常见原因
- Hugo 模板没有输出目标节点
- 选择器写错
- 脚本执行时机早于 DOM 就绪
当前项目中的防御方式
本项目大量使用:
if (!toc) return;
if (!wrapper || !button) return;
这是一种推荐的空节点防御式写法。
排查建议
- 打开浏览器开发者工具
- 检查元素面板中是否真的存在目标节点
- 检查节点上的
data-*属性是否拼写一致 - 检查脚本是否被成功打包并加载
7.3 脚本文件新增了,但页面中没有生效
常见原因
- 新脚本没有加入
scripts.html - 脚本路径写错
- Hugo 资源管线未重新构建
排查重点
检查:
{{ $copy := resources.Get "js/copy-link.js" }}
中的路径是否真的存在于:
assets/js/copy-link.js
7.4 资源路径错误导致图片或图标不显示
常见原因
- 直接硬编码相对路径
- 静态资源文件不在
static/目录 - 部署时站点根路径不同
推荐方式
使用 Hugo 的 URL 处理函数:
{{ .Site.Params.logo | relURL }}
而不是直接写:
<img src="/img/logo.svg">
前者在子路径部署时更安全。
7.5 目录不显示
常见原因
- 正文没有
##或###标题 markup.tableOfContents配置范围不包含当前标题层级.TableOfContents生成了空结构
排查方式
检查 hugo.yaml:
markup:
tableOfContents:
startLevel: 2
endLevel: 3
这意味着:
- 只有二级、三级标题会进入目录
如果正文全是一级标题,则目录为空。
7.6 点赞状态异常
当前实现特性
本项目点赞逻辑依赖浏览器本地存储,因此异常通常来自:
- 浏览器清理缓存
- 切换浏览器
- 隐身模式
- 手动清空 Local Storage
排查建议
打开浏览器开发者工具中的 Application / Storage 面板,检查是否存在如下键:
geekdeath.like.count.xxxgeekdeath.like.liked.xxx
7.7 部署后样式或脚本失效
常见原因
- 构建命令未执行成功
- 部署平台未正确上传
public/输出结果 baseURL与部署地址不一致
排查思路
- 本地先执行:
hugo
- 确认
public/中已生成页面 - 确认 HTML 中引用的 CSS 和 JS 路径可访问
- 若部署到子目录,重新审查
baseURL
8. 面向初级开发者的维护建议
8.1 先改结构,再改样式,最后加交互
推荐顺序:
- 先让 Hugo 模板输出正确 HTML
- 再补 CSS
- 最后编写 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 语法,而在于建立以下认知:
- Hugo 先在构建阶段输出静态 DOM
- JavaScript 再在浏览器中接管需要交互的节点
- 模板与脚本之间通过稳定的 DOM 标识协作
一旦掌握这一点,后续无论是新增页面、调整导航、重构卡片结构,还是为页面增加新的交互行为,都会有清晰的落点和可重复的方法。
如果后续需要继续扩展,建议优先补充以下两类文档:
- 页面模板对照表:列出每个 section 的模板命中规则
- 组件接口文档:列出每个 partial 所需的输入上下文和输出结构
这会让项目在多人协作或长期维护时更稳定。
还要读
博客建构
兜兜转转,终于将它完善了一些。
安静界面的边界感
讨论如何在不制造视觉噪声的前提下,让界面保持清晰的边界。
评论
当前已预留评论区位置。需要接入 Giscus 时,在配置里打开 `params.giscus.enabled` 并补全仓库参数即可。