使用 React 和 Intersection Observer 创建部分导航 使用 React 和 Intersection Observer 创建部分导航

2025-06-09

使用 React 和 Intersection Observer 创建部分导航

使用 React 和 Intersection Observer 创建部分导航


使用 React 和 Intersection Observer 创建部分导航

介绍

在工作中的最后一个项目中,我需要创建一个基于区块的导航。当你滚动到页面的特定区块时,它会高亮显示正确的导航项。在阅读和研究了一些资料后,我发现可以使用 Intersection Observer API。这是一个非常棒的浏览器原生 API,每当所需元素出现在视口中时,它都会触发一个事件。你可以在这里阅读更多相关信息。

今天我想向大家展示我从那个项目中学到的东西。在本教程中,我们将构建一个迷你页面,其中包含来自不同维度(?)的不同类型的 Rick 的描述。请查看工作演示GitHub 仓库

这几乎就是我工作时做的项目的翻版(虽然我很想做,但我做的不是 Rick and Morty 网站)。总之,我们开始吧。

让我们开始吧

样板

首先,我们将从创建项目脚手架开始。我们将使用Create React App。如果你以前用过它,我就不用多说它的优点了。如果你还没用过,那就赶紧改正错误,去看看项目网站吧。在终端中运行以下命令:



    $ npx create-react-app rick-morty-section-navigation
    $ cd rick-morty-section-navigation
    $ npm start


Enter fullscreen mode Exit fullscreen mode

嘭,我们成功了——一个可用的样板文件。让我们清理一些我们不需要的默认内容。删除并移动一些文件,这样你的项目结构就看起来像这样了。



    rick-morty-section-navigation
    ├── README.md
    ├── node_modules
    ├── package.json
    ├── .gitignore
    ├── public
    │   ├── favicon.ico
    │   ├── index.html
    │   └── manifest.json
    └── src
        ├── index.js
        └── components
            ├── App.js
            └── app.css


Enter fullscreen mode Exit fullscreen mode

不要忘记删除对已删除文件(index.css、serviceWorker.js 等)的引用。

数据

至于数据层,我决定使用 Rick and Morty API(为什么不呢?)。点击此处查看——它完全免费,并且包含大量关于我最喜欢的电视节目的信息。此外,它还提供了一个 GraphQL 端点,我们将使用它来代替传统的 REST API。

继续安装urqlgraphql和 graphql-tag 。Urql 是一款非常优秀的 React 应用 GraphQL 客户端,你可以将其用作组件或钩子(目前非常流行)。



    $ npm install --save urql graphql


Enter fullscreen mode Exit fullscreen mode

现在让我们将 App 组件包装到 urql 提供程序中。这非常简单,只需创建一个带有 API URL 的客户端并将其传递给提供程序即可。



    // src/index.js
    import React from 'react';
    import ReactDOM from 'react-dom';
    import App from './components/App';
    import {Provider, createClient} from 'urql';

    const client = createClient({
      url: 'https://rickandmortyapi.com/graphql/',
    });

    ReactDOM.render(
        <Provider value={client}>
          <App />
        </Provider>,
        document.getElementById('root'));


Enter fullscreen mode Exit fullscreen mode

现在您可以开始从端点查询数据。



    // src/compoments/App.js
    import React from 'react';
    import {useQuery} from 'urql';
    import gql from 'graphql-tag';

    const getCharacters = gql`
      query AllCharacters{
        characters(filter: {name: "rick"}) {
          info {
            count
          }
          results {
            id
            name
            image
            species
            status
            location {
              name
            }
            origin {
              dimension
            }
          }
        }
      }
    `;

    export default function App() {
      const [res] = useQuery({
        query: getCharacters,
      });
    if (res.fetching || typeof res.data === 'undefined') {
        return (
          <div>Loading page...</div>
        );
      } else {
        return (
          <div>
                {
                  res.data.characters.results.map((item) => {
                    return (
                      <>
                        <div>
                          <img src={data.image}/>
                        </div>
                        <div className="character-block__text">
                          <h2>{data.name}</h2>
                          <p><b>Status</b>: {data.status}</p>
                          <p><b>Location</b>: {data.location ? data.location.name : '-'}</p>
                          <p><b>Species</b>: {data.species}</p>
                          <p><b>Dimension</b>: {data.origin.dimension || '-'}</p>
                        </div>
                      </>
                    );
                  })
                }
          </div>
        );
      }
    }


Enter fullscreen mode Exit fullscreen mode

让我们看看这里发生了什么:

  • 我们创建一个简单的 API 查询

  • 在我们的 App 组件中,我们useQuery实际上使用 API 获取数据

  • 如果 URQL 仍在获取数据,我们将返回正在加载的组件,

  • 如果 URQL 已获取数据,我们将循环遍历结果并返回字符块列表

结构

我们有几个未设置样式的 div,其中包含一些简单的数据,但这显然不够。在添加样式并创建两个主要组件(导航和角色)之前,我们先来考虑一下状态。为了使其正常工作,我们需要在顶部组件中设置一个活动/当前角色状态。



    // src/compoments/App.js

    *import* React, {useState} *from* 'react';
    ...
    ...
    const [activeCharacter, setActiveCharacter] = useState();


Enter fullscreen mode Exit fullscreen mode

现在我们可以传递状态和将更新状态的方法给子组件。



    // src/components/Navigation.js

    import React from 'react';

    export function Navigation({items, activeCharacter}) {
      function renderItems() {
        return items.map((item) => {
          const activeClass = activeCharacter === item.name
            ? 'navigation-list__item--active'
            : '';
          return (
            <li
              key={item.name}
              id={item.name}
              className={`navigation-list__item ${activeClass}`}>{item.name}</li>
          );
        });
      }
      return (
        <ul className="navigation-list">{renderItems()}</ul>
      );
    }

    // src/components/Character

    import React from 'react';

    export function Character({
      data,
      activeCharacter,
      setActiveCharacter,
    }) {
      const activeClass = activeCharacter === data.name
        ? 'character-block--active'
        : '';

    return (
        <div
          className={`character-block ${activeClass}`}
          id={data.name}>
          <div>
            <img src={data.image} alt="" className="character-block__image"/>
          </div>
          <div className="character-block__text">
            <h2>{data.name}</h2>
            <p><b>Status</b>: {data.status}</p>
            <p><b>Location</b>: {data.location ? data.location.name : '-'}</p>
            <p><b>Species</b>: {data.species}</p>
            <p><b>Dimension</b>: {data.origin.dimension || '-'}</p>
          </div>
        </div>
      );
    }

    // src/components/App.js

    ...

    import {Navigation} from './Navigation';
    import {Character} from './Character';

    export default function App() {

    ...

    if (res.fetching || typeof res.data === 'undefined') {
        return (
          <div>Loading...</div>
        );
      } else {
        const characters = res.data.characters.results.slice(0, 9);
        return (
          <>
            <div className="page-wrapper">
              <aside className="sidebar">
                <Navigation
                  items={characters}
                  activeCharacter={activeCharacter}/>
              </aside>
              <div className="content">
                <div className="page-intro">
                  <h1 className="page-title">Check out these cool Morty&apos;s!</h1>
                  <p>This simple page is an example of using Intersection Observer API with React.
                  </p>
                </div>
                {
                  characters.map((item) => {
                    return (
                      <Character
                        key={item.name}
                        activeCharacter={activeCharacter}
                        data={item}
                        setActiveCharacter={setActiveCharacter}/>
                    );
                  })
                }
              </div>
            </div>
          </>
        );
      }


Enter fullscreen mode Exit fullscreen mode

另外,让我们添加一些基本样式(不要忘记在 app.js 中导入它们):



    /* Mobile styles */
    * {
      box-sizing: border-box;
    }
    body {
      color: #282c34;
      font-family: 'Roboto Mono', monospace;
      padding: 0;
      margin: 0;
      width: 100%;
      position: relative;
      overflow-x: hidden;
    }
    .page-title {
      margin-bottom: 2rem;
    }
    .page-intro {
      max-width: 700px;
      margin-bottom: 3rem;
    }
    .page-wrapper {
      padding: 20px 15px 20px;
      width: 100%;
      max-width: 1300px;
      display: flex;
    }
    .sidebar {
      display: none;
    }
    .character-block {
      display: flex;
      margin-bottom: 2rem;
      transition: .3s;
      flex-direction: column;
    }
    .character-block--active {
      background: #faf575;
    }
    .character-block__image {
      width: 100%;
    }
    .character-block__text {
      padding: 1rem;
    }

    /* Tablet landscape styles */
    @media screen and (min-width: 768px) {
      .page-wrapper {
        padding-bottom: 120px;
      }
      .sidebar {
        display: flex;
        flex: 1;
      }
      .content {
        flex: 2.1;
      }
      .character-block {
        flex-direction: row;
      }
      .character-block__image {
        margin-right: 2rem;
        display: flex;
        align-self: center;
      }
      .character-block__text {
        padding: 0 1rem;
        align-self: center;
      }

    .navigation-list {
        position: fixed;
        top: 50%;
        transform: translate3d(0,-50%,0);
        left: -10px;
        list-style: none;
      }
      .navigation-list__item {
        font-size: 0.9rem;
        max-width: 200px;
        margin-bottom: 0.5em;
        transition: .3s;
        cursor: pointer;
      }
      .navigation-list__item:hover {
        padding-left: 5px;
        background: #faf575;
      }
      .navigation-list__item--active {
        background: #faf575;
        padding-left: 15px;
      }
    }

    /* Tablet vertical styles */
    @media screen and (min-width: 1024px) {
      .sidebar {
        min-width: 250px;
      }
      .content {
        flex: 2.5;
      }
    }
    /* Desktop styles */
    @media screen and (min-width: 1140px) {
      .sidebar {
        min-width: 250px;
      }
      .character-block {
        margin-bottom: 5rem;
      }
      .character-block__image {
        margin-right: 2rem;

      }
      .character-block__text {
        align-self: center;
      }
    }


Enter fullscreen mode Exit fullscreen mode

到目前为止一切顺利。如果你按照说明操作,应该会得到类似这样的结果:

没什么特别的,就是一堆 Rick。为了让它更具交互性,我们需要添加“交叉点观察器”,用来检测当前哪个 Rick 区域位于中间,并将其设置为活动区域。

交叉口观察器 API

Intersection Observer API 究竟是什么?它允许观察元素与视口或祖先元素的交集。例如,我们可以用它来判断目标元素是否对用户可见。该 API 的一大优点在于它不会导致回流/布局崩溃,而这是一个非常常见的性能问题(请查看此处)。

如果您想了解有关 Intersection Observer 的更多信息,我鼓励您阅读MDN 文档

代码

理论讲完了,现在开始实际的代码。我们想给每个 Character 组件添加一个观察器,来检测它是否与视口相交。



    // src/components/Character.js

    import React, {useEffect, useRef} from 'react';

    import React from 'react';

    export function Character({
      data,
      activeCharacter,
      setActiveCharacter,
    }) {
      const activeClass = activeCharacter === data.name
        ? 'character-block--active'
        : '';
     const characterRef = useRef(null);

    useEffect(() => {
        const handleIntersection = function(entries) {
          entries.forEach((entry) => {
            if (entry.target.id !== activeCharacter && entry.isIntersecting) {
              setActiveCharacter(entry.target.id);
            }
          });
        };
        const observer = new IntersectionObserver(handleIntersection);
        observer.observe(characterRef);
        return () => observer.disconnect(); // Clenaup the observer if 
        component unmount.
      }, [activeCharacter, setActiveCharacter, data, characterRef]);

    return (
        <div
          className={`character-block ${activeClass}`}
          id={data.name}
          ref={characterRef}>
          <div>
            <img src={data.image} alt="" className="character-block__image"/>
          </div>
          <div className="character-block__text">
            <h2>{data.name}</h2>
            <p><b>Status</b>: {data.status}</p>
            <p><b>Location</b>: {data.location ? data.location.name : '-'}</p>
            <p><b>Species</b>: {data.species}</p>
            <p><b>Dimension</b>: {data.origin.dimension || '-'}</p>
          </div>
        </div>
      );
    }


Enter fullscreen mode Exit fullscreen mode

让我们看看这里发生了什么:

  • 已添加 useEffect 钩子

  • 已定义每次发生交叉事件时都会触发的 handleIntsersection 方法;如果进入目标与视口相交,则该函数会将其 ID 设置为新的 activeCharacter,并将状态提升到父组件

  • 已创建新的 Intersection Observer 实例(使用 handleIntsersection 作为回调)

  • 已调用观察者方法,参考当前字符包装器(使用了 useRef 钩子)

现在,每当角色组件可见时,它都会触发观察者回调,并设置新的活动角色。但我们不希望该部分一到达视口就立即激活。我们的目标是使其位于视口的中心。为了实现这一点,我们可以将rootMargin配置传递给观察者。此属性使用类似 CSS 的语法,允许我们扩展或缩小元素触发回调的区域。

简单来说:当我们的元素进入这个蓝色区域时,事件就会触发。我们希望蓝色区域的高度为 1px,并位于视口的中心。接下来,让我们添加一些代码。



    // src/components/App.js

    export default function App() {

    ...

    const [pageHeight, setPageHeight] = useState();

    useEffect(() => {
        setPageHeight(window.innerHeight);
        window.addEventListener('resize', (e) => {
          setTimeout(() => {
            setPageHeight(window.innerHeight);
          }, 300);
        });
      }, []);

    ...

    }


Enter fullscreen mode Exit fullscreen mode

我们在这里使用 useState 将页面高度设置为状态的一部分。此外,当窗口大小调整时,我们希望更新该状态以确保其为最新状态。为了提高性能,我们用 setTimeout 方法包装了它,以消除函数抖动。现在让我们更新 Character.js。



    export function Character({
      data,
      activeCharacter,
      setActiveCharacter,
      pageHeight
    }) {

    ...

    const observerMargin = Math.floor(pageHeight / 2);
    useEffect(() => {

    const observerConfig = {
          rootMargin: `-${pageHeight % 2 === 0 ? observerMargin - 1 :    
    observerMargin}px 0px -${observerMargin}px 0px`,
        };
        const handleIntersection = function(entries) {
          entries.forEach((entry) => {
            if (entry.target.id !== activeCharacter && entry.isIntersecting) {
              setActiveCharacter(entry.target.id);
            }
          });
        };
        const observer = new IntersectionObserver(handleIntersection, observ);
        observer.observe(characterRef);
        return () => observer.disconnect(); // Clenaup the observer if 
        component unmount.
      }, [activeCharacter, setActiveCharacter, data, characterRef]);

    ...

    }


Enter fullscreen mode Exit fullscreen mode

我们将页面高度作为 props 传递给 Character.js 组件,计算正确的 rootMargin 并将其作为配置对象传递给新的 IntersectionObserver。



    // pageHeight === 700
    rootMargin: '349px 0px 350px 0px'
    // pageHeight === 701
    rootMargin: '350px 0px 350px 0px'


Enter fullscreen mode Exit fullscreen mode

这样,我们就能确保目标区域的高度始终为 1px,并且位于中心。至此,你应该已经完成​​了一个几乎可以正常工作的示例。是不是很酷很简单?

注意:为了使其在 Internet Explorer 浏览器上运行,请安装Intersection Observer PolyfillReact App Polyfill

可点击链接

最后我们需要添加一个可点击链接的功能。我们将使用 React 的 createRef API 和原生的 scrollIntoView 方法。



    // src/components/App.js

    ...

    if (res.fetching || typeof res.data === 'undefined') {
        return (
          <div>Loading...</div>
        );
      } else {
        const characters = res.data.characters.results.slice(0, 9);

       const refs = characters.reduce((refsObj, character) => {
          refsObj[character.name] = createRef();
          return refsObj;
        }, {});

        const handleCLick = (name) => {
          refs[name].current.scrollIntoView({
            behavior: 'smooth',
            block: 'center',
          });
        };   

       return (
          <>
            <div className="page-wrapper">
              <aside className="sidebar">
                <Navigation
                  items={characters}
                  activeCharacter={activeCharacter}
                  handleCLick={handleCLick}/>
              </aside>
              <div className="content">
                <div className="page-intro">
                  <h1 className="page-title">Check out these cool Morty&apos;s!</h1>
                  <p>This simple page is an example of using Intersection Observer API with React.
                  </p>
                </div>
                {
                  characters.map((item) => {
                    return (
                      <Character
                        key={item.name}
                        activeCharacter={activeCharacter}
                        data={item}
                        setActiveCharacter={setActiveCharacter}
                        refs={refs}/>
                    );
                  })
                }
              </div>
            </div>
          </>
        );
      }

    // src/components/Navigation.js
    import React from 'react';

    export function Navigation({items, activeCharacter, handleCLick}) {
      function renderItems() {
        return items.map((item) => {
          const activeClass = activeCharacter === item.id
            ? 'navigation-list__item--active'
            : '';
          return (
            <li
              key={item.name}
              id={item.name}
              onClick={() => handleCLick(item.name)}
              className={`navigation-list__item ${activeClass}`}>{item.name}</li>
          );
        });
      }
      return (
        <ul className="navigation-list">{renderItems()}</ul>
      );
    }

    // src/components/Character.js
    import React, {useEffect} from 'react';

    export function Character({
      data,
      activeCharacter,
      setActiveCharacter,
      pageHeight = 100,
      refs,
    }) {
      const observerMargin = Math.floor(pageHeight / 2);
      const activeClass = activeCharacter === data.id
        ? 'character-block--active'
        : '';
      useEffect(() => {
        const observerConfig = {
          rootMargin: `-${pageHeight % 2 === 0 ? observerMargin - 1 : observerMargin}px 0px -${observerMargin}px 0px`,
        };
        const handleIntersection = function(entries) {
          entries.forEach((entry) => {
            if (entry.target.id !== activeCharacter && entry.isIntersecting) {
              setActiveCharacter(entry.target.id);
            }
          });
        };
        const observer = new IntersectionObserver(
            handleIntersection,
            observerConfig);
        observer.observe(refs[data.name].current);
        return () => observer.disconnect(); // Clenaup the observer if 
        component unmount.
      }, [activeCharacter, setActiveCharacter, observerMargin, refs, data, pageHeight]);

    return (
        <div
          className={`character-block ${activeClass}`}
          ref={refs[data.name]}
          id={data.id}>
          <div>
            <img src={data.image} alt="" className="character-block__image"/>
          </div>
          <div className="character-block__text">
            <h2>{data.name}</h2>
            <p><b>Status</b>: {data.status}</p>
            <p><b>Location</b>: {data.location ? data.location.name : '-'}</p>
            <p><b>Species</b>: {data.species}</p>
            <p><b>Dimension</b>: {data.origin.dimension || '-'}</p>
          </div>
        </div>
      );
    }


Enter fullscreen mode Exit fullscreen mode

让我们仔细检查一下这一大段代码,看看刚刚发生了什么:

  1. 我们为每个角色创建了一个带有 refs 的对象,并将其传递给 Character 组件,以便稍后使用正确的元素引用来填充它

  2. 我们创建了一个方法来处理导航链接上的点击事件并将其传递给导航组件,并将其附加到每个链接元素

  3. 在 Character.js 中,我们删除了 createRef API,将 ref 分配给 refs 对象,并使用 refs[data.name].current 作为观察者中的目标元素

就这些了

如你所见,在 React 项目中设置 Intersection Observer 非常简单。当然,有一些现成的组件具备此功能,我鼓励你使用。我只是觉得有必要向你展示一下这个 API 的具体工作原理。

我希望您喜欢本教程,如果您有任何问题或意见,请在评论部分告诉我。

鏂囩珷鏉ユ簮锛�https://dev.to/maciekgrzybek/create-section-navigation-with-react-and-intersection-observer-fg0
PREV
使用 Typescript、Jest 和 React 测试库设置 Next.js
NEXT
Next.js 身份验证 - 使用 NextAuth.js 进行 JWT 刷新令牌轮换