Hugo侧边目录实现:页面双列布局+Sticky+ScrollSpy高亮
前段时间,写了篇 2025年终总结 ,但整篇博客没有经过成熟的归纳、整理,导致最终内容过长,在没有成熟的索引目录加持下,阅读体验很不好。
虽然在正文开头启用了目录功能,但只能实现一次性地快速定位,如需再定位另一标题,则需再返回顶部,重新点击目录中的标题。
关于通过目录快速索引页面位置,特别是在电脑端下已非常普遍,而我之前的目录固定在顶部并没起到很好的索引作用,因此花费 2 天时间,对电脑端(宽度达到 1450px)访问下的目录样式进行重构。
下面的内容完整复盘了我在 Hugo 博客里实现侧边目录的整个流程,从 页面布局改变 到 CSS 的 Sticky 与样式美化,再到 通过 JS 实现 ScrollSpy 跟随滚动高亮 。
实现效果
可以概括为 5 点:
- 桌面端(宽屏)出现侧边目录:目录位于正文右侧,不挤压正文宽度。
- 目录可粘性定位(sticky):滚动正文时目录一直在视窗内。
- 目录独立滚动:目录过长时只滚目录,不影响正文滚动。
- 目录样式清晰且兼容复杂标题:标题里含
code等内联元素时,目录排版不乱。 - ScrollSpy 高亮当前章节(仅侧边栏生效):随滚动自动高亮当前标题;高亮是“加粗 + 圆点颜色”。
其中最后一点是“锦上添花”,但前四点是长期可用的基础。
具体效果可通过电脑端访问本篇博客时看到。
HTML: 重构目录在页面中的结构
确定目录在整个模版文件中的位置,是所有工作的前提。
之前目录是统一放在了页面内容正文流里面,但这样的结构会给后期的进一步优化造成很多困难,所以在调整初期,从整页布局上就把“正文”与“目录”作为两个明确区域进行构建。
页面结构
整体采用“左正文 + 右侧边栏“布局
文章页的核心结构在 themes/xiaoten/layouts/_default/single.html:
<div class="post-main-container">
<div class="post-left-column">
<div class="page-content">
{{ .Content }}
</div>
</div>
<aside class="post-right-column">
{{ partial "toc.html" .}}
</aside>
</div>这里我没有把目录“塞进正文流的某个位置”,而是把它作为单独的 aside。
这么做的好处是:
- 语义明确:正文是
article,目录属于辅助导航,适合放在aside。 - 布局更可控:后续做 sticky、做独立滚动、做宽屏“悬挂在正文外侧”,都可以只围绕右侧区域实现。
- 移动端策略更简单:同一份 DOM,在 CSS 上可以调整顺序(目录在上/在右),无需多份结构。
目录结构
目录用 Hugo 原生 .TableOfContents,内容由 themes/xiaoten/layouts/partials/toc.html 输出:
{{- if and $toc $hasEnoughHeadings -}}
<details class="toc" {{ $tocOpen }}>
<summary><b>{{ T "single.table_of_contents" }}</b></summary>
{{ .TableOfContents }}
</details>
{{- end -}}关键点有两个:
- 我保留 Hugo 的自动输出:
{{ .TableOfContents }}(它会生成<nav id="TableOfContents">...)。 - 增加“智能显示”:只有标题足够时才展示目录,避免短文也硬塞一个空目录。
这种方案的“维护成本”极低:文章标题一改,目录自动更新。
CSS:布局(侧边 + Sticky)与目录样式
CSS 分两层:
- 布局层:解决“目录在哪里”“如何 sticky”“如何独立滚动”。
- 模块样式层:解决“目录长什么样”“兼容含
code的标题”“高亮样式”。
布局层:宽屏把目录悬挂到正文外侧
布局在 themes/xiaoten/assets/sass/_posts_toc_sidebar.scss。
移动端:目录在正文上方
.post-main-container {
display: flex;
flex-direction: column;
}
.post-right-column {
order: -1; // 移动端 TOC 在正文上方
}移动端空间有限,这次修改并未考虑移动端的调整,因此仍保持不变,将目录放到正文前面。
宽屏:目录“挂”在正文右侧,不占正文宽度
当屏幕宽度达到 1450px 才启用侧边目录:
@media (min-width: 1450px) {
.post-main-container {
display: block;
position: relative;
}
.post-right-column {
position: absolute;
left: 100%;
top: 0;
width: 300px;
margin-left: 24px;
height: 100%;
}
}这里的设计点是:
.post-left-column仍然是正文“主列”,保持既有正文宽度与排版。.post-right-column用position:absolute + left:100%把目录放到正文容器右侧。
这相当于把目录变成“外挂侧栏”:既不挤压正文,也不破坏正文布局。
Sticky + 独立滚动:只滚目录列表
通过 _posts_toc_sidebar.scss 文件明确:
.post-right-column {
.toc {
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
#TableOfContents {
max-height: calc(100vh - 40px - 2.6rem);
overflow-y: auto;
overflow-x: hidden;
}
}
}实现要点:
sticky是加在.toc(也就是details)上的,保证目录整体在视口里。- 真正滚动的是
#TableOfContents,这样summary行(“目录/展开折叠”)不会被卷走。
目录样式层:兼容 <code> 的标题,避免排版错乱
这里算是 bug 的修复吧,因为在期间测试过程中,我的部分文章的标题中包含了代码块即<a>标签中包含<code>,为了在不该文章内容的前提下兼容显示,因此在 themes/xiaoten/assets/sass/_modules.scss文件中做以下的修改:
为什么不能用 display:flex?
原因分析: <a> 内部包含 <code>时,如果对 a 使用 display:flex,则文本节点与 code 会被当作多个 flex item,最终造成排版崩掉。
所以这里采用更稳的做法:
a用display:block- 圆点用
::before绝对定位绘制
#TableOfContents li > a {
display: block;
position: relative;
padding: .18rem .35rem .18rem 1.25rem;
}
#TableOfContents li > a::before {
content: "";
position: absolute;
left: 0.45rem;
top: 0.95em;
transform: translateY(-50%);
width: 0.36em;
height: 0.36em;
border-radius: 999px;
background-color: currentColor;
opacity: 0.35;
}这样无论标题里有什么内联元素(code/em/strong),都不会被 flex 拆分破坏布局。
只在侧边栏(宽屏)生效的高亮样式
高亮样式同样限制在 min-width:1450px 内:
@media (min-width: 1450px) {
#TableOfContents li > a.is-active {
font-weight: $bold-weight;
}
#TableOfContents li > a.is-active::before {
background-color: $brand-blue;
opacity: 1;
}
@include dark {
#TableOfContents li > a.is-active::before {
background-color: $brand-blue-dark;
}
}
}这里 CSS 做的事非常少:只定义一个状态类 .is-active 的视觉表现。到底哪个条目是 active,交给 JS 决定。
JS:ScrollSpy 如何判定“当前章节”并高亮?
ScrollSpy 的逻辑存放在 themes/xiaoten/assets/js/toc-scrollspy.js 文件,并通过 Hugo Pipes 合并进主 bundle。
只在侧边栏断点启用
const sidebarMq = window.matchMedia('(min-width: 1450px)');不满足宽屏时直接清空 active:
- 避免移动端出现“没侧边栏但在改 class”这种冗余行为
- 也让功能边界更清晰
建立 TOC 链接与正文标题的映射
核心思路:
- TOC 里所有
a[href^="#"]都对应一个正文标题id - 用 Map 做
id -> link,再从正文里筛出有id且能在 Map 命中的 heading
const tocLinks = Array.from(tocRoot.querySelectorAll('a[href^="#"]'));
const linkById = new Map();
for (const link of tocLinks) {
const raw = link.getAttribute('href');
const id = safeDecode(raw.slice(1));
if (id) linkById.set(id, link);
}
const headings = Array.from(
document.querySelectorAll('article h2[id], article h3[id], article h4[id], article h5[id], article h6[id]')
).filter((h) => linkById.has(h.id));判定规则:取“最后一个进入阈值”的标题
const TOP_OFFSET_PX = 50;
let candidateId = null;
for (const h of headings) {
const top = h.getBoundingClientRect().top;
if (top <= TOP_OFFSET_PX) candidateId = h.id;
}
if (candidateId) setActive(candidateId);
else clearActive();这段逻辑解决了两件很重要的体验细节:
- 到第一个标题前不高亮:因为此时
candidateId仍是null,直接clearActive()。 - 当前章节稳定:滚动过程中可能会有多个标题都“在阈值之上”,取最后一个满足条件的标题,最贴合阅读直觉。
用 requestAnimationFrame 合并滚动触发
let rafPending = false;
const requestSync = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
syncActiveFromScroll();
});
};
window.addEventListener('scroll', requestSync, { passive: true });
window.addEventListener('resize', requestSync);让滚动触发变成“每帧最多更新一次”,状态更稳,代码也很短。
构建与注入:Hugo Pipes 如何把脚本合并进主 bundle
脚本入口在 themes/xiaoten/layouts/partials/scriptsBodyEnd.html。
核心点是把 toc-scrollspy.js 加进合并列表:
{{ $main := slice (resources.Get "js/main.js") (resources.Get "js/external-links.js") (resources.Get "js/toc-scrollspy.js") }}
{{ $main = $main | resources.Concat "js/main.js" }}并且在非生产环境用 RelPermalink,避免本地调试时误加载生产域名脚本:
{{ if hugo.IsProduction }}
<script src="{{ $main.Permalink }}" integrity="{{ $main.Data.Integrity }}"></script>
{{ else }}
<script async src="{{ $main.RelPermalink }}"></script>
{{ end }}© 转载需附带本文链接,依据 CC BY-NC-SA 4.0 发布。