如果您的博客页面需要展示锚点目录,但是组件库的Anchor组件不符合您的需求,您可以考虑自定义锚点功能。这可能涉及到自定义滚动区域,使用偏移属性来设置锚点滚动的位置,以及监听链接点击事件以防止浏览器的默认行为,从而不会改变历史记录。您还可以考虑使用其他第三方库或者手动实现锚点效果,以满足您页面的具体需求。
需求背景
最近在负责公司新版博客页面的开发,需要在页面左侧悬浮展示文章的目录,点击目录可以跳转到对应的模块,然后需要展示两级菜单,二级菜单支持展开与折叠。文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
一开始打算直接用组件库提供的锚点组件,项目用的是 Ant-Design-Vue,里面刚好有这个组件:文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
基本上满足需求,但是没法实现二级菜单展开与折叠的效果,而且在滚动页面的同时也是需要实时控制二级菜单的展开与折叠,所以选择自己开发这个组件。考虑到从零开发一个锚点组件成本比较高,因此打算拷贝并修改组件库中 Anchor 组件的代码,在其基础上增加展开与折叠的功能即可,最终选择魔改 Element-Plus 的 Anchor 组件。完整源码放在文章最后面!文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
最终效果
文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
技术点
我们先来拆解这个需求,看下需要实现哪些功能,包括 Anchor 组件自带的一些功能,下面列举了一些:文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
- 目录跟随页面的滚动高亮
- 点击滚动到对应的元素
- 手写实现平滑滚动的效果
- 处理滚动后被导航栏遮挡
- 优化连续快速多次点击
- 二级菜单的展开与折叠
先来看下基本的代码以及结构,然后再详细讲解每个技术点的实现细节。文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
anchor.vue: 文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
目录的数据结构: 文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
1. 目录跟随页面的滚动高亮
首先计算出博客内容里每个标题距离页面顶部的距离(不是距离视口顶部的距离) top
,可以认为是 offsetTop 的值(实际上还会涉及到其他计算),这样便于理解,如下图所示:文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html
计算距离
的代码:
js
export const getOffsetTop = (el: HTMLElement) => {
let offset = 0;
let parent = el;
while (parent) {
offset += parent.offsetTop;
parent = parent.offsetParent as HTMLElement;
}
return offset;
};
计算出所有标题的距离
,存到一个数组中:
js
const anchorTopList: { top: number; href: string }[] = [];
const distance = getOffsetTop(target);
anchorTopList.push({
top: distance - props.offset, // props.offset为锚点滚动的偏移量,后面再讲解它的用意
href,
});
接着是监听 window 的滚动事件,滚动容器可以自行设置,由外部传入控制,我这直接用 window 了,获取 window.scrollY
(页面垂直滚动的像素值),这里还做了个节流处理,通过 requestAnimationFrame,而不是定时器,这里会涉及到定时器不准的问题,有兴趣的小伙伴可以自行去了解。
js
const handleScroll = throttleByRaf(() => {
currentScrollTop = window.scrollY;
const currentHref = getCurrentHref();
if (isScrolling || currentHref == undefined) return;
setCurrentAnchor(currentHref as string);
});
将上面计算好的距离
数组进行升序排序,然后遍历,找出 top 值小于 window.scrollY 的标题,并且下一个标题的 top 值比 window.scrollY 大,那么该标题就是目前应该高亮显示的。
js
const getCurrentHref = () => {
const scrollTop = window.scrollY;
......
anchorTopList.sort((prev, next) => prev.top - next.top);
for (let i = 0; i < anchorTopList.length; i++) {
const item = anchorTopList[i];
const next = anchorTopList[i + 1];
if (i === 0 && scrollTop === 0) {
return '';
}
if (item.top <= scrollTop && (!next || next.top > scrollTop)) {
return item.href;
}
}
};
在页面滚动过程中不断重复上面的过程,从而实现目录跟随滚动高亮的效果。
2. 点击滚动到对应的元素
滚动到目标元素,有两种做法,第一种是使用 scrollIntoView 方法:可以实现平滑滚动的效果,但是存在一个比较大的问题,如果页面顶部有导航栏,会导致滚动之后标题被导航栏遮挡,下面第 4 点再研究如何解决。
js
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
第二种是自己实现滚动效果:也就是让页面从 from 平滑滚动到 to
首先获取到目标元素距离顶部的距离 distance
,以及当前页面垂直滚动的像素值(currentScrollTop,也就是 window.scrollY),from
= currentScrollTop,然后再计算 to
js
// 滚动到指定元素
const scrollToAnchor = (href: string) => {
const target = document.querySelector<HTMLElement>(href);
if (!target) return;
if (clearAnimate) clearAnimate();
isScrolling = true;
const scrollEle = getScrollElement(target as HTMLElement, containerEl.value!);
const distance = getOffsetTop(target);
// max :整个页面的高度(包括滚动隐藏的部分) - 视口的高度
const max = scrollEle.scrollHeight - scrollEle.clientHeight;
const to = Math.min(distance - props.offset, max);
clearAnimate = animateScrollTo(
containerEl.value!,
currentScrollTop,
to,
props.duration,
() => {
setTimeout(() => {
isScrolling = false;
}, 20);
},
);
};
解释下 to = Math.min(distance - props.offset, max)
这行代码:
to 这里需要取个最小值,看下面的一种情况:最后一个标题所属的区域(该区域的高度小于视口的高度),到达页面最底部了,也就是该标题以下的内容高度不足,然后标题在视口以下,这种情况我直接滚动到页面最底端就好,所以此时的 to
取 max
正常情况下点击跳转,如果目标标题以下的内容高度足够,该标题是会处于视口顶部附近的,也就是下图,此时的 to
取 distance
还有一个点,就是当我们进入页面的时候,如果 url 上已经携带了对应的 hash 值,此时也需要进行跳转:
js
onMounted(() => {
const hash = decodeURIComponent(window.location.hash);
const target = getElement(hash);
if (target) {
scrollTo(hash);
}
});
3. 手写实现平滑滚动的效果
接着来看下平滑滚动的效果是如何实现的,进入 animateScrollTo 方法:
js
export function animateScrollTo(
container: HTMLElement | Window,
from: number,
to: number,
duration: number,
callback?: unknown,
) {
const startTime = Date.now();
let handle: number | undefined;
const scroll = () => {
const timestamp = Date.now();
const time = timestamp - startTime;
const nextScrollTop = easeInOutCubic(
time > duration ? duration : time,
from,
to,
duration,
);
// window.scrollTo 将文档滚动到指定的坐标(横坐标,纵坐标)
// pageXOffset:文档在窗口左上角水平方向滚动的像素
container.scrollTo(window.pageXOffset, nextScrollTop);
if (time < duration) {
handle = window.requestAnimationFrame(scroll);
} else if (typeof callback === 'function') {
// 滚动结束
callback();
}
};
scroll();
return () => {
handle && window.cancelAnimationFrame(handle);
};
}
easeInOutCubic
方法的作用:实现滚动速度看起来自然点、实现某个特定的时间内从慢到加速滚动的效果
思路:利用 requestAnimationFrame 在每一帧让页面滚动一次,垂直方向的滚动距离由 easeInOutCubic 计算得出,easeInOutCubic 可以控制每次滚动的距离,duration
用于设置容器滚动持续时间,超过这个时间之后结束滚动。
4. 处理滚动后被导航栏遮挡
上面已经提到过这个问题,滚动到目标元素的两种方式,第一种方式就存在着该问题,因为这种方式的 element.scrollIntoView
会将目标元素滚动到视口最顶部(是这个 API 的特点),所以是会被顶部导航栏挡住的,解决办法可以参考这篇文章:锚点定位被顶部固定导航栏遮住的解决方案
使用第二种方式(手写滚动效果)其实没这个问题,因为这种方式是位置维度的,也就是从一个位置滚动到另一个位置,而且在设置目标位置的时候可以减去一个偏移量
我这里设置了 offset
为 100px:
5. 优化连续快速多次点击
页面滚动和点击跳转是两套逻辑,而且都会影响锚点的高亮效果,由于在点击跳转的时候会触发页面滚动事件,所以需要加个标识,以避免两套逻辑相互影响。
当我们连续快速多次点击的时候,上一次滚动动画还没完成,这样会影响用户体验,因此需要取消上一次动画效果,animateScrollTo 的返回值可用于取消操作。
js
export function animateScrollTo() {
......
let handle: number | undefined;
handle = window.requestAnimationFrame(scroll);
......
return () => {
handle && window.cancelAnimationFrame(handle);
};
}
6. 二级菜单的展开与折叠
这个功能比较简单,在二级菜单外层加个判断用于控制显示与隐藏即可:
hrefShow
的结构如下,key 为一级菜单的 id 值,value 用于控制二级菜单的显隐
在页面滚动以及点击箭头的时候,均需要判断目前属于哪个一级菜单所属的区域,然后修改 hrefShow,findParentHref 用于获取当前 href 属于哪个一级菜单:
js
watch(
() => currentAnchor.value,
() => {
Object.keys(hrefShow.value).forEach(v => {
hrefShow.value[v] = false;
});
const parentHref = findParentHref(currentAnchor.value, hrefMap.value);
hrefShow.value[parentHref] = true;
},
);
总结
这篇文章大部分讲述的都是 Element-Plus 中 Anchor 组件的代码,新加的功能只有 6. 二级菜单的展开与折叠
,也算是研究了一下组件库源码,希望能对大家有所帮助!
在上面的一些代码中,相当一部分都是边界值的处理,尤其是组件库或者第三方插件,做了各种情况的兼容处理,所以代码看起来不是那么容易理解,不够直观,因此在组件库源码的基础上减少了一些代码。如果有讲的不对的地方,大家可以指出来,共同进步!
评论