
文档技术博客教程【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址https://gitcode.com/GitHub_Trending/we/weekly点击查看免费下载Headless 组件无头组件是一类只提供逻辑、不提供 UI的组件设计范式框架把选中态、键盘交互、焦点管理等复杂状态逻辑全部内置把 UI 的自由度完全交给业务。本文以 headlessui 的 Tabs 组件为样本完整讲解其基本用法、RenderProps 定制方式、全部核心配置参数并深入其源码剖析Context 通信 RenderProps 渲染两大支柱的实现原理读完后你可以掌握 Headless 组件的使用技巧并具备自己设计 Headless 组件的能力。Headless 组件是什么Headless 组件即无 UI 组件框架仅提供逻辑UI 交给业务实现。这样带来的好处是业务有极大的 UI 自定义空间而对框架来说只考虑逻辑可以让自己更轻松地覆盖更多场景满足更多开发者不同的诉求。我们以 headlessui 的 Tabs 组件为例看看它的用法并读一读它的源码。对应的官方 React 实现位于headlessui/react包的components/tabs/tabs.tsx中本仓库 readme.md 也收录了与之呼应的 精读《Epitath 源码 - renderProps 新用法》 与 精读《如何安全地使用 React context》可以在阅读本文时对照参考。headless tabs 最简单的用法headless tabs 最简单的用法如下import { Tab } from headlessui/react; function MyTabs() { return ( Tab.Group Tab.List TabTab 1/Tab TabTab 2/Tab TabTab 3/Tab /Tab.List Tab.Panels Tab.PanelContent 1/Tab.Panel Tab.PanelContent 2/Tab.Panel Tab.PanelContent 3/Tab.Panel /Tab.Panels /Tab.Group ); }以上代码没有做任何逻辑定制只用Tab及其提供的标签把 tabs 的结构描述出来此时框架能提供最基础的 tabs 切换特性即按照顺序点击Tab时切换内容到对应的Tab.Panel。此时没有任何额外的 UI 样式甚至连Tab选中态都没有。这正是 Headless 的本质逻辑与 UI 完全解耦框架负责怎么切换业务负责长什么样。用 RenderProps 定制 UI如果需要进一步定制需要用框架提供的 RenderProps 能力拿到状态后做业务层的定制比如选中态Tab as{Fragment} {({ selected }) ( button className{selected ? bg-blue-500 text-white : bg-white text-black} Tab 1 /button )} /Tab要实现选中态就要自定义 UI如果使用 RenderProps 拓展那么Tab就不应该提供任何 UI所以as{Fragment}就表示该节点作为一个逻辑节点而非 UI 节点不产生 dom 节点。类似的框架将 tabs 组件拆分为 Tab 标题区域Tab与 Tab 内容区域Tab.Panel每个部分都可以用 RenderProps 定制而框架早已根据业务逻辑规定好了每个部分可以做哪些逻辑拓展比如Tab就提供了selected参数告知当前 Tab 是否处于选中态业务就可以根据它对 UI 进行高亮处理而框架并不包含如何做高亮的处理因此才体现出该 tabs 组件的拓展性但相应的业务开发成本也较高。拓展性场景自由布局 Tab 标题Headless 的拓展性可以拿一个场景举例如果业务侧要定制 Tab 标题我们可以将Tab.List包裹在一个更大的标题容器内在任意位置添加标题 jsx而不会破坏原本的 tabs 逻辑然后将这个组件作为业务通用组件即可。例如业务可以在Tab.List前后自由插入徽标、图标或自定义工具栏Tab.Group div classNameflex items-center justify-between Tab.List TabTab 1/Tab TabTab 2/Tab /Tab.List span classNametext-xs text-gray-400自定义标题旁的元素/span /div Tab.Panels Tab.PanelContent 1/Tab.Panel Tab.PanelContent 2/Tab.Panel /Tab.Panels /Tab.Group因为Tab.List、Tab.Panels都是普通节点只是通过 Context 与Tab.Group通信所以无论外层如何包裹tabs 逻辑都不会被破坏。核心配置参数一览headless tabs 通过Tab.Group的 props 提供了一系列逻辑配置业务可以按需开启。禁用某个 Tab控制某个 Tab 是否可点击禁用后不可切换Tab disabledTab 2/Tab手动键盘切换模式Tab 切换是否为手动按Enter或Space键而不是默认的方向键自动切换Tab.Group manual默认非 manual模式下焦点在 Tab 上移动时内容会自动跟着切换跟随焦点模式开启manual后内容只会在用户按下Enter或Space时切换方向键仅移动焦点。这一参数决定了 tabs 的键盘交互语义适合对可访问性有严格要求的场景。默认激活 Tab设置初始激活的 Tab 索引从 0 开始Tab.Group defaultIndex{1}例如defaultIndex{1}表示默认激活第二个 Tab。监听激活 Tab 变化当激活 Tab 变化时回调Tab.Group onChange{(index) { console.log(Changed selected tab to:, index) }} onChange接收的是激活 Tab 的索引可以用于埋点、联动其他组件等场景。受控模式如果想完全由业务掌握当前激活的 Tab可以同时传入selectedIndex与onChange形成受控模式Tab.Group selectedIndex{selectedIndex} onChange{setSelectedIndex}此时selectedIndex完全由外部状态决定任何切换动作都会先触发onChange由业务决定是否更新状态。注意defaultIndex与selectedIndex的区别前者是非受控模式的初始值后者是受控模式下的当前值。RenderProps 与 Hooks两种 Headless 拓展模式由此可见Headless 组件在 React 场景更多使用 RenderProps 的方式提供 UI 拓展能力因为 RenderProps 既可以自定义 UI 元素又可以拿到当前上下文的状态天然适合对 UI 的自定义。还有一些 Headless 框架如 TanStack Table还提供了 Hooks 模式如const table useReactTable(options) return table {...table.getTableProps()}/tableHooks 模式的好处是没有 RenderProps 那么多层回调代码层级看起来舒服很多而且 Hooks 模式在其他框架也逐渐被支持使组件库跨框架适配的成本比较低。但 Hooks 模式在 React 场景下会引发不必要的全局 ReRender相比之下RenderProps 只会将重渲染限定在回调函数内部在性能上 RenderProps 更优。关于两种模式的取舍本仓库 精读《React Hooks》 与 精读《React Hooks 数据流》 都有更深入的讨论Hooks 擅长把状态管理逻辑从组件中剥离出来形成可复用单元而 RenderProps 则把状态 渲染打包在回调内天然限制了重渲染范围。原理精读Context 让子组件共享状态分析的差不多我们看看 headlessui-tabs 的源码。首先组件要封装的好一定要把内部组件通信问题给解决了即为什么包裹了Tab.Group后Tab与Tab.Panel就可以产生联动它们一定要访问共同的上下文数据。答案就是 Context。首先在Tab.Group利用ContextProvider包裹一层上下文容器并封装一个 Hook 从该容器提取数据// 导出的别名就叫 Tab.Group const Tabs () { return ( TabsDataContext.Provider value{tabsData} {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_TABS_TAG, name: Tabs, })} /TabsDataContext.Provider ); }; // 提取数据方法 function useData(component: string) { let context useContext(TabsDataContext); if (context null) { let err new Error( ${component} / is missing a parent Tab.Group / component. ); if (Error.captureStackTrace) Error.captureStackTrace(err, useData); throw err; } return context; }所有子组件如Tab、Tab.Panel、Tab.List都从useData获取数据而这些数据都可以从当前最近的Tab.Group上下文获取所以多个 tabs 之间数据可以相互隔离。这个设计有两个值得注意的细节数据隔离useData读取的是最近的TabsDataContext因此组件树中可以嵌套多个Tab.Group各自管理各自的选中状态互不干扰。友好报错当子组件脱离Tab.Group单独使用时useData会抛出明确错误Tab / is missing a parent Tab.Group / component.并借助Error.captureStackTrace定位调用栈让使用者第一时间发现结构错误。关于 Context 的更多注意事项如shouldComponentUpdate可能阻断 context 传播、context 应保持不可变等可以参考本仓库 精读《如何安全地使用 React context》。原理精读_render 与 RenderProps 的实现另一个重点就是 RenderProps 的实现。其实早在 精读《Epitath 源码 - renderProps 新用法》 我们就讲过 RenderProps 的实现方式今天我们来看一下 headlessui 的封装吧。核心代码精简后如下function _renderTTag extends ElementType, TSlot( props: PropsTTag, TSlot { ref?: unknown }, slot: TSlot {} as TSlot, tag: ElementType, name: string ) { let { as: Component tag, children, refName ref, ...rest } omit(props, [unmount, static]) let resolvedChildren (typeof children function ? children(slot) : children) as | ReactElement | ReactElement[] if (Component Fragment) { return cloneElement( resolvedChildren, Object.assign( {}, // Filter out undefined values so that they dont override the existing values mergeProps(resolvedChildren.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 ) }首先为了支持 Fragment 模式所以当指定as{Fragment}时就直接把resolvedChildren作为子元素否则自己就作为 dom 载体createElement(Component, ..., resolvedChildren)来渲染。而体现 RenderProps 的点就在于resolvedChildren处理的这段let resolvedChildren typeof children function ? children(slot) : children;如果children是函数类型就把它当做函数执行并传入上下文此处为slot返回值是 JSX 元素这就是 RenderProps 的本质。对这段核心代码的理解可以拆成三层渲染载体可切换as属性默认渲染为DEFAULT_TABS_TAG即div业务可以通过as替换成任意元素当as{Fragment}时走cloneElement分支不产生额外 dom 节点仅作为逻辑容器。children 即函数RenderProps 本质typeof children function时把它当函数执行并注入slot当前上下文返回值作为实际渲染内容。props 合并非 Fragment 分支把业务传入的restprops 透传给最终元素同时附加dataAttributes如data-headlessui-state便于业务用 CSS 选择器定制样式与refRelatedProps保证ref能正确绑定。slot精心设计的上下文再看上面Tab.Group的用法render({ ourProps, theirProps, slot, defaultTag: DEFAULT_TABS_TAG, name: Tabs, });其中slot就是当前 RenderProps 能拿到的上下文比如在Tab.Group中就提供selectedIndex在Tab就提供selected等等在不同的 RenderProps 位置提供便捷的上下文对用户使用比较友好是比较关键的。比如Tab内已知该Tab的index与selectedIndex那么给用户提供一个组合变量selected就可能比分别提供这两个变量更方便。这里的经验可以总结为slot 的设计要以业务直接可用为目标。框架内部有完整的状态如 index、selectedIndex、disabled但暴露给业务的一定是经过组合、语义化后的值如selected减少业务侧的推导成本。Headless 组件的设计方法论我们总结一下 Headless 的设计与使用思路。作为框架作者首先要分析这个组件的业务功能并抽象出应该拆分为哪些 UI 模块并利用 RenderProps 将这些 UI 模块以 UI 无关方式提供并精心设计每个 UI 模块提供的状态。核心步骤是拆解业务功能确定组件边界如 tabs 拆为 Group / List / Tab / Panels / Panel用 Context 打通各模块的共享状态提供useData式取值入口并做好越界报错用_render式通用渲染器统一处理as切换、Fragment 模式与 RenderProps children为每个模块设计语义化的slot状态如selected而非暴露原始状态。作为使用者了解这些组件分别支持哪些模块各模块提供了哪些状态并根据这些状态实现对应的 UI 组件响应这些状态的变化。由于最复杂的状态逻辑已经被框架内置所以对于 UI 状态多样的业务甚至可以每个组件重写一遍 UI 样式对于样式稳定的场景业务也可以按照 Headless UI 作为整体封装出包含 UI 的组件提供给各业务场景调用// 业务封装Headless UI 合成通用组件 function MyTab({ children }) { return ( Tab as{Fragment} {({ selected }) ( button className{selected ? bg-blue-500 text-white : bg-white text-black} {children} /button )} /Tab ); }Headless 的价值在于逻辑复用、样式自由把最复杂、最易出错的状态与可访问性逻辑交给框架把审美与布局交给业务。无论是设计组件库还是消费组件库理解这一范式都能让你写出更灵活、更易维护的前端代码。赞分享文档技术博客教程【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址https://gitcode.com/GitHub_Trending/we/weekly点击查看免费下载相关推荐前端精读周刊前端组件测试策略前端精读周刊前端组件测试策略 引言 在前端开发中组件是构建用户界面的基本单元。随着前端应用的复杂性不断增加确保组件的质量和稳定性变得至关重要。前端组件测试文档技术博客教程如何永久保存微信聊天记录WeChatMsg完整使用指南让数据真正属于你如何永久保存微信聊天记录WeChatMsg完整使用指南让数据真正属于你 你是否曾为微信聊天记录的丢失而懊恼那些珍贵的对话、重要的约定、温馨的回忆是否因为手前端精读周刊前端内存管理实践前端精读周刊前端内存管理实践 引言 你是否曾遇到过网页随着使用时间增长而变得越来越卡顿是否在开发复杂单页应用时明明代码逻辑没问题却总是出现莫名其妙的性能文档技术博客教程上一篇Textual布局系统终极指南从基础到高级布局技巧下一篇让AI编码助手拥有资深工程师思维agent-skills开源项目深度解析创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考