前言
几乎所有的文章,都存在目录和内容区,而且在阅读文章向下滑动的时候,目录也会做出联动,点击目录的时候,也会跳到内容区的响应位置。对于这一功能,我实在比较好奇,因为这种技术不单单只在文章类中用到,比如商品分类和具体的商品也可以进行联动。
分析
我经常使用掘金,今天就通过分析掘金来实现吧。
打开任意一篇文章,选中侧边栏我们可以看到目录区的结构
选中内容可以看到内容区的结构
侧边栏标题联动到内容区
从以上可以知晓,我们点击目录是通过一个a标签进行跳转的,比如说
目录区的前言
<a href="#heading-0" title="前言" class="catalog-aTag d1-aTag-title">前言</a>
内容区的前言
<h3 data-id="heading-0">前言</h3>
这两者就是一一对应的,这种联动还是比较简单的,直接通过a标签锚点链接就可以实现,但是原生的a标签跳转不太顺滑,我们需要手动去模拟实现一次a标签的跳转(显得更加顺滑)。
滑动内容区联动到侧边栏
这种实现联动通过html结构就看不出来了,我们只能大致知晓当跳动到内容区的某一个x级标题后会改变nav下的a标签的颜色进行联动。查阅MDN文档我们可以知道一个Web API -- IntersectionObserver,对于实现这一功能就比较简单了。
实现
首先需要搭建html结构,侧边栏和内容区两部分。
然后再是css样式。
最后是js处理联动逻辑。
侧边栏标题联动到内容区
首先选中侧边栏下的所有a标签,清除a标签的.active样式,控制每次点击了当前a标签才加上.active样式,并跳转到相应的内容区
document.querySelectorAll('nav ul li a').forEach(anchor => {
// 首先清楚选中的样式
anchor.classList.remove('active')
anchor.addEventListener('click', function (e) {
e.preventDefault(); // 阻止默认的锚点跳转行为
// 每次点击后清楚所有的样式
document.querySelectorAll('nav ul li a').forEach(a => a.classList.remove('active'));
this.classList.add('active'); // 然后新增选择的样式
const targetId = this.getAttribute('href').substring(1);
// 选中需要跳转到的内容
const targetElement = document.querySelector(`h3[data-id="${targetId}"]`);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth' }); // 平滑滚动
}
});
});
侧边栏标题联动到内容区
IntersectionObserver构造方法需要传入两个值,观察元素的回调函数callback和观察对象的配置options(可选)。
callback
当元素可见比例超过指定阈值后,会调用一个回调函数,此回调函数接受两个参数
-
entires: 一个对象的数组,每个被触发的阈值,都或多或少与指定阈值有偏差。
-
observer: 被调用的IntersectionObserver实例。
options
一个可以用来配置 observer 实例的对象。如果options未指定,observer 实例默认使用文档视口作为 root,并且没有 margin,阈值为 0%(意味着即使一像素的改变都会触发回调函数)。你可以指定以下配置:
-
root: 监听元素的祖先元素Element对象,其边界盒将被视作视口。目标在根的可见区域的任何不可见部分都会被视为不可见。
-
rootMargin: 一个在计算交叉值时添加至根的边界盒bounding_box 中的一组偏移量,类型为字符串 (string) ,可以有效的缩小或扩大根的判定范围从而满足计算需要。语法大致和 CSS 中的margin属性等同; 可以参考 [intersection root 和 root margin 来深入了解 margin 的工作原理及其语法。默认值是"0px 0px 0px 0px"。
-
threshold:规定了一个监听目标与边界盒交叉区域的比例值,可以是一个具体的数值或是一组 0.0 到 1.0 之间的数组。若指定值为 0.0,则意味着监听元素即使与根有 1 像素交叉,此元素也会被视为可见。若指定值为 1.0,则意味着整个元素都在可见范围内时才算可见。可以参考阈值来深入了解阈值是如何使用的。阈值的默认值为 0.0。
监听内容区,观察内容区里面的元素,维护一个数组,当有元素进入视图区的时候就将这个元素添加进去,当存在元素的时候我们就取其中的第一个元素进行样式的修改。
function handle(entries, observer) {
const visibleEntries = []
entries.forEach(entry => {
console.log(entry)
if (entry.isIntersecting) {
visibleEntries.push(entry)
}
});
if (visibleEntries.length) {
const entry = visibleEntries[0]; // 取在视图区中的第一个元素
const id = entry.target.getAttribute('data-id');
const navLink = document.querySelector(`nav ul li a[href="#${id}"]`);
// 清楚其他a标签样式
document.querySelectorAll('nav ul li a').forEach(a => a.classList.remove('active'));
// 前a标签添加样式
navLink.classList.add('active');
}
}
let options = {
root: null, // 观察元素的根元素,null表示视窗
rootMargin: '0px 0px -50% 0px', // 根元素的边界,其中 -50% 的下边距会使得目标元素在其底部进入视口的 50% 时触发回调
threshold: 0.0 // 交叉比例的阈值,0.5表示元素一半进入视窗时触发回调
};
let observer = new IntersectionObserver(handle, options)
document.querySelectorAll('h1[data-id], h2[data-id], h3[data-id], h4[data-id], h5[data-id], h6[data-id]').forEach(heading => {
observer.observe(heading);
});
以上代码
应用
以上就实现了两边的联动,但是一篇文章大多数都是用MarkDown格式写的,并不像以上有很好打html结构。以我用Astro搭建的博客为例,我就需要将MarkDown格式进行进行处理,解析出几级标题。
- 构造标题
const headings: { level: number; text: string; id: string }[] = [];
const data = MarkDown文章 // 伪代码
data.split("\n").forEach((line: string) => {
const match = line.trim().match(/^(#{1,6})\s+(.*)/);
if (match) {
const level = match[1].length;
const text = match[2];
const id = text.toLowerCase().replace(/\s+/g, "-");
headings.push({ level, text, id });
}
- 构造侧边栏
<ul id="nav">
{
headings.map((heading) => (
<li style={{ marginLeft: `${(heading.level - 1) * 10}px` }}>
<a href={`#${heading.id}`} class="text-blue-400 font-bold">
{heading.text}
</a>
</li>
))
}
</ul>
结语
侧边栏标题联动到内容区和滑动内容区联动到侧边栏在很多网站上都是常见的技术,特别是文章类的网站,实现起来也不算太难,值得掌握。