• React项目中如何实现一个简单的锚点目录定位


    在这里插入图片描述

    小册

    这是我整理的学习资料,非常系统和完善,欢迎一起学习

    前言

    锚点目录定位功能在长页面和文档类网站中非常常见,它可以让用户快速定位到页面中的某个章节

    • 如何在React中实现锚点定位和平滑滚动
    • 目录自动高亮的实现思路
    • 处理顶部导航遮挡锚点的解决方案
    • 服务端渲染下的实现方案
    • 性能优化策略

    实现基本锚点定位

    首先,我们需要实现页面内基本的锚点定位功能。对于锚点定位来说,主要涉及这两个部分:

    1. 设置锚点,为页面中的某个组件添加id属性
    2. 点击链接,跳转到指定锚点处

    20210106205503299.gif
    例如:

    // 锚点组件
    function AnchorComponent() {
      return 

    This is anchor

    } // 链接组件 function LinkComponent() { return ( Jump to Anchor ) }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    当我们点击Jump to Anchor这个链接时,页面会平滑滚动到AnchorComponent所在的位置。

    使用useScrollIntoView自定义hook

    React中实现锚点定位,最简单的方式就是使用useScrollIntoView这个自定义hook。

    import { useScrollIntoView } from 'react-use';
    
    function App() {
    
      const anchorRef = useRef();  
      const scrollToAnchor = () => {
        useScrollIntoView(anchorRef);
      }
    
      return (
        <>
          
            Jump to Anchor  
          
          
          

    This is anchor

    ) }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    useScrollIntoView接受一个ref对象,当调用这个hook函数时,会自动滚动页面,使得ref对象在可视区域内。

    原生scrollIntoView方法

    useScrollIntoView内部其实就是使用了原生的scrollIntoView方法,所以我们也可以直接调用:

    function App() {
    
      const anchorRef = useRef();
    
      const scrollToAnchor = () => {
        anchorRef.current.scrollIntoView({
          behavior: 'smooth',
          block: 'start'
        })
      };
    
      return (
        <>  
          Jump to Anchor
          

    This is anchor

    ) }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    scrollIntoView可以让元素的父容器自动滚动,将这个元素滚动到可见区域。behavior:'smooth’可以启用平滑滚动效果。

    锚点定位和目录联动

    很多时候,我们会在页面中实现一个目录导航,可以快速定位到各个章节。此时就需要实现锚点定位和目录的联动效果:

    • 点击目录时,自动滚动到对应的章节
    • 滚动页面时,自动高亮正在浏览的章节

    目录导航组件

    目录导航本身是一个静态组件,我们通过props传入章节数据:

    function Nav({ chapters }) {
      return (
        
      )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    锚点组件

    然后在页面中的每一章使用Anchor组件包裹:

    function Chapter({ chapter }) {
      return (
          
          

    {chapter.title}

    {chapter.content}
    ) } function Anchor({ children, id }) { return (
    {children}
    ) }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    这样通过id属性建立章节内容和目录链接之间的关联。

    处理点击事件

    当点击目录链接时,需要滚动到对应的章节位置:

    function App() {
    
      //...
    
      const scrollToChapter = (chapterId) => {
        const chapterEl = document.getElementById(chapterId);
        chapterEl.scrollIntoView({ behavior: 'smooth' });
      }
    
      return (
        <>
          

    给Nav组件传一个onLinkClick回调,当点击链接时,通过chapterId获取到元素,并滚动到可视区域,实现平滑跳转。

    自动高亮

    实现自动高亮也很简单,通过监听滚动事件,计算章节元素的偏移量,判断哪个章节在可视区域内,并更新active状态:

    function App() {
    
      const [activeChapter, setActiveChapter] = useState();
    
      useEffect(() => {
        const handleScroll = () => {
          chapters.forEach(chapter => {
            const element = document.getElementById(chapter.id);
            // 获取元素在可视区域中的位置
            const rect = element.getBoundingClientRect();  
            // 判断是否在可视区域内 
            if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
              setActiveChapter(chapter.id);
            }
          })
        }
    
        window.addEventListener('scroll', handleScroll);
    
        return () => {
          window.removeEventListener('scroll', handleScroll);
        }
      }, []);
    
      return (
        <>
         

    通过getBoundingClientRect可以得到元素相对于视窗的位置信息,根据位置判断是否在可见区域内,如果是就更新activeChapter状态,从而触发目录的高亮效果。

    问题解析

    遮挡问题

    有时锚点会被固定的Header遮挡,此时滚动会定位到元素上方,用户看不到锚点对应的内容。

    常见的解决方案是:

    1. 设置锚点元素margin-top
    #anchor {
      margin-top: 80px; /* header高度 */
    }
    
    • 1
    • 2
    • 3

    直接设置一个和Header高度相同的margin,来防止遮挡。

    1. 在滚动方法中加入offset
    // scroll offset
    const scrollOffset = -80; 
    
    chapterEl.scrollIntoView({
      offsetTop: scrollOffset
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    给scrollIntoView传入一个顶部偏移量,这样也可以跳过Header的遮挡。

    响应式问题

    在响应式场景下,目录的遮挡问题会更复杂。我们需要区分不同断点下,计算匹配的offset。

    可以通过MatchMedia Hook获取当前的断点:

    import { useMediaQuery } from 'react-responsive';
    
    function App() {
    
      const isMobile = useMediaQuery({ maxWidth: 767 });
      const isTablet = useMediaQuery({ minWidth: 768, maxWidth: 1023 });
      const isDesktop = useMediaQuery({ minWidth: 1024 });
      
      let scrollOffset = 0;
    
      if (isMobile) {
        scrollOffset = 46; 
      } else if (isTablet) {  
        scrollOffset = 60;
      } else if (isDesktop) {
        scrollOffset = 80;
      }
    
      const scrollToChapter = (chapterId) => {
        const chapterEl = document.getElementById(chapterId);
    
        chapterEl.scrollIntoView({
          offsetTop: scrollOffset  
        })
      }
    
      //...
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    根据不同断点,动态计算滚动偏移量,这样可以适配所有情况。

    性能优化

    使用节流

    滚动事件会高频触发,直接在滚动回调中计算章节位置会造成性能问题。

    我们可以使用Lodash的throttle函数进行节流:

    import throttle from 'lodash.throttle';
    
    const handleScroll = throttle(() => {
      // 计算章节位置
    }, 100);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这样可以限制滚动事件最多每100ms触发一次。

    IntersectionObserver

    使用IntersectionObserver提供的异步回调,只在章节进入或者离开可视区域时才执行位置计算:

    import { useRef, useEffect } from 'react';
    
    function App() {
    
      const chaptersRef = useRef({});
    
      useEffect(() => {
        const observer = new IntersectionObserver(
          (entries) => {
            // 章节进入或者离开可视区域时更新
          }
        );
    
        chapters.forEach(chapter => {
          observer.observe(
            document.getElementById(chapter.id)  
          );
        })
    
      }, []);
    
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    这种懒加载式的方式可以大幅减少无效的位置计算。

    SSR支持

    在Next.js等SSR场景下,客户端脚本会延后加载,页面初次渲染时目录联动会失效。

    getInitialProps注水

    可以在getInitialProps中提前计算目录数据,注入到页面中:

    Home.getInitialProps = async () => {
    
      const chapters = await fetchChapters();
    
      const mappedChapters = chapters.map(chapter => {
        return {
          ...chapter,
          highlighted: isChapterHighlighted(chapter) 
        }
      });
    
      return {
        chapters: mappedChapters
      };
    
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    hydrate处理

    客户端脚本加载后,需要调用ReactDOM.hydrate而不是render方法,进行数据的补充填充,避免目录状态丢失。

    import { useEffect } from 'react';
    
    function App({ chapters }) {
    
      useEffect(() => {
        ReactDOM.hydrate(
          <App chapters={chapters} />,  
          document.getElementById('root')
        );
      }, []);
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    服务端渲染的实现方案

    image.png

    在使用了服务端渲染(SSR)的框架如Next.js等情况下,实现锚点定位和目录联动也会有一些不同。

    主要区别在于:

    • 服务端和客户端环境不统一
    • 脚本加载时间差

    这会导致一些状态错位的问题。

    问题复现

    假设我们有下面的目录和内容结构:

    function Nav({ chapters }) {
      return (
        
      )
    }
    
    function Chapter({ chapter }) {
    
      const ref = useRef();
    
      // 占位组件
      return 
    {chapter.content}
    } function App() { const chapters = [ { id: 'chapter-1', title: 'Chapter 1' }, { id: 'chapter-2', title: 'Chapter 2' }, ]; return ( <>

    非SSR环境下,点击链接和滚动都可以正常工作。

    但是在Next.js的SSR环境下就会有问题:

    点击目录链接时,页面不会滚动。

    这是因为在服务端,我们无法获取组件的ref,所以锚点元素不存在,自然无法定位。

    滚动页面时,目录高亮也失效。

    服务端渲染的静态HTML中,并没有绑定滚动事件,所以无法自动高亮。

    预取数据

    首先,我们需要解决点击目录链接的问题。

    既然服务端无法获取组件ref,那就需要在客户端去获取元素位置。

    这里有两个方法:

    1. 组件挂载后主动缓存元素位置
    // Chapter组件
    
    useEffect(() => {
      // 缓存位置数据
      cacheElementPosition(chapter.id, ref.current); 
    }, []);
    
    // Utils
    
    const elementPositions = {};
    
    function cacheElementPosition(id, element) {
      const rect = element.getBoundingClientRect();
    
      elementPositions[id] = {
        left: rect.left,
        top: rect.top,
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    1. 点击时实时获取元素位置
    // handle link click
    
    const scrollToChapter = (chapterId) => {
    
      const element = document.getElementById(chapterId);
      const rect = element.getBoundingClientRect();
    
      window.scrollTo({
        top: rect.top,
        behavior: 'smooth'
      })
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    无论哪种方法,都需要在组件挂载后获取元素的位置信息。

    这样我们就可以在点击目录链接时,正确滚动到对应的章节位置了。

    数据注水

    但是点击目录只解决了一半问题,滚动高亮还需要解决。

    这里就需要用到数据注水的技术。

    简单来说就是:

    • 在服务端渲染时,读取路由参数,提前计算高亮状态
    • 将高亮数据注入到响应中
    • 客户端拿到注水的数据后渲染,不会出现高亮错位

    实现步骤:

    1.服务端获取参数和数据

    // 在getServerSideProps中
    
    export async function getServerSideProps(context) {
      
      const { hashtag } = context.query;
    
      const chapters = await fetchChapters();
    
      const highlightedChapter = chapters.find(ch => ch.id === hashtag);
    
      return {
        props: {
          chapters,
          highlightedChapter  
        }
      }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    2.客户端读取props

    function Nav({ chapters, highlightedChapter }) {
    
      return (
        
      {chapters.map(ch => (
    • ))}
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
  • 相关阅读:
    Java面试(基础篇)——解构Java常见的基础面试题 & 结合Java源码分析
    ts学习笔记
    和数集团“区块链+数字化”促进新场景应用落地 为多领域开启无限可能
    Hive特殊函数的使用
    【洛谷P1351】联合权值【数学】
    【C++中cin、cin.get()、cin.getline()、getline() 的区别】
    集合框架:List系列集合:特点、方法、遍历方式、ArrayList,LinkList的底层原理
    深入剖析:如何使用Pulsar和Arthas高效排查消息队列延迟问题
    算法刷题日志——回溯算法
    QT使用时,报错说No suitable kits can be found
  • 原文地址:https://blog.csdn.net/weixin_52898349/article/details/133310980