昨天接了个广子,为了感谢大家的支持,今天又来给大家整点干货。本来计划花一个小时写完的,结果在群里回复了几个问题,一耽误就到这个点才发出来...
这篇文章给大家分享的是,深度解析如何基于 tailwindcss 实现 Button 组件的多种变体。
在聊这个之前,我们必须要同步一个认知,封装一个完整的,可商用 的 Button 组件,可不是一件简单的事情。这里面涉及到有意思的东西还是挺多的。
这篇文章,就以我在付费小册《React 19》中封装的 Button 组件为例,给大家深度拆解一下如何封装一个可商用的 Button 组件。这篇文章不仅包含了对变体的思考,也包含了对 tailwindcss 的实践运用。
✓ 我的在线付费小册中的所有组件,都是从零自己手写封装,没有依赖任何三方工具。
一、难在哪? 有一个称呼,叫做变体 。这是基础组件中经常会遇到的概念。它指的是不同的类型、参数组合在一起,衍生出新的样式。
Button 组件中最大的难度,就在于处理变体。
例如,在我的组件中,我约定 Button 组件有六种种类型
如下图所示
这里有一个比较不好处理的事情是,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} = propsconst 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、md
与 rect
应该有更复杂的变体实现,我这里由于需求并不多,所以做了简化处理,更多的还是要参考颜色变体的实现。
最后返回
return ( <button className ={cls} {...other } disabled ={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 哲学