兼容hash路由的页面内锚点跳转组件

react开发时遇到锚点目录需求,需要在页面滚动时高亮显示对应标题,点击时滚动到对应标题

而使用antd的锚点组件时,遇到hash路由不兼容问题,所以写了个简易的锚点目录跳转组件

功能点:

  1. 高亮显示当前标题
  2. 点击后滚动到对应标题

antd组件的兼容问题

在使用antd组件时,虽然可以正常高亮显示对应标题,但在点击后会跳转到错误的页面

猜测是antd这个组件底层是使用a标签实现页面内滚动

tsx 复制代码
<a href='#title1'/>

而这种滚动方式在遇到哈希路由时会有问题,会将哈希路由原本的#号后面的路由覆盖,导致页面错误

下面开始造轮子,把这个组件实现出来,并且需要更换滚动的逻辑

如何使用

在讲组件实现之前,先确定一下如何使用,这样写起来才比较有方向

锚点组件称为Anchor,使用这个组件,需要传入

  • scrollElement:滚动的容器元素的id
  • list:标题列表
    • id:标题id
    • title:标题的文本
tsx 复制代码
<Anchor
    scrollElement="#main"
    list={[
        { id: "#title1", title: "标题1" },
        { id: "#title2", title: "标题2" },
        { id: "#title3", title: "标题3" },
        { id: "#title4", title: "标题4" },
    ]}
    />
<div id="main">
    <h2 id="title1">标题1</h2>
    <p>内容内容</p>
    <h2 id="title2">标题2</h2>
    <p>内容内容</p>
    <h2 id="title3">标题3</h2>
    <p>内容内容</p>
    <h2 id="title4">标题4</h2>
    <p>内容内容</p>
</div>

核心功能一:高亮显示当前标题

实现步骤

  1. 监听元素滚动
  2. 当前滚动了多少
  3. 比对标题在容器的相对位置
  4. 对应标题设置active
tsx 复制代码
//1.监听元素滚动
let elem = document.querySelector(scrollElement);
elem.addEventListener("scroll", handleScroll);

function handleScroll(){
    titleList.forEach((title, index) => {
        const titleElem = document.querySelector(title.id);

        //2. 当前滚动了多少
        const scrollTop = elem.scrollTop

        //3. 比对标题在容器的相对位置
        if (scrollTop >= titleElem.offsetTop) {
            //4. 对应标题设置为active
            setActive(index);
        }
    });
}
//此处是简洁代码,下方有完整代码

原理

其中比较难的是第三步,可以看下方图解,滚动时scrollTop是越来越大的,当大小超过了某个标题的offsetTop时,就可以将这个标题设为active

图中scrollTop大于标题1的offsetTop,小于标题2,所以当前高亮的标题是标题1

加上对应css就可以高亮显示对应标题了

核心二:点击后滚动到对应标题

原理

这里滚动逻辑靠scroll函数实现,scroll可以将元素滚动到指定位置

Element.scroll()

通过scroll方法将容器元素滚动到标题所在的位置就能实现需求

tsx 复制代码
container.scroll(titlePosition)

实现步骤

  1. 获取标题的相对位置和标题的高度
  2. 计算容器应该滚动到的位置
  3. 容器滚动
tsx 复制代码
//将容器滚动到对应标题位置
//titleId:点击的标题的id
function scrollToElem(titleId) {
    // 1.获取标题的相对位置和标题的高度
    const linkElem = document.querySelector(titleId);
    const top = linkElem.offsetTop;
    const height = linkElem.offsetHeight;

    //2.计算容器应该滚动到的位置
    const position = top - height;

    //3.容器滚动
    const scrollElem = document.querySelector(scrollElement);
    scrollElem.scroll({
        top: position,
        behavior: "smooth",
    });
}
//此处是简洁代码,下方有完整代码

完整代码

使用组件

tsx 复制代码
import React from "react";
import Anchor from "../components/Anchor";

export default function Home() {
  return (
    <div>
      <Anchor
        scrollElement="#main"
        list={[
          { id: "#title1", title: "标题1" },
          { id: "#title2", title: "标题2" },
          { id: "#title3", title: "标题3" },
          { id: "#title4", title: "标题4" },
        ]}
      />
      <div className="main" id="main">
        <h2 id="title1">标题1</h2>
        <p>内容内容</p>
        <h2 id="title2">标题2</h2>
        <p>内容内容</p>
        <h2 id="title3">标题3</h2>
        <p>内容内容</p>
        <h2 id="title4">标题4</h2>
        <p>内容内容</p>
      </div>
    </div>
  );
}

组件jsx

tsx 复制代码
import React, { useEffect, useState } from "react";
import _ from "lodash";
import "./index.css";

export default function PageAnchor({ list: l = [], scrollElement } = {}) {
  //标题列表,默认高亮第一个标题
  const [titleList, setList] = useState(
    l.map((item, index) => {
      if (!index) return { ...item, active: true };
      return { ...item, active: false };
    })
  );


  //监听元素滚动
  useEffect(() => {
    const handleScroll = _.throttle(function () {
      titleList.forEach((title, index) => {
        const titleElem = document.querySelector(title.id);

        //2. 当前滚动了多少
        const scrollTop = elem.scrollTop;
        
        //3. 比对标题在容器的相对位置
        if (scrollTop >= titleElem.offsetTop - 100) {
          //4. 对应标题设置为active
          setActive(index);
        }
      });
    }, 200);
    
    //1.监听元素滚动
    let elem = document.querySelector(scrollElement);
    elem.addEventListener("scroll", handleScroll);
  }, []);

  //设置某个标题高亮
  function setActive(i) {
    setList((list) =>
      list.map((item, index) => {
        if (index == i) return { ...item, active: true };
        return { ...item, active: false };
      })
    );
  }

  //将容器滚动到对应标题位置
  //titleId:点击的标题的id
  function scrollToElem(titleId) {
    // 1.获取标题的相对位置和标题的高度
    const linkElem = document.querySelector(titleId);
    const top = linkElem.offsetTop;
    const height = linkElem.offsetHeight;

    //2.计算容器应该滚动到的位置
    const position = top - height;

    //3.容器滚动
    const scrollElem = document.querySelector(scrollElement);
    scrollElem.scroll({
      top: position,
      behavior: "smooth",
    });
  }
  return (
    <div className="anchor">
      {titleList.map((item) => (
        <div
          onClick={() => scrollToElem(item.id)}
          className={`link ${item.active ? "active" : ""}`}
        >
          {item.title}
        </div>
      ))}
    </div>
  );
}

组件css

css 复制代码
.anchor {
  position: fixed;
  left: 10px;
  top: 50px;
  z-index: 3;

  background-color: #e9eefa;
  border-radius: 6px;
  padding: 8px;
}
.anchor .link {
  user-select: none;
  cursor: pointer;
}
.anchor .active {
  color: #3377ff;
}

其他细节

平缓滚动

通过阅读文档,可以知道scroll函数可以设置behavior来控制滚动是否平缓

  • smooth 表示平滑滚动并产生过渡效果
  • auto 或缺省值会直接跳转到目标位置,没有过渡效果。

这里设置为平缓体验更好

tsx 复制代码
  scrollElem.scroll({
      top: position,
      behavior: "smooth",
    });

节流函数

由于滚动会产生很多次事件,所以滚动事件回调最好用节流函数

tsx 复制代码
//节流
const handleScroll = _.throttle(function () {
    titleList.forEach((title, index) => {
        const titleElem = document.querySelector(title.id);
        const scrollTop = elem.scrollTop;
        if (scrollTop >= titleElem.offsetTop - 100) {
            setActive(index);
        }
    });
}, 200);

//1.监听元素滚动
elem.addEventListener("scroll", handleScroll);

待实现二级目录需求

大部分文章有二级或多级目录,而本组件只实现了一级目录

实现思路

  • 在传入的list添加children属性
  • 通过递归遍历子目录
  • 子目录添加margin-left
tsx 复制代码
list={[
      { 
          id: "#title0", 
          title: "标题0" 
      },
      {
          id: "#title1",
          title: "标题1",
          children: [
              { id: "#title11", title: "标题11" },
              { id: "#title12", title: "标题12" },
          ],
      },
      ]}
相关推荐
拾光拾趣录12 分钟前
for..in 和 Object.keys 的区别:从“遍历对象属性的坑”说起
前端·javascript
OpenTiny社区23 分钟前
把 SearchBox 塞进项目,搜索转化率怒涨 400%?
前端·vue.js·github
编程猪猪侠1 小时前
Tailwind CSS 自定义工具类与主题配置指南
前端·css
qhd吴飞1 小时前
mybatis 差异更新法
java·前端·mybatis
YGY Webgis糕手之路1 小时前
OpenLayers 快速入门(九)Extent 介绍
前端·经验分享·笔记·vue·web
患得患失9491 小时前
【前端】【vueDevTools】使用 vueDevTools 插件并修改默认打开编辑器
前端·编辑器
ReturnTrue8681 小时前
Vue路由状态持久化方案,优雅实现记住表单历史搜索记录!
前端·vue.js
UncleKyrie1 小时前
一个浏览器插件帮你查看Figma设计稿代码图片和转码
前端
遂心_1 小时前
深入解析前后端分离中的 /api 设计:从路由到代理的完整指南
前端·javascript·api
你听得到112 小时前
Flutter - 手搓一个日历组件,集成单日选择、日期范围选择、国际化、农历和节气显示
前端·flutter·架构