关于前端:React项目中如何实现一个简单的锚点目录定位

41次阅读

共计 6505 个字符,预计需要花费 17 分钟才能阅读完成。

小册

这是我整顿的学习材料,十分零碎和欠缺,欢送一起学习

  • 古代 JavaScript 高级小册
  • 深入浅出 Dart
  • 古代 TypeScript 高级小册
  • linwu 的算法笔记📒

前言

锚点目录定位性能在长页面和文档类网站中十分常见, 它能够让用户疾速定位到页面中的某个章节

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

实现根本锚点定位

首先, 咱们须要实现页面内根本的锚点定位性能。对于锚点定位来说, 次要波及这两个局部:

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

例如:

// 锚点组件
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。

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 办法, 所以咱们也能够间接调用:

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 传入章节数据:

function Nav({chapters}) {
  return (
    <ul className="chapters">
      {chapters.map(chapter => (<li key={chapter.id}>
          <a href={'#' + chapter.id}>  
            {chapter.title}
          </a>
        </li>
      ))}
    </ul>
  )
}

锚点组件

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

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 属性建设章节内容和目录链接之间的关联。

解决点击事件

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

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 状态:

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
#anchor {margin-top: 80px; /* header 高度 */}

间接设置一个和 Header 高度雷同的 margin, 来避免遮挡。

  1. 在滚动办法中退出 offset
// scroll offset
const scrollOffset = -80; 

chapterEl.scrollIntoView({offsetTop: scrollOffset})

给 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})
  }

  //...

}

依据不同断点, 动静计算滚动偏移量, 这样能够适配所有状况。

性能优化

应用节流

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

咱们能够应用 Lodash 的 throttle 函数进行节流:

import throttle from 'lodash.throttle';

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

这样能够限度滚动事件最多每 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)  
      );
    })

  }, []);

} 

这种懒加载式的形式能够大幅缩小有效的地位计算。

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};

};

hydrate 解决

客户端脚本加载后, 须要调用 ReactDOM.hydrate 而不是 render 办法, 进行数据的补充填充, 防止目录状态失落。

import {useEffect} from 'react';

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

}

服务端渲染的实现计划

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

次要区别在于:

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

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

问题复现

假如咱们有上面的目录和内容构造:

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. 组件挂载后被动缓存元素地位
// 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. 点击时实时获取元素地位
// handle link click

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

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

}

无论哪种办法, 都须要在 组件挂载后 获取元素的地位信息。

这样咱们就能够在点击目录链接时, 正确滚动到对应的章节地位了。

数据注水

然而点击目录只解决了一半问题, 滚动高亮还须要解决。

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

简略来说就是:

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

实现步骤:

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  
    }
  }

}

2. 客户端读取 props

function Nav({chapters, highlightedChapter}) {

  return (
    <ul>
      {chapters.map(ch => (<li className={ch.id === highlightedChapter?.id ? 'highlighted' : ''}>
        </li>
      ))}
    </

正文完
 0