给Markdown渲染网页增加一个目录组件(Vite+Vditor+Handlebars)(上)

1 引言

在上一篇文章《解决Vditor加载Markdown网页很慢的问题(Vite+JS+Vditor)》中,我们通过设置域内CDN的方式解决Vditor加载Markdown网页很慢的问题。而在这篇文章中,笔者将会开发实现一个前端中很常见的需求:给基于Markdown渲染的文档网页增加一个目录组件。

需要说明的是,原生的Markdown标准并没有规定生成目录的写法,但是国内的博文网站似乎都支持一个拓展来实现目录的生成:

markdown 复制代码
[toc]

但是这样生成的目录是通常是位于文章页面的最上方,这样就失去了目录的意义。比较好的实现是像CSDN或者掘金一样,额外生成一个目录组件,并且固定在侧栏上方。这样可以在浏览文章的时候,随时定位所在的目录;同时还可以使用目录来导航。

阅读本文可能需要的前置文章:

2 详叙

2.1 整体结构

将渲染Markdown文档的部分封装成单独的组件(post-article.js、post-article.handlebars和post-article.css),增加一个文章目录组件(post-toc.js、post-toc.handlebars、post-toc.css)。另外post-data.json是我们提前准备的博客文章,里面除了保存有Markdown格式的文档字符串,还有一些文章的相关数据;1.png和2.png则是文章中图片。项目组织结构如下:

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

还是按照代码的执行顺序来介绍这个功能的实现。首先还是index.html:

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这两个元素,分别作为Markdown博文和博文目录的容器。其实这里面还有个页面布局的问题,不过这个问题我们下一篇文章再说。这里还是先看main.js:

javascript 复制代码
import "./style.css";
import "./components/post-article.js";

2.2 博文内容组件

引用了post-article.js,也就是Markdown博文内容组件。那么就进入post-article.js:

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";

// 初始化文章标签面板
async function InitializePostArticlePanel() {
  try {   
    const response = await fetch("/post-data.json");
    if (!response.ok) {
      throw new Error("网络无响应");
    }
    const blogData = await response.json();
  
    // 编译模板
    const template = Handlebars.compile(templateSource);

    // 渲染模板
    const renderedHtml = template({
      blogMeta: blogData.blogMeta,
    });

    // 将渲染好的HTML插入到页面中
    document.getElementById("post-article-placeholder").innerHTML =
      renderedHtml;

    // 显示内容
    Vditor.preview(document.getElementById("post-content"), blogData.content, {
      cdn: window.location.origin,
      markdown: {
        toc: false,
        mark: true, //==高亮显示==
        footnotes: true, //脚注
        autoSpace: true, //自动空格,适合中英文混合排版
      },
      math: {
        engine: "KaTeX", //支持latex公式
        inlineDigit: true, //内联公式可以接数字
      },
      hljs: {
        style: "github", //代码段样式
        lineNumber: true, //是否显示行号
      },
      anchor: 2, // 为标题添加锚点 0:不渲染;1:渲染于标题前;2:渲染于标题后
      lang: "zh_CN", //中文
      theme: {
        current: "light", //light,dark,light,wechat
      },
      lazyLoadImage:
        "https://cdn.jsdelivr.net/npm/vditor/dist/images/img-loading.svg",
      transform: (html) => {
        // 使用正则表达式替换图片路径,并添加居中样式及题注
        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中的内容改进自《通过JS模板引擎实现动态模块组件(Vite+JS+Handlebars)》中的案例,不过略有不同。首先是获取博文数据:

js 复制代码
const response = await fetch("/post-data.json");
if (!response.ok) {
    throw new Error("网络无响应");
}
const blogData = await response.json();

// 编译模板
const template = Handlebars.compile(templateSource);

// 渲染模板
const renderedHtml = template({
    blogMeta: blogData.blogMeta,
});

// 将渲染好的HTML插入到页面中
document.getElementById("post-article-placeholder").innerHTML =
    renderedHtml;

在实际项目开发中,应该是从远端API获取数据,这里进行了简化,将数据提前准备好了放置在域内。然后,将这个数据与编译的Handlebars模板一起渲染成HTML元素。从下面的post-article.handlebars中可以看到,博文组件中内容不仅包含Markdown博文内容元素,还有诸如时间、统计信息、标签等元素:

html 复制代码
<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">点赞</span>
        </span>
        <span class = "post-stat">
            <span>💬</span>{{blogMeta.postStats.commentCount}}<span class = "text">评论</span>
        </span>
    </div>
    <div class="post-tags">
        <span class = "tags-title">
            <span>🔖</span><span class = "text">文章标签</span>
        </span>
        {{#each blogMeta.tagNames}}
        <span class = "post-tag">{{this}}</span>
        {{/each}}
    </div>
    <div class="post-categories">
        专栏
        {{#each blogMeta.categoryNames}}
        <span> {{this}} </span>
        {{/each}}
        收录该内容
    </div>
    <div id="post-content"></div>
</div>

Markdown博文内容元素是使用Vditor来渲染初始化的,这一点与之前的案例一样。不同的是增加了一个after配置:

js 复制代码
import { CreateTocPanel } from "./post-toc.js";

//...

after() {
    CreateTocPanel();
},

这个after配置的意思是当Vditor渲染完成以后,就立刻执行CreateTocPanel()函数,这个函数来自于博文目录组件post-toc.js,表示要开始创建博文目录了。

2.2 博文目录组件

post-toc.js中的代码如下所示:

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插入到页面中
  const articleTocPlaceholder = document.getElementById(
    "article-toc-placeholder"
  );
  articleTocPlaceholder.innerHTML = renderedHtml;

  // 联动:滚动时同步激活目录项
  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); //安全地转义选择器中的特殊字符
      const activeLink = document.querySelector(
        `.toc-sidebar .toc a[href="#${escapedId}"]`
      );
      if (activeLink) activeLink.classList.add("active");
    }
  });
}

这段代码是实现博文目录功能的关键代码。首先,搜索查询渲染成HTML形式的博文内容中的标题元素h1h2h3

js 复制代码
const headings = document.querySelectorAll(
    "#post-content h1, #post-content h2, #post-content h3"
  );

然后提取出关键数据:

js 复制代码
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模板进行渲染:

js 复制代码
// 编译模板
  const template = Handlebars.compile(templateSource);

  // 渲染模板
  const renderedHtml = template({
    tocContent,
  });

  // 将渲染好的HTML插入到页面中
  const articleTocPlaceholder = document.getElementById(
    "article-toc-placeholder"
  );
  articleTocPlaceholder.innerHTML = renderedHtml;

模板post-toc.handlebars中的内容非常简单:

html 复制代码
<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>

可以看到这里能够获取一级、二级还有三级标题,通过样式的缩进(margin-left)来体现标题的不同。另外,href属性的设置也保证了能通过点击来实现跳转。

最后实现联动,通过文章标题元素范围的判定,来高亮目录中标题元素的样式,让用户直到浏览到博文中的哪一段了:

js 复制代码
// 联动:滚动时同步激活目录项
  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); //安全地转义选择器中的特殊字符
      const activeLink = document.querySelector(
        `.toc-sidebar .toc a[href="#${escapedId}"]`
      );
      if (activeLink) activeLink.classList.add("active");
    }
  });

3 结语

最终实现的效果如下图所示:

虽然功能大致实现了,不过还有一些问题没有说清楚,比如在浏览文章的过程中,博文目录是如何始终保证黏在页面的右上角的?这个问题就放在下篇中继续论述了。

实现代码

相关推荐
努力往上爬de蜗牛32 分钟前
vue3 daterange正则踩坑
javascript·vue.js·elementui
3Katrina34 分钟前
理解Promise:让异步编程更优雅
前端·javascript
星之金币35 分钟前
关于我用Cursor优化了一篇文章:30 分钟学会定制属于你的编程语言
前端·javascript
每天都想着怎么摸鱼的前端菜鸟37 分钟前
【uniapp】uni.chooseImage在Android 13以下机型第一次调用授权后无权限问题
javascript·uni-app
市民中心的蟋蟀38 分钟前
第十一章 这三个全局状态管理库之间的共性与差异 【上】
前端·javascript·react.js
小宋102138 分钟前
el-table的select回显问题
javascript·vue.js·elementui
豆豆(设计前端)1 小时前
在 JavaScript 中,你可以使用 Date 对象来获取 当前日期 和 当前时间、当前年份。
开发语言·javascript·ecmascript
DoraBigHead1 小时前
深入 JavaScript 作用域机制:透视 V8 引擎背后的执行秘密
前端·javascript
薛定谔的算法1 小时前
JavaScript闭包深度解析:从基础概念到柯里化实践
javascript
新晨4371 小时前
vite引入文件
vite