前言
这里并不是想引起所谓 ant-design,element plus 这种样式,dom结构,javascript在一起的传统组件库更好,还是国外 github start 都要接近 100k 的组件库 shadcn/ui (谷歌的 mui 已经在做 headless 版本了,你可以简单理解 headless 就是无样式组件的意思)更好!
而是提供一个更宽广的视角,都说 headless 组件库拓展性很好,可维护性很好,到底为什么这么说?(当然缺点也很明显,技术一般的人 hold 不住在拓展 headless 组件中复杂功能的实现)。
还有,国内没有特别有含金量的实战文章,比如 从 0 到 1 实现一个比较典型能说明 headless 组件优点的文章,又因为我自己在做 headless 组件库( github地址,官网github宣传地址), 也看了一些这方面的文章,受益颇深, 希望能跟大家交流 headless 的思想。
对你的帮助
这篇文章读完,能确保你了解到:
- 具备自己的
headless组件的思路,无论是写组件库,还是实现业务组件的抽象,提供思路 - 想了解
shadcn/ui,reach ui,radix-ui等等这些可组合的headless组件内部是如何构建的
Let's get started!
什么是好的组件
在你工作中,假设你的负责人让你开发一个 手风琴 组件,如下,点击 Accordion 1 或者 2, 3的标题,其折叠在内的文字会展开。你可能会这样做。'

首先用法如下,传入 accordionData:
javascript
const accordionData = [
{ id: 1, headingText: 'Heading 1', panel: 'Panel1 Content' },
{ id: 2, headingText: 'Heading 2', panel: 'Panel2 Content' },
{ id: 3, headingText: 'Heading 3', panel: 'Panel3 Content' },
]
const SomeComponent = () => {
const [activeIndex, setActiveIndex] = useState(0)
return (
<div>
<Accordion
data={accordionData}
activeIndex={activeIndex}
onChange={setActiveIndex}
/>
</div>
)
}
然后,组件内部通过你传入的 data,使用 map 函数渲染,接着给组件的 button(用来装标题的),绑定一个 onClick 事件,当点击的时候,就看看就会 onChange 当前点击的 activeIndex 是否就是自己的 index,从而在展开内容的 <div hidden={activeIndex !== idx}>{item.panel}</div> 部分判断,是否展开自己的文字部分。
javascript
function Accordion({ data, activeIndex, onChange }) {
return (
<div>
{data.map((item, idx) => (
<div key={item.id}>
<button onClick={() => onChange(idx)}>
{item.headingText}
</button>
<div hidden={activeIndex !== idx}>{item.panel}</div>
</div>
))}
</div>
)
}
这种封装在我们日常业务中司空见惯,但假设需求发生了变化,现在您需要在手风琴按钮和标题中添加对图标的支持,并能添加一些样式。你一般就会这样做:
javascript
- function Accordion({ data, activeIndex, onChange }) {
+ function Accordion({ data, activeIndex, onChange, displaySomething }) {
return (
<div>
{data.map((item, idx) => (
<div key={item.id}>
<button onClick={() => onChange(idx)}>
{item.headingText}
+ {item.icon? (
+ <span className='someClassName'>{item.icon}</span>
+ ) : null}
</button>
- <div hidden={activeIndex !== idx}>{item.panel}</div>
+ <div hidden={activeIndex !== idx}>
+ {item.panel}
+ {displaySomething}
+ </div>
</div>
))}
</div>
)
}
对于每一个不断变化的需求,你都需要重构你的组件,以满足业务的需求。这似乎很正常,需求变了难到组件不变吗?你仔细想想,似乎也不太对,就跟组件库一样,难道需求变了,组件库内部也需要不断变吗?这些组件库都是第三方的,很难要求他们根据你的需求去变化?
所以怎么可能有万能的组件库,dom 结构跟得上千变万化的业务呢?这也说明了传统 ant-design, element-plus 组件库的局限。
所以我们想想有什么办法能解决?
现在目光转移到一个简单的问题上来,想想 HTML 中的 <select> 和 <option> 元素,分开来说,这两个元素能做的很有限,可当他们组合起来的时候,通过共享一些状态,可以组合成下拉框组件,这就复合组件的概念,复杂组件组装而来,而不是一味的隐藏黑盒和过度封装。
复合组件
根据上面复合组件的思想,我们改装之前的 手风琴 组件:
jsx
<Accordion>
<AccordionItem>
<AccordionButton>Heading 1</AccordionButton>
<AccordionPanel>
Panel 1
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>Heading 2</AccordionButton>
<AccordionPanel>
Panel 2
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>Heading 3</AccordionButton>
<AccordionPanel>
Panel 3
</AccordionPanel>
</AccordionItem>
</Accordion>
这里,我们把整个手风琴组件拆分为 <Accordion>、<AccordionItem>、<AccordionButton>, <AccordionPanel> 四部分。
这里有同学会说了,你这例如 <AccordionButton> 都把 html 标签设置死了,拓展性也不强呀。(其实这个感觉还好,因为 css 自定义的情况下,完全可以自己改展示元素的样式,就已经脱离了标签本身样式的限制了),但为了做的更好,我们设置用户可以自定义标签类型。
例如 <AccordionButton> 元素我们这样封装:
javascript
const AccordionButton = forwardRef(function (
{ children, as: Comp = "button", ...props }: AccordionButtonProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion-button="">
{children}
</Comp>
);
});
可以看到,上面有 as 属性,可以自定义标签类型。
其它组件也是类似:
javascript
const Accordion = forwardRef(function (
{ children, as: Comp = "div", ...props }: AccordionProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion="">
{children}
</Comp>
);
});
const AccordionItem = forwardRef(function (
{ children, as: Comp = "div", ...props }: AccordionItemProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion-item="">
{children}
</Comp>
);
});
const AccordionPanel = forwardRef(function (
{ children, as: Comp = "div", ...props }: AccordionPanelProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion-panel="">
{children}
</Comp>
);
});
需要说明如下:
- Accordion 组件是由四部分组成
- 每个部分都包装在
forwardRef中,所有外界都可以获取到对应的dom元素的实例。 - as 属性可以自定义渲染元素的是什么
- data-* 属性用于在测试这个组件的时候,咱们设置一个独一无二的属性选择器,好让测试框架选中
组合起来,我们可以这样使用手风琴组件:
javascript
<Accordion>
<Accordion.Item>
<Accordion.Button>Button 1</Accordion.Button>
<Accordion.Panel>Panel 1</Accordion.Panel>
</Accordion.Item>
<Accordion.Item>
<Accordion.Button>Button 2</Accordion.Button>
<Accordion.Panel>Panel 2</Accordion.Panel>
</Accordion.Item>
<Accordion.Item>
<Accordion.Button>Button 3</Accordion.Button>
<Accordion.Panel>Panel 3</Accordion.Panel>
</Accordion.Item>
</Accordion>
到此,大家仔细看看,之前我们有个增加 Icon 的功能,其实我们只需要在<Accordion.Button> 中增加 Icon 组件就可以了。
javascript
<Accordion.Button><自定义 Icon 组件 /> Button 1</Accordion.Button>
是不是很简单就拓展了 dom 原来的 Accordion 组件也完全不用改,这属于用户自定义行为。
当然这里有个坑我们后面解决,就是,每个 <Accordion.Item> 可能都需要一个 index 和 disabled 参数,index 是告诉我这个面板的索引是多少,disabled是告诉这个面板是否是禁用状态。
而这两个参数,可能我们自定义的 Icon 组件需要,比如在 disabeld 状态下,样式会变化。所以这个悬念后面我们解决。
然后因为手风琴组件,大家要共享选中和关闭状态,例如有哪些面板被选中,这里我们使用 Context API 来实现。
javascript
const AccordionContext = createContext({});
const AccordionItemContext = createContext({});
const useAccordionContext = () => {
const context = useContext(AccordionContext);
if (!context) {
throw Error("useAccordionContext must be used within Accordion.");
}
return context;
};
.......
const Accordion = forwardRef(function (
.....
return (
<AccordionContext.Provider value={{}}>
.....
</AccordionContext.Provider>
);
});
-
我们创建了一个上下文共享状态的
context:AccordionContext以及对应的的useContext钩子:`useAccordionContext`` -
AccordionContext用来共享全局的状态,例如关闭和打开哪个<Accordion.Item>(面板) 组件的索引。
小总结
这个 AccordionContext 好处是,如果组件库导出了这个 context,那么你可以在这个里面自己添加任何组件,通过调用useAccordionContext, 就能共享手风琴里的所有共享信息,所以此时这个组件库已经超越了传统组件库不能定制样式和不能定制 dom 结构的问题了。
其实还需要一个 conext,例如 <Accordion.Item>(面板)可以单独传入 disabled 参数,表示是否禁用当前面板,所以在这个<Accordion.Item>下,我们如果自定义的组件,也需要共享到这个 disabled 状态,所以单独导出一个 <Accordion.Item> 共享的 context。
在下面的案例里也会有这个 useAccordionItemContext,大家大概明白是什么意思就行。这篇文章主要是讲解 headless 组件构建思路。
到这里其实就解决之前的疑问,如何让面板共享状态给我们自定义的 Icon 组件,思想使用 Context API.
其实还有一种方式,就是使用 React.cloneElement 语法,这种方式也有其用武之地,但在这里明显不如 Context API 灵活,为啥呢,因为我们自定义 Icon,我们可以用 Context API 获取到共享状态,而 React.cloneElement 只能给组件中已知的,例如 <Accordion.Button> 传递状态。比如这样
javascript
const AccordionItem = forwardRef(function (
{ children, as: Comp = "div", disabled, ...props }: AccordionItemProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion-item="">
```
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
disabled
});
}
return child;
})}
</Comp>
);
});
缺点之前也说过了,这里不赘述,大家可以看做另一种方案。
我们接下来给组件增加一些功能。
继续深入:无状态组件和有状态组件
无状态组件典型的就是 <input> 元素,你输入任何值,它自己单独记录,你不用管。有状态组件就是你要单独自己设置 state 去管理,例如:
html
<input value={someValue} />
说白了,无状态组件,你不需要单独传参数去控制它最终显示的值,有状态组件就需要。
同时需要记住,一个组件要么是有状态组件,要么是无状态的,只能二选一。如果都有的话,那么就是有状态组件,无状态会被无视。
最后一个无状态组件的关键点是可以传入 defaultValue
html
<input defaultValue='John Doe' />
我们为了丰富之前手风琴组件的效果,支持传入如下参数:
-
index: 可选,类型是
number或者number数组,代表当手风琴面板的索引, 应该跟onChange配合使用。 -
onChange: 可选,类型是函数,
(index: number) => void,用法是当手风琴里的子元素打开或者关闭时触发此事件。 -
collapsible:可选,类型是
boolean,默认false. 它决定了是否允许用户关闭所有面板,有些产品要求至少有一个面板是展开的,所以增加了这个参数。此参数仅对非受控组件(即没有index和onChange属性的组件)有效。在受控组件中,面板的打开与关闭状态完全由父组件通过index属性控制。 -
defaultIndex,可选,类型是
number或者number数组,代表打开面板的索引默认值,如果collapsible设置为true,没有设置defaultIndex,那么所有面板初始化都是关闭的。否则,默认第一个面板打开。 -
multiple,可选,类型是
boolean,默认false,在非受控组件的情况下,是否允许同时打开多个面板 -
readOnly,可选,类型是
boolean,默认false,手风琴组件是否是可读状态,也就意味着用户是否可以切换面板状态。
改造组件如下:
javascript
const Accordion = forwardRef(function (
{
children,
as: Comp = "div",
defaultIndex,
index: controlledIndex,
onChange,
multiple = false,
readOnly = false,
collapsible = false,
...props
}: AccordionProps,
forwardedRef
) {
....
const AccordionItem = forwardRef(function (
{
children,
as: Comp = "div",
disabled = false,
...props
}: AccordionItemProps,
forwardedRef
) {
....
增加无状态组件功能
涉及无状态组件功能参数包括:defaultIndex, multiple, collapsible, 但是不包括 index, onChange。
然后我们也会给 AccordionItem 一个 index 参数,表示每个面板的索引
javascript
<Accordion defaultIndex={[0, 1]} multiple collapsible>
<Accordion.Item index={0}> // <= index
....
</Accordion.Item>
<Accordion.Item index={1}> // <= index
....
</Accordion.Item>
....
</Accordion>
然后我们继续丰富组件内容,以下不是完整代码,主要是帮助大家快速理解 headless 组件构建思路。
javascript
const Accordion = (...) => {
const [openPanels, setOpenPanels] = useState(() => {
// 根据 multiple, collapsible 参数设置初始化展开哪些面板
});
const onAccordionItemClick = (index) => {
setOpenPanels(prevOpenPanels => { // 更新面板展开或者关闭逻辑 })
}
const context = {
openPanels,
onAccordionItemClick
};
return (
<AccordionContext.Provider value={context}>
....
</AccordionContext.Provider>
);
};
const AccordionItem = ({ index, ...props }) => {
const { openPanels } = useAccordionContext();
const state = openPanels.includes(index) ? 'open' : 'closed'
const context = {
index,
state,
};
return (
<AccordionItemContext.Provider value={context}>
....
</AccordionItemContext.Provider>
);
};
const AccordionButton = () => {
const { onAccordionItemClick } = useAccordionContext();
const { index } = useAccordionItemContext();
const handleTriggerClick = () => {
onAccordionItemClick(index);
};
return (
<Comp
....
onClick={handleTriggerClick}
>
{children}
</Comp>
);
};
const AccordionPanel = (...) => {
const { state } = useAccordionItemContext();
return (
<Comp
....
hidden={state === 'closed' }
>
{children}
</Comp>
);
});
增加有状态功能
有状态组件是很简单的,因为是用户自己传入参数来控制。我们增加 index 和 onChange 参数,从而让用户能更新内部状态。
javascript
const Accordion = forwardRef(function ({
+ index: controlledIndex,
+ onChange,
....
const onAccordionItemClick = useCallback(
(index: number) => {
+ onChange && onChange(index);
setOpenPanels((prevOpenPanels) => {
...
);
const context = {
+ openPanels: controlledIndex ? controlledIndex : openPanels,
.....
};
如上,,受控状态 controlledIndex 按预期覆盖了 openPanels 中的非受控状态。(openPanels 是前面我们在Accordion组件内定义记录当前打开的是哪些面板的 state)。
关于 onChange,它并不决定我们的组件是受控还是非受控。无论是否传递了受控的 index 属性,都可以传递 onChange。onChange 属性的目的是向父组件通知状态变更。
其实到这里差不多就结束了,其实很多组件库,把无状态和有状态都合并为了一个 hooks 这样可以解决用条件去判断(如我们上面)的繁琐。
这个函数我的 t-ui组件库也有借鉴(求一个 start, 致力于打造最好的组件库教程网站 ,github地址)源码如下:
javascript
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';
export function useMergeValue<T>(
defaultStateValue: T,
props?: {
defaultValue?: T;
value?: T;
},
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
const { defaultValue, value } = props || {};
const firstRenderRef = useRef(true);
const prevPropsValue = usePrevious(props?.value);
const [stateValue, setStateValue] = useState<T>(
!isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
);
// 受控转为非受控的时候,需要做转换处理
useEffect(() => {
if (firstRenderRef.current) {
firstRenderRef.current = false;
return;
}
if (value === undefined && prevPropsValue !== value) {
setStateValue(value);
}
}, [value]);
const mergedValue = isUndefined(value) ? stateValue : value;
return [mergedValue, setStateValue, stateValue];
}
这里简单解释以下,其实就是你传了 value 我就认为你是受控组件,然后 value 就透传出去,我这个 hooks 不管,如果有 defaultValue 或者组件库想默认给个默认值, 我会用其初始化 stateValue 然后传出去。并且 setStateValue 方法能改变其值。
setStateValue 其实在传入 value 的情况下,也没什么用,因为改变不了 value 的值。