原文: 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,我们可以在页面目录中用任何一种数据获取方法,getStaticProps
、getStaticPaths
和 getServerSideProps
,进行服务器端操作:
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
数组中过滤出 visibleElements
,isElementInViewport
函数被用来检查哪个标题元素当前在视口中——这可以通过 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.innerHeight
和 window.innerWidth
或 documentElement.clientHeight
和 documentElement.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 的资料。