Blog页面要展示锚点目录,组件库的Anchor组件不满足需求?

零 JavaScript教程评论190字数 5192阅读17分18秒阅读模式

如果您的博客页面需要展示锚点目录,但是组件库的Anchor组件不符合您的需求,您可以考虑自定义锚点功能。这可能涉及到自定义滚动区域,使用偏移属性来设置锚点滚动的位置,以及监听链接点击事件以防止浏览器的默认行为,从而不会改变历史记录。您还可以考虑使用其他第三方库或者手动实现锚点效果,以满足您页面的具体需求。

需求背景

最近在负责公司新版博客页面的开发,需要在页面左侧悬浮展示文章的目录,点击目录可以跳转到对应的模块,然后需要展示两级菜单,二级菜单支持展开与折叠。文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html

一开始打算直接用组件库提供的锚点组件,项目用的是 Ant-Design-Vue,里面刚好有这个组件:文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html

image.png文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html

基本上满足需求,但是没法实现二级菜单展开与折叠的效果,而且在滚动页面的同时也是需要实时控制二级菜单的展开与折叠,所以选择自己开发这个组件。考虑到从零开发一个锚点组件成本比较高,因此打算拷贝并修改组件库中 Anchor 组件的代码,在其基础上增加展开与折叠的功能即可,最终选择魔改 Element-Plus 的 Anchor 组件。完整源码放在文章最后面!文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html

最终效果

QQ录屏20240526180612.gif文章源自灵鲨社区-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: image.png文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html

目录的数据结构: image.png文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html

1. 目录跟随页面的滚动高亮

首先计算出博客内容里每个标题距离页面顶部的距离(不是距离视口顶部的距离) top,可以认为是 offsetTop 的值(实际上还会涉及到其他计算),这样便于理解,如下图所示:文章源自灵鲨社区-https://www.0s52.com/bcjc/javascriptjc/15443.html

image.png

计算距离的代码:

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

image.png

正常情况下点击跳转,如果目标标题以下的内容高度足够,该标题是会处于视口顶部附近的,也就是下图,此时的 to 取 distance

image.png

还有一个点,就是当我们进入页面的时候,如果 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 方法的作用:实现滚动速度看起来自然点、实现某个特定的时间内从慢到加速滚动的效果

image.png

思路:利用 requestAnimationFrame 在每一帧让页面滚动一次,垂直方向的滚动距离由 easeInOutCubic 计算得出,easeInOutCubic 可以控制每次滚动的距离,duration 用于设置容器滚动持续时间,超过这个时间之后结束滚动。

4. 处理滚动后被导航栏遮挡

上面已经提到过这个问题,滚动到目标元素的两种方式,第一种方式就存在着该问题,因为这种方式的 element.scrollIntoView 会将目标元素滚动到视口最顶部(是这个 API 的特点),所以是会被顶部导航栏挡住的,解决办法可以参考这篇文章:锚点定位被顶部固定导航栏遮住的解决方案

使用第二种方式(手写滚动效果)其实没这个问题,因为这种方式是位置维度的,也就是从一个位置滚动到另一个位置,而且在设置目标位置的时候可以减去一个偏移量

image.png

我这里设置了 offset 为 100px:

image.png

5. 优化连续快速多次点击

页面滚动和点击跳转是两套逻辑,而且都会影响锚点的高亮效果,由于在点击跳转的时候会触发页面滚动事件,所以需要加个标识,以避免两套逻辑相互影响。

image.png

image.png

当我们连续快速多次点击的时候,上一次滚动动画还没完成,这样会影响用户体验,因此需要取消上一次动画效果,animateScrollTo 的返回值可用于取消操作。

image.png

js

复制代码
export function animateScrollTo() {
  ......
  let handle: number | undefined;
  handle = window.requestAnimationFrame(scroll);
  ......
  return () => {
    handle && window.cancelAnimationFrame(handle);
  };
}

6. 二级菜单的展开与折叠

这个功能比较简单,在二级菜单外层加个判断用于控制显示与隐藏即可:

image.png

hrefShow 的结构如下,key 为一级菜单的 id 值,value 用于控制二级菜单的显隐

image.png

在页面滚动以及点击箭头的时候,均需要判断目前属于哪个一级菜单所属的区域,然后修改 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. 二级菜单的展开与折叠,也算是研究了一下组件库源码,希望能对大家有所帮助!

在上面的一些代码中,相当一部分都是边界值的处理,尤其是组件库或者第三方插件,做了各种情况的兼容处理,所以代码看起来不是那么容易理解,不够直观,因此在组件库源码的基础上减少了一些代码。如果有讲的不对的地方,大家可以指出来,共同进步!

零
  • 转载请务必保留本文链接:https://www.0s52.com/bcjc/javascriptjc/15443.html
    本社区资源仅供用于学习和交流,请勿用于商业用途
    未经允许不得进行转载/复制/分享

发表评论