实现一个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✨

感谢看到最后💟💟💟

相关推荐
多多*35 分钟前
Spring之Bean的初始化 Bean的生命周期 全站式解析
java·开发语言·前端·数据库·后端·spring·servlet
linweidong40 分钟前
在企业级应用中,你如何构建一个全面的前端测试策略,包括单元测试、集成测试、端到端测试
前端·selenium·单元测试·集成测试·前端面试·mocha·前端面经
满怀10151 小时前
【HTML 全栈进阶】从语义化到现代 Web 开发实战
前端·html
东锋1.31 小时前
前端动画库 Anime.js 的V4 版本,兼容 Vue、React
前端·javascript·vue.js
满怀10151 小时前
【Flask全栈开发指南】从零构建企业级Web应用
前端·python·flask·后端开发·全栈开发
小杨升级打怪中2 小时前
前端面经-webpack篇--定义、配置、构建流程、 Loader、Tree Shaking、懒加载与预加载、代码分割、 Plugin 机制
前端·webpack·node.js
Yvonne爱编码2 小时前
CSS- 4.4 固定定位(fixed)& 咖啡售卖官网实例
前端·css·html·状态模式·hbuilder
SuperherRo3 小时前
Web开发-JavaEE应用&SpringBoot栈&SnakeYaml反序列化链&JAR&WAR&构建打包
前端·java-ee·jar·反序列化·war·snakeyaml
大帅不是我3 小时前
Python多进程编程执行任务
java·前端·python
前端怎么个事3 小时前
框架的源码理解——V3中的ref和reactive
前端·javascript·vue.js