手把手带你入门「Headless UI」

本文详细介绍了Headless UI的概念和收益,并以数字输入组件为例讲解了Headless UI的实现方式。

1. [What] 什么是 Headless UI

在前端开发中,我们经常会用到数字输入组件,例如arco-design的InputNumber组件,可以通过传入形如mode这样的属性来配置按钮展示的形式。

mode = 'embed'

mode = 'button'

在实际工作中,不同场景下的数字输入组件可能会有不同的样式或布局,例如:

  1. 自定义样式:修改加减按钮的背景颜色

对于这类诉求,组件使用者可能会找到加减按钮对应的CSS类名,然后覆盖原有样式。

  1. 自定义布局:修改加减按钮的位置

对于这类诉求,组件维护者可能会新增入参,但入参的数量总是有限的,而布局的可能性是无限的。

随着UI自定义的诉求越来越丰富,组件的维护成本也越来越高。自然而然地,我们就会思考是否可以把逻辑从视图中剥离出来,这就是Headless UI的使用场景。

什么是 「Headless UI」

  • 字面含义:无UI的组件,只包含状态和交互等逻辑;
  • 实际含义:分别维护UI和逻辑Hooks,从而实现「关注点分离」,解除UI和逻辑之间的耦合。

2. [Why] 为什么要用 Headless UI

Headles UI是一种复用逻辑代码 的手段,它适用于"不同页面呈现不同UI,但交互逻辑高度相似"的场景。

如果基于Headless UI去开发一个组件库(例如Chakra-UI),同时暴露UI组件和逻辑Hooks,那么对于组件库的使用者和维护者而言,都能有不错的收益:

  • 组件库使用者:可以基于逻辑Hooks,灵活地自定义UI样式;
  • 组件库维护者:可以实现关注点分离,UI组件仅关注样式本身,逻辑Hooks仅关注状态和交互。

3. [How] Headless UI 怎么写

以上文提到的数字输入组件为例说明实现方式。

3.1 定义组件的状态

对于数字输入组件而言,它的状态如下:(包括但不仅限于)

  • count:当前数值;

  • step:数值变化步长;

  • disabled:组件整体是否禁用;

  • plusDisabled:增加按钮是否禁用;

  • minusDisabled:减少按钮是否禁用。

3.2 分析组件的生命周期

组件的生命周期可概括如下:

  • 初态:对应页面挂载时;
  • 运行时:页面挂载完成后,用户进行交互;
  • 终态:对应页面卸载时。

接下来就要去分析各个阶段下,有哪些行为会改变组件的状态。

状态 初态 运行时 终态
count 调用方传入初始值 用户点击增加或减少按钮
step 调用方传入初始值 调用方传入
disabled 调用方传入初始值 调用方传入
plusDisabled 初始值取决于disabled的值 取决于count的值(是否达到最大值)
minusDisabled 初始值取决于disabled的值 取决于count的值(是否达到最小值)

3.3 明确逻辑Hooks的形状

  • 逻辑Hooks需要从参数中获取:状态的初始值;
  • 调用方需要从返回值中获取:状态、状态变化时的回调函数、直接更新状态的函数。

定义参数形状如下:

TypeScript 复制代码
interface UseInputNumberProps {
  max?: number;
  min?: number;
  step?: number;
  defaultCount?: number
  disabled?: boolean
}

定义返回值形状如下:

TypeScript 复制代码
interface UseInputNumberReturn {
  count: number;
  disabled: boolean;
  setDisabled: (v: boolean) => void;
  plusDisabled: boolean;
  minusDisabled: boolean;
  onPlusClick: () => void;
  onMinusClick: () => void;
}

3.4 代码实现

按照上述思路进行实现,我们就得到了一个只包含状态和交互的逻辑Hooks。

TypeScript 复制代码
export function useInputNumber(props: UseInputNumberProps) {
  const {
    max,
    min,
    step: propsStep,
    defaultCount,
    disabled: propsDisabled,
   } = props
   
  const maxCount = typeof max === 'number' ? max : Number.MAX_SAFE_INTEGER
  const minCount = typeof min === 'number' ? min : Number.MIN_SAFE_INTEGER
  const step = typeof propsStep === 'number' ? propsStep : 1
  const [count, setCount] = useState(() => {
    if (typeof defaultCount !== 'number') {
      return 0
    }
    if (defaultCount > maxCount) {
      return max
    }
    if (defaultCount < minCount) {
      return min
    }
    return defaultCount
  })
  const [disabled, setDisabled] = useState(Boolean(propsDisabled))
  const [plusDisabled, setPlusDisabled] = useState(Boolean(propsDisabled))
  const [minusDisabled, setMinusDisabled] = useState(Boolean(propsDisabled))

  function onPlusClick() {
    updateCount(count + step)
  }

  function onMinusClick() {
    updateCount(count - step)
  }

  function updateCount(newCount) {
    setCount(newCount)
    if (newCount + step > maxCount) {
      setPlusDisabled(true)
    } else if (newCount - step < minCount) {
      setMinusDisabled(true)
    } else {
      setPlusDisabled(false)
      setMinusDisabled(false)
    }
  }

  return {
    count,
    disabled,
    setDisabled,
    plusDisabled,
    minusDisabled,
    onPlusClick,
    onMinusClick,
  }
}

调用方只需从逻辑Hooks的返回值取出所需的状态和函数,传递给UI组件即可。

TypeScript 复制代码
export function App() {
  const {
    count,
    onMinusClick,
    onPlusClick,
    plusDisabled,
    minusDisabled,
  } = useInputNumber({
    max: 100,
    min: 0,
    defaultCout: 1
  })
  
  return (
    <div>
      <h2>当前数值为{count}</h2>
      <button onClick={onPlusClick} disabled={plusDisabled}>增加</button>
      <button onClick={onMinusClick} disabled={minusDisabled}>减少</button>
    </div>
  )
}

参考资料

相关推荐
小磊哥er12 分钟前
【前端工程化】前端开发中的这些设计规范你知道吗
前端
江城开朗的豌豆13 分钟前
路由守卫:你的Vue路由‘保安’,全局把关还是局部盯梢?
前端·javascript·vue.js
Jinxiansen021121 分钟前
Vue 3 响应式核心源码详解(基于 @vue/reactivity)
前端·javascript·vue.js
OEC小胖胖5 小时前
去中心化身份:2025年Web3身份验证系统开发实践
前端·web3·去中心化·区块链
vvilkim6 小时前
Electron 进程间通信(IPC)深度优化指南
前端·javascript·electron
ai小鬼头8 小时前
百度秒搭发布:无代码编程如何让普通人轻松打造AI应用?
前端·后端·github
漂流瓶jz8 小时前
清除浮动/避开margin折叠:前端CSS中BFC的特点与限制
前端·css·面试
前端 贾公子8 小时前
在移动端使用 Tailwind CSS (uniapp)
前端·uni-app
散步去海边8 小时前
Cursor 进阶使用教程
前端·ai编程·cursor