作者最近在学习headless组件的设计思想,即无 UI 组件,框架仅提供逻辑,UI 交给业务实现。这样带来的好处是业务有极大的 UI 自定义空间,而对框架来说,只考虑逻辑可以让自己更轻松的覆盖更多场景,满足更多开发者不同的诉求。并借此机会,学习更优秀的设计,加强Render Props设计模式的使用,与组件封装能力。
资料总结
网上查阅资料,这里统一把参照资料进行展示:
话不多说,我们开始实践
实践过程
业务状态获取
首先我们来看看headlessui中Tabs基本的形式
插一句😊:可以看到,以上代码没有做任何逻辑定制,没有任何额外的 UI 样式(需要自行设计)。
作者理解,Headless 的拓展性使其可以成为各种ui库的最底层,被使用封装成通用组件(比如antd?)。
组件的业务状态可以通过Render Props形式拿到
内部组件通信
虽然我们可以通过Render Props的方式拿到其中业务状态,但是各个Tabs组件之间是无法共享状态,接下来我们来解决一下这个问题:在 TabGruop 利用 ContextProvider 解决。所有子组件如 Tab、Tab.Panel、Tab.List 都从 useData 获取数据,而这些数据都可以从当前最近的 Tab.Group 上下文获取,所以多个 tabs 之间数据可以相互隔离
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部分我们主要做了以下几件事:
- context传递组件状态。这里将状态与状态控制分成不同的context,解耦逻辑,减少重复渲染。headlessui中使用
useReducer进行管理,这里暂时不用这么麻烦。 - 传递
slot,并使用_render渲染
TabList部分
简单slot传递selectedIndex
Tab部分
在Tab部分我们主要做了以下几件事:
- 获取每个Tab的标识
index并且判断selected:渲染时保存每一个Tab的引用,然后通过判断引用的位置确定其对应index,然后判断selected
- 绑定点击事件:在
ourProps中进行传递 - 暴露业务状态:在
slot中传递selected、disabled等状态
其它部分
TabPanels同TabList;TabPanel同Tab
待解决问题
闭包问题
如图所示的部分,通过Render Props 方式回显的selectedIndex在渲染中准确显示,但是在绑定的onClick函数中拿到的selectedIndex是上一次的旧值。
即使使用useRef绑定了最新值,在点击时依旧取的是旧值,百思不得其解,望相助。
最终效果与总结
关键点:
1. 动态状态传递:slot 参数机制
-
父组件生成
slot -
子组件(Tab)消费
slot:
在_render函数中,将slot传递给 Render Props,确保子组件使用最新值。
2. 全局状态管理:React Context
-
状态上下文(TabsDataContext) :
统一管理selectedIndex、tabs和panels的引用集合,确保组件间状态同步。 -
操作上下文(TabsActionsContext) :
提供registerTab、change等方法,集中处理状态更新逻辑。
3. 索引计算优化:稳定集合与DOM排序
- DOM 排序同步:
根据实际 DOM 顺序动态排序tabs数组,确保索引与视觉顺序一致。