前言

最近在项目中发现之前的表单锚点有点问题,而且动画效果实现的不好,想着抽点时间简单重构一下。没想到这一重构就花了两三天的时间,期间核心功能的实现非常简单,但是各种边界情况的处理确实非常烧脑。这篇文章就复盘一下我的踩坑实践过程。

什么是锚点目录

锚点目录其实就是一个可以用于定位内容的目录,很多的应用中都有锚点目录功能,例如文章右侧的目录:

看似简单的丝滑锚点目录,背后隐藏着很多小坑,详细介绍锚点目录功能实现

随着左侧内容的滚动,锚点会高亮当前展示的内容的标题,点击锚点则会让内容滚动到指定的位置,一般还会搭配吸顶效果使用。

下面先看一下我最终实现的效果:

看似简单的丝滑锚点目录,背后隐藏着很多小坑,详细介绍锚点目录功能实现

怎么样,效果还是不错吧,下面就开始介绍一下这样的一个锚点目录效果要如何实现,以及你可能会遇到的问题。

代码实现

⚠️ 以下所有效果都是使用 react + chakraUI 实现

滑块栏实现

首先左侧有一个细条,细条中有一个滑块,滑块会随着当前选中的标题进行滚。外层容器的高度是直接撑满的,但是里面的小滑块的高度需要我们动态去计算,通过 (1/当前标题数量) * 100 计算内部滑块高度的百分比。

而滑块滚动的实现是通过 transformtransition ,滚动的距离通过 (当前选中的标题) * 100 计算滚动的百分比,translateY 百分之一百就等于滚动了自身的高度,同理百分之三百就是三倍滑块自身的高度。

实现的代码如下:

const Anchor: React.FC<AnchorProps> = ({ anchorData }) => {
  const [current, setCurrent] = useState<{ label: string; index: number }>({
    label: '',
    index: 0,
  });
  // Height of the left slider
  const sliderHeight = useMemo(
    () => (anchorData.length ? ((1 / anchorData.length) * 100).toFixed(2) : 0),
    [anchorData.length]
  );
  // The distance the left slider is offset
  const sliderTransform = useMemo(
    () => `${current.index * 100}%`,
    [current.index, anchorData.length]
  );
  return (
    <Flex>
      <Box w="2px" bg="Gray.200">
        <Box
          h={`${sliderHeight}%`}
          transition="transform 0.2s ease-in-out"
          transform="auto-gpu"
          translateY={sliderTransform}
          bg="black"
        />
      </Box>
    </Flex>
  );
};

锚点样式实现

为了实现这个效果,最初我使用的是树状结构,例如:

const anchorData = [
  {
    label: 'API',
    items: [
      {
        label: 'API 1',
      },
      {
        label: 'API 2',
      },
      {
        label: 'API 3',
      },
    ],
  },
  {
    label: 'Application',
  },
];

然后使用一个递归方法去渲染组件,每一次递归的子组件都会有更大的左边距以形成目录分级的排版。但这个方案后面被我 pass 掉了,因为如果只是为了一个左边距去使用这个数据结构已经递归渲染其实没有必要,于是我改成了下面这种扁平结构:

anchorData={[
    {
      label: 'Basic',
      level: 1,
    },
    {
      label: 'Upstream',
      level: 1,
    },
    {
      label: 'Policies',
      level: 1,
    },
    {
      label: 'Policies 1',
      level: 2,
    },
    {
      label: 'Policies 1',
      level: 3,
    },
]}

直接根据 level 决定目录标题的层级,给予不同的左边距,再判断是否为当前选中的标题,赋予一个高亮的样式。代码实现如下:

<Box>
    {anchorData.map((item, index) => (
      <Box
        key={item.label}
        onClick={() => scrollAnchor(item.label)}
        cursor="pointer"
        color={current.label === item.label ? 'black' : 'Gray.400'}
        _hover={{
          color: 'black',
        }}
        transition="all 0.1s ease-in-out"
        ml={`${item.level * 16}px`}
        mt={index ? '8px' : 0}
        fontWeight="500"
        fontSize="16px"
        lineHeight="24px"
        as="p"
      >
        {item.label}
      </Box>
    ))}
  </Box>

滚动高亮实现

动效果才是实现一个丝滑锚点目录的核心,也是其中的难点,实现的方式有很多种,先说说我踩的坑。最初我使用的是 intersectionobserver 这个 api 监听容器内出现的元素,并动态设置左侧高亮标题。

但是在使用 intersectionobserver 遇到的问题太多了,需要处理各种各样的边界情况,最终各种缝缝补补的代码可维护性和拓展性太差,于是我干脆直接不用这个 API 了。

最终我选择的方法是直接监听窗口的滚动,在触发滚动回调方法时获取锚点目录中所有 dom 节点距离窗口顶部的高度,选择距离最近的作为当前高亮的标题。先上代码:

const { run: handleScroll } = useThrottleFn(
    () => {
      const domArray: { label: string; top: number }[] = [];
      if (
        document.documentElement.clientHeight + window.scrollY ===
        document.body.scrollHeight
      ) {
        setCurrent({
          label: anchorData[anchorData.length - 1].label,
          index: anchorData.length - 1,
        });
      } else {
        anchorData.forEach((item) => {
          const dom = document.getElementById(item.label);
          if (dom && dom.getBoundingClientRect().top > 0) {
            domArray.push({
              label: item.label,
              top: Math.abs(dom?.getBoundingClientRect().top) - OFFSET_TOP,
            });
          }
        });
        if (domArray.length) {
          domArray.sort((a, b) => Math.abs(a.top) - Math.abs(b.top));
          const { label } = domArray[0];
          const index = anchorData.findIndex((item) => item.label === label);
          setCurrent({ label, index });
        }
      }
    },
    { wait: 150, leading: true }
  );
useEffect(() => {
    window?.addEventListener('scroll', handleScroll);
    handleScroll();
    return () => {
      window?.removeEventListener('scroll', handleScroll);
    };
}, []);
  1. 在触发方法时,我会先通过 document.documentElement.clientHeight + window.scrollY === document.body.scrollHeight 判断当前窗口是否已经滚动到底部了,如果已经滚动到底部则直接将高亮设置为最后一项,这么做的目的是防止最后一个元素由于无法滚动导致永远都不会高亮了。

  2. 如果窗口还没有滚动到底部,我会提供通过 Math.abs(dom?.getBoundingClientRect().top) - OFFSET_TOP 获取所有的 dom 节点,然后判断每一个 dom 节点距离窗口顶部的高度,这里的 OFFSET_TOP 是我的表单距离窗口顶部的高度。

  3. 获取距离顶部最近的 dom 元素我是通过 domArray.sort((a, b) => Math.abs(a.top) - Math.abs(b.top)); 进行数组排序,最小的值即为距离最近的元素,也就是排序后数组的第一项。

  4. 再通过 findIndex 方法找到这个锚点的下标位置,设置为当前高亮的锚点。

  5. 最外层使用了 ahook 中的 use-throttle-fn 实现了节流,避免滚动事件触发太过于频繁导致页面卡顿。

通过以上步骤就可以实现一个滚动时动态高亮的效果了,滑块也会跟着走。

点击滚动实现

接下来实现一下点击锚点滚动到对应位置的方法,想要实现让窗口平滑滚动到指定的位置也有很多种方法,例如 window.scrollToelement.scrollIntoView 等等。

注意点: 这里有一个问题,当我们点击锚点后,在执行滚动滚动的同时会触发我们上面写的滚动方法,会导致这样的效果:

看似简单的丝滑锚点目录,背后隐藏着很多小坑,详细介绍锚点目录功能实现

有没有发现问题呢?在滚动过程中,例如从 A 滚动到 D 的过程中触发了滚动事件的回调,本来左侧的高亮应该会轮流亮起 (A->B->C->D) 直到滑块滚动到下一个位置,我将节流去掉再展示一次:

看似简单的丝滑锚点目录,背后隐藏着很多小坑,详细介绍锚点目录功能实现

这一次应该很直观了吧,效果虽然很好看,但是这样我们就没法用节流了,而且窗口的滚动速度还比较慢,在表单的场景中用户可能不需要这样的效果。因此我需要想办法直接让滑块直接 (A->D) 中途不需要高亮其他锚点

想要实现这个效果,重点在于在页面滚动的过程中不能触发回调,而解决方式也不难,就是在点击锚点时,取消监听滚动事件,当滚动结束后再重新监听滚动

但是这时候问题就来了,我们怎么知道页面已经滚动结束了呢?不论是 window.scrollToelement.scrollIntoView 都没有一个滚动结束的回调,如果使用定时器的话,当页面比较长的时候,滚动的时间也长,没有办法预估出一个固定值。

这里我使用了animated-scroll-to这个库替代原生 js 滚动,animated-scroll-to 提供了一个 then 方法来处理滚动结束后的逻辑,可以完美解决我们的问题。再结合上面的分析,最终的实现代码如下:

 // Scroll to an anchor point
  const scrollAnchor = (label: string) => {
    window?.removeEventListener('scroll', handleScroll);
    setCurrent({
      label,
      index: anchorData.findIndex((item) => item.label === label),
    });
    animateScrollTo(
      document.getElementById(label)!.getBoundingClientRect().top -
        document.body.getBoundingClientRect().top -
        OFFSET_TOP,
      { speed: 100 }
    ).then(() => {
        window?.addEventListener('scroll', handleScroll);
    });
  };

这里还使用了 speed 参数设置滚动时间为 100ms 这样就与我们的滑块滚动速度一样,整体看起来更加协调。如下图(重点看左侧滑块最右侧滚动条的移动速度)

看似简单的丝滑锚点目录,背后隐藏着很多小坑,详细介绍锚点目录功能实现

锚点动态改变

这里也是一个小细节,如果没有在锚点数据修改的时候做额外的处理,就会出现下面这种情况:

看似简单的丝滑锚点目录,背后隐藏着很多小坑,详细介绍锚点目录功能实现

删除锚点后,并没有获取新的高亮块,因为并没有触发窗口的滚动,因此我们需要做一个额外的处理:

  useEffect(() => {
    handleScroll();
  }, [anchorData]);

如何使用

这个组件的使用方式也很简单,由于涉及业务,下面用伪代码实现实现一下:

import Anchor  from 'components';
const Page = () => {
const [anchorData, setAnchorData] = useState<AnchorProps['anchorData']>([
    {
      label: 'Title1',
      level: 1,
    },
    {
      label: 'Title2',
      level: 1,
    },
  ]);
  return (
      <Flex>
          <Anchor anchorData={anchorData} />
          <div>
              <div id="Title1">content1</div>
              <div id="Title2">content2</div>
          </div>
      </Flex>
  )
}

首先创建锚点数据,传入锚点组件,然后在相关联的容器中指定一个与 label 相同的 id 即可,添加或者删减节点可以直接操作 anchorData 数组。

总结

最后贴一下完整实现代码:

import { useEffect, useMemo, useState } from 'react';
import animateScrollTo from 'animated-scroll-to';
import { Flex, Box } from '@chakra-ui/react';
import { useThrottleFn } from 'ahooks';
export type AnchorProps = {
  anchorData: {
    label: string;
    level: number;
  }[];
};
const OFFSET_TOP = 180;
const Anchor: React.FC<AnchorProps> = ({ anchorData }) => {
  const [current, setCurrent] = useState<{ label: string; index: number }>({
    label: '',
    index: 0,
  });
  const { run: handleScroll } = useThrottleFn(
    () => {
      const domArray: { label: string; top: number }[] = [];
      if (
        document.documentElement.clientHeight + window.scrollY ===
        document.body.scrollHeight
      ) {
        setCurrent({
          label: anchorData[anchorData.length - 1].label,
          index: anchorData.length - 1,
        });
      } else {
        anchorData.forEach((item) => {
          const dom = document.getElementById(item.label);
          if (dom && dom.getBoundingClientRect().top > 0) {
            domArray.push({
              label: item.label,
              top: Math.abs(dom?.getBoundingClientRect().top) - OFFSET_TOP,
            });
          }
        });
        if (domArray.length) {
          domArray.sort((a, b) => Math.abs(a.top) - Math.abs(b.top));
          const { label } = domArray[0];
          const index = anchorData.findIndex((item) => item.label === label);
          setCurrent({ label, index });
        }
      }
    },
    { wait: 150, leading: true }
  );
  // The distance the left slider is offset
  const sliderTransform = useMemo(
    () => `${current.index * 100}%`,
    [current.index, anchorData.length]
  );
  // Height of the left slider
  const sliderHeight = useMemo(
    () => (anchorData.length ? ((1 / anchorData.length) * 100).toFixed(2) : 0),
    [anchorData.length]
  );
  // Scroll to an anchor point
  const scrollAnchor = (label: string) => {
    window?.removeEventListener('scroll', handleScroll);
    setCurrent({
      label,
      index: anchorData.findIndex((item) => item.label === label),
    });
    animateScrollTo(
      document.getElementById(label)!.getBoundingClientRect().top -
        document.body.getBoundingClientRect().top -
        OFFSET_TOP,
      { speed: 100 }
    ).then(() => {
      setTimeout(() => {
        window?.addEventListener('scroll', handleScroll);
      }, 50);
    });
  };
  useEffect(() => {
    window?.addEventListener('scroll', handleScroll);
    handleScroll();
    return () => {
      window?.removeEventListener('scroll', handleScroll);
    };
  }, []);
  useEffect(() => {
    handleScroll();
  }, [anchorData]);
  return (
    <Flex>
      <Box w="2px" bg="Gray.200">
        <Box
          h={`${sliderHeight}%`}
          transition="transform 0.2s ease-in-out"
          transform="auto-gpu"
          translateY={sliderTransform}
          bg="black"
        />
      </Box>
      <Box>
        {anchorData.map((item, index) => (
          <Box
            key={item.label}
            onClick={() => scrollAnchor(item.label)}
            cursor="pointer"
            color={current.label === item.label ? 'black' : 'Gray.400'}
            _hover={{
              color: 'black',
            }}
            transition="all 0.1s ease-in-out"
            ml={`${item.level * 16}px`}
            mt={index ? '8px' : 0}
            fontWeight="500"
            fontSize="16px"
            lineHeight="24px"
            as="p"
          >
            {item.label}
          </Box>
        ))}
      </Box>
    </Flex>
  );
};
export default Anchor;

一个小功能想要实现的好还是很不容易的,上面的代码中可以根据自己的业务需要进行参数的添加,如果有更优雅的实现方式可以评论区留言。如果文章对你有帮助不妨点个赞 。respect!