Hugo侧边目录实现:页面双列布局+Sticky+ScrollSpy高亮

前段时间,写了篇 2025年终总结 ,但整篇博客没有经过成熟的归纳、整理,导致最终内容过长,在没有成熟的索引目录加持下,阅读体验很不好。

虽然在正文开头启用了目录功能,但只能实现一次性地快速定位,如需再定位另一标题,则需再返回顶部,重新点击目录中的标题。

关于通过目录快速索引页面位置,特别是在电脑端下已非常普遍,而我之前的目录固定在顶部并没起到很好的索引作用,因此花费 2 天时间,对电脑端(宽度达到 1450px)访问下的目录样式进行重构。

下面的内容完整复盘了我在 Hugo 博客里实现侧边目录的整个流程,从 页面布局改变CSS 的 Sticky 与样式美化,再到 通过 JS 实现 ScrollSpy 跟随滚动高亮

实现效果

可以概括为 5 点:

  1. 桌面端(宽屏)出现侧边目录:目录位于正文右侧,不挤压正文宽度。
  2. 目录可粘性定位(sticky):滚动正文时目录一直在视窗内。
  3. 目录独立滚动:目录过长时只滚目录,不影响正文滚动。
  4. 目录样式清晰且兼容复杂标题:标题里含 code 等内联元素时,目录排版不乱。
  5. 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-columnposition: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,最终造成排版崩掉。

所以这里采用更稳的做法:

  • adisplay: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 }}

评论