本文详细介绍了Headless UI的概念和收益,并以数字输入组件为例讲解了Headless UI的实现方式。
1. [What] 什么是 Headless UI
在前端开发中,我们经常会用到数字输入组件,例如arco-design的InputNumber组件,可以通过传入形如mode
这样的属性来配置按钮展示的形式。
mode = 'embed'
mode = 'button'
在实际工作中,不同场景下的数字输入组件可能会有不同的样式或布局,例如:
- 自定义样式:修改加减按钮的背景颜色
对于这类诉求,组件使用者可能会找到加减按钮对应的CSS类名,然后覆盖原有样式。
- 自定义布局:修改加减按钮的位置
对于这类诉求,组件维护者可能会新增入参,但入参的数量总是有限的,而布局的可能性是无限的。
随着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>
)
}