前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >React项目中如何实现一个简单的锚点目录定位

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

作者头像
linwu
发布2023-10-16 19:29:11
6600
发布2023-10-16 19:29:11
举报
文章被收录于专栏:编程时光编程时光

前言

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

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

实现基本锚点定位

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

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

例如:

代码语言:javascript
复制
// 锚点组件
function AnchorComponent() {
  return <h2 id="anchor">This is anchor</h2> 
}

// 链接组件
function LinkComponent() {
  return (
    <a href="#anchor">Jump to Anchor</a> 
  )
}

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

使用useScrollIntoView自定义hook

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

代码语言:javascript
复制
import { useScrollIntoView } from 'react-use';

function App() {

  const anchorRef = useRef();  
  const scrollToAnchor = () => {
    useScrollIntoView(anchorRef);
  }

  return (
    <>
      <a href="#anchor" onClick={scrollToAnchor}>
        Jump to Anchor  
      </a>
      
      <h2 id="anchor" ref={anchorRef}>This is anchor</h2>
    </>
  )
}

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

原生scrollIntoView方法

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

代码语言:javascript
复制
function App() {

  const anchorRef = useRef();

  const scrollToAnchor = () => {
    anchorRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'start'
    })
  };

  return (
    <>  
      <a href="#anchor" onClick={scrollToAnchor}>Jump to Anchor</a>
      <h2 id="anchor" ref={anchorRef}>This is anchor</h2> 
    </>
  )

}

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

锚点定位和目录联动

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

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

目录导航组件

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

代码语言:javascript
复制
function Nav({ chapters }) {
  return (
    <ul className=" chapters">
      {chapters.map(chapter => (
        <li key={chapter.id}>
          <a href={'#' + chapter.id}>  
            {chapter.title}
          </a>
        </li>
      ))}
    </ul>
  )
}

锚点组件

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

代码语言:javascript
复制
function Chapter({ chapter }) {
  return (
    <Anchor id={chapter.id}>  
      <h2>{chapter.title}</h2>
      {chapter.content}
    </Anchor>
  )
}

function Anchor({ children, id }) {
  return (
    <div id={id}>
      {children}  
    </div>
  )
}

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

处理点击事件

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

代码语言:javascript
复制
function App() {

  //...

  const scrollToChapter = (chapterId) => {
    const chapterEl = document.getElementById(chapterId);
    chapterEl.scrollIntoView({ behavior: 'smooth' });
  }

  return (
    <>
      <Nav 
        chapters={chapters}
        onLinkClick={(chapterId) => scrollToChapter(chapterId)} 
      />
      
      {chapters.map(chapter => (
        <Chapter 
         key={chapter.id}
         chapter={chapter}
        />
      ))}
    </>
  )
}

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

自动高亮

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

代码语言:javascript
复制
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 (
    <>
     <Nav
       chapters={chapters}
       activeChapter={activeChapter}
      />
    </>
  )

}

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

问题解析

遮挡问题

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

常见的解决方案是:

  1. 设置锚点元素margin-top
代码语言:javascript
复制
#anchor {
  margin-top: 80px; /* header高度 */
}

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

  1. 在滚动方法中加入offset
代码语言:javascript
复制
// scroll offset
const scrollOffset = -80; 

chapterEl.scrollIntoView({
  offsetTop: scrollOffset
})

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

响应式问题

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

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

代码语言:javascript
复制
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  
    })
  }

  //...

}

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

性能优化

使用节流

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

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

代码语言:javascript
复制
import throttle from 'lodash.throttle';

const handleScroll = throttle(() => {
  // 计算章节位置
}, 100);

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

IntersectionObserver

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

代码语言:javascript
复制
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)  
      );
    })

  }, []);

} 

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

SSR支持

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

getInitialProps注水

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

代码语言:javascript
复制
Home.getInitialProps = async () => {

  const chapters = await fetchChapters();

  const mappedChapters = chapters.map(chapter => {
    return {
      ...chapter,
      highlighted: isChapterHighlighted(chapter) 
    }
  });

  return {
    chapters: mappedChapters
  };

};

hydrate处理

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

代码语言:javascript
复制
import { useEffect } from 'react';

function App({ chapters }) {

  useEffect(() => {
    ReactDOM.hydrate(
      <App chapters={chapters} />,  
      document.getElementById('root')
    );
  }, []);

}

服务端渲染的实现方案

image.png
image.png

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

主要区别在于:

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

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

问题复现

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

代码语言:javascript
复制
function Nav({ chapters }) {
  return (
    <ul>
      {chapters.map(ch => (
        <li>
          <a href={'#' + ch.id}>{ch.title}</a>
        </li>
      ))}
    </ul>
  )
}

function Chapter({ chapter }) {

  const ref = useRef();

  // 占位组件
  return <div ref={ref}>{chapter.content}</div> 
}

function App() {

  const chapters = [
    { id: 'chapter-1', title: 'Chapter 1' },
    { id: 'chapter-2', title: 'Chapter 2' },
  ];

  return (
    <>
      <Nav chapters={chapters} />

      <Chapter chapter={chapters[0]} />
      <Chapter chapter={chapters[1]} />
    </>
  )

}

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

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

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

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

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

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

预取数据

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

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

这里有两个方法:

  1. 组件挂载后主动缓存元素位置
代码语言:javascript
复制
// 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. 点击时实时获取元素位置
代码语言:javascript
复制
// handle link click

const scrollToChapter = (chapterId) => {

  const element = document.getElementById(chapterId);
  const rect = element.getBoundingClientRect();

  window.scrollTo({
    top: rect.top,
    behavior: 'smooth'
  })

}

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

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

数据注水

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

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

简单来说就是:

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

实现步骤:

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

代码语言:javascript
复制
// 在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  
    }
  }

}

2.客户端读取props

代码语言:javascript
复制
function Nav({ chapters, highlightedChapter }) {

  return (
    <ul>
      {chapters.map(ch => (
        <li className={ch.id === highlightedChapter?.id ? 'highlighted' : ''}>
        </li>
      ))}
    </
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2023-10-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 实现基本锚点定位
    • 使用useScrollIntoView自定义hook
      • 原生scrollIntoView方法
      • 锚点定位和目录联动
        • 目录导航组件
          • 锚点组件
            • 处理点击事件
              • 自动高亮
              • 问题解析
                • 遮挡问题
                  • 响应式问题
                  • 性能优化
                    • 使用节流
                      • IntersectionObserver
                      • SSR支持
                        • getInitialProps注水
                          • hydrate处理
                          • 服务端渲染的实现方案
                            • 问题复现
                              • 预取数据
                                • 数据注水
                                相关产品与服务
                                容器服务
                                腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                                领券
                                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档