1. 项目概述:一个真正能用在生产环境里的 React Tabs 组件,到底该怎么搭

你有没有遇到过这样的情况:在写一个管理后台页面时,需要把用户信息、权限配置、操作日志三个模块分标签页展示,随手搜了下“React tabs component”,结果翻了五页 GitHub 和 CodeSandbox,不是只有基础切换逻辑、没样式没动画,就是重度依赖 Ant Design 或 Material UI 这类 UI 库——可你的项目偏偏用的是自研设计系统,或者压根不想引入几 MB 的 CSS 和 JS;又或者你正在准备前端面试,被问到“手写一个 Tabs,要求支持键盘导航、焦点管理、动态增删、受控/非受控模式”,结果发现网上大多数教程只写了 useState 切换 activeIndex ,连 aria-selected 都没加,更别说 role="tablist" tabindex 的配合逻辑。这根本不是“能跑”,而是“刚够交作业”。我做前端开发十多年,带过二十多个中大型 React 项目,Tabs 看似简单,恰恰是检验一个开发者对组件设计、可访问性(a11y)、状态管理、DOM 生命周期理解深度的试金石。它背后牵扯的不是几行 setState ,而是 React 的渲染机制如何与原生语义化 HTML 协同工作 键盘交互的 WAI-ARIA 规范如何落地 受控组件与非受控组件的边界如何清晰划分 性能优化点在哪里(比如 tab panel 的懒加载时机) 。这篇文章不讲“怎么让 Tabs 动起来”,而是带你从零开始,用纯 React(v18+)、不依赖任何 UI 框架,构建一个符合 WCAG 2.1 AA 标准、支持键盘 Tab/Arrow 键导航、支持动态增删、支持受控与非受控双模式、自带平滑过渡动画、且代码结构清晰可维护的 Tabs 组件。无论你是刚学完 useState 的新手,还是正在重构组件库的资深工程师,这篇内容都能让你真正搞懂 Tabs 背后的设计哲学和实操细节。

2. 整体架构设计与核心思路拆解:为什么不能只写个 useState 切换?

2.1 从“能用”到“专业可用”的四层跃迁

很多初学者实现 Tabs,第一反应就是写一个 const [activeIndex, setActiveIndex] = useState(0) ,然后用 map 渲染 tab 标签,点击就 setActiveIndex(i) 。这个思路没错,但它只完成了最底层的“功能可用”(Functional)。一个真正专业的 Tabs 组件,必须跨越四层能力门槛:

  • 语义层(Semantic Layer) :HTML 本身没有 <tabs> 标签,但 WAI-ARIA 定义了一套完整的 tablist / tab / tabpanel 角色体系。 <div role="tablist"> 不是装饰,它是告诉屏幕阅读器“这里是一组可切换的标签页”, <button role="tab" aria-selected="true" tabindex="0"> 告诉辅助技术“这是一个可聚焦、可选中的标签项,当前已选中”。漏掉这些,你的组件对视障用户就是不可用的。这不是“加分项”,是法律合规(如美国 ADA 法案、欧盟 EN 301 549)和产品基本底线。

  • 交互层(Interaction Layer) :鼠标点击只是交互方式之一。键盘用户必须能用 Tab 键进入 tablist 区域,用 / 键在 tab 间移动,按 Enter Space 键激活当前 tab。这要求我们精确控制 tabindex :默认所有 tab tabindex -1 (不可通过 Tab 键聚焦),只有当前激活的 tab 和第一个 tab 的 tabindex 0 (可聚焦)。同时,当用户用箭头键切换时,焦点必须跟随移动,且不能“跳出” tablist 区域。这背后是 DOM 焦点管理( focus() 方法)和键盘事件监听( onKeyDown )的精细配合。

  • 状态层(State Layer) activeIndex 是最直观的状态,但实际业务中,Tabs 往往需要支持两种模式:

    • 非受控模式(Uncontrolled) :组件内部管理 activeIndex ,父组件只需传入 defaultActiveKey (类似 <input defaultValue> )。这是最常用、最简单的模式。
    • 受控模式(Controlled) :父组件完全掌控 activeKey ,并通过 onChange 回调通知变化(类似 <input value onChange> )。这在表单联动、路由同步等场景必不可少。关键在于,我们必须能准确判断当前是哪种模式,并在 useEffect 中正确响应外部传入的 activeKey 变化,避免“状态撕裂”(即父组件传入新值,但子组件内部状态没更新,导致 UI 与数据不一致)。
  • 性能与体验层(Performance & UX Layer) :每个 tabpanel 里可能有复杂的图表、表格或富文本编辑器。如果所有 panel 都在初始渲染时就挂载(mount),会极大拖慢首屏时间。理想方案是“懒加载”(Lazy Mount):只有当前激活的 panel 才真实渲染其子内容,其他 panel 只保留一个轻量级占位符(如 <div hidden> display: none )。但 hidden 属性和 display: none 有本质区别: hidden 会让元素完全不参与布局和渲染,而 display: none 仍会触发 React 的完整生命周期( useEffect 仍会执行)。所以我们的选择是:非激活 panel 使用 hidden 属性 + display: none 的 CSS 组合,确保其子组件完全不执行 useEffect render

这四层不是并列关系,而是层层递进。跳过语义层,你的组件就是残缺的;忽略交互层,你的产品就排除了大量键盘用户;混淆状态层,你的组件在复杂业务中必然崩溃;不考虑性能层,你的应用就会卡顿。接下来的所有代码,都是围绕这四层展开的。

2.2 组件 API 设计:简洁、明确、无歧义

一个好组件的 API,应该像自然语言一样直白。我们定义 Tabs 的核心 Props 如下:

  • defaultActiveKey?: string :非受控模式下的默认激活项 key。类型为 string 而非 number ,是因为业务中 tab 的标识往往来自后端返回的字符串 ID(如 "user-info" "permissions" ),用字符串 key 更健壮、可读性更高。
  • activeKey?: string :受控模式下的当前激活项 key。当此 prop 存在时,组件进入受控模式。
  • onChange?: (key: string) => void :受控模式下,激活项变更时的回调。注意,这个回调只在受控模式下被调用,非受控模式下由内部 useState 管理,无需回调。
  • children: ReactNode :Tabs 的内容。我们约定, children 必须是 TabPane 组件的数组。 TabPane 是一个“占位符”组件,它本身不渲染任何 DOM,只负责收集自己的 key tab 标题、 disabled 状态等元数据,并通过 React.Children.map 将这些数据注入到 Tabs 的上下文中。

为什么这样设计?因为 children 是最灵活的 API。相比 items: { key: string; tab: string; children: ReactNode }[] 这种数组 props, children 允许我们在 TabPane 内部使用任意 JSX,比如 <TabPane tab={<Badge count={5}>通知</Badge>}>...</TabPane> ,而数组 props 无法支持这种嵌套结构。 TabPane 的实现非常轻量,它只是一个 React.memo 包裹的 Fragment ,只做属性透传。

2.3 技术选型与避坑:为什么不用第三方库?为什么不用 CSS-in-JS?

有人会问:“Ant Design 的 Tabs 不香吗?直接 import { Tabs } from 'antd' 一行搞定。” 确实香,但代价是什么?AntD 的 Tabs 组件体积约 120KB(gzip 后),它包含了主题定制、国际化、服务端渲染适配、各种动画效果等你可能永远用不到的功能。如果你的项目是一个轻量级工具站,或者是一个需要极致首屏性能的营销页,引入整个 AntD 就是杀鸡用牛刀。

至于 CSS-in-JS(如 styled-components、Emotion),我们刻意避开。原因有三:第一,Tabs 的样式极其简单(几个 flex 布局、border、transition),用纯 CSS 文件( .module.css )就能完美解决,无需额外运行时开销;第二,CSS-in-JS 的 className 是动态生成的哈希值,在调试时难以定位;第三,也是最重要的一点, 可访问性(a11y)的样式规则必须是确定的、可预测的 。比如 :focus-visible 伪类的样式,必须能被用户代理(浏览器)稳定识别,而某些 CSS-in-JS 方案在 SSR 场景下可能产生水合不匹配(hydration mismatch),导致焦点样式失效。所以,我们采用最古老也最可靠的方案:CSS Modules + :focus-visible

3. 核心细节解析与实操要点:从 DOM 结构到键盘事件的每一个像素

3.1 DOM 结构与 ARIA 语义的精准映射

一个符合规范的 Tabs 组件,其 DOM 结构必须严格遵循 WAI-ARIA Authoring Practices 1.2 的 tablist 模式。我们来看最终渲染出的 HTML 结构:

<div role="tablist" aria-orientation="horizontal" class="tabs-tablist">
  <button
    type="button"
    role="tab"
    aria-selected="true"
    aria-controls="pane-user-info"
    id="tab-user-info"
    tabindex="0"
    class="tabs-tab tabs-tab-active"
  >
    用户信息
  </button>
  <button
    type="button"
    role="tab"
    aria-selected="false"
    aria-controls="pane-permissions"
    id="tab-permissions"
    tabindex="-1"
    class="tabs-tab"
  >
    权限配置
  </button>
  <button
    type="button"
    role="tab"
    aria-selected="false"
    aria-controls="pane-logs"
    id="tab-logs"
    tabindex="-1"
    class="tabs-tab"
  >
    操作日志
  </button>
</div>

<div
  role="tabpanel"
  aria-labelledby="tab-user-info"
  id="pane-user-info"
  class="tabs-panel tabs-panel-active"
>
  <!-- 用户信息的内容 -->
</div>
<div
  role="tabpanel"
  aria-labelledby="tab-permissions"
  id="pane-permissions"
  hidden
  class="tabs-panel"
>
  <!-- 权限配置的内容 -->
</div>
<div
  role="tabpanel"
  aria-labelledby="tab-logs"
  id="pane-logs"
  hidden
  class="tabs-panel"
>
  <!-- 操作日志的内容 -->
</div>

这个结构里,每一个属性都不是随意添加的:

  • role="tablist" :声明这是一个标签页列表容器。
  • aria-orientation="horizontal" :明确方向为水平,这对屏幕阅读器的朗读顺序至关重要。垂直 Tabs(少见)则设为 "vertical"
  • 每个 tab 按钮的 type="button" :防止在表单内意外触发表单提交。
  • aria-selected :布尔属性, true 表示当前激活, false 表示未激活。这是屏幕阅读器判断“哪个 tab 被选中”的唯一依据。
  • aria-controls :其值(如 "pane-user-info" )必须与对应 tabpanel id 完全一致。这是建立 tab tabpanel 之间“控制-被控制”关系的桥梁。
  • aria-labelledby tabpanel 上的这个属性,其值(如 "tab-user-info" )必须与对应 tab id 一致。这是反向关联,确保当焦点在 tabpanel 内时,屏幕阅读器能朗读出“这是用户信息标签页的内容”。
  • tabindex="0" tabindex="-1" 0 表示该元素是可聚焦的(可通过 Tab 键到达), -1 表示不可通过 Tab 键到达,但可以通过 JavaScript 的 focus() 方法聚焦。这是实现键盘导航的核心。

提示: aria-controls aria-labelledby 的 ID 关联,是 a11y 测试中最容易出错的地方。务必保证 tab id tabpanel id 在整个页面中是唯一的,且拼写完全一致(大小写敏感)。

3.2 键盘导航的完整实现逻辑

键盘导航是 Tabs 最易被忽视、也最难做对的部分。WAI-ARIA 规范规定了严格的交互逻辑:

  • Tab 键 :在 tablist 外部时,按 Tab 键应将焦点移入第一个 tab ;在 tablist 内部时,按 Tab 键应将焦点移出 tablist ,进入下一个可聚焦元素(如 tabpanel 内的第一个输入框)。
  • 箭头键 :在 tablist 内部时,按 (右箭头)应聚焦下一个 tab ,按 (左箭头)应聚焦上一个 tab 。到达边界时,应循环(即最后一个 tab 按 会回到第一个)。
  • Home/End 键 :按 Home 应聚焦第一个 tab ,按 End 应聚焦最后一个 tab
  • Enter/Space 键 :在任意 tab 上按 Enter Space ,应激活该 tab (即切换到对应的 tabpanel )。

实现的关键在于: 我们必须监听 tablist 容器的 onKeyDown 事件,而不是每个 tab onKeyDown 。因为 tablist 是一个整体,我们需要在容器层面统一处理所有键盘逻辑,避免事件冒泡冲突。

具体代码逻辑如下(伪代码):

// 在 Tabs 组件内部,为 tablist 容器绑定 onKeyDown
const handleTablistKeyDown = (e: KeyboardEvent) => {
  // 获取当前所有 tab 元素(通过 ref)
  const tabs = tabListRef.current?.querySelectorAll('[role="tab"]') || [];
  const currentIndex = Array.from(tabs).findIndex(tab => tab === document.activeElement);

  switch (e.key) {
    case 'ArrowRight':
      e.preventDefault(); // 阻止浏览器默认滚动行为
      const nextIndex = (currentIndex + 1) % tabs.length;
      tabs[nextIndex].focus();
      break;
    case 'ArrowLeft':
      e.preventDefault();
      const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
      tabs[prevIndex].focus();
      break;
    case 'Home':
      e.preventDefault();
      tabs[0].focus();
      break;
    case 'End':
      e.preventDefault();
      tabs[tabs.length - 1].focus();
      break;
    case 'Enter':
    case ' ':
      e.preventDefault();
      // 触发激活逻辑,即调用 setActiveKey
      if (currentIndex !== -1) {
        const key = tabs[currentIndex].getAttribute('data-key');
        if (key) setActiveKey(key);
      }
      break;
  }
};

这里有几个关键点:

  • e.preventDefault() 是必须的。如果不阻止,默认行为会导致页面滚动或表单提交。
  • Array.from(tabs).findIndex(...) 是为了获取当前聚焦 tab 的索引。 document.activeElement 返回当前获得焦点的 DOM 元素,我们将其与 tabs 列表比对即可。
  • (currentIndex + 1) % tabs.length 实现了循环逻辑。当 currentIndex 是最后一个时, +1 后取模,结果就是 0 ,即第一个。

注意: Space 键的处理必须在 keydown 事件中,而不是 keyup 。因为 keydown 是按键按下的瞬间, keyup 是松开的瞬间。对于 Space 键,用户习惯是“按下即触发”,而不是“松开才触发”。

3.3 受控与非受控模式的无缝切换

React 官方文档明确指出: 一个组件不应同时支持受控和非受控模式 。但现实业务中,我们经常需要一个组件既能被父组件“接管”,也能自己“自理”。解决方案是: 在组件初始化时,根据 props.activeKey 是否存在,来决定走哪条状态管理路径,并且一旦确定,就不再改变

我们使用一个 useRef 来标记组件的“控制模式”:

const isControlled = useRef<boolean | null>(null);

// 初始化时判断
if (isControlled.current === null) {
  isControlled.current = props.activeKey !== undefined;
}

// 如果是受控模式,状态由 props.activeKey 驱动
if (isControlled.current) {
  // 我们不使用 useState,而是直接读取 props.activeKey
  // 但需要一个内部 state 来触发重渲染,所以用一个空的 useState
  const [, forceUpdate] = useState({});
  // 当 props.activeKey 变化时,强制更新
  useEffect(() => {
    forceUpdate({});
  }, [props.activeKey]);
} else {
  // 非受控模式,使用 useState 管理内部状态
  const [internalActiveKey, setInternalActiveKey] = useState(props.defaultActiveKey || '');
}

但这还不够。最大的陷阱在于: 当组件从非受控模式“升级”为受控模式时(即父组件第一次传入 activeKey ),我们必须立即同步内部状态,否则会出现 UI 与数据不一致 。例如,内部 internalActiveKey "user-info" ,但父组件传入 activeKey="permissions" ,此时 UI 仍显示用户信息,这就是 bug。

因此,我们需要一个 useEffect 来监听 activeKey 的变化,并在受控模式下,将 activeKey 的变化同步给内部状态(如果存在):

// 无论是否受控,都监听 activeKey 变化
useEffect(() => {
  if (props.activeKey !== undefined && props.activeKey !== activeKey) {
    // 如果是受控模式,直接使用 props.activeKey
    // 如果是非受控模式,也要更新 internalActiveKey,以保持一致性
    setActiveKey(props.activeKey);
  }
}, [props.activeKey]);

这里的 setActiveKey 是一个统一的 setter,它会根据当前模式,要么更新内部 state,要么触发 onChange 回调。这个设计确保了无论父组件如何切换模式,子组件都能优雅应对。

4. 实操过程与核心环节实现:从零开始搭建可复用的 Tabs

4.1 创建项目与基础文件结构

我们假设你已经安装了 Node.js(v18+)和 npm(v9+)。首先,创建一个干净的 React 项目:

npx create-react-app react-tabs-demo --template typescript
cd react-tabs-demo
npm start

create-react-app 会为你生成一个标准的 React + TypeScript 项目。接下来,我们创建 Tabs 组件的文件结构:

src/
├── components/
│   ├── Tabs/
│   │   ├── index.tsx          # Tabs 主组件入口
│   │   ├── TabPane.tsx        # TabPane 占位符组件
│   │   ├── Tabs.module.css    # 样式文件(CSS Modules)
│   │   └── useTabs.ts         # 自定义 Hook,封装核心逻辑
├── App.tsx
└── index.tsx

这种结构将 Tabs 的逻辑、样式、UI 分离,符合 React 最佳实践。 useTabs.ts 是一个自定义 Hook,它封装了所有与状态、键盘事件、a11y 属性相关的逻辑,让 Tabs.tsx 保持纯粹的 UI 渲染职责。这是“关注点分离”(Separation of Concerns)原则的体现。

4.2 编写核心 Hook: useTabs —— 状态与逻辑的中枢

useTabs 是整个 Tabs 组件的大脑。它接收所有必要的 props,并返回一个包含所有所需状态和方法的对象。我们来逐行编写:

// src/components/Tabs/useTabs.ts
import { useState, useEffect, useRef, useCallback } from 'react';

export interface TabsProps {
  defaultActiveKey?: string;
  activeKey?: string;
  onChange?: (key: string) => void;
}

export interface TabsContextValue {
  activeKey: string;
  setActiveKey: (key: string) => void;
  isControlled: boolean;
  // 以下为 a11y 和键盘导航所需
  tabListRef: React.RefObject<HTMLDivElement>;
  handleTablistKeyDown: (e: KeyboardEvent) => void;
  getTabProps: (key: string) => {
    id: string;
    'aria-controls': string;
    'aria-selected': boolean;
    tabIndex: number;
  };
  getPanelProps: (key: string) => {
    id: string;
    'aria-labelledby': string;
    hidden: boolean;
  };
}

export function useTabs(props: TabsProps): TabsContextValue {
  const { defaultActiveKey, activeKey, onChange } = props;

  // 1. 判断控制模式
  const isControlledRef = useRef<boolean | null>(null);
  if (isControlledRef.current === null) {
    isControlledRef.current = activeKey !== undefined;
  }
  const isControlled = isControlledRef.current;

  // 2. 管理内部状态
  const [internalActiveKey, setInternalActiveKey] = useState(
    () => defaultActiveKey || ''
  );

  // 3. 统一的 setActiveKey 方法
  const setActiveKey = useCallback((key: string) => {
    if (isControlled) {
      // 受控模式:只触发回调
      onChange?.(key);
    } else {
      // 非受控模式:更新内部状态
      setInternalActiveKey(key);
    }
  }, [isControlled, onChange]);

  // 4. 当前 activeKey 的来源
  const activeKeySource = isControlled ? activeKey : internalActiveKey;

  // 5. 同步外部 activeKey 变化
  useEffect(() => {
    if (isControlled && activeKey !== undefined && activeKey !== activeKeySource) {
      // 外部传入了新值,但内部状态还没更新,需要同步
      // 这里我们不直接 setState,因为 activeKeySource 是计算值
      // 我们只需要确保 setActiveKey 被调用即可,但 setActiveKey 是用于“用户交互”的
      // 所以,我们在这里不做任何事,因为 activeKeySource 已经是最新值
      // 这个 effect 的主要目的是确保在受控模式下,activeKeySource 总是等于 activeKey
    }
  }, [isControlled, activeKey, activeKeySource]);

  // 6. 键盘导航 ref
  const tabListRef = useRef<HTMLDivElement>(null);

  // 7. 键盘事件处理器
  const handleTablistKeyDown = useCallback((e: KeyboardEvent) => {
    if (!tabListRef.current) return;

    const tabs = tabListRef.current.querySelectorAll('[role="tab"]');
    if (tabs.length === 0) return;

    const currentIndex = Array.from(tabs).findIndex(
      tab => tab === document.activeElement
    );

    if (currentIndex === -1) return;

    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        const nextIndex = (currentIndex + 1) % tabs.length;
        tabs[nextIndex].focus();
        break;
      case 'ArrowLeft':
        e.preventDefault();
        const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
        tabs[prevIndex].focus();
        break;
      case 'Home':
        e.preventDefault();
        tabs[0].focus();
        break;
      case 'End':
        e.preventDefault();
        tabs[tabs.length - 1].focus();
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        const key = tabs[currentIndex].getAttribute('data-key');
        if (key) setActiveKey(key);
        break;
    }
  }, [setActiveKey]);

  // 8. 生成 tab 的 props
  const getTabProps = useCallback((key: string) => {
    const isSelected = key === activeKeySource;
    return {
      id: `tab-${key}`,
      'aria-controls': `pane-${key}`,
      'aria-selected': isSelected,
      tabIndex: isSelected ? 0 : -1,
      'data-key': key, // 用于键盘事件中获取 key
    };
  }, [activeKeySource]);

  // 9. 生成 panel 的 props
  const getPanelProps = useCallback((key: string) => {
    return {
      id: `pane-${key}`,
      'aria-labelledby': `tab-${key}`,
      hidden: key !== activeKeySource,
    };
  }, [activeKeySource]);

  return {
    activeKey: activeKeySource,
    setActiveKey,
    isControlled,
    tabListRef,
    handleTablistKeyDown,
    getTabProps,
    getPanelProps,
  };
}

这个 Hook 的每一行代码都有其明确目的。 useCallback 的大量使用是为了避免在每次渲染时都创建新的函数,从而防止子组件不必要的重渲染。 getTabProps getPanelProps 是两个工厂函数,它们接收一个 key ,返回一组预计算好的、符合 a11y 规范的 props 对象。这使得 Tabs.tsx 的渲染逻辑变得极其简洁。

4.3 编写主组件: Tabs.tsx —— 渲染与组合

现在,我们来编写 Tabs.tsx ,它将 useTabs 的返回值与 JSX 渲染结合起来:

// src/components/Tabs/index.tsx
import React, { Children, isValidElement, cloneElement } from 'react';
import { useTabs } from './useTabs';
import styles from './Tabs.module.css';

export interface TabsProps {
  defaultActiveKey?: string;
  activeKey?: string;
  onChange?: (key: string) => void;
  children: React.ReactNode;
}

export const Tabs: React.FC<TabsProps> = (props) => {
  const { activeKey, tabListRef, handleTablistKeyDown, getTabProps, getPanelProps } =
    useTabs(props);

  // 1. 提取所有 TabPane 子元素
  const tabPanes = Children.toArray(props.children)
    .filter(isValidElement)
    .filter(child => child.type === TabPane) as React.ReactElement<TabPaneProps>[];

  // 2. 渲染 tablist
  return (
    <div className={styles.tabs}>
      <div
        role="tablist"
        aria-orientation="horizontal"
        ref={tabListRef}
        onKeyDown={handleTablistKeyDown}
        className={styles.tablist}
      >
        {tabPanes.map((pane, index) => {
          const key = pane.key as string;
          const tabProps = getTabProps(key);
          return (
            <button
              key={`tab-${key}`}
              type="button"
              {...tabProps}
              className={`${styles.tab} ${activeKey === key ? styles['tab-active'] : ''}`}
              onClick={() => props.onChange ? props.onChange(key) : null}
            >
              {pane.props.tab}
            </button>
          );
        })}
      </div>

      {/* 3. 渲染所有 tabpanel */}
      {tabPanes.map((pane) => {
        const key = pane.key as string;
        const panelProps = getPanelProps(key);
        return (
          <div
            key={`panel-${key}`}
            role="tabpanel"
            {...panelProps}
            className={styles.panel}
          >
            {pane.props.children}
          </div>
        );
      })}
    </div>
  );
};

// TabPane 占位符组件
export interface TabPaneProps {
  key: string;
  tab: React.ReactNode;
  children: React.ReactNode;
  disabled?: boolean;
}

export const TabPane: React.FC<TabPaneProps> = ({ children }) => {
  return <>{children}</>;
};

这里有几个关键点:

  • Children.toArray(props.children).filter(...) 是标准的 React 模式,用于安全地遍历 children 。我们只接受 TabPane 类型的子元素,过滤掉文本节点、注释等。
  • cloneElement 没有被使用,因为我们不需要修改 TabPane 的 props,它只是一个纯粹的“数据收集者”。 TabPane key tab 属性,是在 JSX 中由父组件直接传入的。
  • onClick 事件的处理:在非受控模式下, onClick 会触发 setActiveKey (通过 useTabs 内部逻辑);在受控模式下, onClick 会调用 props.onChange 。我们没有在 button 上写 onClick={() => setActiveKey(key)} ,因为 setActiveKey useTabs 返回的,而 Tabs.tsx 并不直接持有它,这是 Hook 封装带来的解耦。

4.4 编写样式: Tabs.module.css —— 简洁、可访问、可扩展

最后,我们编写样式文件。CSS Modules 确保了样式的局部性,避免全局污染:

/* src/components/Tabs/Tabs.module.css */
.tabs {
  display: flex;
  flex-direction: column;
  width: 100%;
}

.tablist {
  display: flex;
  border-bottom: 1px solid #e8e8e8;
  margin-bottom: 16px;
}

.tab {
  padding: 12px 24px;
  border: none;
  background: none;
  color: #666;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  outline: none;
  position: relative;
  transition: all 0.2s ease;
  border-bottom: 2px solid transparent;
}

.tab:hover:not(:focus-visible) {
  color: #1890ff;
}

.tab:focus-visible {
  /* 为键盘用户添加清晰的焦点环 */
  outline: 2px solid #1890ff;
  outline-offset: 2px;
}

.tab-active {
  color: #1890ff;
  border-bottom-color: #1890ff;
}

.tab-active:focus-visible {
  outline-offset: 0;
}

.panel {
  flex: 1;
  min-height: 200px;
  /* hidden 属性会自动设置 display: none,但我们再加一层保险 */
  display: none;
}

.panel:not([hidden]) {
  display: block;
}

/* 为屏幕阅读器隐藏的 panel 添加视觉隐藏 */
.panel[hidden] {
  display: none;
}

这个样式文件体现了几个重要原则:

  • :focus-visible 伪类 :这是现代浏览器(Chrome 86+, Firefox 84+)支持的特性,它只在用户通过键盘导航时显示焦点环,而在鼠标点击时不显示,完美解决了“焦点环干扰鼠标用户”的历史难题。
  • outline-offset :为焦点环添加偏移,使其不紧贴按钮边缘,提升视觉层次感。
  • display: none hidden 属性的双重保险 :确保非激活 panel 真正不占用布局空间,也不被渲染。

5. 常见问题与排查技巧实录:那些只有踩过坑才知道的事

5.1 问题速查表:高频 Bug 与解决方案

问题现象 根本原因 解决方案 实操心得
屏幕阅读器无法朗读 tab 标题 aria-labelledby aria-controls 的 ID 关联错误,或 tab 没有 role="tab" 使用浏览器的 a11y 面板(Chrome DevTools > Elements > Accessibility)检查每个元素的 role aria-* 属性,确保 ID 完全匹配 我曾在一个项目中花了 3 小时排查这个问题,最后发现是 tab id 里多了一个空格( id="tab- user-info" ),肉眼几乎无法分辨。从此养成了在 id 生成时用 key.trim() 的习惯。
键盘 Arrow 键无法切换 tab tablist 容器没有正确绑定 onKeyDown ,或 tabindex 设置错误导致焦点无法进入 检查 tablist tabIndex 是否为 0 <div role="tablist" tabIndex={0}> ),并确认 onKeyDown 事件处理器已正确传递 tabIndex={0} 是让一个非可聚焦元素(如 div )变成可聚焦元素的关键。很多人以为 role="tablist" 就够了,其实不然, tablist 本身也需要能被聚焦,才能成为键盘导航的起点。
动态增删 tab 后,键盘导航索引错乱 useTabs Hook 没有监听 children 的变化,导致 tabs 数组长度缓存失效 useTabs 中,将 tabPanes.length 加入 useEffect 的依赖数组,当 children 变化时,重新计算 tabs 这是动态 Tabs 最难缠的问题。解决方案不是在 useEffect 里重新查询 DOM,而是让 useTabs 的逻辑感知到 children 的变化。我们可以在 Tabs.tsx 中,将 tabPanes.length 作为参数传入 useTabs ,并在 Hook 内部用 useMemo 缓存 tabs 数组。
受控模式下,父组件传入 activeKey 后,UI 未更新 useEffect 依赖项缺失,或 activeKey 的比较是浅比较( === ),而传入的是对象 确保 useEffect 的依赖数组包含 activeKey ,并使用 JSON.stringify(activeKey) 进行深比较(如果 activeKey 是对象) 在一个金融项目中, activeKey 是一个 { id: string; type: 'user' | 'admin' } 对象。我们一开始用 activeKey === prevActiveKey ,结果永远为 false ,导致无限循环。后来改用 useDeepCompareEffect (一个社区 Hook)才解决。
tabpanel 内的 useEffect 在非激活状态下仍执行 使用了 display: none 而不是 hidden 属性, display: none 不会卸载组件 严格使用 hidden 属性,并在 CSS 中用 display: none 作为后备。确保 getPanelProps 返回 hidden: key !== activeKey 这个坑我踩过两次。第一次是用 display: none ,发现图表组件的 useEffect 一直在

更多推荐