从零开始搭建简易headless Tabs组件(参考headlessui库的设计思想)

253 阅读4分钟

作者最近在学习headless组件的设计思想,即无 UI 组件,框架仅提供逻辑,UI 交给业务实现。这样带来的好处是业务有极大的 UI 自定义空间,而对框架来说,只考虑逻辑可以让自己更轻松的覆盖更多场景,满足更多开发者不同的诉求。并借此机会,学习更优秀的设计,加强Render Props设计模式的使用,与组件封装能力。

资料总结

网上查阅资料,这里统一把参照资料进行展示:

  1. Headless 组件用法与原理
  2. Epitath 源码 - renderProps 新用法
  3. headlessui 源码

话不多说,我们开始实践

实践过程

业务状态获取

首先我们来看看headlessui中Tabs基本的形式

image.png

插一句😊:可以看到,以上代码没有做任何逻辑定制,没有任何额外的 UI 样式(需要自行设计)。

作者理解,Headless 的拓展性使其可以成为各种ui库的最底层,被使用封装成通用组件(比如antd?)。

image.png

组件的业务状态可以通过Render Props形式拿到

内部组件通信

虽然我们可以通过Render Props的方式拿到其中业务状态,但是各个Tabs组件之间是无法共享状态,接下来我们来解决一下这个问题:在 TabGruop 利用 ContextProvider 解决。所有子组件如 TabTab.PanelTab.List 都从 useData 获取数据,而这些数据都可以从当前最近的 Tab.Group 上下文获取,所以多个 tabs 之间数据可以相互隔离

image.png

Render Props

TabGruop组件中,我们保存了选中Tab的index,就可以通过Render Props的核心代码暴露使用

这里展示一下Render Props的核心代码(由于是简易场景,其它复杂的参数被屏蔽)

import {
  cloneElement,
  ElementType,
  ReactElement,
  ReactNode,
  Fragment,
  createElement,
} from "react";
export interface RenderProps<TTag, TSlot> {
  children?: ReactNode | ((bag: TSlot) => ReactElement);
  as?: TTag;
  refName?: string;
  ref?: unknown;
}
export function _render<TTag extends ElementType, TSlot>(
  props: RenderProps<TTag, TSlot>,
  slot: TSlot = {} as TSlot,
  tag: ElementType
  //   name: string
) {
  let { as: Component = tag, children, refName = "ref", ...rest } = props;

  let resolvedChildren = (
    typeof children === "function" ? children(slot) : children
  ) as ReactElement | ReactElement[];

  if (Component === Fragment) {
    return cloneElement(
      resolvedChildren as ReactElement,
      Object.assign(
        {},
        // Filter out undefined values so that they don't override the existing values
        mergeProps(
          (resolvedChildren as ReactElement)?.props,
          compact(omit(rest, ["ref"]))
        )
        // dataAttributes,
        // refRelatedProps,
        // mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref)
      )
    );
  }

  return createElement(
    Component,
    Object.assign(
      {},
      omit(rest, ["ref"])
      //   Component !== Fragment && refRelatedProps,
      //   Component !== Fragment && dataAttributes
    ),
    resolvedChildren
  );
}

如果 children 是函数类型,就把它当做函数执行并传入上下文(此处为 slot),返回值是 JSX 元素,这就是 RenderProps 的本质

其中 slot 就是当前 RenderProps 能拿到的上下文,比如在 Tab.Group 中就提供 selectedIndex,在 Tab 就提供 selected 等等,在不同的 RenderProps 位置提供便捷的上下文,对用户使用比较友好是比较关键的。

TabGruop部分

TabGruo是Tabs组件的根组件,在TabGruop部分我们主要做了以下几件事:

  1. context传递组件状态。这里将状态与状态控制分成不同的context,解耦逻辑,减少重复渲染。headlessui中使用useReducer进行管理,这里暂时不用这么麻烦。
  2. 传递slot,并使用_render渲染

TabList部分

简单slot传递selectedIndex

image.png

Tab部分

在Tab部分我们主要做了以下几件事:

  1. 获取每个Tab的标识index并且判断selected:渲染时保存每一个Tab的引用,然后通过判断引用的位置确定其对应index,然后判断selected

image.png

  1. 绑定点击事件:在ourProps中进行传递
  2. 暴露业务状态:在slot中传递selecteddisabled等状态

其它部分

TabPanelsTabListTabPanelTab

待解决问题

闭包问题

image.png

如图所示的部分,通过Render Props 方式回显的selectedIndex在渲染中准确显示,但是在绑定的onClick函数中拿到的selectedIndex是上一次的旧值。

image.png image.png

即使使用useRef绑定了最新值,在点击时依旧取的是旧值,百思不得其解,望相助。

最终效果与总结

xiezuo20250512-185539.gif

关键点:

1. 动态状态传递:slot 参数机制

  • 父组件生成 slot

  • 子组件(Tab)消费 slot
    在 _render 函数中,将 slot 传递给 Render Props,确保子组件使用最新值。

2. 全局状态管理:React Context

  • 状态上下文(TabsDataContext)
    统一管理 selectedIndextabs 和 panels 的引用集合,确保组件间状态同步。

  • 操作上下文(TabsActionsContext)
    提供 registerTabchange 等方法,集中处理状态更新逻辑。

3. 索引计算优化:稳定集合与DOM排序

  • DOM 排序同步
    根据实际 DOM 顺序动态排序 tabs 数组,确保索引与视觉顺序一致。