大家好呀,最近在开发我的个人博客模版InkBlogger - vue3+ts+vite
时,为了更方便浏览长篇的博客,我参照一直在用的云笔记平台wolai的目录样式,自己构思了一款轻量级的目录组件,它包含以下功能:
- 自动检测网页html,生成目录树
- 鼠标移入,即hover样式
- 监听浏览进度,自动切换在看标题
- 点击后平滑跳转至所选内容
- 响应式设计,即屏幕较小时自动移出屏幕
- ... 持续更新中,需要可关注我的全球同性交友号😀
此外,他还提供了应对使用诸如element-plus
的el-scrollbar 这样的替代性滚动组件,而导致原生滚动事件无法监听的解决方案。
话不多说,步骤娓娓道,请君倾耳听👂
数据获取
标题元素
使用document.querySelectorAll
,我们检测所有可能的h*
类标签
// toc组件 - 标题元素集合
const headElems = ref<NodeListOf<HTMLElement> | any>();
// 元素获取,这里为了防止页面文章内容外其它标题元素干扰,可在选择器前加入限制类名
headElems.value = Array.from(document.querySelectorAll(".post-body h2,h3,h4"));
因为我使用的Vue3的setup钩子,他的代码主要在create周期运行,而博客文章由于所占内存可能较大,一般会采用异步导入 - import('filename')
的模式,因此我们将标题获取这一步放在onMounted钩子中进行,实测无缝出现,不影响页面浏览体验哈
// toc组件 - 标题元素集合
const headElems = ref<NodeListOf<HTMLElement> | any>();
// toc操作都得等dom渲染完
onMounted(() => {
headElems.value = Array.from(document.querySelectorAll(".post-body h2,h3,h4"));
...
}
文档实时滚动高度
明眼人一看,就知道我们要请出dom
或bom
的api了,比如监听window - scroll
事件,实时获取。
但我的博客在一开始为了滚动栏的好康,使用了element-plus
的el-scrollbar 这一组件(根组件App.vue中),这使得我们在嵌套的子组件内无法监听原生的滚动事件
// 监听浏览器滚动事件
window.addEventListener("scroll", function () {
scrollTop.value = window.pageYOffset;
});
但替代组件也提供了滚动事件的响应接口,于是乎,我们使用vue的依赖注入provide
和 inject
,将滚动高度包裹在响应式对象中向下传递,这样代码量很小
// 父组件
<el-scrollbar @scroll="onScroll">
let curScrollTop = ref<number>(0);
// provide() can only be used inside setup().
provide("scrollTop", curScrollTop);
// 监听滚动事件,
function onScroll({ scrollTop }: { scrollTop: number }): void {
curScrollTop.value = scrollTop;
}
// 子组件
// 获取文档滚动实时高度
let scrollTop: Ref<number>;
// 不可用原生 window api 则使用 inject 获取
scrollTop = inject("scrollTop") as Ref<number>;
方法
点击滚动
为了最基础的功能 - 点击目录项跳转,我们需要使用dom元素原生的滚动方法scrollIntoView
,它接受一个对象参数,以配置滚动行为
// smooth就是滚动过去,而不是直接跳转,center就是滚动到视口的中点
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
届时,我们会将该方法赋给目录项的点击事件
监听滚动
既然有了实时的滚动高度,我们就可以watch
它,来判断当前浏览的是哪一个章节
不过要注意的是,为了保持作用域正确,因为之前数据是mounted
之后获取的,我们也在mounted
之后监听
watch(scrollTop, (newVal) => {...})
啊,等等,在开始监听之前,我们要想想滚动高度和页面当前浏览的标题之间的关系
目录响应滚动思路
- 滚动高度
scrollTop
+ 标题与屏幕上部的距离ClientRect
= 标题相对文档顶部的距离relativeHeight
- 当文档开始滚动,标题滚动到我们设置的在屏幕中的某个高度,即监测点
point
时,我们就判定当前已浏览到此章节,并为目录中对应项附上样式// 滚动高度 + 视口高度/2 = 监测点 // 因为之前设置的是center,点击后将滚动到屏幕中点 // 所以这里检测点高度这样设置,同理,你滚动到屏幕哪,你就加多少 const point = Math.floor( scrollTop + document.documentElement.clientHeight / 2 );
- 接着我们记录下所有标题相对文档的高度
relativeHeightArr
,然后监听窗口滚动,当监测点高度 = 数组中某个高度时,就是有个标题滚到那了,得更新目录样式了 - 啊,想到这里,我觉得自己已经可以了,然后写完发现有时候滚得到,有时候滚不到,突然想到之前调试滚动时,每次滚动高度和之前的差值是不一样的,不信你看看
watch(scrollTop, (newVal, oldVal) => { console.log("滚动响应差", newVal - oldVal);
看到了吧,滚动事件的触发,是根据你单次滚动的力度来的,越用力越快,差值越高,那么我们记录的标题高度可能直接被忽略了,也就检测不到
- 于是乎,我们就要把每次检测一点升级成每次检测一段,没更新一次滚动高度,我们就去数组里查,看他在哪个区间,并返回该区间的左值,也就是我们要的标题序号,这样就能保证目录里始终有被选中的标题啦,而且阅读标题以下,即其所在章节,下一个标题之前,标题选中状态也不会变哦。
- 如果点击目录,直接命中数组中元素,我们就直接返回它的序号
- 是不是想到什么,没错,这就是面试常考的
数组插入位置
,算法是有用滴!
算法:数组插入位置
我知道你会,但是为了节约你的时间,我就直接贴上来了哈
因为querySelectorAll
是顺序遍历,我们的数组relativeHeightArr
天然有序,就不用处理啦,使用二分查找是因为效率最高
/**
* 二分查找,没找到就返回插入位置左侧的索引
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
export function searchInsert(nums: number[], target: number): number {
let left = 0;
let right = nums.length - 1;
while (left <= right) {
let mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
return mid;
} else if (nums[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return right;
}
闭包记录前标题
因为我们的滚动行为是不可预测的,而点亮当前标题之前得取消上一个标题,所以我们得使用闭包在监测函数外面记录一下上一个标题的序号,然后在监测函数里使用和改变它
// 上一个被点亮的toc
let lastIndex: number;
Script全貌
叭叭这么多,就不再卖关子了哈哈,所有细节都在代码里了!
import { inject, onMounted, Ref, ref, watch } from "vue";
import { searchInsert } from "../../utils/index";
// toc组件 - 标题元素集合
const headElems = ref<NodeListOf<HTMLElement> | any>();
// 获取文档滚动实时高度
let scrollTop: Ref<number>;
// 监听浏览器滚动事件
// window.addEventListener("scroll", function () {
// scrollTop.value = window.pageYOffset;
// });
// 不可用原生 window api 则使用 inject 获取
scrollTop = inject("scrollTop") as Ref<number>;
// toc操作都得等dom渲染完
onMounted(() => {
// querySelectorAll 返回的是类型NodeList
// NodeList是类数组,不具有某些数组方法如 map,为了非要用map我转成数组,别学我,有自带的.foreach
headElems.value = Array.from(
document.querySelectorAll(".post-body h2,h3,h4")
);
// console.log(headElems.value instanceof Array); // false,惊了,是类数组
// 元素相对文档高度 = elem.getBoundingClientRect() + 当前页面滚动
// 初始时页面滚动为0
const relativeHeightArr = headElems.value.map(
(ele: HTMLElement, index: number) => {
return Math.floor(ele.getBoundingClientRect().top);
}
);
// 上一个被点亮的toc
let lastIndex: number;
watch(scrollTop, (newVal) => {
// watch的回调参数会自动解包
// 滚动高度 + 视口高度/2 = 监测点
const point = Math.floor(
newVal + document.documentElement.clientHeight / 2
);
// 使用二分查找判断包含监测点的标题序号
const curIndex = searchInsert(relativeHeightArr, point);
// 判断亮点是否切换
if (lastIndex !== curIndex) {
// 取消上一个点亮
// 这里标题的id,可以在模板里给加上,easy的
document
.querySelector(`#toc-${lastIndex}`)
?.classList.toggle("toc-choosen");
// 点亮当前
document
.querySelector(`#toc-${curIndex}`)
?.classList.toggle("toc-choosen");
// 更新前标题
lastIndex = curIndex;
}
});
});
html结构
<!-- toc组件 -->
<div class="toc remove">
<ul>
<!-- 为了防止标题内容一致,给每个标题加上唯一的id -->
<!-- 为了设置各级标题的不同样式,添加了类,h1标签类为item-1,h2标签类为item-2 -->
<li
v-for="(item, index) in headElems"
:id="`toc-${index}`"
:class="`item-${item.tagName.charAt(1)}`"
@click="item.scrollIntoView({ behavior: 'smooth', block: 'center' })"
>
{{ item.innerText }}
</li>
</ul>
</div>
呐,html就是酱紫简单
外观与动效
我用的是sass
哈,这样嵌套写很方便
.toc {
position: fixed;
transition: all 0.3s ease;
top: 200px;
left: 20px;
border-left: 3px solid #f0e7e7;
cursor: pointer;
color: rgba(3, 21, 34, 0.644);
ul {
li {
box-sizing: border-box;
list-style: none;
width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
background: transparent;
transition: all 0.5s ease;
padding: 2px 0px;
border-left: 3px solid transparent;
transform: translateX(-3px);
}
li:hover {
background-color: #ffebeb;
border-left: 3px solid #cf5659;
}
.toc-choosen {
background-color: rgba(27, 31, 35, 0.1);
border-left: 3px solid #cf5659;
color: #476582;
}
.item-2 {
font-weight: 600;
padding-left: 13px;
}
.item-3 {
padding-left: 23px;
opacity: 0.95;
}
.item-4 {
padding-left: 33px;
opacity: 0.9;
}
}
}
// 目录消失
@media (max-width: 1245px) {
.remove {
transform: translateX(-250px);
}
}
总结
谢谢你耐心看到最后,其实最难也就监听滚动那,不过看完会发现其实也很简单对吧,哈哈
这个组件我当前只是在自己的项目里用用,但是我打算把他做大做强!做成一个浏览器插件,让每一篇文章得到懂他的伴侣!
计划特性:
- 选择器接口 - 不同页面为了排除无关标题元素干扰,可由用户提供CSS选择器,限制查找的区域
- 移入 - 现在可以根据媒体查询,让目录在空间不够的情况下移出屏幕,但是还没有点击移入,可做!
- 折叠 - 参照
wolai
,长文目录多且嵌套深,可在标题前加三角,使点击折叠目录 - 拖拽移动 - 现在目录固定出现在屏幕左侧,github的md阅读是右侧空间更大,所以为了应对这种情况,目录需要可拖拽自行固定
- 性能 - 频繁触发,所以节流防抖
- ... 由你补充!
未来如果有补充,我会继续更新文章哒,喜欢就点个关注吧,哈哈