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

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

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

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

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


✨ 核心亮点

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

  1. 开箱即用:组件内置滚动容器,无需外部再包一层复杂的布局,把内容传进去就能跑。
  2. 智能吸顶 (Sticky):当页面向上滚动时,导航栏会自动吸附在顶部,不用担心用户找不到导航。
  3. 双向联动
    • 点击导航 -> 平滑滚动到对应内容。
    • 滚动内容 -> 自动高亮对应的导航 Tab。
  4. 高度自适应:无论内容模块是高是矮,都能精准定位,且支持"触底自动选中最后一项"的兜底逻辑。
  5. 丝滑无抖动:完美解决了 Tab 切换时的字体粗细变化导致的布局抖动问题。
  6. 极简 API :直接传递组件数组,自动提取标题,告别手动维护 idkey 的烦恼。
  7. 高度可定制:从导航栏到每一个 Tab,样式全开放配置。

🛠️ 使用体验

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

而现在,你只需要这样:

  1. 准备你的内容数组,在每个组件上标记 title
tsx 复制代码
const items = [
  <div title="推荐">这里是推荐板块的内容...</div>,
  <div title="热点">这里是热点板块的内容...</div>,
  <div title="视频">这里是视频板块的内容...</div>,
];
  1. 渲染组件
tsx 复制代码
<Anchor 
  items={items} 
  header={<div>这里可以放顶部的 Banner</div>}
  style={{ height: '100vh' }} // 设置容器高度
/>就这么简单!组件会自动生成导航栏,并处理好所有的滚动逻辑。

💡 核心技术实现

1. 极简的 Props 设计

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

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

2. 精准的滚动定位 (getBoundingClientRect)

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

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

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

3. 解决"Tab 切换抖动"

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

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

4. 智能的 ScrollSpy (滚动监听)

如何判断当前应该高亮哪个 Tab?我们不仅判断了元素位置,还加了一个触底兜底逻辑:

tsx 复制代码
// 如果滚到底了,强制选中最后一个,防止最后一个模块太矮永远无法被激活
const isBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 5;
if (isBottom) {
  setActiveKey(lastItem.key);
  return;
}

5. 防抖处理

在用户快速连续点击 Tab 时,如果仍然触发滚动监听,会导致左侧 Tab 乱跳。我们引入了一个简单的"锁"机制:

tsx 复制代码
// 点击时上锁
isManualScrolling.current = true;

// 滚动结束后解锁 (使用 setTimeout 防抖)
clearTimeout(timer);
timer = setTimeout(() => {
  isManualScrolling.current = false;
}, 600);---

🎨 样式完全定制

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

tsx 复制代码
<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' }}
  // ...
/>---

5. 完整代码结构+样式

tsx 复制代码
import React, { useEffect, useRef, useState, useMemo } from 'react';
import styles from './index.module.scss';
import classNames from 'classnames';

/**
 * 通用 Anchor 锚点导航组件
 *
 * 功能特点:
 * 1. 自带滚动容器,无需外部包裹,开箱即用。
 * 2. 支持吸顶导航(Sticky),可配置吸顶偏移量。
 * 3. 自动根据滚动位置高亮对应的 Tab。
 * 4. 点击 Tab 平滑滚动到对应内容区域,自动修正吸顶高度遮挡。
 * 5. 解决 Tab 切换抖动问题(使用 text-shadow 模拟加粗)。
 * 6. 支持连续快速点击的防抖处理。
 * 7. 支持高度不一致的内容模块,支持触底自动选中最后一项。
 *
 * 使用方式:
 * 直接传递 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 属性
  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;     // 下划线样式
}

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

  tabBarClassName,
  tabItemClassName,
  activeTabClassName,
  lineClassName,

  tabBarStyle,
  tabItemStyle,
  activeTabStyle,
  lineStyle
}) => {
  const scrollContainerRef = useRef<HTMLDivElement>(null); // 整个组件的滚动容器
  const navRef = useRef<HTMLDivElement>(null); // 导航条容器(吸顶部分)
  const navScrollRef = useRef<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();

    // 清除之前的定时器,防止过早释放锁
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }

    setActiveKey(item.key);
    isManualScrolling.current = true;

    const targetEl = document.getElementById(item.key);
    const containerEl = scrollContainerRef.current;

    if (targetEl && containerEl) {
         const navHeight = navRef.current?.offsetHeight || 0;

         // 目标位置 = 当前scrollTop + (目标相对于视口的top - 容器相对于视口的top) - (吸顶距离 + 导航高度)
         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' });

         // 重新设置定时器,确保锁住直到本次滚动大致结束
         scrollTimerRef.current = setTimeout(() => {
           isManualScrolling.current = false;
           scrollTimerRef.current = null;
         }, 600);
    }
  };

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

    const handleScroll = () => {
      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 = document.getElementById(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}>
            {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} id={item.key}>
             {item.content}
          </div>
        ))}
      </div>
    </div>
  );
};

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

.header {
  // 顶部区域样式,如果需要
}

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

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

.contentWrapper {
  // 内容区域样式
}

📝 总结

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

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

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

相关推荐
遇见~未来3 小时前
前端原生能力速查笔记(HTML + 浏览器 API 实战篇)
前端
博客zhu虎康3 小时前
Vue全局挂载Element消息组件技巧
前端·javascript·vue.js
尼罗河女娲3 小时前
【测试开发】为什么 UI 自动化总是看起来不稳定?为什么需要引入SessionDirty flag?
开发语言·前端·javascript
Alair‎3 小时前
200React-Query基础
前端·react.js·前端框架
Alair‎3 小时前
201React-Query:useQuery基本使用
前端·react.js
神秘的猪头3 小时前
# Vue项目初识:从零开始搭建你的第一个现代前端工程化Vue3项目
前端·vue.js·面试
fe小陈3 小时前
React 奇技淫巧——内联hook
前端·react.js
前端西瓜哥3 小时前
Suika图形编辑器的文字支持手动换行了
前端
Можно3 小时前
ES6 Map 全面解析:从基础到实战的进阶指南
前端·javascript·html