实现一个vue3组件库 - button按钮

pid: 103585053

前言

相信大伙在写组件库项目时,大多数选择的都会是button组件,因为他很简单很基础。但也正因为他简单,我一直在思考应该如何写这篇文章才能让它有参考价值。既然button组件的js逻辑很简单,那么就从它的css样式入手,本篇文章主要介绍如何规范化书写css, 使得代码更加简化、语义化。

效果展示: Button 按钮 | SSS UI Plus (4everland.app) 可能会报错不安全,因为托管方被举报了:( 不过请放心,这只是纯文档的静态网站。

正文

先设想自己是使用组件库的开发者,当使用某一个组件时,组件往往会有一些默认样式, 而开发者通常会通过添加class类来修改默认样式。

这就要求我们写的样式优先级尽量低,能够被开发者自定义样式轻易覆盖、并且要保证组件样式不会影响全局。为此组件的样式要做到通过单个类来控制,比如以下css样式是合理的:

less 复制代码
.s-button{
    //css规则集
}
.s-button--primary{
    //css规则集
}

而应该尽量减少以下css样式:

less 复制代码
//非法,不应该出现会影响全局的样式
button{
    //css规则集
}
//不合理, 组件样式不能采用常用单个单词作为类名, 需要加上特定前缀
.container{
    //css规则集
}
//尽量避免, 这样会抬高样式的优先级, 导致开发者不能轻易覆盖这些css规则集
//在下文当中会提供避免方案
.s-button--primary .s-button--empty{
    //css规则集
}

约定

为了简化说明,假设button需要实现的有:

  • 可以指定不同的类型(type),比如primary、success、info、danger、warning...

    具体表现为:

  • 可以指定为虚幻(fantasy)按钮、幽灵(ghost)按钮、空(emoty)按钮

    具体表现为:

  • 可以指定为圆形(round)按钮或者圆角(circle)按钮

同类型prop合并

对于上述需要实现的效果, 当然需要通过props让用户选择,但能很明显的注意到:不同的表现(fantasy、ghost、empty)应该处于同一个prop, 不同的状态(round、circle)也应该处于同一个prop。

要避免用户写出这样的代码:

bash 复制代码
<s-button type="primary" ghost fantasy round circle></s-button>

可以将某些明显是互斥的props合并为同一个prop避免冲突:

ts 复制代码
//仅提供部分props用于展示说明
export const SButtonProps = {
    /**
     * @description 按钮的类型, 主要控制颜色
     */
    type: String as PropType<buttonTypes>,

    /**
     * @description 按钮的主题, 主要控制交互上的表现
     */
    variant:String as PropType<'ghost' | 'fantasy' | 'empty'>,
    /**
     * @description 按钮的状态, 主要控制外观
     */
    status:String as PropType<'round' | 'circle'>,

} as const

遵循BEM规范

BEM规范非常适合用在组件库的项目中, 在避免全局样式冲突的情况下还使得类名具有实际意义,便于调试。

BEM(block、element、modifier)规范其实就是一种命名规范,一般情况下:

  • 一个组件就是一个block(在vue中通常是template中的根元素),通常block不会单个单词,单词直接可以使用-进行链接, 比如s-message-box
  • 组件内部的元素则是一个个element, 使用__element链接元素
  • modifier则表示修饰符,使用--modifier链接修饰符

当然,一个组件内部可以不止一个block,比如某些html结构较为复杂的element可以单独命名为一个block。例如:s-message-header, s-message-body。(block element之间不是绝对的)

第一版template:

vue 复制代码
<template>
    <button
       :class="[
          's-button',
          props.type? `s-button--${props.type}`:'',
          props.variant? `s-button--${props.variant}`:'',
          props.status? `s-button--${props.status}`:'',
          {
             'is-disabled':props.disabled
          }
       ]"
    >
       <s-icon class="s-button__icon s-button__icon--prefix"></s-icon>
       <slot></slot>
       <s-icon class="s-button__icon s-button__icon--suffix"></s-icon>
    </button>
</template>

egs:

html 复制代码
<s-button 
    type="primary" 
    status="round" 
    variant="fantasy"
>
button 1
</s-button>
//将会被翻译为:

<button class="s-button s-button--primary s-button--fantasy s-button--round">
<label class="s-button__icon s-button__icon--prefix" target="edit"></label>
button 1
<label class="s-button__icon s-button__icon--suffix" target="edit"></label>
</button>

第二版template

第一版效果上符合了BEM规范,但有什么问题呢?

  • 大量重复代码一定会带来维护上的不便:例如我组件前缀被迫修改,组件名字修改等操作,将会导致上述代码狠狠滴修改(血泪史啊!)

  • 类名不直观: 例如下面一段代码,可能需要观察半天才能看明白

    less 复制代码
    :class="[  's-button',  props.type? `s-button--${props.type}`:'',  props.variant? `s-button--${props.variant}`:'',  props.status? `s-button--${props.status}`:'',  {     'is-disabled':props.disabled  }]"

为此我们引入命名空间的概念, 并通过hook实现它,先来看如何使用;

vue 复制代码
<template>
    <button
       :class="kls"
    >
       <s-icon
          :class="[ns.e('icon'), ns.em('icon', 'prefix')]"
       >
       </s-icon>
       //其余元素省略
    </button>
</template>


<script setup lang="ts">
import {useNS} from "@sss-ui-plus/hooks/useNS";
import {SButtonProps} from "@sss-ui-plus/components/SButton/src/button";
import {computed} from "vue";


defineOptions({
    name: "s-button",
    inheritAttrs: false
})
const ns = useNS('button');
const props = defineProps({...SButtonProps})
const kls = computed(() => {
    return [
       ns.namespace,
       ns.m(props.type),
       ns.m(props.size),
       ns.m(props.variant),
       ns.is(props.status),
       ns.is(props.disabled, 'disabled'),
       ns.is(props.loading, 'loading'),
    ]
})
</script>

上述代码中useNS('button')

  • ns.namespace: 表示block命名空间
  • ns.m(modifer:string | undifined): 返回modifier修饰后的类名
  • ns.is: 根据参数不同实现不同效果,但最后都会返回类似于is-disabled is-round这样的类名

第二版相比于第一版不但避免了重复代码,而且类名命名上更加贴近BEM规范,更容易理解。

useNS的实现

useNS目的是为了简化代码,同时让代码更加语义化, 其实现无非是根据BEM规范完成字符串的拼接:

javascript 复制代码
const componentPrefix = 's';

const variablePrefix = '--sss'

const statusPrefix = 'is';

export const useNS = function (name: string) {
    const namespace = `${componentPrefix}-${name}`;
    const b = (block?: string | number) => {
        return block ? `${namespace}-${block}` : '';

    }
    const e = (element?: string | number) => {
        return element ? `${namespace}__${element}` : '';
    }
    const m = (modifier?: string | number) => {
        return modifier ? `${namespace}--${modifier}` : '';
    }
    const is: IS = (status?: boolean | string, s?: string | boolean) => {

        if (isString(status) && isBoolean(s)) {
            return s ? `${statusPrefix}-${status}` : '';
        }

        if (isString(status)){
            return `${statusPrefix}-${status}`;
        }

        if (isBoolean(status) && isString(s)){
            if (status){
                return `${statusPrefix}-${s}`;

            }
        }

        return '';



    }
    //other func...
    
    return {
        namespace,
        b,
        e,
        m,
        is,
    }
    
}

除此之外,你还可以实现ns.bmns.be, ns.em ...之类套娃操作。总之遵循BEM规范进行字符串拼接就行。

tips: 既然需要ns.bm、ns.bem之类套娃操作, 你也可以考虑将useNS写成链式函数,将ns.bem简化为ns.b().e().m()这样语义化更优的方案!

如何书写less样式文件

下列less样式文件只用于说明用,可能不会写实际的样式

前面已经指出了,大量重复的会造成维护上的困难, 且我们的目的是为了写出简化、语义化的代码。我们不应该写出类似于这样的代码:

sql 复制代码
@import "../mixn/util";
.s-button{
    //css规则集
}

.s-button--primary{
    .s-button--ghost{
        //css规则集
    }
    .s-button--fantasy{
    
    }
    .s-button--empty{

    }
}

为此我们要善于利用less的特性,先给出部分最终形态的代码:

less 复制代码
@import "../../styles/mixn/_index";

@plugin "../../styles/plugin/index";


.@{componentPrefix}-button {
    @ns: @{componentPrefix}-button;
    @typeList: primary, success, info, warning, danger, cyan;


    @themeList: ghost, fantasy, empty;

    //var
    & {
        --sss-button-font-color: var(--sss-color-black);
        --sss-button-bg-color: var(--sss-color-bg);
        --sss-button-br-color: var(--sss-color-gray);
    }

    //base
    & {
        color: var(--sss-button-font-color);
        background-color: var(--sss-button-bg-color);
        border-color: var(--sss-button-br-color);

        &:hover,
        &:focus {
            --sss-button-font-color: var(--sss-color-primary);
        }

        &:hover {
            --sss-button-bg-color: var(--sss-color-gray-deep-fade);;
        }

        &:active {
            --sss-button-br-color: var(--sss-color-primary);
        }
    }

    //modifier types
    each(@typeList, .(@type) {
        .m(@type, {

            --sss-button-font-color: getClrVar(white);
            --sss-button-bg-color: getClrVar(@type);

            &:hover,
            &:focus {
                --sss-button-font-color: getClrVar(white);
                --sss-button-bg-color: getClrVar(@type, light);
            }

            &:active {
                --sss-button-bg-color: getClrVar(@type, dark);
            }
        })

    })

    //modifier empty theme
    .m(empty, {
        --sss-button-bg-color: var(--sss-color-bg);
        &:hover,
        &:focus {
            --sss-button-font-color: var(--sss-color-black);
            --sss-button-bg-color: var(--sss-color-bg);
        }
        &:active {
            --sss-button-br-color: var(--sss-color-gray-dark)
        }

        each(@typeList, .(@type) {
            .with(@ns, {
                .m(@type, {
                    --sss-button-bg-color: var(--sss-color-bg);
                    --sss-button-font-color: getClrVar(@type);
                    --sss-button-br-color: var(--sss-color-gray);
                    &:active {
                        --sss-button-br-color: getClrVar(@type, dark);
                    }
                });
            });
        });
    });
    
}

需要注意的只有两点: 利用css变量完成动态属性的提升, 利用less混合和变量完成代码简化和语义化。

动态css属性提升

为何需要这样做?

现在假设一个button, 默认情况下button的背景颜色为:

less 复制代码
.s-button{ background-color:var(--sss-color-white); }

当设置button类型为primary时,背景颜色为priamry:

less 复制代码
.s-button--primary{ background-color:var(--sss-color-primary); }

当设置button表现为fantasy时, 背景色淡化20%:

less 复制代码
.s-button--fantasy{ background-color:lighten(var(--sss-color-white), 20%); }

当同时设置类型为primary, 表现为fantasy时,背景为primary淡化20%:

less 复制代码
.s-button--primary .s-button--fantasy { background-color:lighten(var(--sss-color-primary), 20%); }

假设用户在使用button时,同时设置了类型和表现两个props, 那么势必会使用.s-button--type .s-button-variant {}规则集的结果。 如果用户不满意这个颜色而想要自定义按钮背景色时, 单独使用一个class来尝试修改样式,但由于样式权重的问题,会导致修改失效。

为了解决这个问题,我们统一将这类会改变的样式使用css变量代替:

css 复制代码
.s-button{
    --sss-button-bg-color:var(--sss-button-bg-color);
    
    background-color: var(--sss-button-bg-color);
}

.s-button--primary{
    --sss-button-bg-color: var(--sss-color-primary);
    &.s-button--fantasy{
        --sss-button-bg-color: var(--sss-color-primary-light);
    }
}

这样一来,真正设置按钮背景颜色的是在.s-button{}选择器中, 而.s-button--type .s-button--variant {}修改的只是css变量。用户通过单一的class也能修改按钮的默认样式。

有时你在使用某些ui组件库时,会发现该ui组件库的组件跟节点上面绑定了许多css变量,比如naive-ui:

这也是为了降低某些样式的权重,比如假设按钮的背景颜色是通过props.color设置的, 那么我可以:

ts 复制代码
const sdl = computed(() => {
    return{
       [ns.cssVar('bg-color')]: props.color
    }
})

然后将sdl变量绑定到组件根元素的style上,这样一来,用户也可以通过单一class来改变按钮背景颜色!

在less中写出符合BEM规范的代码

首先明确一点,组件的所有样式文件都是统一存放的,由index.less文件引入, 由于less本身对变量的处理,不同文件下的同名变量会在less编译期间整合到一起变成同一个变量,因此在less中一定要少使用全局变量.

简化上文最终代码为:

less 复制代码
@import "../../styles/mixn/_index";

@plugin "../../styles/plugin/index";


.@{componentPrefix}-button {
    @ns: @{componentPrefix}-button;
    @typeList: primary, success, info, warning, danger, cyan;

    //var 存放根节点css变量
    & {}

    //base 基础样式
    & {}

    //modifier types 不同类型下的样式
    each(@typeList, .(@type) {
        .m(@type, {//...})
    })

    //modifier empty theme empty表现下的样式
    .m(empty, {
        //...

        //empty 和 type同时存在下的样式
        each(@typeList, .(@type) { e
            .with(@ns, {
                .m(@type, {//...});
            });
        });
    });

}

需要注意的点也就是 .m(), .with() .d() ...这类混合的实现了

在往期文章中,详细介绍了less的一些高级用法;less深入指南 - 掘金 (juejin.cn)

后记

总的来说,本篇文章主要是为了规范化有关css的书写。

组件库项目地址:lastertd/sss-ui-plus: 适用于vue3的组件库 (github.com)在这里求一个star✨

感谢看到最后💟💟💟

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui