🖱️使用 Vue+pandaCSS+tsx 实现一个超精致的按钮组件

前言

最近在重构 vuesax 的过程中遇到了组件库整体样式变量维护困难的问题,我原本使用的是 sass + css 变量去维护所有组件的样式,但是这样在使用组件时如果想要修改原有组件的样式,那只能通过 class 去覆盖原生组件样式,而且使用 sass 的方式没有办法提供很好的类型提示,于是就调研了一下目前的一些样式类的库,最终发现了 pandaCSS。于是就开始一次按钮组件重构的尝试~

pandaCSS 介绍

先放一段官方介绍:

Panda 是一个样式引擎,可生成样式基元,以类型安全和可读的方式编写原子 CSS 和配方。 Panda 结合了 CSS-in-JS 的开发者体验和原子 CSS 的性能。它利用静态分析来扫描 JavaScript 和 TypeScript 文件中的 JSX 样式属性和函数调用,按需生成样式(也称为即时生成 JIT)

它的基础使用方式如下:

ts 复制代码
<script setup lang="ts">
import { css } from "../styled-system/css";
</script>
 
<template>
  <div :class="css({ fontSize: '5xl', fontWeight: 'bold' })">Hello 🐼!</div>
</template> 

可以看到我们通过在 class 中使用函数 css 并传入一个样式对象作为参数,上面这段代码在 pandaCSS 解析后会生成如下样式,解析都是在构建的过程中执行的:

css 复制代码
@layer utilities {
  .fs_5xl {
    font-size: var(--font-sizes-5xl);
  }

  .font_bold {
    font-weight: var(--font-weights-bold);
  }
}

大家都熟知的 tailwind css 是直接使用各种提前定义好的 className 去实现样式,代码提示是通过 tailwind css 的 VsCode 插件去实现的,因此可编程性没有那么强,本质上样式和逻辑还是分离的,但在 pandaCSS 中由于我们使用的是函数去生成 className,因此样式都具有 ts 类型提示,我们可以直接将样式和逻辑都用 ts 去实现,至于如何生成真正的样式就交给 pandaCSS 去实现。

实现按钮组件

在 pandaCSS 中存在一个配方(Recipes)的概念,它专门用于创建组件的各种变体,配方包含了以下四种属性:

  • base: 组件的基本样式
  • variants: 组件的不同视觉样式
  • compoundVariants: 组件变量的不同组合
  • defaultVariants: 组件的默认变量值

下面从实际的实践中详细介绍。

定义按钮类型

通常一个按钮具有比较多的形态,例如主要按钮,次要按钮,文字按钮等等,但是不同样式的按钮之间基础的样式是可以复用的,因此我们可以使用 cva 函数创建一个按钮的配方,并定义以下按钮的基本样式:

ts 复制代码
import { cva } from '@/styled-system/css'

const buttonRecipe = cva({
    base: {
        position: "relative",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        boxSizing: "border-box",
        cursor: "pointer",
        transition: "all 0.3s ease",
        _active: {
            transform: "scale(0.98)"
        },
    }
})

export default buttonRecipe

在组件中使用:

ts 复制代码
import {
  PropType,
  defineComponent,
} from 'vue'

import buttonRecipe from './recipe';

const Button = defineComponent({
  setup(props, { slots }) {
    return () =>
      <button class={buttonRecipe()}>{slots?.default?.()}</button>
  }
})

export default Button

这里定义并导出了了一个 buttonRecipe 变量,在其中的 base 字段配置了按钮的基本样式。buttonRecipe 是一个函数,在实际执行后会生成对应的 className,而 pandaCSS 则会在你构建时根据你的 className 提前生成好样式。

但我们的按钮现在还没有背景颜色,因此我们为它创建几个变体,基于原本的 buttonRecipe 增加 variants 字段:

ts 复制代码
// recipe.ts
const buttonRecipe = cva({
    base: {
        position: "relative",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        boxSizing: "border-box",
        cursor: "pointer",
        transition: "all 0.3s ease",
        _active: {
            transform: "scale(0.98)"
        },
    },
    variants: {
        type: {
            primary: { bgColor: 'blue.600', color: 'white' },
            flat: { bgColor: "blue.50", color: 'blue.600' },
            outline: {
                borderWidth: '1.5px', borderColor: "gray.200", color: "gray.800", "&:hover": {
                }
            },
        },
        shape: {
            circle: { borderRadius: "25px" },
            square: { borderRadius: "0px" }
        },
        size: {
            xs: { px: '8px', py: '4px', fontSize: '0.55rem', borderRadius: '8px' },
            sm: { px: '10px', py: '6px', fontSize: '0.7rem', borderRadius: '10px' },
            md: { px: '12px', py: '8px', fontSize: '0.85rem', borderRadius: '12px' },
            lg: { px: '14px', py: '10px', fontSize: '1rem', borderRadius: '14px' },
            xl: { px: '16px', py: '12px', fontSize: '1.15rem', borderRadius: '16px' },
        },
    }
})

基于原本的基础样式,我为按钮增加了类型(type),形状(shape),size(尺寸),每种变体先包含一些基础样式。我们在使用配方的时候就可以将这些变体类型作为变量传入例如:

tsx 复制代码
<button class={buttonRecipe(type:"primary",size:"xs")}></button>

可以看到基础的按钮效果就渲染出来了:

接下来我们将这些变体作为组件的 props 进行定义,方便调用组件的时候动态修改,首先我们需要获取 buttonRecipe 中传入的参数类型,这样方便我们定义 props 的类型:

ts 复制代码
// recipe.ts
import { RecipeVariantProps, cva } from '@/styled-system/css'
const buttonRecipe = cva({
//...
})
export default buttonRecipe

export type ButtonVariants = Exclude<RecipeVariantProps<typeof buttonRecipe>, undefined>
//...

在原有函数定义基础上额外导出一个 ButtonVariants 类型,这个类型由 RecipeVariantProps 工具函数为我们提取,外面一层的 Exclude 是我用于去除类型为 undefined 的情况,不这么写我们无法获取到 ButtonVariants 上的字段类型。

ts 复制代码
import {
  PropType,
  defineComponent,
} from 'vue'

import buttonRecipe, { ButtonVariants } from './recipe';

const Button = defineComponent({
  props: {
    type: {
      type: String as PropType<ButtonVariants['type']>,
      default: "flat"
    },
    size: {
      type: String as PropType<ButtonVariants['size']>,
      default: "md"
    },
    shape: {
      type: String as PropType<ButtonVariants['shape']>,
    }
  },
  setup(props, { slots }) {
    return () =>
      <button class={buttonRecipe({ type: props.type, size: props.size, shape: props.shape })
      }>{slots?.default?.()}</button>
  }
})

export default Button

利用 ButtonVariantsPropType 相互配合,我们就能复用变体中不同的类型,在使用组件的时候就能获取完善的代码提示了:

实现的效果如下图:

实现点击后波浪反馈效果

接下来我们继续丰富组件的效果,很多组件库中的按钮都有点击后的波浪反馈效果,实现的原理其实不复杂,就是在鼠标点击的位置创建一个元素,然后执行一个从小到大变化的动画,最后消失。

首先我们实现一个展示波浪效果的函数 showRipple:

ts 复制代码
import { css } from "@/styled-system/css";

const showRipple = (event: MouseEvent) => {
    const el = event.currentTarget as HTMLElement
    const rippleContainer = document.createElement('div')
    rippleContainer.className = css({
        position: 'absolute',
        left: "0",
        top: "0",
        width: '100%',
        height: '100%',
        overflow: "hidden",
        borderRadius: "inherit"
    })


    const rippleElement = document.createElement('span');
    rippleElement.className = css({
        position: 'absolute',
        width: '100%',
        height: '100%',
        transform: "scale(0) translate(-50%, -50%)",
        transformOrigin: "0 0",
        opacity: 0.5,
        borderRadius: '50%',
        transition: 'all 0.8s ease',
        background: "radial-gradient(circle, rgba(200,200,200,0.5), rgba(200,200,200,1))"
    })
    rippleElement.style.left = `${event.pageX - el.offsetLeft}px`;
    rippleElement.style.top = `${event.pageY - el.offsetTop}px`;

    rippleContainer.appendChild(rippleElement)
    el.appendChild(rippleContainer)
    requestAnimationFrame(() => {
        rippleElement.style.transform = 'scale(3) translate(-50%, -50%)';
        rippleElement.style.opacity = '0';
    })

    setTimeout(() => {
        rippleContainer.remove();
    }, 800);
}

export default showRipple
  1. 首先,通过 event.currentTarget 获取当前点击的元素,并通过元素的属性计算获取鼠标的位置
  2. 创建一个新的 <div> 元素作为水波纹的容器,并给它设置一些样式属性。
  3. 创建一个新的 <span> 元素作为水波纹的图像,并给它设置一些样式属性。transform: "scale(0) translate(-50%, -50%)", transformOrigin: "0 0" 样式的目的是将执行 transform 动画时的基准点从容器的左上角移动到圆心处。
  4. 根据点击事件的位置,设置水波纹图像的位置,即 rippleElement.style.leftrippleElement.style.top
  5. 将水波纹图像添加到水波纹容器中,并将水波纹容器添加到当前点击元素中。
  6. 通过 requestAnimationFrame 在下一帧中触发一个动画,将水波纹图像进行缩放和透明度的变化,实现水波纹效果的展示。
  7. 在 800 毫秒动画结束后,移除水波纹容器,从而消除水波纹效果。

event.pageX 表示鼠标点击事件的水平坐标,el.offsetLeft表示按钮元素的左边距。通过计算鼠标点击事件的水平位置相对于按钮元素的左边距,可以得到水波纹元素相对于按钮元素的左边距。同理也可以获取上边距:

这里之所以需要两个容器嵌套,是因为外层的容器需要设置为与父级相同的大小宽高,然后设置 overflow:hidden,防止波浪跑到按钮外面,如果直接在按钮上添加 overflow:hidden 会导致按钮无法展示阴影效果。

这里还使用了 pandaCSS 中提供的一个核心函数 css,它的原理同样是将传入的样式生成为 class 名称,并设置相应的样式,在这里使用 css 函数我们就不需要额外去写一个样式文件,直接在一个 ts 函数中就能实现完整的波浪效果。

我将外层容器移除,并将波浪改为红色,方便大家更清晰的理解实现的原理:

接下来只需要在按钮的事件上绑定一下这个事件即可:

ts 复制代码
<button 
    onMousedown={el => showRipple(el)} 
      {slots?.default?.()}
 </button>

这里我绑定在 mouseDown事件上,mouseDown 事件在鼠标按下时触发,click 事件在鼠标按下并松开后才触发。

最终实现的效果,大家感觉怎么样:

实现真实按钮效果

接下来我们实现一个仿真的真实按钮效果。在现实世界中,按钮往往是突起的,且在按了之后会按钮凹下去。

我们在前面的按钮配方中新增一个类型 pressable,代表使用这个属性后按钮是可按压的,代码如下:

ts 复制代码
const buttonRecipe = cva({
    variants: {
        type: {
            pressable: {
                bgColor: "blue.600",
                color: 'white',
                _before: {
                    content: '""',
                    position: 'absolute',
                    bottom: '0px',
                    left: '0px',
                    width: '100%',
                    height: `calc(100% - 3px)`,
                    borderRadius: 'inherit',
                    pointerEvents: 'none',
                    transition: 'all 0.4s ease',
                    filter: "contrast(2) grayscale(0.4)",
                    borderBottom: '4px solid token(colors.blue.600)',
                },
                _active: {
                    transform: "translate(0, 1px)",
                    _before: {
                        borderBottom: "0px solid token(colors.blue.600)",
                    }
                }
            }
        }, 
})

这里通过在 _before 伪元素中配置了一个绝对定位的伪元素,来实现在按钮底部创建一个带有 4 个像素厚的边框的条纹。这里有一个滤镜属性:

  • contrast(2):增加了元素的对比度。
  • grayscale(0.4):将元素的图像转换为灰度,并降低了图像的饱和度,值为 0.4 表示转换后的灰度图像中有 40% 的饱和度。

在按钮被按下时( _active 状态),通过给按钮应用一个位移的样式效果来模拟按钮被按下的效果。同时,通过修改 _before 伪元素的边框样式来实现在按钮按下时去掉底部边框的效果。

最终实现的效果,大家感觉怎么样:

复合变体实现图标按钮

除了文字按钮外,通常还会有一种图标按钮,图标按钮的宽高一般相等的,要么是方形要么是圆型。但在前面我们的不同尺寸的变体中,按钮的的水平垂直间距并不相等,因此我们如果只放置一个图标在按钮中的效果是这样的:

可以看到效果并不好,按钮的图标太小了,且按钮的形状还是一个矩形,而不是方形。

那接下来就开始优化图标按钮的样式,首先我们定义一个新的变体 icon:

ts 复制代码
const buttonRecipe = cva({
    //...
    variants: {
       //...
        icon: {
            true: {},
            false: {}
        }
    },
    
})

icon 的的属性是 truefalse 代表它是一个布尔值变体,pandaCSS 会自动为我们处理类型,而这里的 icon 我并没有写样式,因为在不同 size 中,内边距和字体大小都是不同的,我们不能直接将各个尺寸的样式都统一了。这里定义 icon 变体是为了下一步实现 复合变体(Compound Variants) 做准备。

复合变体是将多个变体组合在一起以创建更复杂的样式集的一种方法。它们是使用 compoundVariants 属性定义的,该属性接受一个对象数组作为其参数。数组中的每个对象表示为了应用相应的样式必须满足的一组条件。

ts 复制代码
const buttonRecipe = cva({
    //...
    variants: {
       //...
        icon: {
            true: {},
            false: {}
        }
    },
    compoundVariants: [
        {
            icon: true,
            size: "xs",
            css: {
                p: "4px",
                fontSize: '0.7rem',
            }
        },
        {
            icon: true,
            size: "sm",
            css: {
                p: "6px",
                fontSize: '0.85rem',
            }
        },
        {
            icon: true,
            size: "md",
            css: {
                p: "8px",
                fontSize: '1rem',
            }
        },
        {
            icon: true,
            size: "lg",
            css: {
                p: "10px",
                fontSize: '1.15rem',
            }
        },
        {
            icon: true,
            size: "xl",
            css: {
                p: "12px",
                fontSize: '1.3rem',
            }
        }
    ]
})

这里将所有 icontrue 时的所有 size 进行的样式的覆盖,统一的垂直水平内边距,并且调整了字体的大小。

调整后的效果如下,是不是看起来协调了:

通过虚拟色彩自定义样式

最后一步,我们来提高一下这个按钮组件的复用性,首先是按钮的主题色,前面我们在按钮的配方中硬编码了 blue.600blue.50 作为按钮的配色,那如果我们要动态的去修改这个颜色要怎么做呢?这里我还是踩了很多坑的。

首先需要引入一个 pandaCSS 中的概念,虚拟色彩(Virtual Color),官方介绍如下:

pandaCSS 允许您在项目中创建虚拟颜色或颜色占位符。ColorPalette 属性是创建虚拟颜色的方式。

简单来说就是一个占位符,我们可以使用自己的颜色去替换这个占位符,我们修改前面的配方:

ts 复制代码
// old
primary: { bgColor: 'blue.600', color: 'white' },


// new
primary: { bgColor: 'colorPalette.600', color: 'white' }

将所有的 blue 修改为 colorPalette, 然后调整了按钮配方的传入方式:

tsx 复制代码
import { PropType, defineComponent } from "vue";

import buttonRecipe, { ButtonVariants } from "./recipe";
import showRipple from "./ripple";
import { css, cx } from "@/styled-system/css";

const Button = defineComponent({
  props: {
    color: {
      type: String,
      default: css({ colorPalette: "blue" }),
    },
    //...
  },
  setup(props, { slots }) {
    return () => (
      <button
        onMousedown={(el) => showRipple(el)}
        class={cx(
          buttonRecipe({
            type: props.type,
            size: props.size,
            shape: props.shape,
            block: props.block,
            icon: props.icon,
          }),
          props.color,
        )}
      >
        {slots?.default?.()}
      </button>
    );
  },
});

export default Button;

我们这里使用引入了新函数 cx 这个函数的作用类似于 ts 展开符,将其中的两个样式字符串参数进行合并,props 中新增了一个 css 参数用于用户自定义传入样式参数。

css({ colorPalette: "blue" }) 这代表默认颜色是蓝色的,如果组件使用者传入的样式中带有 colorPalette 就会将默认颜色覆盖。

可能这里你觉得实现的有点复杂,为什么不能直接传入样式对象变量,然后动态设置 css 函数的参数呢?

这是因为 pandaCSS 中的样式都是预生成的,我们在使用 csscva 等函数都不能在其中使用变量,必须是 pandaCSS 可以在构建时解析到的,在运行时是不能动态修改的,如下例所示:

tsx 复制代码
import { useState } from 'react'
import { css } from '../styled-system/css'
 
const App = () => {
  const [color, setColor] = useState('red.300')
 
  return (
    <div
      className={css({
        // ❌ Avoid: Panda can't determine the value of color at build-time
        color
      })}
    />
  )
}

这里详细的规则可以参考这篇文档:Dynamic styling

因此这里我们绕过了一下这个限制,我们在使用组件的时候可以通过自己执行 css 函数去生成 className ,这样在满足 pandaCss 静态生成的同时也能自定义按钮的颜色或其他样式:

html 复制代码
<Button type="flat" size="lg" :color="css({ colorPalette: 'teal' })"
  >oil-oil</Button
>

在自定义样式的时候也有完善的代码提示:

最终实现的效果:

但这么做也有缺点,就是组件的使用者也得具备 pandaCSS 依赖以生成对应样式,在抽离为通用组件库后不知道会不会有坑,这一点在后面实现组件库打包构建时我会验证并再写一篇文章做具体介绍。

实现按钮加载效果

在一些表单中,如果用户点击提交表单按钮会进入加载状态,展示一个加载动画,并且不可点击,那么我们来实现一下这个效果,首先我们单独使用 pandaCSS 实现一个样式:

ts 复制代码
import { css } from "@/styled-system/css";

const loading = css({
  width: "full",
  height: "full",
  position: "absolute",
  top: "0",
  left: "0",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  borderRadius: "inherit",
  bgColor:
    "color-mix(in srgb, token(colors.colorPalette.600) 60%, transparent)",
  _after: {
    content: '""',
    width: "16px",
    height: "16px",
    borderLeft: "3px dotted rgba(255, 255, 255, 0.6)",
    borderRadius: "50%",
    position: "absolute",
    top: "0",
    bottom: "0",
    left: "0",
    right: "0",
    margin: "auto",
    animation: "spin 0.6s linear infinite",
  },
  _before: {
    content: '""',
    width: "16px",
    height: "16px",
    borderLeft: "3px dotted rgb(255, 255, 255)",
    borderRadius: "50%",
    position: "absolute",
    top: "0",
    bottom: "0",
    left: "0",
    right: "0",
    margin: "auto",
    animation: "spin 0.6s ease infinite",
  },
});

export default loading;

分析一下这个加载样式:

  • widthheight 属性设置了元素的宽度和高度为 "full",即与父元素等宽等高。
  • borderRadius属性定义了元素的边框圆角半径,设置为 inherit 表示继承父元素的圆角半径。
  • bgColor 属性定义了元素的背景颜色,使用了一个混合函数color-mix,将颜色 transparent 与颜色 token(colors.colorPalette.600) 按照 60% 的比例混合生成背景颜色。token() 函数同样是 pandaCSS 中的语法,用于在边框或者阴影这种复合样式中引用这些复合值中的标记。
  • _after_before 伪类定义了两个绝对定位的伪元素,用来实现加载动画效果。具体的效果原理是通过设置矩形的一条边框,再加上圆角和旋转动画,就能实现一个曲线旋转的效果。

然后我们在组件中引入 loading 并在一个单独的元素中使用:

tsx 复制代码
<button>
    // ...
    {props.loading && <div class={cx(props.color, loading)} />}
</button>

这里再次使用了 cx 函数将组件中传入的 color 参数传入我们 loading 样式中的 clolrPalette

最终实现的效果:

不同颜色的按钮也都可以清晰展示加载动画:

总结

文章中提到的所有示例代码可以在这里看到最新版本:github.com/oil-oil/zax... , 后续的更新也都在这个仓库中, 快去点个 star 🌟

本文通过实现一个按钮组件介绍 pandaCSS 的一些基本概念和使用方式,虽然 pandaCSS 中定义了许多听起来陌生的概念,但是整体来说上手难度不高,而且基于 ts 生成的样式文件类型提示非常完善,很符合我想要找的样式库的预期。

但现在 pandaCSS 还处于非常初期的阶段,我认为不适合生成环境使用,尤其是静态生成的限制特别多,用起来会比较麻烦,目前我还没有摸索到一个非常好的实践方式,因此不建议直接在自己的生产项目中直接上,先作为尝鲜的库观望一下。

后续还有更多关于使用 pandaCSS 实现组件的文章,如果文章对你有帮助在收藏的同时也可以为我点个赞👍,respect!

相关推荐
Boilermaker199216 分钟前
【Java EE】SpringIoC
前端·数据库·spring
中微子27 分钟前
JavaScript 防抖与节流:从原理到实践的完整指南
前端·javascript
天天向上102442 分钟前
Vue 配置打包后可编辑的变量
前端·javascript·vue.js
芬兰y1 小时前
VUE 带有搜索功能的穿梭框(简单demo)
前端·javascript·vue.js
好果不榨汁1 小时前
qiankun 路由选择不同模式如何书写不同的配置
前端·vue.js
小蜜蜂dry1 小时前
Fetch 笔记
前端·javascript
拾光拾趣录1 小时前
列表分页中的快速翻页竞态问题
前端·javascript
小old弟1 小时前
vue3,你看setup设计详解,也是个人才
前端
Lefan1 小时前
一文了解什么是Dart
前端·flutter·dart
Patrick_Wilson1 小时前
青苔漫染待客迟
前端·设计模式·架构