Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React Headless Components #20

Open
bouquetrender opened this issue Sep 11, 2024 · 0 comments
Open

React Headless Components #20

bouquetrender opened this issue Sep 11, 2024 · 0 comments
Labels

Comments

@bouquetrender
Copy link
Owner

bouquetrender commented Sep 11, 2024

随着前端 UI 变得越来越复杂,复杂的逻辑可能与视觉重度交织在一起。这使开发人员很难整理组件的行为,很难对其进行测试,并且需要构建不同外观的类似组件。无头组件提取所有非视觉逻辑和状态管理,将组件的大脑与其外观分开。React Hooks 的出现很好的能实现这一设计模式。

历史背景和现状

在 React Hooks 之前,组件封装普遍使用 Class 模式,但 Class 组件有两大问题:

  • 状态逻辑复用:高阶组件层层包裹,调试难度提高,嵌套过多,拉低运行效率
  • 组件的维护:代码逻辑混杂在同个文件,相关逻辑分散在不同生命周期位置,生命周期函数中含有大量代码

为了解决 Class 组件的问题,React 推出了 Hooks,这样组件开发和逻辑封装就可以通过多个 hooks 去管理和划分不同的功能模块,现如今最常见的一个组件结构如下:

组件面临的问题

基础 新功能
实现管理打开/关闭状态
一个默认的外观
UI 和样式变体
多选过滤
异步数据
状态管理
键盘导航

基础组件的设计都是清晰简易的,但由于后续各种各样的需求,不同的变体主题,会导致组件props 和配置增长,混合状态、逻辑和 UI 样式不同会降低其可维护性,同时多个业务多个页面多个地方引用了同个组件,不停添加 props 兼容各处逻辑,复杂样式下,修改组件一处地方需要打开很多页面查看是否影响了 UI。

虽然可以复制原来相同组件进行特殊定制化,但这种方式会导致会有许多重复相同的代码在项目中。为了解决这个问题,我们可以使用无头组件的设计模式进行组件开发。

无头组件介绍

无头组件是一种设计模式,负责逻辑和状态管理,独立于UI专注于行为,不涉及任何的样式。接下来以一个下拉列表来改造成 headless 组件,就可以清晰理解为什么需要这样做。

基础组件封装

下面是一个基础的下拉列表 dropdown:

这个下拉列表包含两个部分,一个是 Trigger 触发下拉列表,一个是 Menu 下拉列表结构。并且有 isOpen 控制是否打开下拉列表和 selectedItem 下拉列表数据两个状态。

将Trigger部分和Menu部分创建成一个子组件在 Dropdown 中引入:

这是目前最常选用的封装思路。

再往 Dropdown 里添加一个新的功能,键盘导航。

SelectedIndex 标记当前位置,利用 switch 来判断按下按键并相应地执行操作,当按下 Enter 或 Space 键时下拉菜单被触发,下箭头和上箭头按键允许在列表项中导航。

再往 Dropdown 里添加一个新的功能 ...

再往 Dropdown 里添加一个新的功能 ...

再往 Dropdown 里添加一个新的功能 ...

随着功能增加,状态管理代码和渲染逻辑统一放在一起,像包含了按键处理逻辑,以及一些状态管理的结构 selectedItem、selectedIndex、setSelectedItem 等等。这是正常的,一个组件随着功能的扩展必定会到这个阶段。

但组件在不同地方引入过多,修改一个地方就要考虑到四五个页面的影响,更别提要不同的结构样式 UI 主题样式。这时组件的修改维护就会变得十分麻烦。一旦出现这种状况,我们需要将组件改造成无头组件。

改造成无头组件

我们可以将下拉列表中的逻辑状态管理统一放到一个 Hooks 中,并且给不同的变体 UI 引用。

这里可以看到,所有 return 暴露出去的状态变量方法,都是之前定义在 Dropdown 组件里的。

而 Dropdown 组件,就可以引入这个 hooks,直接拿到状态和方法引用和控制UI渲染,我们可以在许多地方都引用这个 hooks,但 UI 视图完全是不同自定义的变体:

这就是一个基础的 Hooks 无头组件。

React Context API

我们可以更进一步,将 UI 下的 div 根据不同的功能拆分,这里用到 React Context API。这里对 API 不了解的读者简单说明下,这个 API 类似于 Vue 框架中的 Provide/Inject 功能,能跨多个子组件获取参数。

回到刚刚的 Dropdown 组件,需要关注的是这一个部分:

我们首先创建一个 Root 根,来提供所有 Hooks 暴露出的状态:

定义 Trigger、List、Option变量(其实就是将子组件移动到了这些定义的变量内,并且通过 context 去获取 Hooks 暴露的状态方法):

这时我们 Dropdown 的结构就是 Root、Trigger、List、Option,并将这些参数暴露:

每个子组件放到定义的同级变量并导出的目的是,例如在一个业务代码中引入这个 Dropdown:

这里我们所有的样式都是定义在这个无头组件外部,通过 props 传入 className,并且结构清晰。这样就能解决组件在不同地方引入过多而担忧的影响和维护问题。我们可以通过这样的方式去改造一些特定的业务组件。

Radix

除了我们自己开发无头组件外,还可以看看开源的一些成熟无头组件库是如何做的,这里简单介绍下 Radix 这个组件库以及特点。

我们应该还对上面通过 Context API 改造后的 Dropdown 还有印象,下面这个是 Radix 官方组件库的 Select:

颗粒度和划分程度明显比我们上面演示的下拉 dropdown 要细腻得多,这就是一个基础的无头组件最极致的划分状态,并且所有样式通过 props 的 className 传入。

查看下 Select 的源码,我们发现 Radix 实现无头组件的方式也是利用了 Context API 和 Hooks 的封装:

Radix 中还可以单独安装某个组件而不是全量,官方称之为增量采用(Incremental adoption):

npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tooltip

查看源码中文件发现,每个组件下都会有一个 package.json 作为单独包描述文件:

当在 Radix 项目根目录运行构建脚本 node build.mjs,使用文件路径匹配库 glob 匹配 packages 目录下符合条件的路径(所有组件)执行 build 方法构建。build 方法则是使用 esbuild 打包工具构建输出包。

项目实践

这是某个项目中的一个厂牌车款车型选择业务组件:

由于历史迭代原因,这个组件有多个样式和功能有特殊不同的变体:

  • <CarBrandMultiSelect />
  • <NewCarSingleBrandKindSelect />
  • <SingleBrandKindSelect />
  • ......

这些组件中存在着许多相同逻辑的代码,虽然复制粘贴修改组件是一种快速的方式,但现在我们理解无头组件的设计模式后,就可以通过这种方式来定义一个用于厂牌车款车型选择的无头组件了。

按照相同的思路,将厂牌车款车型的一些状态操作逻辑存放在一个 Hooks 中并暴露需要的参数:

这个 useBKMSelector Hooks 包含:

  • 是否显示厂牌车款下拉选择器
  • 是否显示车型下拉选择器
  • 清空选择项
  • 厂牌列表
  • 厂牌热门列表
  • 车款列表
  • 车型列表
  • 当前选择项自定义车型名字
  • 当前标签
  • 高亮标识
  • A-Z标签跳转
  • ......

接着划分组件,将 Hooks 放在 Root 中通过 Context API 提供给所有子组件使用:

Root 中引入了 useBKMSelector 并且暴露参数,通过 Context API 传递给子组件。

在项目中引入使用:

通过无头组件设计模式的改造,如果以后需要开发厂牌车款车型的组件,我们可以:

  1. 基于 useBKMSelector Hooks 去创建新的组件
  2. 基于 CarSelect 去创建不同的样式主题变体

这样可以解决

  • 在不同页面引入,又因为每个页面又不同特殊展示需求,功能累计导致的难以维护
  • 复制粘贴组件引起的问题

无头组件设计规范

这里定义了一些基础无头组件设计开发规范:

  • 单一职责 只处理一个特定业务逻辑或功能,不应该混合多个职责
  • 可复用性 处理的逻辑可在不同的 UI 组件中复用
  • 视图解耦 不包含任何样
  • 清晰的 API 组件参数和返回值易于理解和使用
  • 统一命名 Hooks 无头组件使用 use 前缀
  • 注释说明 在代码中添加必要的注释,解释关键逻辑

关于 Vue 框架的补充

本篇文章所用的代码 demo 均使用了 React 框架的 API,但无头组件这个概念并不是 React 定义的,这是一种与其他前端框架共用的设计模式。

例如 Vue 中在基于作用域插槽的无渲染组件:它将组件的逻辑与其展示分离开来。这种模式提供了一种封装功能的方法,而不规定组件的视觉表现。无渲染组件只关注逻辑和行为,将渲染交给父组件来处理,这和无头组件是一样的设计模式。

也可以利用 Vue 3 中的组合式函数,参考本文中 React Hooks 的封装模式去做无头组件。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant