給Markdown渲染網(wǎng)頁(yè)增加一個(gè)目錄組件(Vite+Vditor+Handlebars)(上)
1 引言
在上一篇文章《解決Vditor加載Markdown網(wǎng)頁(yè)很慢的問(wèn)題(Vite+JS+Vditor)》中,我們通過(guò)設(shè)置域內(nèi)CDN的方式解決Vditor加載Markdown網(wǎng)頁(yè)很慢的問(wèn)題。而在這篇文章中,筆者將會(huì)開(kāi)發(fā)實(shí)現(xiàn)一個(gè)前端中很常見(jiàn)的需求:給基于Markdown渲染的文檔網(wǎng)頁(yè)增加一個(gè)目錄組件。
需要說(shuō)明的是,原生的Markdown標(biāo)準(zhǔn)并沒(méi)有規(guī)定生成目錄的寫(xiě)法,但是國(guó)內(nèi)的博文網(wǎng)站似乎都支持一個(gè)拓展來(lái)實(shí)現(xiàn)目錄的生成:
[toc]
但是這樣生成的目錄是通常是位于文章頁(yè)面的最上方,這樣就失去了目錄的意義。比較好的實(shí)現(xiàn)是像CSDN或者掘金一樣,額外生成一個(gè)目錄組件,并且固定在側(cè)欄上方。這樣可以在瀏覽文章的時(shí)候,隨時(shí)定位所在的目錄;同時(shí)還可以使用目錄來(lái)導(dǎo)航。

閱讀本文可能需要的前置文章:
- 《通過(guò)JS模板引擎實(shí)現(xiàn)動(dòng)態(tài)模塊組件(Vite+JS+Handlebars)》
- 《使用Vditor將Markdown文檔渲染成網(wǎng)頁(yè)(Vite+JS+Vditor)》
2 詳敘
2.1 整體結(jié)構(gòu)
將渲染Markdown文檔的部分封裝成單獨(dú)的組件(post-article.js、post-article.handlebars和post-article.css),增加一個(gè)文章目錄組件(post-toc.js、post-toc.handlebars、post-toc.css)。另外post-data.json是我們提前準(zhǔn)備的博客文章,里面除了保存有Markdown格式的文檔字符串,還有一些文章的相關(guān)數(shù)據(jù);1.png和2.png則是文章中圖片。項(xiàng)目組織結(jié)構(gòu)如下:
my-native-js-app/
├── public/
│ ├── 1.png
│ ├── 2.png
│ └── post-data.json
├── src/
│ ├── components/
│ │ ├── post-article.css
│ │ ├── post-article.handlebars
│ │ ├── post-article.js
│ │ ├── post-toc.css
│ │ ├── post-toc.handlebars
│ │ └── post-toc.js
│ ├── main.js
│ └── style.css
├── index.html
└── package.json
還是按照代碼的執(zhí)行順序來(lái)介紹這個(gè)功能的實(shí)現(xiàn)。首先還是index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app">
<div id="post-article-placeholder"></div>
<div id="article-toc-placeholder"></div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
主要就是增加了post-article-placeholder和article-toc-placeholder這兩個(gè)元素,分別作為Markdown博文和博文目錄的容器。其實(shí)這里面還有個(gè)頁(yè)面布局的問(wèn)題,不過(guò)這個(gè)問(wèn)題我們下一篇文章再說(shuō)。這里還是先看main.js:
import "./style.css";
import "./components/post-article.js";
2.2 博文內(nèi)容組件
引用了post-article.js,也就是Markdown博文內(nèi)容組件。那么就進(jìn)入post-article.js:
import "./post-article.css";
import { CreateTocPanel } from "./post-toc.js";
import Handlebars from "handlebars";
import templateSource from "./post-article.handlebars?raw";
import "vditor/dist/index.css";
import Vditor from "vditor";
// 初始化文章標(biāo)簽面板
async function InitializePostArticlePanel() {
try {
const response = await fetch("/post-data.json");
if (!response.ok) {
throw new Error("網(wǎng)絡(luò)無(wú)響應(yīng)");
}
const blogData = await response.json();
// 編譯模板
const template = Handlebars.compile(templateSource);
// 渲染模板
const renderedHtml = template({
blogMeta: blogData.blogMeta,
});
// 將渲染好的HTML插入到頁(yè)面中
document.getElementById("post-article-placeholder").innerHTML =
renderedHtml;
// 顯示內(nèi)容
Vditor.preview(document.getElementById("post-content"), blogData.content, {
cdn: window.location.origin,
markdown: {
toc: false,
mark: true, //==高亮顯示==
footnotes: true, //腳注
autoSpace: true, //自動(dòng)空格,適合中英文混合排版
},
math: {
engine: "KaTeX", //支持latex公式
inlineDigit: true, //內(nèi)聯(lián)公式可以接數(shù)字
},
hljs: {
style: "github", //代碼段樣式
lineNumber: true, //是否顯示行號(hào)
},
anchor: 2, // 為標(biāo)題添加錨點(diǎn) 0:不渲染;1:渲染于標(biāo)題前;2:渲染于標(biāo)題后
lang: "zh_CN", //中文
theme: {
current: "light", //light,dark,light,wechat
},
lazyLoadImage:
"https://cdn.jsdelivr.net/npm/vditor/dist/images/img-loading.svg",
transform: (html) => {
// 使用正則表達(dá)式替換圖片路徑,并添加居中樣式及題注
return html.replace(
/<img\s+[^>]*src="\.\/([^"]+)\.([a-zA-Z0-9]+)"\s*alt="([^"]*)"[^>]*>/g,
(match, p1, p2, altText) => {
// const newSrc = `${backendUrl}/blogs/resources/images/${postId}/${p1}.${p2}`;
const newSrc = `${p1}.${p2}`;
const imgWithCaption = `
<div style="text-align: center;">
<img src="${newSrc}" class="center-image" alt="${altText}">
<p class="caption">${altText}</p>
</div>
`;
return imgWithCaption;
}
);
},
after() {
CreateTocPanel();
},
});
} catch (error) {
console.error("獲取博客失敗:", error);
}
}
document.addEventListener("DOMContentLoaded", InitializePostArticlePanel);
post-article.js中的內(nèi)容改進(jìn)自《通過(guò)JS模板引擎實(shí)現(xiàn)動(dòng)態(tài)模塊組件(Vite+JS+Handlebars)》中的案例,不過(guò)略有不同。首先是獲取博文數(shù)據(jù):
const response = await fetch("/post-data.json");
if (!response.ok) {
throw new Error("網(wǎng)絡(luò)無(wú)響應(yīng)");
}
const blogData = await response.json();
// 編譯模板
const template = Handlebars.compile(templateSource);
// 渲染模板
const renderedHtml = template({
blogMeta: blogData.blogMeta,
});
// 將渲染好的HTML插入到頁(yè)面中
document.getElementById("post-article-placeholder").innerHTML =
renderedHtml;
在實(shí)際項(xiàng)目開(kāi)發(fā)中,應(yīng)該是從遠(yuǎn)端API獲取數(shù)據(jù),這里進(jìn)行了簡(jiǎn)化,將數(shù)據(jù)提前準(zhǔn)備好了放置在域內(nèi)。然后,將這個(gè)數(shù)據(jù)與編譯的Handlebars模板一起渲染成HTML元素。從下面的post-article.handlebars中可以看到,博文組件中內(nèi)容不僅包含Markdown博文內(nèi)容元素,還有諸如時(shí)間、統(tǒng)計(jì)信息、標(biāo)簽等元素:
<div id="main-content">
<h1 id="post-title">{{blogMeta.title}}</h1>
<div class="post-stats">
<span class = "post-stat">
<span>??</span><span class = "text">已于</span>{{blogMeta.createdTime}}<span class = "text">修改</span>
</span>
<span class = "post-stat">
<span>???</span>{{blogMeta.postStats.viewCount}}<span class = "text">閱讀</span>
</span>
<span class = "post-stat">
<span>??</span>{{blogMeta.postStats.likeCount}}<span class = "text">點(diǎn)贊</span>
</span>
<span class = "post-stat">
<span>??</span>{{blogMeta.postStats.commentCount}}<span class = "text">評(píng)論</span>
</span>
</div>
<div class="post-tags">
<span class = "tags-title">
<span>??</span><span class = "text">文章標(biāo)簽</span>
</span>
{{#each blogMeta.tagNames}}
<span class = "post-tag">{{this}}</span>
{{/each}}
</div>
<div class="post-categories">
專(zhuān)欄
{{#each blogMeta.categoryNames}}
<span> {{this}} </span>
{{/each}}
收錄該內(nèi)容
</div>
<div id="post-content"></div>
</div>
Markdown博文內(nèi)容元素是使用Vditor來(lái)渲染初始化的,這一點(diǎn)與之前的案例一樣。不同的是增加了一個(gè)after配置:
import { CreateTocPanel } from "./post-toc.js";
//...
after() {
CreateTocPanel();
},
這個(gè)after配置的意思是當(dāng)Vditor渲染完成以后,就立刻執(zhí)行CreateTocPanel()函數(shù),這個(gè)函數(shù)來(lái)自于博文目錄組件post-toc.js,表示要開(kāi)始創(chuàng)建博文目錄了。
2.2 博文目錄組件
post-toc.js中的代碼如下所示:
import "./post-toc.css";
import Handlebars from "handlebars";
import templateSource from "./post-toc.handlebars?raw";
export function CreateTocPanel() {
const headings = document.querySelectorAll(
"#post-content h1, #post-content h2, #post-content h3"
);
const tocContent = [];
headings.forEach((heading, index) => {
const content = {};
content["id"] = heading.id;
content["title"] = heading.textContent;
const marginLeft =
heading.tagName === "H2" ? 20 : heading.tagName === "H3" ? 40 : 0;
content["marginLeft"] = marginLeft;
tocContent.push(content);
});
// 編譯模板
const template = Handlebars.compile(templateSource);
// 渲染模板
const renderedHtml = template({
tocContent,
});
// 將渲染好的HTML插入到頁(yè)面中
const articleTocPlaceholder = document.getElementById(
"article-toc-placeholder"
);
articleTocPlaceholder.innerHTML = renderedHtml;
// 聯(lián)動(dòng):滾動(dòng)時(shí)同步激活目錄項(xiàng)
window.addEventListener("scroll", () => {
let activeHeading;
headings.forEach((heading) => {
const rect = heading.getBoundingClientRect();
if (rect.top >= 0 && rect.top <= window.innerHeight / 2) {
activeHeading = heading;
}
});
if (activeHeading) {
document
.querySelectorAll(".toc-sidebar .toc a")
.forEach((link) => link.classList.remove("active"));
const escapedId = CSS.escape(activeHeading.id); //安全地轉(zhuǎn)義選擇器中的特殊字符
const activeLink = document.querySelector(
`.toc-sidebar .toc a[href="#${escapedId}"]`
);
if (activeLink) activeLink.classList.add("active");
}
});
}
這段代碼是實(shí)現(xiàn)博文目錄功能的關(guān)鍵代碼。首先,搜索查詢(xún)渲染成HTML形式的博文內(nèi)容中的標(biāo)題元素h1、h2和h3:
const headings = document.querySelectorAll(
"#post-content h1, #post-content h2, #post-content h3"
);
然后提取出關(guān)鍵數(shù)據(jù):
const tocContent = [];
headings.forEach((heading, index) => {
const content = {};
content["id"] = heading.id;
content["title"] = heading.textContent;
const marginLeft =
heading.tagName === "H2" ? 20 : heading.tagName === "H3" ? 40 : 0;
content["marginLeft"] = marginLeft;
tocContent.push(content);
});
將其傳入Handlebars模板進(jìn)行渲染:
// 編譯模板
const template = Handlebars.compile(templateSource);
// 渲染模板
const renderedHtml = template({
tocContent,
});
// 將渲染好的HTML插入到頁(yè)面中
const articleTocPlaceholder = document.getElementById(
"article-toc-placeholder"
);
articleTocPlaceholder.innerHTML = renderedHtml;
模板post-toc.handlebars中的內(nèi)容非常簡(jiǎn)單:
<div class="toc-sidebar">
<div class="toc">
<h3>文章目錄</h3>
<ul>
{{#each tocContent}}
<li style="margin-left: {{marginLeft}}px;">
<a href="#{{id}}" class="">
{{title}}
</a>
</li>
{{/each}}
</ul>
</div>
</div>
可以看到這里能夠獲取一級(jí)、二級(jí)還有三級(jí)標(biāo)題,通過(guò)樣式的縮進(jìn)(margin-left)來(lái)體現(xiàn)標(biāo)題的不同。另外,href屬性的設(shè)置也保證了能通過(guò)點(diǎn)擊來(lái)實(shí)現(xiàn)跳轉(zhuǎn)。
最后實(shí)現(xiàn)聯(lián)動(dòng),通過(guò)文章標(biāo)題元素范圍的判定,來(lái)高亮目錄中標(biāo)題元素的樣式,讓用戶(hù)直到瀏覽到博文中的哪一段了:
// 聯(lián)動(dòng):滾動(dòng)時(shí)同步激活目錄項(xiàng)
window.addEventListener("scroll", () => {
let activeHeading;
headings.forEach((heading) => {
const rect = heading.getBoundingClientRect();
if (rect.top >= 0 && rect.top <= window.innerHeight / 2) {
activeHeading = heading;
}
});
if (activeHeading) {
document
.querySelectorAll(".toc-sidebar .toc a")
.forEach((link) => link.classList.remove("active"));
const escapedId = CSS.escape(activeHeading.id); //安全地轉(zhuǎn)義選擇器中的特殊字符
const activeLink = document.querySelector(
`.toc-sidebar .toc a[href="#${escapedId}"]`
);
if (activeLink) activeLink.classList.add("active");
}
});
3 結(jié)語(yǔ)
最終實(shí)現(xiàn)的效果如下圖所示:

雖然功能大致實(shí)現(xiàn)了,不過(guò)還有一些問(wèn)題沒(méi)有說(shuō)清楚,比如在瀏覽文章的過(guò)程中,博文目錄是如何始終保證黏在頁(yè)面的右上角的?這個(gè)問(wèn)題就放在下篇中繼續(xù)論述了。

浙公網(wǎng)安備 33010602011771號(hào)