headings
headings 是 Astro 的 post 提供的一个非常方便的 prop
1const { Content, headings } = await post.render();
结构处理
如果我的Markdown文件是如下结构
1# headings2## 结构处理3## 构建树形的headings4# 渲染组件5# 让TOC变得丝滑!6## 平滑定位7## 目录同步
得到的headings是一个扁平化的数组
1//headings2[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
通常情况下三级目录就已经足够了,所以我这里并没有用递归,只是简单处理了下
1import type { MarkdownHeading } from 'astro';2
3type HeadingWithSubheadings = MarkdownHeading & {4 subheadings?: HeadingWithSubheadings[];5};6
7const 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//grouppedHeadings2[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变得丝滑!
上面实现的目录定位十分生硬,没有那种响应式的丝滑感觉 :( 还得让它变得高级起来!
实现下面的效果时,先准备好一个对应关系的列表
1const 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>
目标就好了
1toc.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!无敌!
1const 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});7toc.forEach(({target})=>targetsObserver.observe(target))8
9/* 弃用的代码10toc.forEach(({link, target})=>{11 window.addEventListener("scroll", throttle(()=> {12 const {offsetTop:top , clientHeight:height} = tocTarget13 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了,纵享丝滑~