headings

headings 是 Astro 的 post 提供的一个非常方便的 prop

1
const { Content, headings } = await post.render();

结构处理

如果我的Markdown文件是如下结构

1
# headings
2
## 结构处理
3
## 构建树形的headings
4
# 渲染组件
5
# 让TOC变得丝滑!
6
## 平滑定位
7
## 目录同步

得到的headings是一个扁平化的数组

1
//headings
2
[
3
{ "depth": 1, "slug": "处理headings", "text": "处理headings" },
4
{ "depth": 2, "slug": "headings结构", "text": "headings结构" },
5
{ "depth": 2, "slug": "构建树形的headings", "text": "构建树形的headings" },
6
{ "depth": 1, "slug": "渲染组件", "text": "渲染组件" },
7
{ "depth": 1, "slug": "让toc变得丝滑", "text": "让TOC变得丝滑!" },
8
{ "depth": 2, "slug": "平滑定位", "text": "平滑定位" },
9
{ "depth": 2, "slug": "目录同步", "text": "目录同步" }
10
]

然而一般toc的结构都是<ol> > <li> > <ol>,将headings聚合成树形会比较好实现

构建树形的headings

通常情况下三级目录就已经足够了,所以我这里并没有用递归,只是简单处理了下

1
import type { MarkdownHeading } from 'astro';
2
3
type HeadingWithSubheadings = MarkdownHeading & {
4
subheadings?: HeadingWithSubheadings[];
5
};
6
7
const grouppedHeadings = headings.reduce((array, heading) => {
8
if (heading.depth === 1) {
9
array.push({ ...heading, subheadings: [] });
10
} else if (heading.depth === 2) {
11
array.at(-1)?.subheadings?.push({ ...heading, subheadings: [] });
12
} else if (heading.depth === 3) {
13
array.at(-1)?.subheadings?.at(-1)?.subheadings?.push({ ...heading })
14
}
15
return array;
16
}, [] as HeadingWithSubheadings[]);

然后就得到了

1
//grouppedHeadings
2
[
3
{
4
"depth": 1, "slug": "headings", "text": "headings",
5
"subheadings": [
6
{ "depth": 2, "slug": "结构处理", "text": "结构处理", "subheadings": [] },
7
{ "depth": 2, "slug": "构建树形的headings", "text": "构建树形的headings", "subheadings": [] }
8
]
9
},
10
{
11
"depth": 1, "slug": "渲染组件", "text": "渲染组件",
12
"subheadings": []
13
},
14
{
15
"depth": 1, "slug": "让toc变得丝滑", "text": "让TOC变得丝滑!",
16
"subheadings": [
17
{ "depth": 2, "slug": "平滑定位", "text": "平滑定位", "subheadings": [] },
18
{ "depth": 2, "slug": "目录同步", "text": "目录同步", "subheadings": [] }
19
]
20
}
21
]

渲染组件

这里采用了递归缩减行数 :p 如果用astro写三层ol缩进将会看着极其恶心

1
<ol>
2
{(()=>{
3
4
const h = (heading:HeadingWithSubheadings,number:string) => (
5
<li>
6
<a href={`#${heading.slug}`}>{number}. {heading.text}</a>
7
{ heading.subheadings && <ol> { heading.subheadings.map((subheading, i) => h(subheading,`${number}.${i+1}`)) } </ol> }
8
</li>
9
)
10
return grouppedHeadings.map((heading, i)=> h(heading,`${i+1}`))
11
12
})()}
13
</ol>

这样,我们就得到了一坨三级toc目录~

1
<ol>
2
<li>
3
<a>一级</a>
4
<ol>
5
<li>
6
<a>二级</a>
7
<ol>
8
<li>
9
<a>三级</a>
10
</li>
11
</ol>
12
</li>
13
</ol>
14
</li>
15
...
16
<li>
17
...
18
</li>
19
</ol>

让TOC变得丝滑!

上面实现的目录定位十分生硬,没有那种响应式的丝滑感觉 :( 还得让它变得高级起来!

实现下面的效果时,先准备好一个对应关系的列表

1
const toc = [...document.querySelectorAll(".card-toc a")].map((a) => ({
2
link: a as HTMLElement,
3
target: document.querySelector(a.getAttribute("href")!)! as HTMLElement,
4
}))

这个列表的每个对象的 link = <a>target = <h1><h2>...

平滑定位

很简单,阻止 <a>标签冒泡,通过window.scrollTo(smooth)定位到<h1>目标就好了

1
toc.forEach(({link,target}) => {
2
link.addEventListener('click', event => {
3
event.preventDefault()
4
window.scrollTo({ top: target.offsetTop, behavior: 'smooth' });
5
})
6
})

目录同步

最开始我是使用window.addEventListener("scroll")去实现的,但是通过监听滚动事件,开销会比较大,而且同步并不是很丝滑,最终找到了IntersectionObserver的方案,开销比监听滚动事件小很多,稍微修改了一下,效果十分的amazing!无敌!

1
const targetsObserver = new IntersectionObserver(targets => {
2
targets.forEach(entry => {
3
const link = document.querySelector(`.card-toc a[href="#${entry.target.getAttribute('id')}"]`)!
4
link.classList[entry.intersectionRatio > 0 ? "add" : "remove"]('active')
5
});
6
});
7
toc.forEach(({target})=>targetsObserver.observe(target))
8
9
/* 弃用的代码
10
toc.forEach(({link, target})=>{
11
window.addEventListener("scroll", throttle(()=> {
12
const {offsetTop:top , clientHeight:height} = tocTarget
13
if (window.scrollY >= top && window.scrollY < top+height) {
14
toc.forEach(({link:otherLink})=> otherLink.classList.remove('active'))
15
link.classList.add('active')
16
}
17
},300))
18
})
19
*/

稍微加个样式

1
.card-toc a.active {
2
color: #49b1f5;
3
4
text-decoration: none;
5
-webkit-transition: all 0.2s;
6
-moz-transition: all 0.2s;
7
-o-transition: all 0.2s;
8
-ms-transition: all 0.2s;
9
transition: all 0.2s;
10
}

最终效果就是这个博客的toc了,纵享丝滑~