深度解析如何基于 tailwindcss 实现 Button 组件的多种变体

文摘   2024-08-14 10:37   广东  

昨天接了个广子,为了感谢大家的支持,今天又来给大家整点干货。本来计划花一个小时写完的,结果在群里回复了几个问题,一耽误就到这个点才发出来...

这篇文章给大家分享的是,深度解析如何基于 tailwindcss 实现 Button 组件的多种变体。

在聊这个之前,我们必须要同步一个认知,封装一个完整的,可商用的 Button 组件,可不是一件简单的事情。这里面涉及到有意思的东西还是挺多的。

这篇文章,就以我在付费小册《React 19》中封装的 Button 组件为例,给大家深度拆解一下如何封装一个可商用的 Button 组件。这篇文章不仅包含了对变体的思考,也包含了对 tailwindcss 的实践运用。

我的在线付费小册中的所有组件,都是从零自己手写封装,没有依赖任何三方工具。


一、难在哪?

有一个称呼,叫做变体。这是基础组件中经常会遇到的概念。它指的是不同的类型、参数组合在一起,衍生出新的样式。

Button 组件中最大的难度,就在于处理变体。

例如,在我的组件中,我约定 Button 组件有六种种类型

  • default 表示默认状态
  • danger 表示危险操作
  • primary 表示主题色按钮
  • success 表示成功按钮
  • signal 表示信号
  • ghost 表示

如下图所示

这里有一个比较不好处理的事情是,ghost 表达的类型,与其他类型在语义上并不是一个互斥关系。例如,我一个按钮,可以同时是 ghost 与 danger

于是,变体样式就产生了

<Button ghost>Default</Button>
<Button ghost primary>primary</Button>
<Button ghost danger>Danger</Button>
<Button ghost success>Success</Button>
<Button ghost signal>Signal</Button>

如下图所示

除此之外,每一个按钮还有 hover、active 等状态需要我们单独处理,注意看下面按钮的变化

另外,这些按钮还需要跟是否禁用的 disabled 组成新的变体样式

这复杂度不就上来了吗,运用代码如下

<Button ghost disabled>Default</Button>
<Button ghost disabled primary>primary</Button>
<Button ghost disabled danger>Danger</Button>
<Button ghost disabled success>Success</Button>
<Button ghost disabled signal>Signal</Button>

除此之外,还有尺寸、大小、形状的变体。

要完美的处理好这些变体,是封装 Button 组件的主要难点。接下来,我们就来深度分析一下,如何基于 tailwindcss 来完成我们的需求。

当然,我也不能确保我的封装思路是最佳实践,大家有更好的方案可以在评论区跟道友们一起交流。


二、弱化变体的复杂度

多种基本类型、多种交互状态、ghost、disabled、大小、形状等糅合在一起,变体的复杂度确实非常高。因此,在封装之前,我们要准确的分析这些变体的组合逻辑,并合理的弱化他们的复杂度。

首先,弱化子组件的考虑

作为一个按钮,很有可能会在子组件中出现各种不同的情况,例如,我需要的可能是文本按钮、图标 + 文本结合按钮、纯图标按钮等

如下图演示所示

这种情况下,我们只需要把 Button 组件,当成父组件来考虑,放弃在 Button 组件的逻辑中思考子组件的情况即可。

这样做的一个好处是子组件的组合情况会更灵活,会极大的降低 Button 组件中的逻辑。例如,一个纯图标的按钮组件,他只需要确保形状为一个矩形即可,那么我们可以这样做

<Button rect signal>
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-5">
    <path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
  </svg>
</Button>

注意看我这里使用了一个 svg 的图标,事实上,由于我在 Button 内部并没有处理子组件的逻辑,因此,我们可以传入任意的图标类型,字体文件,Icon 组件,图标等。灵活性得到了极大的提高。更大的好处是可以提高开发者的掌控力度。而不用随时想着某个需求不满足就跑去改组件源码

我以前的封装思路是传入一个图标名,然后让 Button 内部去处理图标存在的情况。

<Button icon='bell' />

这样做的好处是写法会简洁一些,坏处就是灵活性大打折扣。根据我多年的开发体验下来,我会选择更强的灵活性。例如,当我需要一个上下结构的按钮组件,我们可以自己组合

这里的巧妙之处就在于,我把 Button 组件与图标组件的维护解耦开了,也把子组件的样式解耦开了。对于开发者来说,子组件的所有属性充分可控,我们就可以非常轻松的应对各种无理的设计师需求。

<Button primary ghost>
  <Flex col className="space-y-2">
    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
      <path strokeLinecap="round" strokeLinejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
    </svg>
    <span>bolt</span>
  </Flex>

</Button>

其次,合理利用 tw 的变体特性

实际上 tailwindcss 的设计中,已经充分考虑了变体的存在,因此设计了很多语法来满足变体的需求

因此,当我们基于 tw 封装时,可以充分利用它的设计来简化我们的封装。

在即将发布的 tailwindcss 4.0 中,新增了变体组合的特性,可能会对 Button 变体的处理更有利,不过我还没有详细研究过,大家可以保持关注

然后,准确分析变体属性

有的变体是互斥的关系。因此我们不能合并处理。有的是融合的关系,那么我们就需要特殊处理。

例如,类型 primary 与 danger 是互斥的,但是 primary 与 ghost 则是可以融合的。我们在封装时,要完整的把这些特性区分开。

类型属性基本上是与颜色有关。大概率包含三个颜色:边框颜色、背景颜色、文字颜色。antd 的组件还更复杂,因为他还设计了一个点击时的波型特效,因此还要额外考虑这个的颜色

形状和大小则更多的与尺寸属性有关,因此和类型属性基本上相互不干扰,那么他们的融合会更加的方便。

最重要的一点,活用 className

我们可以在内部逻辑中,把外部传入的 className 作为生效优先级最高的样式设计,这样,我们在使用时,就可以充分利用 className 的灵活性来增加 Button 组件的适用范围,弱化内部的逻辑处理。

例如,在设计之初,我约定的是 primary 类型是没有边框的,这个时候,有一个需求是要得到下面这个样式的组件。

需求超出了约定规则,那么我们就可以利用 className 进行补强。例如,上方的矩形按钮代码如下,我在 className 中,传入了一个边框颜色

<Button primary ghost className="border-blue-500">
  <Flex col className="space-y-2">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5"><path fill-rule="evenodd" d="M19.5 21a3 3 0 0 0 3-3V9a3 3 0 0 0-3-3h-5.379a.75.75 0 0 1-.53-.22L11.47 3.66A2.25 2.25 0 0 0 9.879 3H4.5a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h15Zm-6.75-10.5a.75.75 0 0 0-1.5 0v4.19l-1.72-1.72a.75.75 0 0 0-1.06 1.06l3 3a.75.75 0 0 0 1.06 0l3-3a.75.75 0 1 0-1.06-1.06l-1.72 1.72V10.5Z" clip-rule="evenodd"></path></svg>
    <span>File</span>
  </Flex>

</Button>

上方的圆形图标代码如下,我在 className 中传入了圆角为圆形,并且设置了边框颜色

<Button success ghost rect className="rounded-full border-green-500">
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
    <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
  </svg>

</Button>

这样,如果我们发现,可以简单利用 className 来解决的问题,就可以不需要再 Button 组件内部单独额外处理,即增强了灵活性,又简化了 Button 的内部逻辑。

OK,分析了这些之后,我们接下来着手开发封装。

三、传参设计

我们可以采用常规的参数传递方式

<Button type="primary" shap="circle"></Button>

但是我个人现在更偏好另外一种风格:把所有类型参数设计成为 boolean 值,然后在组件内部判断

<Button ghost primary>primary</Button>
<Button ghost danger>Danger</Button>
<Button ghost success>Success</Button>
<Button ghost signal>Signal</Button>

注意: 这只是个人偏好,并不做任何提倡。

四、封装

首先,先根据需求,约定一组默认样式

const base = 'rounded-md border border-transparent font-medium cursor-pointer transition relative text-gray-600 hover:bg-transparent'

// size
const md = 'text-xs py-2 px-4'

然后,接收所有可能传入的类型

const {className, primary, danger, sm, lg, signal, success, ghost, rect, disabled, ...other} = props

const def = !primary && !danger && !success && !signal

接下来,一个麻烦的地方就来了,如何正确处理变体的组合情况。首先是类型,我的设计方案是把每个类型都拍平单独考虑。

defualt、primary、danger、success、signal 作为基线,分别处理他们与其他变体的融合情况。我声明了一个函数来处理这样的情况

// 专门处理 default 的变体样式
function generatorDefault(disabled, ghost{
  let base = 'bg-gray-100 text-gray-500'
  let inter = 'hover:bg-gray-200 active:bg-gray-300'

  if (ghost) {
    base = 'bg-transparent text-gray-500'
    inter = 'hover:bg-gray-50 active:bg-gray-100 hover:text-gray-600 active:text-gray-700'
  }
  if (disabled) {
    return clsx('opacity-70 cursor-not-allowed', base)
  }
  return clsx(base, inter)
}

primary、danger、success 都和 default 基本一样。只是 signal 有点小区别

function generatorSignal(disabled{
  let base = 'bg-white border-sky-300 text-sky-500'
  let inter = 'hover:border-sky-400 active:bg-sky-500 hover:text-sky-600 active:text-sky-700 hover:bg-sky-50 active:bg-sky-100'

  if (disabled) {
    return clsx('opacity-70 cursor-not-allowed', base)
  }
  return clsx(base, inter)
}

这样分开处理有一个巨大的好处就是逻辑解耦。在不同的设计师眼中,没有形成一套通用的规范,所以经常搞得乱七八糟的,我们这样做的话就可以适应这些不专业的设计规范。

当然,我们也可以把这些方法抽象到一起来考虑。

// 为啥不能这样写
function generatorBGClass(color, disabled{
  const base = `bg-${color}-500`
  const hover = `hover:bg-${color}-600`
  const active = `active:bg-${color}-700`
  const disable = 'bg-opacity-80'

  if (disabled) {
    return clsx(base, disable)
  }
  return clsx(base, hover, active)
}

但是我没有这样做,是因为 tw 无法正确识别非完整的字符串。bg-${color}-500,这样的写法 tw 不知道要如何生成对应的 css 文件。

!

需要特别注意的是:tw 是在运行时生成 css 文件的

当然,也有补救的办法,那就是在配置文件中手动配置。这种补救办法我并不是很推荐使用,因为会造成你封装的代码逻辑依赖于更多的外部条件,失去了独立性和可扩展性。因此,综合权衡之下,我选择了分开写 5 个类型函数来生产变体样式

有了这几个函数之后,我们把他运用到代码中,利用 clsx 与 twMerge 来合并最终的代码样式。

const cls = twMerge(clsx(base, md, {
  // type
  [generatorDefault(disabled, ghost)]: def,
  [generatorPrimary(disabled, ghost)]: primary,
  [generatorDanger(disabled, ghost)]: danger,
  [generatorSucess(disabled, ghost)]: success,
  [generatorSignal(disabled)]: signal,

  // size
  ['text-xs py-1.5 px-3']: sm,
  ['text-lg py-2 px-6']: lg,
  ['p-2']: rect,
}, className))

sm、lg、mdrect 应该有更复杂的变体实现,我这里由于需求并不多,所以做了简化处理,更多的还是要参考颜色变体的实现。

最后返回

return (
  <button className={cls} {...otherdisabled={disabled}>
    {props.children}
    {signal && (
      <span className="absolute flex h-3 w-3 right-[-5px] top-[-5px]">
        <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
        <span className="relative inline-flex rounded-full h-3 w-3 bg-sky-500"></span>
      </span>
    )}
  </button>

)

实现效果刚才已经演示了很多,这里简单看一下就行。


总结

本文中的 Button 组件来源于我的付费小册《React 19》。在这本付费小册中,你可以在学习过程中,沉浸式的体验所有在线演示案例。

Button 组件的封装作为一个网站的基础能力,但是由于其复杂的变体,封装起来非常的麻烦。封装完之后很容易出现某些场景不满足的情况。本文从需求出发,结合 tailwindcss 的基础能力,为大家一步一步分析了如何思考和解决这些变体,最终构建一个功能完善的、可维护性强、可扩展性强的 Button 组件。

当然,真实的商用项目中,依然还有可能在我的这个基础之上增加更多的复杂度,例如增加 dark 模式,适配移动端等,大家可以根据我的方式进一步扩展即可

更重要的是,我在文章中借助了封装 Button 组件为大家演示了一个完整的思考,这个思考可以帮助你在面试中去应对:『你曾经解决了什么困难的事情,是如何解决的...,有什么收获』 这样的问题


推荐阅读

掌握 React 19,推荐阅读我的 付费小册 React19

成为 React 高手,推荐阅读 React 哲学

这波能反杀
往者不可谏,来者犹可追
 最新文章