下一代组件的奥义在此!headless 组件构建思想探索!

前言

这里并不是想引起所谓 ant-designelement 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> 可能都需要一个 indexdisabled 参数,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. 它决定了是否允许用户关闭所有面板,有些产品要求至少有一个面板是展开的,所以增加了这个参数。此参数仅对非受控组件(即没有 indexonChange 属性的组件)有效。在受控组件中,面板的打开与关闭状态完全由父组件通过 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>
  );
});

增加有状态功能

有状态组件是很简单的,因为是用户自己传入参数来控制。我们增加 indexonChange 参数,从而让用户能更新内部状态。

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 属性,都可以传递 onChangeonChange 属性的目的是向父组件通知状态变更。

其实到这里差不多就结束了,其实很多组件库,把无状态和有状态都合并为了一个 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 的值。

相关推荐
牛奶2 小时前
2026年大模型怎么选?前端人实用对比
前端·人工智能·ai编程
牛奶2 小时前
前端人为什么要学AI?
前端·人工智能·ai编程
Kagol5 小时前
🎉OpenTiny NEXT-SDK 重磅发布:四步把你的前端应用变成智能应用!
前端·开源·agent
GIS之路6 小时前
ArcGIS Pro 中的 notebook 初识
前端
JavaGuide6 小时前
7 道 RAG 基础概念知识点/面试题总结
前端·后端
ssshooter7 小时前
看完就懂 useSyncExternalStore
前端·javascript·react.js
格砸7 小时前
从入门到辞职|从ChatGPT到OpenClaw,跟上智能时代的进化
前端·人工智能·后端
Live000008 小时前
在鸿蒙中使用 Repeat 渲染嵌套列表,修改内层列表的一个元素,页面不会更新
前端·javascript·react native
柳杉8 小时前
使用Ai从零开发智慧水利态势感知大屏(开源)
前端·javascript·数据可视化