将 Markdown 解析成自动生成的目录

Avatar of Lisi Linhart
Lisi Linhart

DigitalOcean 为您的旅程各个阶段提供云产品。立即开始使用 $200 免费积分!

目录是一个链接列表,它允许您快速跳转到同一页面上的特定内容部分。它有利于长篇内容,因为它向用户显示内容的便捷概述,并提供方便的访问方式。

本教程将向您展示如何将长篇 Markdown 文本解析为 HTML,然后从标题生成链接列表。之后,我们将使用 Intersection Observer API 找出当前活动的部分,在点击链接时添加滚动动画,最后,学习 Vue 的 <transition-group> 如何让我们根据当前活动的部分创建漂亮的动画列表。

解析 Markdown

在网络上,文本内容通常以 Markdown 的形式提供。如果您从未使用过它,有很多理由 说明为什么 Markdown 是文本内容的绝佳选择。我们将使用名为 marked 的 markdown 解析器,但 任何其他解析器 也很好。 

我们将从 GitHub 上的 Markdown 文件中获取内容。加载完 Markdown 文件后,我们只需要调用 marked(<markdown>, <options>) 函数来将 Markdown 解析为 HTML。

async function fetchAndParseMarkdown() {
  const url = 'https://gist.githubusercontent.com/lisilinhart/e9dcf5298adff7c2c2a4da9ce2a3db3f/raw/2f1a0d47eba64756c22460b5d2919d45d8118d42/red_panda.md'
  const response = await fetch(url)
  const data = await response.text()
  const htmlFromMarkdown = marked(data, { sanitize: true });
  return htmlFromMarkdown
}

获取和解析完数据后,我们将通过使用 innerHTML 替换内容将解析后的 HTML 传递到我们的 DOM。

async function init() {
  const $main = document.querySelector('#app');
  const htmlContent = await fetchAndParseMarkdown();
  $main.innerHTML = htmlContent
}


init();

现在我们已经生成了 HTML,我们需要将我们的标题转换为可点击的链接列表。为了找到标题,我们将使用 DOM 函数 querySelectorAll('h1, h2'),它选择我们 markdown 容器内的所有 <h1><h2> 元素。然后,我们将遍历标题并提取我们需要的信息:标签内的文本、深度(为 1 或 2)以及我们可以用来链接到每个相应标题的元素 ID。

function generateLinkMarkup($contentElement) {
  const headings = [...$contentElement.querySelectorAll('h1, h2')]
  const parsedHeadings = headings.map(heading => {
    return {
      title: heading.innerText,
      depth: heading.nodeName.replace(/\D/g,''),
      id: heading.getAttribute('id')
    }
  })
  console.log(parsedHeadings)
}

此代码段将生成一个类似于以下的元素数组

[
  {title: "The Red Panda", depth: "1", id: "the-red-panda"},
  {title: "About", depth: "2", id: "about"},
  // ... 
]

从标题元素中获取到所需信息后,我们可以使用 ES6 模板字面量生成目录所需的 HTML 元素。

首先,我们遍历所有标题并创建 <li> 元素。如果我们处理的是 <h2>,其 depth: 2,我们将添加一个额外的填充类 .pl-4 来缩进它们。这样,我们就可以在链接列表中将 <h2> 元素显示为缩进的子标题。

最后,我们将 <li> 代码段数组连接起来,并将其包装在一个 <ul> 元素中。

function generateLinkMarkup($contentElement) {
  // ...
  const htmlMarkup = parsedHeadings.map(h => `
  <li class="${h.depth > 1 ? 'pl-4' : ''}">
    <a href="#${h.id}">${h.title}</a>
  </li>
  `)
  const finalMarkup = `<ul>${htmlMarkup.join('')}</ul>`
  return finalMarkup
}

这就是生成链接列表所需的所有内容。现在,我们将生成的 HTML 添加到 DOM。

async function init() {
  const $main = document.querySelector('#content');
  const $aside = document.querySelector('#aside');
  const htmlContent = await fetchAndParseMarkdown();
  $main.innerHTML = htmlContent
  const linkHtml = generateLinkMarkup($main);
  $aside.innerHTML = linkHtml        
}

添加 Intersection Observer

接下来,我们需要找出我们当前正在阅读的内容的哪一部分。Intersection Observers 是最完美的选择。MDN 将 Intersection Observer 定义如下

Intersection Observer API 提供了一种异步观察目标元素与祖先元素或顶级文档视窗的交点变化的方法。

所以,基本上,它们允许我们观察元素与视窗或其父级元素之一的交点。要创建一个,我们可以调用 new IntersectionObserver(),它将创建一个新的观察者实例。每当我们创建一个新的观察者时,我们需要传递一个回调函数,该函数在观察者观察到元素的交点时被调用。Travis Almand 有一个关于 Intersection Observer 的详细解释,您可以阅读,但我们现在需要一个回调函数作为第一个参数,一个选项对象作为第二个参数。

function createObserver() {
  const options = {
    rootMargin: "0px 0px -200px 0px",
    threshold: 1
  }
  const callback = () => { console.log("observed something") }
  return new IntersectionObserver(callback, options)
}

观察者已创建,但目前没有观察任何内容。我们需要观察 Markdown 中的标题元素,所以让我们遍历它们,并使用 observe() 函数将它们添加到观察者中。

const observer = createObserver()
$headings.map(heading => observer.observe(heading))

因为我们想更新链接列表,我们将把它作为 $links 参数传递给 observer 函数,因为为了性能,我们不想在每次更新时都重新读取 DOM。在 handleObserver 函数中,我们找出标题是否与视窗相交,然后获取其 id,并将其传递给名为 updateLinks 的函数,该函数处理更新目录中链接的类。

function handleObserver(entries, observer, $links) {
  entries.forEach((entry)=> {
    const { target, isIntersecting, intersectionRatio } = entry
    if (isIntersecting && intersectionRatio >= 1) {
      const visibleId = `#${target.getAttribute('id')}`
      updateLinks(visibleId, $links)
    }
  })
}

让我们编写更新链接列表的函数。我们需要遍历所有链接,如果存在 .is-active 类,则将其删除,并且只将其添加到实际活动的元素中。

function updateLinks(visibleId, $links) {
  $links.map(link => {
    let href = link.getAttribute('href')
    link.classList.remove('is-active')
    if(href === visibleId) link.classList.add('is-active')
  })
}

我们 init() 函数的结尾创建了一个观察者,观察了所有标题,并更新了链接列表,以便观察者注意到变化时,高亮显示活动链接。

async function init() {
  // Parsing Markdown
  const $aside = document.querySelector('#aside');


  // Generating a list of heading links
  const $headings = [...$main.querySelectorAll('h1, h2')];


  // Adding an Intersection Observer
  const $links = [...$aside.querySelectorAll('a')]
  const observer = createObserver($links)
  $headings.map(heading => observer.observe(heading))
}

滚动到部分动画

下一部分是创建一个滚动动画,以便当点击目录中的链接时,用户会平滑地滚动到标题位置,而不是突然跳到那里。这通常被称为 平滑滚动.

滚动动画可能是有害的,如果用户 偏好减少运动,所以我们应该只有在用户没有指定其他情况时才对滚动行为进行动画处理。使用 window.matchMedia('(prefers-reduced-motion)'),我们可以读取用户偏好并相应地调整动画。这意味着我们需要在每个链接上添加一个点击事件监听器。因为我们需要滚动到标题,所以我们也将传递我们的 $headings 列表和 motionQuery。 

const motionQuery = window.matchMedia('(prefers-reduced-motion)');


$links.map(link => {
  link.addEventListener("click", 
    (evt) => handleLinkClick(evt, $headings, motionQuery)
  )
})

让我们编写我们的 handleLinkClick 函数,该函数在每次点击链接时被调用。首先,我们需要阻止链接的默认行为,该行为将直接跳转到该部分。然后,我们将读取所点击链接的 href 属性并找到具有相应 id 属性的标题。通过将 tabindex 值设置为 -1 并使用 focus(),我们可以使标题获得焦点,让用户知道他们跳到了哪里。最后,我们通过对窗口调用 scroll() 来添加滚动动画。 

这是我们的 motionQuery 发挥作用的地方。如果用户偏好减少运动,行为将是 instant;否则,行为将是 smoothtop 选项在标题顶部添加了一些滚动边距,以防止它们粘贴到窗口的最顶部。

function handleLinkClick(evt, $headings, motionQuery) {
  evt.preventDefault()
  let id = evt.target.getAttribute("href").replace('#', '')
  let section = $headings.find(heading => heading.getAttribute('id') === id)
  section.setAttribute('tabindex', -1)
  section.focus()


  window.scroll({
    behavior: motionQuery.matches ? 'instant' : 'smooth',
    top: section.offsetTop - 20
  })
}

对于最后一部分,我们将使用 Vue 的 <transition-group>,它非常适合 列表过渡。如果您从未使用过它们,这里有 Sarah Drasner 关于 Vue 过渡的精彩介绍。它们特别棒,因为它们为我们提供了动画生命周期钩子,可以轻松访问 CSS 动画。

当元素从列表中添加 (v-enter) 或删除 (v-leave) 时,Vue 会自动为我们附加 CSS 类,以及当动画处于活动状态时附加的类 (v-enter-activev-leave-active)。这非常适合我们的情况,因为我们可以根据子标题从列表中添加或删除时的不同动画。要使用它们,我们需要将目录中的 <li> 元素包装在 <transition-group> 元素中。<transition-group> 的 name 属性定义了 CSS 动画的调用方式,tag 属性应该是我们的父级 <ul> 元素。

<transition-group name="list" tag="ul">
  <li v-for="(item, index) in activeHeadings" v-bind:key="item.id">
    <a :href="item.id">
      {{ item.text }}
    </a>
  </li>
</transition-group>

现在,我们需要添加实际的 CSS 过渡。每当元素进入或离开时,它都应该从不可见 (opacity: 0) 和稍微向下移动 (transform: translateY(10px)) 动画开始。

.list-enter, .list-leave-to {
  opacity: 0;
  transform: translateY(10px);
}

然后,我们定义我们要为其添加动画的 CSS 属性。为了性能,我们只想为 transformopacity 属性添加动画。CSS 允许我们使用不同的计时将过渡链接在一起:transform 应该花费 0.8 秒,淡入淡出只花费 0.4 秒。

.list-leave-active, .list-move {
  transition: transform 0.8s, opacity 0.4s;
}

然后,我们希望在添加新元素时添加一点延迟,以便子标题在父标题向上或向下移动后淡入。我们可以使用 v-enter-active 钩子来做到这一点

.list-enter-active { 
  transition: transform 0.8s ease 0.4s, opacity 0.4s ease 0.4s;
}

最后,我们可以为即将离开的元素添加绝对定位,以避免其他元素动画时出现突然跳跃。

.list-leave-active {
  position: absolute;
}

由于滚动交互会淡出和淡入元素,因此建议在有人快速滚动时对滚动交互进行去抖。通过对交互进行去抖,我们可以避免未完成的动画与其他动画重叠。您可以编写自己的去抖函数,或者直接使用 lodash 的 debounce 函数。对于我们的示例,避免未完成的动画更新的最简单方法是用去抖函数包装 Intersection Observer 回调函数,并将去抖函数传递给观察者。

const debouncedFunction = _.debounce(this.handleObserver)
this.observer = new IntersectionObserver(debouncedFunction,options)

这是最终的演示


再说一次,目录是任何长篇内容的绝佳补充。它有助于明确涵盖的内容,并提供对特定内容的快速访问。在它之上使用 Intersection Observer 和 Vue 的列表动画可以使目录更加交互,甚至使其成为阅读进度指示器。但是即使您只添加一个链接列表,它仍然将是用户阅读您的内容的绝佳功能。