手把手教你封装一个高性能、多功能的 React 锚点导航组件 (Anchor)

106 阅读8分钟

🚀 手把手教你封装一个高性能、多功能的 React 锚点导航组件 (Anchor)

在开发长页面(如新闻资讯、产品详情、帮助文档)时,锚点导航是一个非常常见的需求。它能让用户快速定位到感兴趣的内容区域,提升阅读体验。

市面上虽然有很多 UI 库提供了 Anchor 组件(如 Ant Design),但在移动端或特定复杂场景下(如吸顶、滚动容器自适应、高度不一),往往还是需要我们自己封装一个更灵活、更轻量的组件。

今天,我就带大家详细解析一下,我们是如何封装一个 开箱即用、功能强大 的 Anchor 组件的。


popup.gif

✨ 核心亮点

这个组件不仅仅是一个简单的“点击跳转”,它解决了许多实际开发中的痛点:

  1. 开箱即用:组件内置滚动容器,无需外部再包一层复杂的布局,把内容传进去就能跑。
  2. 智能吸顶 (Sticky):当页面向上滚动时,导航栏会自动吸附在顶部,支持自定义吸顶偏移。
  3. 高性能 DOM 操作:告别 document.getElementById,使用 useRef 直接管理 DOM 引用,减少查询开销。
  4. 智能双向联动
    • 点击导航 -> 平滑滚动到对应内容(支持防抖锁,防止乱跳)。
    • 滚动内容 -> 自动高亮对应的导航 Tab(支持智能 ScrollSpy)。
  5. 高度自适应:无论内容模块是高是矮,都能精准定位,且支持“触底自动选中最后一项”的兜底逻辑。
  6. 丝滑无抖动:完美解决了 Tab 切换时的字体粗细变化导致的布局抖动问题。
  7. 样式全开放:从导航栏到每一个 Tab,支持通过 Props 完全自定义样式(ClassName 或 Inline Style)。

🛠️ 使用体验

在使用这个组件之前,你可能需要手动写很多 ID、监听 Scroll 事件、计算高度……

而现在,你只需要这样:

1. 准备内容数组

直接写 TSX,在每个组件上标记 title 属性,组件会自动提取它作为 Tab 标题。

const items = [
  <div title="推荐">这里是推荐板块的内容...</div>,
  <div title="热点">这里是热点板块的内容...</div>,
  <div title="视频">这里是视频板块的内容...</div>,
];

2. 渲染组件

<Anchor 
  items={items} 
  header={<div>这里可以放顶部的 Banner</div>}
  style={{ height: '100vh' }} // 设置容器高度
  // 还可以轻松定制样式
  activeTabStyle={{ color: 'red', fontWeight: 'bold' }}
/>

就这么简单!组件会自动生成导航栏,并处理好所有的滚动逻辑。

💡 核心技术实现

1. 极简的 Props 设计与数据提取

为了让开发者用得爽,我们放弃了传统的 data + content 分离的写法,采用了“组件即数据”的思路:

// 组件内部自动提取 title
const anchorData = useMemo(() => {
  return items.map((child, index) => ({
    key: `anchor-item-${index}`,
    title: child.props.title || `Tab ${index + 1}`, // 自动提取 props.title
    content: child
  }));
}, [items]);

2. 高性能 DOM 引用 (useRef vs getElementById)

为了避免在滚动高频事件中频繁调用 document.getElementById,我们使用了 useRef 来存储所有内容块的 DOM 引用。

1. 定义引用容器

const itemsRef = useRef<Record<string, HTMLDivElement | null>>({});

2. 渲染时绑定

<div ref={el => { itemsRef.current[item.key] = el }}>...</div>

3. 使用时直接读取 (零查询开销)

const targetEl = itemsRef.current[item.key];

3. 精准的滚动定位

很多组件使用 offsetTop 来计算位置,但在多层嵌套或有 sticky 元素的情况下容易出错。我们统一使用 getBoundingClientRect() 来计算相对可视区的距离,确保万无一失。

// 计算目标元素距离容器顶部的真实视觉距离
const relativeTop = targetEl.getBoundingClientRect().top - containerEl.getBoundingClientRect().top;

// 目标位置 = 当前滚动 + 相对距离 - 吸顶高度 - 导航栏高度
let scrollTop = containerEl.scrollTop + relativeTop - offsetTop - navHeight;

// 边界检查:防止滚出负数
scrollTop = Math.max(0, scrollTop);### 4. 智能防抖锁 (IsManualScrolling)

在用户快速连续点击 Tab 时,如果仍然触发滚动监听,会导致左侧 Tab 乱跳。我们引入了一个**动态防抖锁**机制,而不是硬编码的定时器。

// 点击时上锁
isManualScrolling.current = true;

// 监听滚动时,动态检测滚动停止
// 只要还在触发 scroll 事件,就不断重置定时器
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);

scrollTimerRef.current = setTimeout(() => {
  // 100ms 内没有新的 scroll 事件,认为滚动结束,解锁
  isManualScrolling.current = false;
}, 100);

// 如果锁着,就不执行高亮计算
if (isManualScrolling.current) return;

5. 解决“Tab 切换抖动”

当选中的 Tab 字体变粗(font-weight: bold)时,宽度会增加,导致整个导航栏“跳”一下。我们使用了一个巧妙的 CSS 技巧:text-shadow 模拟加粗

.navItem.active {
  color: #0084ff;
  /* 看起来像粗体,但完全不占额外空间 */
  text-shadow: 0 0 .25px currentcolor; 
}

🎨 样式完全定制

组件提供了丰富的样式接口,你可以把它变成任何你想要的样子:

<Anchor
  // 定制 Tab 栏背景和边框
  tabBarStyle={{ background: '#f9f9f9', borderBottom: '1px solid #eee' }}
  // 定制 Tab 项间距和字体
  tabItemStyle={{ padding: '10px 20px', fontSize: 16 }}
  // 定制激活状态 (比如变成胶囊按钮)
  activeTabStyle={{ background: '#e6f7ff', color: '#0084ff', borderRadius: 20 }}
  // 隐藏默认下划线
  lineStyle={{ display: 'none' }}
/>

6. 完整代码结构+样式

import React, { useEffect, useRef, useState, useMemo } from "react";
import styles from "./index.module.scss";
import classNames from "classnames";

/**
 * 通用 Anchor 锚点导航组件
 *
 * @props {React.ReactElement[]} items - 必填,内容组件数组。每个组件必须包含 `title` 属性(如 `<div title="推荐">...</div>`),用于生成导航 Tab。
 * @props {React.ReactNode} [header] - 可选,顶部不需要吸顶的内容(如 Banner)。
 * @props {string} [className] - 可选,最外层容器类名。
 * @props {React.CSSProperties} [style] - 可选,最外层容器样式,通常需要设置 `height` (如 `100vh` 或固定高度)。
 * @props {number} [offsetTop=0] - 可选,吸顶距离,默认为 0。
 * @props {string} [tabBarClassName] - 可选,导航条容器类名。
 * @props {string} [tabItemClassName] - 可选,Tab 项类名。
 * @props {string} [activeTabClassName] - 可选,激活状态 Tab 项类名。
 * @props {string} [lineClassName] - 可选,下划线类名。
 * @props {React.CSSProperties} [tabBarStyle] - 可选,导航条容器内联样式。
 * @props {React.CSSProperties} [tabItemStyle] - 可选,Tab 项内联样式。
 * @props {React.CSSProperties} [activeTabStyle] - 可选,激活状态 Tab 项内联样式。
 * @props {React.CSSProperties} [lineStyle] - 可选,下划线内联样式。
 * @props {number | string} [gap] - 可选,Tab 之间的间距。
 *
 * 使用方式:
 * 直接传递 React Element 数组作为 items,组件会自动提取元素上的 title 属性作为 Tab 标题。
 *
 * 示例:
 * ```tsx
 * <Anchor
 *   items={[
 *     <div title="推荐">推荐内容...</div>,
 *     <div title="热点">热点内容...</div>
 *   ]}
 *   header={<div>顶部 Banner</div>}
 *   style={{ height: '100vh' }}
 *   tabItemStyle={{ fontSize: 16 }}
 *   activeTabStyle={{ color: 'red' }}
 * />
 * ```
 */

// 内部使用的 Item 结构
interface AnchorItem {
  key: string;
  title: React.ReactNode;
  content: React.ReactNode;
}

interface Props {
  // 接收 React Element 数组,要求每个 Element 有 title 属性,用于生成导航 Tab。
  items: React.ReactElement<{ title?: React.ReactNode }>[];
  header?: React.ReactNode; // 顶部不需要吸顶的内容
  className?: string; // 最外层容器类名
  style?: React.CSSProperties; // 最外层容器样式,通常需要设置 height
  offsetTop?: number; // 吸顶距离,默认 0
  // 自定义样式配置
  tabBarClassName?: string; // 导航条容器类名
  tabItemClassName?: string; // Tab 项类名
  activeTabClassName?: string; // 激活状态 Tab 项类名
  lineClassName?: string; // 下划线类名
  tabBarStyle?: React.CSSProperties; // 导航条容器样式
  tabItemStyle?: React.CSSProperties; // Tab 项样式
  activeTabStyle?: React.CSSProperties; // 激活状态 Tab 项样式
  lineStyle?: React.CSSProperties; // 下划线样式
  gap?: number | string; // Tab 之间的间距
}

const Anchor: React.FC<Props> = ({
  items,
  header,
  className,
  style,
  offsetTop = 0,
  gap,

  tabBarClassName,
  tabItemClassName,
  activeTabClassName,
  lineClassName,

  tabBarStyle,
  tabItemStyle,
  activeTabStyle,
  lineStyle,
}) => {
  const scrollContainerRef = useRef<HTMLDivElement>(null); // 整个组件的滚动容器
  const navRef = useRef<HTMLDivElement>(null); // 导航条容器(吸顶部分)
  const navScrollRef = useRef<HTMLDivElement>(null); // 导航条内部的水平滚动区域
  const itemsRef = useRef<Record<string, HTMLDivElement | null>>({}); // 存储所有内容区块的引用

  // 处理 items,生成内部使用的数据结构
  const anchorData = useMemo(() => {
    return items.map((child, index) => ({
      key: `anchor-item-${index}`,
      title: child.props.title || `Tab ${index + 1}`,
      content: child,
    }));
  }, [items]);

  const [activeKey, setActiveKey] = useState<string>(anchorData[0]?.key);
  const isManualScrolling = useRef(false); // 是否是手动滚动,用于防止滚动抖动
  const scrollTimerRef = useRef<NodeJS.Timeout | null>(null); // 用于保存定时器

  // Tab 自动居中逻辑
  useEffect(() => {
    if (activeKey && navScrollRef.current) {
      const index = anchorData.findIndex((item) => item.key === activeKey);
      if (index >= 0) {
        const itemNodes = navScrollRef.current.children;
        const targetNode = itemNodes[index] as HTMLElement;
        if (targetNode) {
          const navContainer = navScrollRef.current;
          const scrollLeft =
            targetNode.offsetLeft +
            targetNode.offsetWidth / 2 -
            navContainer.offsetWidth / 2;
          navContainer.scrollTo({ left: scrollLeft, behavior: "smooth" });
        }
      }
    }
  }, [activeKey, anchorData]);

  // 点击 Tab 处理
  const handleClick = (item: AnchorItem, e: React.MouseEvent) => {
    e.preventDefault();
    // 只要点击了,就强制上锁,并设置目标
    isManualScrolling.current = true;
    setActiveKey(item.key);

    const targetEl = itemsRef.current[item.key];
    const containerEl = scrollContainerRef.current;

    if (targetEl && containerEl) {
      const navHeight = navRef.current?.offsetHeight || 0;
      const relativeTop =
        targetEl.getBoundingClientRect().top -
        containerEl.getBoundingClientRect().top;
      let scrollTop =
        containerEl.scrollTop + relativeTop - offsetTop - navHeight;

      scrollTop = Math.max(0, scrollTop); // 边界检查防止滚动到负数
      containerEl.scrollTo({ top: scrollTop, behavior: "smooth" });
    }
  };

  // 监听页面滚动
  useEffect(() => {
    const container = scrollContainerRef.current;
    if (!container) return;

    const handleScroll = () => {
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      scrollTimerRef.current = setTimeout(() => {
        isManualScrolling.current = false;
        scrollTimerRef.current = null;
      }, 100);
      if (isManualScrolling.current) return;
      const navHeight = navRef.current?.offsetHeight || 0;
      // 触底判断
      const isBottom =
        container.scrollTop + container.clientHeight >=
        container.scrollHeight - 5;
      if (isBottom) {
        setActiveKey(anchorData[anchorData.length - 1]?.key);
        return;
      }

      // 基准线,当元素的顶部 滚入 到 Anchor 下方时激活
      const checkPoint =
        container.getBoundingClientRect().top + offsetTop + navHeight + 10;

      let currentKey = anchorData[0]?.key;

      for (const item of anchorData) {
        const targetEl = itemsRef.current[item.key];
        if (targetEl) {
          const targetTop = targetEl.getBoundingClientRect().top;
          if (targetTop <= checkPoint) {
            currentKey = item.key;
          } else {
            break;
          }
        }
      }
      setActiveKey(currentKey);
    };

    container.addEventListener("scroll", handleScroll);
    return () => container.removeEventListener("scroll", handleScroll);
  }, [anchorData, offsetTop]);

  return (
    <div
      className={classNames(styles.anchorContainer, className)}
      style={style}
      ref={scrollContainerRef}
    >
      {/* 顶部内容区域 */}
      {header && <div className={styles.header}>{header}</div>}

      {/* 吸顶导航条 */}
      <div
        className={classNames(styles.navWrapper, tabBarClassName)}
        style={{ top: offsetTop, ...tabBarStyle }}
        ref={navRef}
      >
        <div
          className={styles.navScrollContainer}
          ref={navScrollRef}
          style={{ gap }}
        >
          {anchorData.map((item) => {
            const isActive = activeKey === item.key;
            return (
              <div
                key={item.key}
                className={classNames(styles.navItem, tabItemClassName, {
                  [styles.active]: isActive,
                  [activeTabClassName || ""]: isActive,
                })}
                style={{
                  ...tabItemStyle,
                  ...(isActive ? activeTabStyle : {}),
                }}
                onClick={(e) => handleClick(item, e)}
              >
                {item.title}
                {isActive && (
                  <div
                    className={classNames(styles.line, lineClassName)}
                    style={lineStyle}
                  />
                )}
              </div>
            );
          })}
        </div>
      </div>

      {/* 内容区域 */}
      <div className={styles.contentWrapper}>
        {anchorData.map((item) => (
          <div
            key={item.key}
            ref={(el) => {
              itemsRef.current[item.key] = el;
            }}
          >
            {item.content}
          </div>
        ))}
      </div>
    </div>
  );
};

export default Anchor;
.anchorContainer {
  width: 100%;
  height: 100%;
  position: relative;
  overflow-y: auto; // 纵向滚动容器
  -webkit-overflow-scrolling: touch;
}

.navWrapper {
  width: 100%;
  background: #fff;
  border-bottom: 1px solid #eee;
  position: sticky; // 吸顶
}

.navScrollContainer {
  display: flex;
  overflow-x: auto; // 横向滚动
  white-space: nowrap;
  -webkit-overflow-scrolling: touch;

  &::-webkit-scrollbar {
    display: none;
  }

  padding: 0 10px;
}

.navItem {
  flex-shrink: 0;
  padding: 12px 16px;
  font-size: 15px;
  color: #666;
  position: relative;
  transition: all 0.3s;
  display: flex;
  flex-direction: column;
  align-items: center;
  cursor: pointer;

  &.active {
    color: #0084ff;
    // 使用 text-shadow 模拟加粗,防止抖动
    text-shadow: 0 0 .25px currentcolor;
  }

  .line {
    position: absolute;
    bottom: 6px;
    width: 20px;
    height: 3px;
    background: #0084ff;
    border-radius: 2px;
  }
}

📝 总结

通过封装这个 Anchor 组件,我们将复杂的滚动计算、事件监听、防抖处理、样式定制全部收敛到了内部。对于外部使用者来说,它就是一个简单的、可配置的容器组件。

这不仅提高了开发效率,保证了各处交互的一致性,也让代码变得更加整洁、易维护。

如果你也在做移动端长页面的开发,强烈建议尝试一下这种封装思路!