閱讀一篇較長的技術文章時,旁邊有一個**目錄(Table of Contents)**可以快速跳轉章節,是很常見的設計。
這篇會介紹這個 Blog 的 TOC 是怎麼做的,包含:
- 如何從 MDX 文章內容自動提取標題
- 如何把標題渲染成可點擊的目錄
- 如何用 IntersectionObserver 追蹤當前閱讀位置,高亮對應的目錄項目
整體資料流
TOC 的資料流大致如下:
MDX 文章原始內容(.mdx 檔案)
│
▼
extractToc() ← 用 regex 解析 ## / ### 標題
│
▼
TocItem[] ← [{ id, text, level }]
│
▼
<Toc /> 元件 ← 渲染成目錄清單,用 IntersectionObserver 追蹤
解析和渲染是分開的:解析在 Server 端做,追蹤在 Client 端做。
第一步:定義資料型別
在 lib/posts.ts 定義 TocItem,描述每一個目錄項目:
export type TocItem = {
id: string; // heading 的 id,對應 rehypeSlug 產生的值
text: string; // 標題文字
level: 2 | 3; // h2 或 h3
};只處理 h2 和 h3,層次夠用,也不會讓目錄過深。
第二步:從 MDX 提取標題
在 Server 端解析 MDX 原始文字,用 regex 找出所有 ## 和 ### 標題:
import GithubSlugger from "github-slugger";
function extractToc(mdx: string): TocItem[] {
const slugger = new GithubSlugger();
const toc: TocItem[] = [];
const lines = mdx.split("\n");
let inFence = false;
for (const line of lines) {
// 跳過 code fence 內的標題(避免把程式碼裡的 # 當成標題)
if (line.trim().startsWith("```")) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const m = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
if (!m) continue;
const level = m[1].length as 2 | 3;
const text = m[2]
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // 去掉 markdown link
.replace(/`([^`]+)`/g, "$1") // 去掉 inline code
.trim();
const id = slugger.slug(text);
toc.push({ id, text, level });
}
return toc;
}這裡有一個細節很重要:產生 id 要用 github-slugger,跟 rehype-slug 用的演算法一樣。如果兩邊不一樣,目錄點下去就會跳不到正確的位置。
第三步:在 getPostBySlug 回傳 toc
export const getPostBySlug = cache(async (slug: string) => {
const raw = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(raw);
const toc = extractToc(content); // ← 在 Server 端解析
const mdx = await compileMDX({ /* ... */ });
return { meta, toc, content: mdx.content }; // ← toc 一起回傳
});第四步:建立 <Toc /> 元件
<Toc /> 是一個 Client Component,負責渲染目錄並用 IntersectionObserver 追蹤當前閱讀位置:
"use client";
import { useEffect, useRef, useState } from "react";
import type { TocItem } from "@/lib/posts";
export default function Toc({ toc }: { toc: TocItem[] }) {
const [activeId, setActiveId] = useState<string>("");
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (!toc.length) return;
// 找到所有標題元素
const headingElements = toc
.map(({ id }) => document.getElementById(id))
.filter(Boolean) as HTMLElement[];
observerRef.current = new IntersectionObserver(
(entries) => {
// 找出在畫面中最靠近頂部的標題
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
setActiveId(visible[0].target.id);
}
},
{
rootMargin: "-10% 0px -70% 0px", // 只偵測畫面上方 20% 的區域
threshold: 0,
}
);
headingElements.forEach((el) => observerRef.current!.observe(el));
return () => { observerRef.current?.disconnect(); };
}, [toc]);
if (!toc.length) return null;
return (
<nav>
{toc.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
style={{ marginLeft: item.level === 3 ? "1rem" : "0" }}
className={item.id === activeId ? "text-cyan-500 font-medium" : "text-gray-500"}
>
{item.text}
</a>
))}
</nav>
);
}IntersectionObserver 的 rootMargin 調整
rootMargin: "-10% 0px -70% 0px" 這個設定的意思是:
- 只把畫面上方 10%~下方 30% 之間的區域算成「可見」
- 這樣當一個標題進入視野上方時,目錄才會切換,而不是標題一出現在畫面底部就觸發
調整這個值可以改變目錄切換的時機,讓它跟閱讀節奏更吻合。
第五步:@right Parallel Routes 與 TOC 的放置位置
Next.js App Router 有一個功能叫做 Parallel Routes(平行路由),可以讓同一個 layout 根據當前路由,在不同的「插槽(slot)」裡顯示不同的內容。
這個 Blog 的右欄就是利用這個功能實作的:
app/dashboard/
├── layout.tsx ← 宣告接收 @right slot
├── page.tsx ← 文章列表
├── @right/
│ ├── default.tsx ← fallback(列表頁、其他頁面用)
│ ├── about/
│ │ └── page.tsx ← 進入 /dashboard/about 時右欄顯示這個
│ └── [...catchAll]/
│ └── page.tsx ← 進入 /dashboard/posts/[slug] 時右欄顯示這個
└── posts/
└── [slug]/
└── page.tsx
layout.tsx 宣告接收 right 這個 slot:
export default function DashboardLayout({
children,
right,
}: {
children: React.ReactNode;
right: React.ReactNode;
}) {
return (
<div className="flex">
<main>{children}</main>
<aside>{right}</aside>
</div>
);
}Next.js 會自動根據當前 URL 匹配對應的 @right/*/page.tsx,把它注入到 right 這個 prop。不需要在任何地方寫 if (pathname === ...) 的判斷。
文章頁面對應的右欄(@right/[...catchAll]/page.tsx)就負責渲染 TOC:
import Toc from "@/components/Toc";
import { getPostBySlug } from "@/lib/posts";
export default async function RightSlot({
params,
}: {
params: { catchAll: string[] };
}) {
const slug = params.catchAll.at(-1) ?? "";
const post = await getPostBySlug(slug).catch(() => null);
if (!post?.toc.length) return null;
return <Toc toc={post.toc} />;
}這樣的設計讓「右欄要顯示什麼」完全由路由決定,每個頁面的右欄邏輯各自獨立,不會互相干擾。
小結
TOC 的實作總共分兩個部分:
| 部分 | 位置 | 說明 |
|---|---|---|
| 提取標題 | lib/posts.ts(Server) | 解析 MDX 原始文字,用 github-slugger 產生 id |
| 渲染目錄 | components/Toc.tsx(Client) | 渲染清單,用 IntersectionObserver 追蹤位置 |
關鍵是兩邊的 id 要一致:extractToc() 和 rehype-slug 都透過 github-slugger 產生 id,才能確保目錄點擊後能正確跳到對應標題。
下一篇會繼續介紹這個 Blog 的全文搜尋功能是怎麼實作的。