原文: How to Build a Table of Contents Component for Your Blog

当你访问文档网站时,你会注意到许多网站都有一个组件:<TableOfContent /> 组件。

它背后的想法是给读者一个关于他们试图获取的信息的“提示”。

这个功能反过来又帮助读者直接访问某个部分,这个部分为他们所面临的任何错误或问题提供了解决方案,因此他们无需阅读整篇文章。这有助于形成良好的用户体验,因为你最终为你的受众省去了额外的滚动和搜索的麻烦。

我有一个个人博客,我花了很多时间在上面。而在很长一段时间里,我想过要增加这个功能。它将帮助任何访问我的网站的人阅读起来更开心,并找到他们需要的东西。

这篇文章是对我的过程的总结,所以你不需要经历我所经历的问题。如果你想在你的博客上添加目录功能,你可以和我一起看看这个过程。

我分享了一段视频,说明该组件完成后的样子。你可以在这里查看视频。

如何从前页获取标题文本

为了建立一个目录功能,我知道我需要做什么。由于我的博客上的文章是用 markdown 写的,我只是使用 markdown 的超集——MDX——它允许我在 markdown 文件中使用 React 组件。

我的清单上的第一件事是获得一种在组件中渲染标题文本的方法。这样,当人们点击标题时,浏览器就会滚动到文章的那个点。

在 HTML 中,你可以通过使用锚标签并将其值传递给一个 href 属性来实现这一目的。

要让链接的文本指向一个部分,理想的方法是像下面的代码片段那样:

<a href="#section-one">Go to section one</a>
<a href="#section-two">Go to section two</a>
<a href="#section-three">Go to section three</a>

<section id="section-one">some content</section>
<section id="section-two">yet, a content that seems weird</section>
<section id="section-three">some content, again</section>

在上面的片段中,DOM 中的锚点标签通过 id 属性与各部分相关联。当你点击任何文本时,它会把你带到相应的部分。

有了这个心理模型,我想在我写过的所有文章中用标题来填充每篇文章的前页(frontmatter)。我知道这将有些压力,但我还是这么做了。

这就是 markdown 文件中的 frontmatter 的样子。前页包含了我博客上所有文章的元数据,诸如标题、发布日期、文章所属的标签或类别、描述、规范 URL,以及其他任何你想添加的东西,以提升文章的 SEO。

当你用 Next.js 和 MDX 构建博客时,这种模式很常见。它也有一个类似 YAML 的语法。

---
id: 20
title: Building a Table of Content component
publishedAt: '2023-02-28'
excerpt: description of the article
tags:
  - ux
  - nextjs
headings:
  - heading-one
  - heading-two
  - heading-three
cover_image: /img/covers/toc.jpg
---

上面的片段是本文前页的样子,但有 headings 条目。我将用它来解释我最初的方法。如果我继续前进并映射(map through)前页,我将能够从 headings 数组中检索内容。

这很好,因为我将能够在 TableOfContent 组件中使用 headings 数组中的项目。这感觉很不真实,我高兴了一会儿。该组件看起来像这样:

import React from 'react'
import { HeadingContainer } from './style/toc.styled'

export default function TableOfContents({ headings }) {
  return (
    <HeadingContainer>
      <p>In this article</p>
      <ul>
        {headings.map((item, index) => (
          <li key={index}>
            <a href={`#${item}`}>{item}</a>
          </li>
        ))}
      </ul>
    </HeadingContainer>
  )
}

上面的组件接收一个 headings prop,而这个 prop 又通过 Next.js 的 getStaticProps() 方法从 frontmatter 接收一个值。

export default function Blog({
  post: {
    frontmatter: { title, headings },
  },
}) {
  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>
      <TableOfContents headings={headings} />
    </>
  )
}

// 解构参数以获得唯一的 slug
export async function getStaticProps({ params }) {
  const { slug } = params
  const { frontmatter } = await getArticleFromSlug(slug)

  return {
    props: {
      post: {
        frontmatter,
      },
    },
  }
}

如果上面的片段看起来有点混乱,你可以看看这篇文章,我写了一个 Next.js 博客的设置过程。

该组件渲染了 frontmatter 中的项目列表,看起来很好。

但是,当我点击一个项目,希望滚动到那个部分的时候,它却没有像预期的那样工作。我遇到了一个错误,你会在下一节看到。

如何使用 extract-md-headings

我意识到,当我点击组件中的一个项目时,浏览器用空格的编码参数对当前 slug 的 URL 进行编码——%20%——这反过来导致了这个问题。

我意识到这也可能是我在 frontmatter 中引用标题元素的方式。但这并不重要,因为我找到了一个替代方案,而且效果很好。

在我确定它完美地工作之后,我继续把这个替代方案作为一个发布到 npm registery。

这个包扩展了一个函数,extractHeadings(),它接受一个字符串,作为一个路径,指向 markdown 文件的位置,并提取任何符合 markdown 文件中标题文本写法的文字。如果你想看看它是如何工作的,你可以看看这里的源代码。

有了这个工具,我修改了 getStaticProps 方法来使用这个函数。你可能会问我,为什么?嗯,因为这个包完全依赖于 Node 的 fs 模块,这相当于一个服务器端的脚本方法。

使用 Next.js,我们可以在页面目录中用任何一种数据获取方法,getStaticPropsgetStaticPathsgetServerSideProps,进行服务器端操作:

import React from 'react'
import { extractHeadings } from 'extract-md-headings'

export default function Blog({
  post: {
    fileContent,
    frontmatter: { title },
  },
}) {
  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>
      <TableOfContents headings={fileContent} />
    </>
  )
}

export async function getStaticProps({ params }) {
  const { slug } = params
  const { frontmatter } = await getArticleFromSlug(slug)
  const mdxContent = extractHeadings(`/path/to/where/${slug}.mdx`)

  return {
    props: {
      post: {
        frontmatter,
        fileContent: mdxContent,
      },
    },
  }
}

现在 [slug].js 页面通过 TOC 组件的 heading prop 知道了 fileContent 的情况。所以我需要修改它,使它能适应函数返回的属性。

import React from 'react'
import { HeadingContainer } from './style/toc.styled'

export default function TableOfContents({ headings }) {
  return (
    <HeadingContainer>
      <p>In this article</p>
      <ul>
        {headings.map(({ slug, title, id }) => (
          <li key={id}>
            <a href={`#${slug}`}>{title}</a>
          </li>
        ))}
      </ul>
    </HeadingContainer>
  )
}

目前,该组件只是渲染了从函数返回的数组中的项目列表,没有交互性,没有办法跟踪哪个元素是活动的,还有很多东西我暂时还没能添加。

如何添加基于点击和滚动的状态

如果说我喜欢 React 的什么,那就是它追踪状态的能力。我已经看到这在其他文档平台上是如何工作的——当你点击一个项目时,它就变成活动的,当你滚动到有标题标签的部分时,它就变成活动的。

很多人都有不同的方法来监控这些状态。我选择了最简单的方法——改变颜色——因为,像往常一样,“我不喜欢压力”。在我的组件的用户界面中,默认的文本颜色有点灰,所以当它被激活时,它就变成白色。

我先介绍一下我用 useState hook、一些 DOM API 和 getBoundingClientRect web API 对组件进行修改的代码片段。内容挺多——我知道,但是,我试着把它简单地分解一下。

当我们使用 useState hook 时,有一个默认值——一个布尔值、字符串或数字,这是一个常见的方法。在下面的片段中,该组件使用 headings prop 来检查数组的长度是否为空,是否大于零,并将组件的默认状态设置为第一个元素的默认状态。

const [active, setActive] = React.useState(
  headings.length > 0 ? headings[0].slug : ''
)

如果数组是空的,没有元素会有活动状态的样式。现在,如果你在列表元素中放置一个 onClick 属性——就像我做的那样——并传递 slug 作为参数,它将切换你在 style 属性中写的样式。

<li
  key={index}
  onClick={() => setActive(slug)}
  style={{
    color: active === slug ? '#fff' : '',
  }}
>
  <a href={`#${slug}`}>{title}</a>
</li>

处理滚动状态需要使用 React 的 useEffect hook,因为它包含了所有的生命周期方法——componentDidMount()componentDidMount()componentWillUnmount()。在这里,我决定通过用 DOM EventTarget 接口监听本地滚动事件来跟踪滚动状态。

下面的函数 handleScroll 通过对对象的 slug 属性进行解构,映射我们从 extractHeadings() 函数中得到的结果。它继续用 getElementById 返回所有包含适当 id 属性的元素,并将其值分配给 headingElements

const handleScroll = () => {
  const headingElements = headings.map(({ slug }) =>
    document.getElementById(slug)
  )
  const visibleHeadings = headingElements.filter((el) =>
    isElementInViewport(el)
  )
  if (visibleHeadings.length > 0) {
    setActive(visibleHeadings[0].id)
  }
}

还是在这个函数中,从 headingElements 数组中过滤出 visibleElementsisElementInViewport 函数被用来检查哪个标题元素当前在视口中——这可以通过 getBoundingClientRect 实现,我很快会讲到。

该函数以一个条件结束,如果可见标题的长度大于 0,则设置一个活动元素。

现在,我可以继续把这个函数包在 Effect 中,启动滚动事件的清理,并在依赖数组中传递 headings prop。然后,只有当 headings prop 发生变化时,才会触发 Effect。

React.useEffect(() => {
  const handleScroll = () => {
    const headingElements = headings.map(({ slug }) =>
      document.getElementById(slug)
    )
    const visibleHeadings = headingElements.filter((el) =>
      isElementInViewport(el)
    )
    if (visibleHeadings.length > 0) {
      setActive(visibleHeadings[0].id)
    }
  }

  document.addEventListener('scroll', handleScroll)

  // 通过删除事件监听器来清理 effect
  return () => {
    document.removeEventListener('scroll', handleScroll)
  }
}, [headings])

isElementInViewport 是这个功能的重中之重。该函数接受一个元素 el 作为参数,并检查其边界矩形(这再次证明了网络上的盒子原则是正确的)是否在浏览器的视口内。

const isElementInViewport = (el) => {
  const rect = el.getBoundingClientRect()
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <=
      (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  )
}

借助 getBoundingClientRect web API,这是可能的。该方法返回一个对象,包含元素的上、左、下和右边缘相对于视口的坐标。

getBoundingClientRect 被调用时,它返回一个包含特定标题元素相对于视口的上、左、下和右边缘坐标的对象。

在这个功能中,相对于视口的元素是使用 getElementById 方法检索到的标题元素。

如果顶部和左侧坐标大于或等于零,底部和右侧坐标分别小于或等于视口的高度和宽度,该函数返回 true

为了使该函数返回 true,我们必须得到视口的高度和宽度的值。这就是为什么用 window.innerHeightwindow.innerWidthdocumentElement.clientHeightdocumentElement.clientWidth 来比较这些值是很方便的。

为什么会有压力?IntersectionObserver 解决了这个问题

我知道走 intersectionObserver 的路线会为我减少很多压力。但是,我还是选择了这种方法,因为我想了解其他人是如何构建这个功能的。

我想你也可以用一个 intersectionObserver 包来监控 React 应用程序中的滚动事件,所以你可能根本不需要走这条路。但我想分享一些我决定使用这个 API 而不是 IntersectionObserver 的原因。

就准确性而言,getBoundingClientRect 返回元素相对于视口的更精确的位置,而 IntersectionObserver 使用基于元素边界盒的近似值。

这意味着 getBoundingClientRect 在某些用例中可以更精确,比如当你需要在元素进入视口时立即触发一个动作——就像我们在组件中改变列表项的活动状态。

在浏览器兼容性方面,IntersectionObserver 是一个相对较新的 API,其他浏览器可能不支持它。但是,另一方面,getBoundingClientRect 被现代浏览器广泛支持。

getBoundingClientRect 相比,IntersectionObserver 的一个优势是在性能方面。这是因为该 API 使用了一种优化的算法,当你跟踪这么多元素时,它可以最大限度地减少检测相交状态变化所需的工作量。

getBoundingClientRect API 不能处理这么多元素。

总结

我知道,很多人还是喜欢使用 intersectionObserver。但是,我决定采用另一种方法,因为它让我看到了 intersectionObserver 本身是如何在后台工作的,而且最重要的是,它适合我的用例。

这就是 TOC 组件的逻辑,没有标记。如果你想的话,你可以复制并使用它。

import React from 'react'
import { HeadingContainer } from './style/toc.styled'

const TableOfContents = ({ headings }) => {
  const [active, setActive] = React.useState(
    headings.length > 0 ? headings[0].slug : ''
  )

  React.useEffect(() => {
    const handleScroll = () => {
      const headingElements = headings.map(({ slug }) =>
        document.getElementById(slug)
      )
      const visibleHeadings = headingElements.filter((el) =>
        isElementInViewport(el)
      )
      if (visibleHeadings.length > 0) {
        setActive(visibleHeadings[0].id)
      }
    }

    document.addEventListener('scroll', handleScroll)
    return () => {
      document.removeEventListener('scroll', handleScroll)
    }
  }, [headings])

  const isElementInViewport = (el) => {
    const rect = el.getBoundingClientRect()
    return (
      rect.top >= 0 &&
      rect.left >= 0 &&
      rect.bottom <=
        (window.innerHeight || document.documentElement.clientHeight) &&
      rect.right <= (window.innerWidth || document.documentElement.clientWidth)
    )
  }

  return // component markup
}

export default TableOfContents

如果你读到这里,请分享这篇文章,谢谢!如果你想深入了解,你也可以阅读关于 getBoundingClientRect() web API 的资料。