Tailwind CSS + cva 实现样式变体组件

前言

button 组件是我们在开发中经常会用到的组件,这是一个看起来十分简单的组件,但是在实际开发中,我们经常会遇到这样的需求:同一个 button 组件,需要实现不同的样式,比如:不同的颜色、不同的大小、不同的形状等等。那么,我们如何通过 Tailwind CSS 优雅的实现这样的需求呢?

需求

为了方便演示,我们先来简单定义一下我们的基本需求:

  • button 组件有几种语义类型 type:primary、secondary、success、danger 等
  • button 组件有三种大小 size:small、medium、large
  • button 组件有三种填充 fill:solid、outline、text
  • button 组件有两种形状 shape:方形(小圆角)、胶囊形(full rounded)
  • button 组件支持 disabled
  • button 组件支持 Icon
  • button 组件支持 loading

简单分析一下需求,前四个需求都是对应按钮的不同样式变体(variants),我们只需要给每种变体指定自己特有的样式即可。而 disabled 和 loading 对应按钮的不同状态。我们使用组合的方式构建 Button 组件,因此 Icon 的逻辑不必写在 Button 组件内部。这样的话,loading 态也可以分解成一个 loading-icon + disabled 的组合。

初步实现

我们会用到一个 clsx 库,它可以帮助我们更方便的通过条件去控制样式的变化。我们先来看一下我们的 Button 组件的代码:

jsx 复制代码
import clsx from "clsx";

interface Props
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
  type?: "primary" | "success" | "danger";
  size?: "small" | "large" | "middle";
  fill?: "solid" | "outline" | "text";
  shape?: "round" | "square";
  disabled?: boolean;
}
export const Button = ({
  type = "primary",
  size = "middle",
  fill = "solid",
  shape = "square",
  children,
  ...props
}: Props) => {
  return (
    <button
      className={clsx(
        "inline-flex items-center justify-center rounded-lg text-sm font-medium disabled:opacity-20",
        {
          "bg-primary text-primary border-primary": type === "primary",
          "bg-success text-success border-success": type === "success",
          "bg-danger text-danger border-danger": type === "danger",
          "h-8  px-2 py-2": size === "small",
          "h-10 px-4 py-2": size === "middle",
          "h-12 px-6 py-2": size === "large",
          "text-white": fill === "solid",
          "border bg-white": fill === "outline",
          "bg-white": fill === "text",
          "!rounded-full": shape === "round",
        }
      )}
      {...props}
    >
      {children}
    </button>
  );
};

以上代码基本实现了我们的需求,但是存在两个问题:

  1. clsx 中大量的条件判断代码,使代码看起来很臃肿,没有组织性,不利于代码的阅读与维护。
  2. 我们无法保证 tailwind 中 class 的优先级,我们无法保证写在后面的样式可以覆盖前面的样式,比如 shape 为 round 的情况下,我们希望rounded-full更够覆盖基本样式中rounded-lg,但是我们无法保证这一点。只能通过!rounded-full来覆盖rounded-lg,!important的使用会导致样式的不可预测性,不利于代码的维护。

使用 cva + tailwind-merge 改写

cva 是 Class Variance Authority 的缩写,cva 是一个用于管理样式变体的库,它可以帮助我们更好的组织样式变体,使得代码更加清晰,更加易于维护。我们可以使用 cva 来重写我们的 Button 组件,使得代码更加清晰。具体的用法,可以去看官方文档。这里就不做过多用法介绍了。

tailwind-merge 用来处理 tailwind 样式冲突问题,它可以让写在后面的样式覆盖前面的样式,这样我们就不需要使用!important来覆盖样式了。

以下是重构后的代码:

jsx 复制代码
import { cva, type VariantProps } from "class-variance-authority";
import clsx from "clsx";
import { twMerge } from "tailwind-merge";

interface Props
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type">,
    VariantProps<typeof buttonVariants> {}

const buttonVariants = cva(
  "inline-flex rounded-lg items-center justify-center text-sm font-medium disabled:opacity-50",
  {
    variants: {
      type: {
        primary: "bg-primary text-primary border-primary",
        success: "bg-success text-success border-success",
        danger: "bg-danger text-danger border-danger",
      },
      size: {
        middle: "h-10 px-4 py-2",
        small: "h-8  px-2 py-2",
        large: "h-12 px-6 py-2",
      },
      fill: {
        solid: "text-white",
        outline: "border bg-white",
        text: "bg-white",
      },
      shape: {
        round: "rounded-full",
        square: "rounded-lg",
      },
    },
    defaultVariants: {
      type: "primary",
      size: "middle",
      fill: "solid",
      shape: "square",
    },
  }
);

export const Button = ({
  type,
  size,
  fill,
  shape,
  children,
  ...props
}: Props) => {
  return (
    <button
      className={twMerge(clsx(buttonVariants({ type, size, fill, shape })))}
      {...props}
    >
      {children}
    </button>
  );
};

可以看到,改写后的 Button 组件清晰简洁了很多,我们把样式变体的定义和组件的实现分离开来,使得代码更加清晰,更加易于维护。

使用 twMerge 来处理样式冲突问题,使得我们可以更加方便的控制样式的优先级。

一些优化点

  1. 如果组件的使用场景是 PC 端,可以添加focus-visible伪类,美化键盘聚焦 Button 时的样式。还可以给不同的 type 加上对应的 hover 态样式。
  2. 如果组件的使用场景是移动端,可以添加active伪类,美化点击 Button 时的样式。
  3. 如果希望外部对 Button 组件的样式进行覆盖,可以使用className属性,这样可以让 Button 组件更加灵活。代码如下:
jsx 复制代码
export const Button = ({
  className,
  type,
  size,
  fill,
  shape,
  children,
  ...props
}: Props) => {
  return (
    <button
      className={twMerge(
        clsx(buttonVariants({ type, size, fill, shape, className }))
      )}
      {...props}
    >
      {children}
    </button>
  );
};

总结

本文通过一个简单的 Button 组件,介绍了如何使用 cva 和 tailwind-merge 来更好的组织样式变体,使得代码更加清晰,更加易于维护。

欢迎在评论区留下你的看法,如果觉得本文对你有帮助,欢迎点赞、收藏。十分感谢!!!

相关推荐
子兮曰5 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰5 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万5 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝5 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋5 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁5 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王95275 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大5 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师5 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学5 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端