前几天处理一个内部中后台项目左侧导航从两级调成了三级产品经理又顺手提了一堆需求标签栏要支持关闭、拖拽排序、右键菜单、刷新后保留当前打开的页面。我看了下代码里依赖的组件库Tabs越看越觉得不对劲——改到后面基本是在跟组件库的API打架。干脆自己写一个。这一写不要紧发现自定义tab组件这件事远不止切换显示哪块内容那么简单里面涉及的数据驱动、受控组件设计、键盘交互、路由联动几乎把React的基础知识点串了一遍。这篇就把我实现一个基础功能完整版tab组件的过程拆开来说适合刚学完React基础、想通过一个小项目把状态管理搞明白的开发者也适合那种用组件库总觉得束手束脚、想自研一套tab来满足复杂业务场景的人。1. 为什么要把tab组件自己写一遍容易被忽略的产品细节tab组件在教程里通常是这样的一个activeIndex点谁谁亮下面内容跟着切换。看起来五分钟写完但放真实业务里根本不是那么回事。我这次接到的需求背景是一个团队协作工作台页面顶部需要有一排标签页来代表用户打开的不同功能模块类似浏览器标签栏。除了最基本的点击切换产品那边陆陆续续提了几轮细节第一轮基本切换。要求点击标签能切换内容当前选中项有高亮未选中的置灰或弱化。这部分最容易。第二轮状态保持。用户在表格页面翻到第五页切走再切回来第五页不能丢。这要求内容区域不是切走就销毁而是做缓存。第三轮交互增强。需要支持键盘方向键切换、Tab键能聚焦到标签栏、标签上有禁用态、有关闭按钮、有红点提示未读。这些都是无障碍和产品体验上的硬要求。第四轮路由联动。标签页状态要同步到URL刷新浏览器后能恢复到刚才打开的标签页同事发一个链接过来对方打开后能直接落在同一个标签页上。看到这里你应该明白了——产品说的基础功能完整版从来不只是视觉上的一排按钮。如果全部依赖组件库比如Ant Design的Tabs前两轮很轻松第三轮开始就得用more和自定义tabBarExtraContent去硬凑第四轮基本要重写。我自己试下来与其在组件库的边界上反复试探不如从零写一个可控性极强的自定义tab把行为和数据结构握在自己手里。另外从学习角度讲手写tab是一个非常理想的React练手项目。它麻雀虽小五脏俱全useState管状态、props管数据流、ReactNode做内容插槽、ref拿DOM坐标、事件监听做键盘交互、自定义Hook抽公共逻辑。你把这一个组件写明白再去看手风琴、轮播图、步骤条这类组件思路是通用的。2. 先把核心架子搭起来状态、数据与渲染结构2.1 数据结构是tab组件的灵魂很多新手写tab脑子里先想的是怎么画一排按钮其实第一件事应该是定义数据结构。我最终用的是这样一个配置项数组interface TabItemT string { key: T; label: ReactNode; children: ReactNode; disabled?: boolean; closable?: boolean; icon?: ReactNode; badge?: number | string; // 业务扩展字段任意加不会破坏组件主体逻辑 }key不是数组下标而是每个tab的唯一标识我用路由路径或者业务实体ID来填。为什么不直接用index因为一旦涉及关闭、排序、增删下标会变用下标当身份标识会导致内容区域的组件状态丢失或错乱。后面踩坑部分我会细说。label允许传ReactNode这样可以塞图标、红点、自定义样式。很多组件库的label只接受字符串产品一要求标签旁边加个小徽标就卡住了。children是标签对应的内容区域可以传任意React元素。这里要注意不要把tab的配置和tab的渲染混在一起。配置项只描述有什么标签至于当前选中谁、每个标签是否显示关闭按钮、内容是否缓存这些是组件内部状态或者另一个层面的控制逻辑拆开了代码才不会乱。2.2 受控与非受控activeKey的设计取舍tab组件最核心的状态就是当前选中哪个标签。我一开始直接在组件内部用useState存了activeKey用起来很方便const [activeKey, setActiveKey] useState(defaultActiveKey);但项目跑起来就发现问题了。在协作工作台里点击左侧导航用户管理右侧tab栏要自动切换到这个标签页用户在tab栏把某个标签关闭了左侧导航的选中项也要跟着变。这意味着tab的选中状态不是tab组件自己能决定的它要跟全页面共享的地址栏路由、导航菜单联动。所以必须把activeKey提升到父级做成受控组件。最后我的props设计是这个样子interface TabsPropsT string { items: TabItemT[]; activeKey: T; onChange: (key: T) void; // 扩展能力 onClose?: (key: T) void; defaultActiveKey?: T; type?: line | card; size?: sm | md | lg; }受控模式的核心是组件自己不修改activeKey点击标签时只调用onChange(key)由父组件决定新的选中值。如果父组件不更新传进来的activeKey那tab就永远不会切过去。这种设计最初会让人不习惯总想在组件里偷偷改一下状态但这是React社区组件设计的主流做法它保证了数据流的单向性。不过完全受控也有烦的地方——如果只是一个独立的demo页面父组件每次都要写useState和onChange太啰嗦了。我的方案是组件内部同时支持两种模式const isControlled props.activeKey ! undefined; const [innerKey, setInnerKey] useState(props.defaultActiveKey); const currentKey isControlled ? props.activeKey : innerKey; function handleTabClick(key: T) { if (!isControlled) { setInnerKey(key); } props.onChange?.(key); }这样外部想接管就传activeKey和onChange不想接管就什么都不传组件自己玩。很多组件库的Select、Tabs都是这么做的这也是React官方文档里推荐的那套受控/非受控模式。2.3 渲染结构与内容区缓存渲染结构其实很直白上面是标签列表下面是对应内容区。但如果内容区直接这样写{items.map(item ( div hidden{item.key ! currentKey}{item.children}/div ))}问题就来了切走的tab虽然用hidden隐藏了但React还是会把所有children渲染出来。如果某个tab的内容区包含一个数据请求页面一加载所有tab的前端代码都会执行这通常不是我们想要的。所以基础版的内容区有两派做法懒渲染只渲染当前激活的tab内容切走就卸载。有状态保持需求时不适用。缓存渲染首次激活某个tab时把它渲染出来之后切走只隐藏不卸载。这是中后台场景的主流做法。缓存渲染的关键是不要把内容区写成{activeKey item.key ...}那样切换走组件直接被卸载。我用的是渲染所有CSS控制显隐的下标方案div className{tabs-content ${item.key currentKey ? active : }} {item.children} /div然后CSS里.tabs-content-panel { display: none; } .tabs-content-panel.active { display: block; }这样每个children在第一次render后就一直保留在DOM中子组件内部的useState、滚动位置、输入框内容都在。代价是首屏会一次性渲染所有tab的内容如果内容量特别大这个成本要掂量一下。更进阶的做法是用一个visitedKeys集合记录哪些tab已经被激活过只缓渲染过的那些tab第一次切换时才挂载对应内容。代码大概是const visitedRef useRef(new SetT([currentKey])); // 点击新tab时把key加入visitedRef const renderItems items.filter(item visitedRef.current.has(item.key));这套轻量级缓存逻辑对中后台项目足够了比引入keep-alive那套方案轻得多。3. 标签栏的交互细节键盘、焦点、图标和禁用态3.1 键盘可达性不是加分项是必备项tab组件在无障碍里是一个经典的tablist模式。如果只支持鼠标点击键盘用户和读屏用户根本没法用。我当时是在自查清单里发现这一条的——几个主要组件库全部实现了键盘操作没理由自己写的组件砍掉这个能力。基础的键盘交互包含Tab键聚焦到标签列表后用左右方向键横向tab或上下方向键纵向tab在标签间移动Home键跳到第一个标签End键跳到最后一个标签焦点所在标签自动变成激活标签无需再按回车实现的时候要注意一个细节左右方向键的焦点移动和选中切换是同时发生的而且需要避免让浏览器默认滚动。一个最小实现是这样的function handleKeyDown(e: React.KeyboardEvent, index: number) { let nextIndex index; if (e.key ArrowRight) { nextIndex (index 1) % items.length; } else if (e.key ArrowLeft) { nextIndex (index - 1 items.length) % items.length; } else if (e.key Home) { nextIndex 0; } else if (e.key End) { nextIndex items.length - 1; } else { return; } e.preventDefault(); const nextKey items[nextIndex].key; // 焦点移动到下一个可用的tab tabRefs.current[nextKey]?.focus(); // 同步选中 handleTabClick(nextKey); }preventDefault()一定要加不然方向键会同时滚动页面体验很怪。tabRefs用useRefRecordstring, HTMLButtonElement | null({})存储每个tab按钮的DOM引用移动焦点时直接操作DOM的.focus()方法。这里React的声明式思维和命令式DOM操作有一个交汇点——ref就是为这种场景设计的。每个tab按钮还需要加上正确的ARIA属性读屏软件才认得出来button roletab id{tab-${key}} aria-selected{active} aria-controls{panel-${key}} aria-disabled{disabled} tabIndex{active ? 0 : -1} 关键点是tabIndex——只有当前激活的标签应该在Tab键的焦点序列里其余标签用-1跳过否则用户按Tab键会一个标签一个标签地走而不是直接跳到标签组外面。这个细节很多半路出家的组件都没做对。3.2 图标、红点、关闭按钮用ReactNode带来的扩展空间如果tab数据项的label被设计成ReactNode那么图标、自定义字体、徽标都可以让调用方自己组合组件不需要为每一种花活写分支。我自己常用的组合写法{ key: /dashboard, label: ( span classNametab-label DashboardIcon / span工作台/span {unread 0 span classNametab-badge{unread}/span} /span ), children: DashboardPage /, }关闭按钮则建议组件内部渲染不要放在label里。因为关闭按钮的点击事件需要阻止冒泡否则会触发标签的选中逻辑而放在label里会让标签的onClick处理变得不可控{closable ( span classNametab-close rolebutton aria-label{关闭${label}} onClick{(e) { e.stopPropagation(); onClose?.(item.key); }} × /span )}stopPropagation()是必须的。如果不阻止冒泡点击关闭按钮会变成先关闭再选中而选中的又是一个即将被移除的key最后组件会落到一个不存在的高亮状态上。禁用态的处理也比较容易翻车。禁用tab要同时做到样式置灰、点击无效、键盘跳过。前两者大家都会写键盘跳过经常漏function findNextEnabledIndex(fromIndex: number, direction: 1 | -1) { let i fromIndex; do { i (i direction items.length) % items.length; } while (items[i].disabled i ! fromIndex); return i; }这个循环用do...while而不是while是为了至少检查一次防止在没有可用标签时无限循环。3.3 点击事件的边界onClick、onChange、onClose的职责划分交互一多事件就容易乱。我最后定了一个规矩onClick只负责用户点了这个标签这个动作组件内部判断是否禁用、是否是当前项然后调用onChangeonChange只在激活标签发生变化时触发重复点击当前标签不应该触发onClose独立出来关闭和切换是完全两件事在handleTabClick里做一次判断即可function handleTabClick(key: T, disabled?: boolean) { if (disabled) return; if (key currentKey) return; // ... onChange?.(key); }这个看似简单的守卫能省掉后面大量的重复渲染和状态错乱。4. 与路由联动刷新不丢状态URL可分享4.1 三个联动方案对比hash、query、嵌套路由单独的组件做出来只算完成一半在业务系统里tab和路由几乎总是绑定的。常见的联动方案有三种方案实现方式优点缺点hash传值location.hash #/tabKey最简单刷新保留无法参与路由嵌套SEO天然不利query参数?tabtabKey语义清晰兼容性好URL略冗余需要解析嵌套路由每个tab一个子路由最规范支持深度链接需要改路由表结构较重我这边服务端渲染和SEO都不涉及所以就选了query参数方案。用URLSearchParams读写底层是react-router的useSearchParamsHook。4.2 实操把activeKey同步到URL核心逻辑就是单行数据流对上了const [searchParams, setSearchParams] useSearchParams(); const tabKeyFromUrl searchParams.get(tab) ?? defaultTabKey; const activeKey items.some(item item.key tabKeyFromUrl) ? tabKeyFromUrl : items[0].key; function handleTabChange(key: string) { setSearchParams(prev { const next new URLSearchParams(prev); next.set(tab, key); return next; }, { replace: false }); }这里有三个关键点第一校验URL参数的有效性。用户可能手动改URL把一个不存在的tab key塞进来。如果直接用URL里的值当activeKey整个tab栏会变成空白。所以要先items.some(...)检查一遍无效值回退到第一个tab。这个防御性参数校验在跟URL打交道时几乎是必须的。第二replace: false还是true如果用户只是切换tab我是希望浏览器历史里留下记录的这样按返回键能回到上一个tab。所以没有用replace。但有些场景比如初始化重定向反而应该用replace避免用户点返回按钮退不出去。这个要按产品逻辑取舍。第三刷新后恢复子组件的内部状态。刷新后URL里的tab key能恢复用户之前打开的tab列表浏览器标签栏那种多标签恢复就需要再往前一步把已打开的tab集合也持久化到sessionStorage。我写了个几十行的自定义Hook实现这个能力const [openTabs, setOpenTabs] useSessionStoragestring[](workbench.tabs, [defaultTabKey]); function openTab(key: string) { setOpenTabs(prev prev.includes(key) ? prev : [...prev, key]); } function closeTab(key: string) { setOpenTabs(prev prev.filter(k k ! key)); }这个Hook本质上是useState加一层sessionStorage读写但带来的体验提升是质的用户刷新浏览器之前手动打开的一排标签页原样恢复跟浏览器重启恢复上次会话一个感觉。4.3 关闭最后一个tab怎么办关闭逻辑里最容易出bug的是当前激活的tab被关掉。我的处理方式是function handleClose(key: string) { const currentIndex items.findIndex(item item.key activeKey); const closableKeys items.filter(item item.closable ! false); if (key ! activeKey) { // 关的不是当前项直接关 setOpenTabs(prev prev.filter(k k ! key)); return; } // 关的是当前项激活隔壁的tab const nextTab findNextAvailableKey(items, currentIndex, -1) ?? findNextAvailableKey(items, currentIndex, 1); if (nextTab) { handleTabChange(nextTab.key); } setOpenTabs(prev prev.filter(k k ! key)); }优先向前找可用tab前面没了再向后找这样最符合用户心理预期。如果最后一个可关闭tab被关掉整个标签栏区域可以显示一个空状态占位而不是留下一片空白。5. 高亮滑块动画从left换成transform踩过的坑5.1 线型tab的下划线要能跟着鼠标移动市面上主流的tab组件通常带一条会滑动的高亮下划线或者背景块。这个动效看着高级实现上也不难但有个经典性能问题。最容易想到的方案是给下划线设left: 100px然后用CSS动画过渡left值。这在tab数量少的时候完全没问题但频繁点击、快速切换时会发现动画略有卡顿、掉帧尤其页面里有大量数据表格时更明显。原因在于left属性变化会触发布局计算Layout再触发绘制Paint而transform只触发合成Composite性能开销小得多。所以我最终的高亮滑块用transform: translateX()来移动function updateIndicator() { const el tabRefs.current[activeKey]; if (!el) return; const { offsetLeft, offsetWidth } el; indicatorRef.current.style.width ${offsetWidth}px; indicatorRef.current.style.transform translateX(${offsetLeft}px); }配合CSS.tabs-indicator { position: absolute; bottom: 0; left: 0; height: 2px; background: #2563eb; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s; }5.2 什么时候触发位置计算既然滑块坐标要手动算就绕不开什么时候算这个问题。我踩坑后发现至少有三个时机activeKey变化时组件更新后通过useEffect或useLayoutEffect重新计算滑块位置。容器大小变化时窗口缩放、侧边栏折叠导致tab容器宽度变化滑块坐标要跟着重新计算。tab内容宽度变化时比如某个tab的label文字从工作台变成工作台(12条未读)宽度变了滑块也要联动。针对后两个时机用ResizeObserver监听tab容器是最省事的useEffect(() { const container containerRef.current; if (!container) return; const observer new ResizeObserver(() updateIndicator()); observer.observe(container); return () observer.disconnect(); }, []);ResizeObserver兼容性在2024年之后的浏览器里已经很好了老项目可以降级到window.addEventListener(resize) 手动算。这个小细节不做就会出现窗口一缩放开滑块跟选中的tab位置对不上的诡异bug。5.3 useLayoutEffect和useEffect的区别这里必须说清楚滑块位置计算有一个用户不可见的闪烁问题如果用useEffect浏览器会在DOM变化后先绘制再执行effect用户可能看到滑块从旧位置跳到新位置的过程虽然只有一帧。换成useLayoutEffectReact会在DOM变更后、浏览器绘制前同步执行副作用滑块直接落在最终位置没有任何闪烁。这里的经验是涉及读取DOM坐标、修改DOM样式的副作用优先用useLayoutEffect而不是useEffect。只有不涉及可见样式变更的副作用比如发请求、写日志才用useEffect。6. 组件封装与TypeScript进阶从能用变好用6.1 泛型参数让key不局限于string很多业务场景里tab页面的身份ID是一个数字ID或者一个复杂对象。如果组件写死key: string调用方就得做一层字符串转换很别扭。用TypeScript泛型可以一行解决function TabsT extends string | number(props: TabsPropsT) { // ... }这样外部传入items: TabItemnumber[]也能正常工作。不过要注意一点如果key用对象序列化到URL就麻烦了。所以我限制T只能是string | number够用且安全。6.2 自定义Hook把会话恢复逻辑抽出去组件本身的代码写完后我发现打开的tab列表这一层逻辑跟tab组件其实可以解耦。于是抽了一个hook出来专门负责哪些页签属于打开状态function useWorkbenchTabs(defaultTabKey: string) { const [openTabs, setOpenTabs] useSessionStoragestring[](workbench.tabs, [defaultTabKey]); const [activeKey, setActiveKey] useState(defaultTabKey); const openTab useCallback((key: string) { setOpenTabs(prev prev.includes(key) ? prev : [...prev, key]); setActiveKey(key); }, [setOpenTabs]); const closeTab useCallback((key: string) { setOpenTabs(prev prev.filter(k k ! key)); }, [setOpenTabs]); const switchTab useCallback((key: string) { setActiveKey(key); }, []); return { openTabs, activeKey, openTab, closeTab, switchTab }; }页面里的用法变得很清爽const tabs useWorkbenchTabs(/dashboard); // 左侧导航点击时 tabs.openTab(menuKey); // tab栏关闭时 tabs.closeTab(tabKey);这个hook同时把打开列表当前选中打开动作关闭动作打包成一个整体不仅tab栏能读左侧菜单、面包屑、标题栏都可以共享同一份状态。这是我从这个小项目里最值得保留的设计。6.3 面向场景分组tab组件不只是上面一排按钮写的过程中我逐渐意识到tab在中后台里可以分两个层次用页面内一个卡片式tab切换表单视图、列表视图纯内部状态即可。页面间tab代表一条路由、一个功能模块需要全局状态、需要会话恢复。两者不该是两套组件而该是同一套组件的不同用法。所以我保留了一个不受控模式给前者一套受控模式 URL同步 sessionStorage恢复给后者。这种一个组件两种用法同一份数据流的设计比一开始就堆一堆配置项要优雅得多。7. 踩坑记录总有几个问题让人怀疑人生7.1 问题一关闭tab后高亮滑块飘到了不对的地方表现点关闭按钮关掉第三个tab本来选中第三个tab按逻辑应该切到第二个但滑块跑到了第一个。排查后发现问题出在事件顺序——关闭按钮的onClick先触发了标签的选中逻辑然后才执行关闭逻辑。键的切换和关闭的顺序纠缠在一起。解决方式把关闭和切换完全拆开。关闭按钮stopPropagation关闭后的tab选择逻辑放在handleClose里显式计算不依赖点击事件传上来的key。修复后代码边界清晰多了。7.2 问题二用了display:none缓存内容页面高度一会儿有一会儿没有内容区从display:none切换到display:block时父容器高度没有跟着变化导致页面出现滚动条闪烁。这个问题的根因是我给内容区设置了固定高度而缓存内容又不在正常文档流里。最后的解决办法是让内容区跟随内部内容的自然高度不给固定高度用min-height做下限。如果业务上确实需要固定高度比如全屏工作台就在内容区内部自己撑开不要在外层容器上写死。7.3 问题三React 18 StrictMode下useEffect执行两次导致事件重复监听开发环境下配了StrictMode发现键盘事件监听被添加了两次切tab时上一次的监听还没清理导致方向键移动了两次。原因是useEffect的清理函数写得不对// 错误示范cleanup里只清了一半 useEffect(() { document.addEventListener(keydown, handler); // 忘了 removeEventListener }, []); // 正确写法 useEffect(() { document.addEventListener(keydown, handler); return () document.removeEventListener(keydown, handler); }, [handler]);StrictMode的重复挂载其实是用来暴露这种清理遗漏的不要为了消除报错把StrictMode去掉正确的做法是把effect写干净保证卸载时完全还原现场。7.4 问题四key{index}导致输入框内容丢失这个踩得很经典。有个tab内容区是一个搜索表单我在渲染内容区时图省事用了items.map((item, index) Panel key{index} ...)。结果每次切换tab回来表单里填的内容全没了。原因React通过key识别组件实例当tab列表变化比如关闭一个后index变了React认为右边是两个新的组件直接卸载重建。页面上的表现是内容丢失、请求重新发起。修复很简单key改用item.key而不是index。这个事在React面试题里问烂了但实际写代码时还是会顺手用index。我的习惯是所有列表项只要涉及动态增删改一律不用index做key哪怕麻烦一点也要用唯一ID。7.5 问题五页面宽度变化后滑块不同步tab在多列布局里会遇到侧边栏折叠容器宽度一下就变了。只监听resize事件不够因为触发容器宽度变化的可能不是窗口大小而是某个兄弟元素的显隐。解决方式在5.2里写过了——用ResizeObserver监听tab容器本身。这个API用起来很顺手监听元素自身尺寸变化窗口缩放、兄弟组件布局变化都能覆盖不用去猜到底是什么导致了宽度变化。8. 最后分享两个我在实操中的小技巧第一个调试tab组件的定位问题我用了一个土办法给每个tab按钮加>media (prefers-reduced-motion: reduce) { .tabs-indicator { transition: none; } }这个细节虽然不起眼但对光敏感用户来说是真的有帮助而且会让你的组件比市面上很多现成组件库都更细致。这个tab组件写下来前后迭代了大概两周最深的体会是一个基础功能完整版的组件难点从来不在视觉效果上而是在事件边界、状态管理和交互细节这些一眼看不出来的地方。把这些问题一个个理顺之后你回头看React的很多知识点——受控组件、key的作用、ref的适用场景、useLayoutEffect和useEffect的区别、自定义Hook的抽法——全都不是背概念了而是真的在代码里遇到过、修过、理解了。如果你也打算拿一个小组件练手我强烈推荐从自己写一个tab开始性价比真的高。