实现类似element-plus官网右侧跟随页面滚动变化高亮

背景

element-plus官网,右侧有辅助当前页面滚动的导航栏,有如下能力:

  1. 随着当前页面滚动,右侧导航栏的当前高亮会动态进行切换,并且浏览器地址栏的锚点hash会跟随动态变化。

  2. 点击右侧导航栏,会将页面滚动到相应文档位置。

这个功能很实用,可惜官网并没有提供这个功能,这是提供了左侧配合路由进行页面级别跳转的el-menu。如下图所示:

实现

效果

我们要自己实现绿色部分想要的功能,

代码

接下来,直接上代码,重要部分都在注释中有写: demo.vue文件,可以直接使用

<template>
  <!-- 鉴于一般这种页面都用了 element-plus,如果没有用,也可以自己用flex写个左右布局即可 -->
  <el-container>
    <el-aside width="150px" class="doc-toc-container">
      <ul class="toc-wrapper">
        <li class="toc-item" v-for="(name, index) in sideList" :key="index">
          <a :class="['toc-link', { active: sideNow === index }]" :href="`#${name}`">
            <p>{{ name }}</p>
          </a>
        </li>
      </ul>
    </el-aside>
    <el-main class="doc-content">
      <div ref="docContentRef" class="docContentRef">
        <section class="content-item" :id="`${name}`" v-for="(name, index) in sideList" :key="index">{{ name }}</section>
      </div>
    </el-main>
  </el-container>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const docContentRef = ref(null)
const sideNow = ref(0)
const sideList = ref(['锚点一', '锚点二', '锚点三', '锚点四'])
/**
 * 滚动回调处理
 * 计算当前滚了多少,如果离某个锚点够近就设置导航高亮
 */
const scrollHandler = () => {
  let itemDoms = docContentRef.value.querySelectorAll('.content-item')
  let st = docContentRef.value.scrollTop
  let idxOld = sideNow.value

  for (let index = 0; index < itemDoms.length; index++) {
    const element = itemDoms[index]
    if (Math.abs(st - element.offsetTop) < 60) {
      sideNow.value = index
    }
  }

  // 有时最后一条滚动不到容器顶部,这时直接高亮最后一个
  if (st - itemDoms[itemDoms.length - 2].offsetTop > 200) {
    sideNow.value = itemDoms.length - 1
  }

  console.log(111, st, sideNow.value, `#${itemDoms[sideNow.value].id}`)

  // 变化浏览器地址栏的锚点hash值为当前导航
  if (sideNow.value !== idxOld) {
    // 这样直接修改hash会导致页面跳到该锚点,并且页面刷新了,可以观察到触发了 onBeforeUnmount
    // window.location.hash = `#${itemDoms[sideNow.value].id}`

    // 用 replaceState 来动态修改浏览器地址栏中的锚点内容
    let { origin, pathname, search } = location
    let newUrl = `${origin}${pathname}${search}#${itemDoms[sideNow.value].id}`

    console.log('newUrl', newUrl)
    history.replaceState({}, '', newUrl)
  }
}

/**
 * 节流
 * @param {function} fun
 * @param {number} delay
 * @returns
 */
function throttle(fun, delay) {
  let last, deferTimer
  return function (args) {
    let that = this
    let _args = arguments
    let now = +new Date()
    if (last && now < last + delay) {
      clearTimeout(deferTimer)
      deferTimer = setTimeout(function () {
        last = now
        fun.apply(that, _args)
      }, delay)
    } else {
      last = now
      fun.apply(that, _args)
    }
  }
}

// 给滚动处理加入节流
const scrollHandlerThrottled = throttle(scrollHandler, 100)

onMounted(() => {
  // 进入页面时平滑滚动到之前hash锚点的位置,如果没有的话,滚动到第一个
  let parent = docContentRef.value
  let hashNow = decodeURIComponent(window.location.hash) || `#${sideList.value[0]}`
  let child = parent.querySelector(hashNow)

  console.log('hashNow', hashNow)
  parent.scrollTo({
    top: child.offsetTop, //需要父元素设置postion(relative、absolute、fixed)
    behavior: 'smooth',
  })

  // 监听内容区域滚动,检测滚动到了哪个hash
  docContentRef.value.addEventListener('scroll', scrollHandlerThrottled)
})
// 退出页面时销毁注册
onBeforeUnmount(() => {
  console.log('leave page')
  docContentRef.value.removeEventListener('scroll', scrollHandlerThrottled)
})
</script>
<style lang="scss" scoped>
.doc-toc-container {
  overflow: visible;
  border-right: 1px solid #eee;
  .toc-wrapper {
    // 通过这里让左侧导航在页面中不动
    position: sticky;
    top: 32px;
    margin-top: 0;
    padding: 4px 8px 4px 12px;
    margin-bottom: 32px;

    list-style: none;

    .toc-item {
      margin-bottom: 30px;

      .toc-link {
        position: relative;
        color: #909399;
        transition: color 0.2s;
        font-weight: 500;
        text-decoration: none;
      }
      .active {
        color: #409eff;
      }
    }
  }
}

.doc-content {
  .docContentRef {
    // 设置这个position用于子元素的offsetTop是相对于它的
    position: relative;
    height: calc(100vh - 150px);
    padding-bottom: 50px;
    overflow: auto;
  }
  .content-item {
    margin-bottom: 20px;
    height: 500px;
    background-color: yellow;
  }
}
</style>

进一步

如果想进一步改建,可以考虑封装成组件,比如锚点列表和正文内容通过prop和slot传入,dom结构也很简单,就是左右两侧的结构,那就暂时请自行处理了.

全部评论

相关推荐

05-07 17:58
门头沟学院 Java
wuwuwuoow:1.简历字体有些怪怪的,用啥写的? 2.Redis 一主二从为什么能解决双写一致性? 3.乐观锁指的是 SQL 层面的库存判断?比如 stock > 0。个人认为这种不算乐观锁,更像是乐观锁的思想,写 SQL 避免不了悲观锁的 4.奖项证书如果不是 ACM,说实话没什么必要写 5.逻辑过期时间为什么能解决缓存击穿问题?逻辑过期指的是什么 其实也没什么多大要改的。海投吧
点赞 评论 收藏
分享
06-13 17:33
门头沟学院 Java
顺序不记了,大致顺序是这样的,有的相同知识点写分开了1.基本数据类型2.基本数据类型和包装类型的区别3.==和equals区别4.ArrayList与LinkedList区别5.hashmap底层原理,put操作时会发生什么6.说出几种树型数据结构7.B树和B+树区别8.jvm加载类机制9.线程池核心参数10.创建线程池的几种方式11.callable与runnable区别12.线程池怎么回收线程13.redis三剑客14.布隆过滤器原理,不要背八股,说说真正使用时遇到了问题没有(我说没有,不知道该怎么回答了)15.堆的内存结构16.自己在写项目时有没有遇见过oom,如何处理,不要背八股,根据真实经验,我说不会17.redis死锁怎么办,watchdog机制如何发现是否锁过期18.如何避免redis红锁19.一个表性别与年龄如何加索引20.自己的项目的QPS怎么测的,有没有真正遇到大数量表21.说一说泛型22.springboot自动装配原理23.springmvc与springboot区别24.aop使用过嘛?动态代理与静态代理区别25.spring循环依赖怎么解决26.你说用过es,es如何分片,怎么存的数据,1000万条数据怎么写入库中27.你说用limit,那么在数据量大之后,如何优化28.rabbitmq如何批次发送,批量读取,答了延迟队列和线程池,都不对29.计网知不知道smtp协议,不知道写了对不对,完全听懵了30.springcloud知道嘛?只是了解反问1.做什么的?短信服务,信息量能到千万级2.对我的建议,基础不错,但是不要只背八股,多去实际开发中理解。面试官人不错,虽然没露脸,但是中间会引导我回答问题,不会的也只是说对我要求没那么高。面完问我在济宁生活有没有困难,最快什么时候到,让人事给我聊薪资了。下午人事打电话,问我27届的会不会跑路,还在想办法如何使我不跑路,不想扣我薪资等。之后我再联系吧,还挺想去的😭,我真不跑路哥😢附一张河科大幽默大专图,科大就是大专罢了
查看30道真题和解析
点赞 评论 收藏
分享
06-20 14:27
中山大学 C++
rt,day3就开始接需求
星际探神:你就想 你是水货他们都没面出来 他们也水 管他呢
点赞 评论 收藏
分享
评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务