组件库实战—— BEM 命名规范

5,575 阅读11分钟

哈喽大家好,好久没写组件库的文章了。之前搞完初版的组件库后搁置了一段时间,最近有资源投入,趁机会继续完善组件库建设,加个 BEM 规范!接下来有时间的话会完善组件库的单元测试,到时候还会再写一篇文章!

组件库系列文章:

  1. 快上车!从零开始搭建一个属于自己的组件库!
  2. 组件库建设——实现一个跨框架的「组件库文档」
  3. 完结篇!一步一步实现一个专业的前端组件库~

回顾之前,笔者写了三篇关于组件库建设的文章,包括:组件库项目结构、多包管理、开发环境搭建、文档站点搭建、解决多框架融合问题、文档自动引入组件演示案例源码等等。虽然内容也挺多,但是距离一个完善的组件库还是差了点,比如发包流程、版本控制、BEM规范缺失、单元测试缺失...

但是,笔者一直认为:人不能一口吃成一个胖子(其实就是懒),功能也是这样,要一点点完善、新增,所以这次我们先来搞个 BEM 规范吧。let's go!

源码了解

一、BEM 是什么

网上关于 BEM 的定义、介绍的文章有很多(一搜一大把),笔者不打算在本文进行过多展开,仅围绕核心的点和本文实战相关的点进行讲解说明。有些时候仅仅从概念上去学习、理解新知识可能会比较糙,所以我们结合一些实际项目来进行了解。

也许有覆盖过原生组件库样式的童鞋可能会遇到这样的元素类名:x-xxxx-xxx__xx-xxx--xxx。不知道你们第一次遇到的时候会不会跟笔者有一样的想法,这玩意怎么这么长这么多线段...光说无益,我们一起看看一些组件库中 Button 组件的类名:

  • element-plus:el-buttonel-button--primary
  • t-design:t-button t-button--theme-primary

由上可以发现他们都有些共同点:

  1. 有一个独立的命名空间,如 elt
  2. 有一个组件名称,如 button
  3. 有一个组件特性标识,如 primary

于是在这个时候,我们引出 BEM 的命名规范:

  1. B:块(block)。逻辑、功能独立的组件,就如上述的 button 组件。
  2. E:元素(element)。块(block)的组成部分。比如有文案 text 元素,其就可作为 button 的元素。
  3. M:修改器(modifier)。顾名思义修改器,就是对 blockelement 中的样式进行修改的。比如 button 组件的不同状态:danger、primary、warning 就可以为其分配不同的修改器...

一些连接符规范:

  1. 命名连接符用 - 连接。如:el-tableel-table-column
  2. 块与元素用 __ 连接。如:el-table__header
  3. 块与修改器用 -- 连接。如:el-button--primaryel-button--danger

那么有了以上规范以后,我们就可以通过这种规范来设置组件的样式了。以 el-button 为例来加深一下对 BEM 规范的理解:

/* 普通的 button 样式 */
.el-button {
  color: var(--el-button-text-color);
  background-color: var(--el-button-bg-color);
}

/* warning 的样式 */
.el-button--warning {
  --el-button-text-color: 'warning text color';
  --el-button-bg-color: 'warning bg color';
}

很显然,上述例子中,当存在修改器 --warning 的类名时,button 组件的背景色、字体会是 warning 状态的颜色。

其实 BEM 规范没啥难点,就是一个约定俗成的规则,笔者认为在了解 BEM 的时候,更需要了解其使用场景,也就是为什么、在什么时候需要使用 BEM 规范来开发我们的项目,我们接着往下看。

二、为什么需要 BEM

相信应该不少开发童鞋跟笔者一样没怎么写过 BEM 规范的类名。毕竟在现如今的业务开发中,配合着 CssScopeCssModules 等 Css模块化方案,已经没有了旧时代写 Css 时容易导致命名冲突等问题的心智负担。简单展开说说:

  1. CssScope。如写 Vue 项目时,通常会在模版的 style 标签中加入 scope 属性:
    <!-- template中 -->
    <style scope>
      .el-button { ... }
    </style>
    
    <!-- 打包完后效果 -->
    <div class="el-button" data-v-123456></div>
    <style>
      /* 多了个属性选择器 */
      .el-button[data-v-123456] { ... }
    </style>
    
  2. CssModules。如写 React 项目时会引入一个 style,然后在类名中使用插值 style.xxx
    /* jsx中 */
    import style from './style'
    
    () => <div className={style.button}></div>
    
    /* 打包完后效果 */
    <div class="button_123456"></div>
    <style>
      .button_123456 { ... }
    </style>
    

正因为如此,现代的前端开发写 Css 样式时已经不需要人为的处理 nameSpace 等为了避免命名冲突、样式覆盖等问题,那我们还为什么需要使用 BEM 规范呢?这不是加大自己的开发工作量和参与组件库共建童鞋的心智负担吗?

就从笔者自己的实战经验来看,做组件库的开发采用 BEM 规范极为重要。为什么?因为规范不仅仅是为了规范,更是为了方便组件库用户能轻松地覆盖原组件样式

举一个实际场景:组件库中有一个 menu 组件,组件库的开发同学由于当前没有一个命名空间规范,害怕引起全局样式的冲突,于是用了 CssScope 写了点样式如下:

image.png

由上图可知,其有一个属性选择器 [data-v-73c599ea],然后 meun背景色是白色的。现有一个安装了该组件库的童鞋,由于系统有其独自的风格,他就想在全局对 menu 进行统一的样式修改以便后续使用,将背景颜色调整为红色。于是他就在 main.js 中引入一个 index.css,并在其中根据类名改写了 menu 的背景色如下:

.vc-ui-menu-wrapper {
  background: red;
}

但是此时页面上的 menu 背景色无动于衷...我们看看实时的「样式表」发现: image.png

红色背景的样式覆盖失败了。这个现象很好理解,根据浏览器对 Css 的权重计算,带属性选择器的权重是(前者):(0, 0, 2, 0),没有属性选择器的权重是(后者):(0, 0, 1, 0),所以红色背景的样式覆盖失败。那么,为了解决这个问题我们可以增加后者的权重:

  1. id 选择器

  2. 加属性选择器:

    .vc-ui-menu-wrapper[data-v-73c599ea] {
      background: red;
    }
    

    效果如下:

    image.png

  3. 使用 !important

    .vc-ui-menu-wrapper {
      background: red !important;
    }
    

    效果如下:

    image.png

  4. 等等...

实现的方案可以有很多,但此时的你有没有发现?我们为了覆盖这个样式要付出很大的代价呢?而且属性选择器的值是通过前端工程化处理的,每次打包出来的结果可能都不一样,所以上述的方案2并不实际。再者,如果当前中台是由一个后端来主导开发的,那对于谈Css变色的他们来说,怕是头都要变大了...

上面长篇大论了一个实际案例,总结出两个点:

  1. 组件库需要有一个独立的命名空间规范来避免跟业务项目产生类名、样式冲突
  2. 组件库实现 Css模块化、独立命名空间的同时,又要方便业务项目按照自己的需求对样式进行修改

或许组件库使用 BEM 规范的重要性还有很多方面没提到,但仅按照笔者亲身经历来的这两点来说,足够痛到要让我给组件库加上一个 BEM 规范了。所以,不为了 BEM 而 BEM,加上这个规范是为了解决项目投产中的实际问题的。

三、实战组件库添加 BEM

对于 BEM 的实战应用,方式有很多。笔者参加过两个开源项目的建设,其组件库对 BEM 的实现都不太一样,当然我们也没必要说要把规范做到什么程度,只要能够实现我们功能,解决实际问题即可。

这里笔者简单跟大家分析一下 element-plustdesign-react 两个组件库对 BEM 规范的实现。

1. elp 对 BEM 的实现:

首先来看 element-plus 的,我们从用法看起:

<template>
  <button
    :class="[
      ns.b(),
      ns.m(_type),
      ns.m(_size),
      ns.is('disabled', _disabled),
      ns.is('loading', loading),
      ...
    ]"
  />
</template>
<script>
  // 使用了一个 hook,传入组件名称
  const ns = useNamespace('button')
</script>

其实从用法上来看,已经不难猜出 useNamespace 返回的 ns 是什么了。这里盲猜一下,ns.b() 就代表了 el-buttonns.m(_type) 这里是 .m(对应 modify),也就是说,如果 _typeprimary 那就是 el-button--primary ...

那接下来一起看看 useNamespace 的源码实现(精简过):

const _bem = (
  namespace: string,
  block: string,
  blockSuffix: string,
  element: string,
  modifier: string
) => {
  let cls = `${namespace}-${block}`
  if (blockSuffix) {
    cls += `-${blockSuffix}`
  }
  if (element) {
    cls += `__${element}`
  }
  if (modifier) {
    cls += `--${modifier}`
  }
  return cls
}

export const useNamespace = (block: string) => {
  const namespace = 'el'
  const b = (blockSuffix = '') =>
    _bem(namespace.value, block, blockSuffix, '', '')
  const e = (element?: string) =>
    element ? _bem(namespace.value, block, '', element, '') : ''
  const m = (modifier?: string) =>
    modifier ? _bem(namespace.value, block, '', '', modifier) : ''
  const be = (blockSuffix?: string, element?: string) =>
    blockSuffix && element
      ? _bem(namespace.value, block, blockSuffix, element, '')
      : ''
  ...
  return {
    namespace,
    b,
    e,
    m,
    be,
    ...
  }
}

虽然精简过的代码还是很长,但是大家可以不用看,笔者简单说说。_bem函数 就是对各种命名规范的一个封装,比如 block 之间的名称用 - 分割;block 和 elmenet 的连接用 __;连接 modify 的用 --useNamespace 中根据不同的组合情况控制参数调用 _bem,提供如 bebembm 等组合给用户。

说白了就是做了一层封装,主要按照封装规范使用即可,比如需要加一个 buttonred 作为 modify,我们就调用 ns.bm('red') 即可。

2. tdesign-react 对 BEM 的实现

这里看看 tdesign 的实现。其实这个库的实现就很简单了,直接上一张图:

image.png

没错,非常的直接,可以说除了 prefix 之外都是直接根据规范写类名的。这样开发者根本不需要去了解任何封装,直接上手开发,可以说心智负担是很低的了。毕竟任何的封装,即使再简单,非开发者使用时都需要去了解一下封装后的用法。

由于笔者都参加过这两个项目,对于这两种 BEM 的实现方式算是有一丢丢的心得。总的来说:

  1. element-plus 的:

    • 优点:无需关注各名称之间的符号连接。不管是 bmem,反正调用封装好的函数传入字符串就行了。

    • 优点:连接符强规范。不用担心由于开发者的粗心写错连接符。比如 bm 之间写少了一个 -,由 el-button--primary 变成 el-button-primary

    • 缺点:需要先了解 useNamespace 封装规范,调用规范的方法传参使用有一定的学习成本。

    • 缺点:类名搜索难度提高。当开发者想要改某块样式时,想通过直接搜索类名比较难命中,需要掌握一定的搜索规律(如通过 bm('xxx') 去搜索),特别是一些文件分布较多的组件,如 table:

      image.png

  2. tdesign 的:

    当然,对于 tdesign 来说就恰恰相反的,上述 element-plus 的优点在这里可能就是缺点,而上述的缺点就是这里的优点。

    • 比如没有强规范的封装,可能会写错连接符,或者没按 BEM 规范来命名;
    • 另一方面,由于其的直观性,开发者无需任何学习成本即可直接写类名、并且可以通过对类名直接搜索命中我们想要找到的文件~

笔者了解过这两种方式加入 BEM 规范后,根据当前自己的“小”组件库规模,决定采用直观、简单的方式应用(说实话连 prefix 都想直接写死),也就是后者。具体实现就很简单了,几行代码搞定。

首先在组件库目录中新建 hooks 目录: image.png

usePrefix 中写几句简单的代码即可:

const DEFAULT_PREFIX = 'mm'

export function usePrefix () {
  return {
    classPrefix: DEFAULT_PREFIX
  }
}

紧接着我们对 element-plus 的自定列 table 进行 BEM 规范的改造: 首先拿到 classPrefix

import { usePrefix } from '@much-more/hooks';

const { classPrefix } = usePrefix()

再在 template 使用 BEM 规范改造类名: image.png

css 中编写样式(使用css预处理器写法很方便):

.mm-table {
  &__options {
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    &__item {
      cursor: pointer;
      margin-right: 8px;
    }
  }
}

最后在浏览器中看看 BEM 应用后的类名效果: image.png

当然,如果是大型的项目,对于 class命名、css写法 都可以适当的抽象封装,使用 lessscss 都可以像 class 那样,定义一些全局的变量,如 prefix 结合使用。本文就不再展开了,如果后续有相关的优化实现,也会同步写成一篇文章来分享。

写在最后

其实从个人的期望度来讲,更希望后续组织上有时间投入到组件库就把单元测试加上,毕竟对于组件库这种基建工程来说,单元测试还是非常有必要的。一个大型的基建项目,需要保证其迭代、bug fix或者重构后功能依旧稳定,单元测试可以说是一把利器。特别是一些复杂bug的修复、大型重构,如果每个用例都能跑过那基本上出现问题的概率会大大降低。好吧,本文就先到这里吧,如果有下一篇,那一定是:实战组件库加入单元测试!(标题我都想好了,就差实战撸码了😝)