CSS 架构模式之 BEM 在组件库中的实践
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究
前言在软件领域有很多的架构思想,通过不同的架构模式,可以让你的软件工程易拓展、易维护、易复用。同样在 CSS 工程当中,我们也需要使用架构思想,如果 CSS 没有使用架构思想的话,就会存在 CSS 代码极度混乱,难复用、难拓展、难维护等问题。特别是如果一个系统页面极度复杂的情况下,没有对 CSS 代码进行一个规划的话,那么后期维护 CSS 代码则堪称灾难。
所以我们需要像其他编程语言那样通过一些架构模式进行提高 CSS 代码的健壮性和可维护性。本文将探讨 Element Plus 组件库中的 CSS 架构思想。
CSS 设计模式之 OOCSS我们如果对 Element Plus 的 CSS 架构稍微有些了解的话,就知道 Element Plus 的 CSS 架构使用了 BEM 设计模式,而 BEM 是 OOCSS 的一种实现模式,可以说是进阶版的 OOCSS。那么什么是 OOCSS 呢?
OOCSS 的全称为 Object Oriented CSS (面向对象的 CSS),它让我们可以使用向对象编程思想进行编写 CSS。
面向对象有三大特征:封装、继承、多态,在 OOCSS 中,主要应用到了面向对象的封装和继承的思想。我们以掘金的下图这个部分来进行说明:
item.png图中画红色的部分,可以看成是有四个容器组成的,每个容器里面的内容又不一样。那么每个容器都有相同的样式,那么我们就可以进行封装。
把每一个容器封装成一个叫item的class,因为它们都有一些共同的样式。
.item{
position:relative;
margin-right:20px;
font-size:13px;
line-height:20px;
color:#4e5969;
flex-shrink:0;
}
然后如果我们需要对它们每一项进行拓展的话,那么我们只需要在原来的样式基础上进行新增一个 class,再针对这个 class 写不同的样式即可,这样达到继承原来基础部分的样式进行拓展自己独有的样式。
li.png通过上图可以得知我们相当于继承基础类型 item 后,然后分别拓展出浏览 view、点赞 like、评论 comment、更多 more的 CSS 内容。
.item.view{
//浏览
}
.item.like{
//点赞
}
.item.comment{
//评论
}
.item.more{
//更多
}
通过这种模式就大大增加了 CSS 代码的可维护性,可以在没有修改源代码的基础上进行修正和拓展。同时通过上面的例子,我们可以引出 OOCSS 的两大原则:
容器(container)与内容(content)分离结构(structure)与皮肤(skin)分离例如在 Element Plus 组件库中就有两个经典的布局组件Container 布局容器和Layout 布局,这是 OOCSS 的典型应用:
container.png实质上我们在写 Vue 组件的时候,就是在对 CSS 进行封装,这也是 OOCSS 的实践方式之一。
el-buttonclass="self-button"默认按钮/el-button
stylelang="stylus"rel="stylesheet/stylus"scoped
.self-button{
color:white;
margin-top:10px;
width:100px;
}
/style
例如上述代码,我们的 Element Plus 组件库已经对 el-button 组件的样式进行了封装,但我们还可以基于 el-button 组件的样式进行拓展我们符合我们项目 UI 的样式,这就是典型的封装与继承。
OOCSS 强调重复使用类选择器,避免使用 id 选择器,最重要的是从项目的页面中分析抽象出“对象”组件,并给这些对象组件创建 CSS 规则,最后完善出一套基础组件库。这样业务组件就可以通过组合多个 CSS 组件实现综合的样式效果,这体现了 OOCSS 的显著优点:可组合性高。
OOCSS 为我们提供了一种编写 CSS 代码的思维模型或者说方法论,后续则演化出更加具体的一种实现模式,也就是 BEM。
CSS 设计模式之 BEMBEM 是由 Yandex 团队提出的一种 CSS 命名方法论,即 Block(块)、Element(元素)、和 Modifier(修改器)的简称,是 OOCSS 方法论的一种实现模式,底层仍然是面向对象的思想。下面我们从 Element Plus 的 Tabs 组件进行讲解 BEM 的核心思想。
tab.png那么整一个组件模块就是一个 Block(块),classname 取名为:el-tabs。Block 代表一个逻辑或功能独立的组件,是一系列结构、表现和行为的封装。 其中每个一个切换的标签就是一个 Element(元素),classname 取名为:el-tabs__item。Element(元素)可以理解为块里的元素。 Modifier(修改器)用于描述一个 Block 或者 Element 的表现或者行为。例如我们需要对两个 Block(块) 或者两个 Element(元素)进行样式微调,那么我们就需要通过 Modifier(修改器),Modifier(修改器)只能作用于 Block(块)或者 Element(元素),Modifier(修改器)是不能单独存在的。
例如按钮组件的 classname 取名为 el-button,但它有不通过状态譬如:primary、success、info,那么就通过 Modifier(修改器)进行区分,classname 分别为: el-button--primary、el-button--success、el-button--info。从这里也可以看出 BEM 本质上就是 OOCSS,基础样式都封装为 el-button,然后通过继承 el-button 的样式,可以拓展不同的类,例如:el-button--primary、el-button--success、el-button--info。
BEM 规范下 classname 的命名格式为:
block-name__element-namemodifier-namemodifier_value
所有实体的命名均使用小写字母,复合词使用连字符 “-” 连接。Block 与 Element 之间使用双下画线 “__” 连接。Mofifier 与 Block/Element 使用双连接符 “--” 连接。modifier-name 和 modifier_value 之间使用单下画线 “_” 连接。当然这些规则并不一定需要严格遵守的,也可以根据你的团队风格进行修改。
在 OOCSS 中,我们通过声明一个选择器对一个基础样式进行封装的时候,这个选择器是全局的,当项目庞大的时候,这样就容易造成影响到其他元素。通过 CSS 命名方法论BEM,则在很大程度上解决了这个问题。因为 BEM 同时规定 CSS 需要遵循只使用一个 classname 作为选择器,选择器规则中既不能使用标签类型、通配符、ID 以及其他属性,classname 也不能嵌套,此外通过 BEM 可以更加语义化我们的选择器名称。BEM 规范非常适用于公共组件,通过 BEM 命名规范可让组件的样式定制具有很高的灵活性。
此外通过 BEM 的命名规范可以让页面结构更清晰。
formclass="el-form"
divclass="el-form-item"
labelclass="el-form-item__label"名称:/label
divclass="el-form-item__content"
divclass="el-input"
divclass="el-input__wrapper"
inputclass="el-input__inner"/
/div
/div
/div
/div
/form
我们以 Element Plus 的 Form 表单的 HTML 结构进行分析,我们可以看到整个 classname 的命名是非常规范的,整个 HTML 的结构是非常清晰明了的。
通过 JS 生成 BEM 规范名称在编写组件的时候如果通过手写 classname 的名称,那么需要经常写el、-、__、--,那么就会变得非常繁琐,通过上文我们可以知道 BEM 命名规范是具有一定规律性的,所以我们可以通过 JavaScript 按照 BEM 命名规范进行动态生成。 命名空间函数是一个 hooks 函数,类似这样的 hooks 函数在 Element Plus 中有非常多,所以我们可以在 packages 目录下创建一个 hooks 模块(具体创建项目过程可参考本专栏的第二篇《2. 组件库工程化实战之 Monorepo 架构搭建》),进入到 hooks 目录底下初始化一个 package.json 文件,更改包名:@cobyte-ui/hooks。文件内容如下:
{
"name":"@cobyte-ui/hooks",
"version":"1.0.0",
"description":"ElementPluscomposables",
"license":"MIT",
"main":"index.ts",
"module":"index.ts",
"unpkg":"index.js",
"jsdelivr":"index.js",
"types":"index.d.ts",
"peerDependencies":{
"vue":"^3.2.0"
},
"gitHead":""
}
接着在 hooks 目录下再创建一个 use-namespace 目录用于创建 BEM 命名空间函数,再在 hooks 目录下创建一个 index.ts 文件用于模块入口文件。
index.ts 文件内容:
export*from'./use-namespace'
本项目的 GitHub 地址:https://github.com/amebyte/element-plus-guide
首先引入一个命名空间的概念,所谓命名空间就是加多一个命名前缀。
import{computed,unref}from'vue'
//默认命名前缀
exportconstdefaultNamespace='el'
exportconstuseNamespace=(block:string)={
//命名前缀也就是命名空间
constnamespace=computed(()=defaultNamespace)
return{
namespace,
}
}
通过加多一个命名前缀,再加上 BEM 的命名规范就可以大大降低我们组件的 classname 与项目中的其他 classname 发生名称冲突的可能性。
通过前文我们知道 BEM 的命名规范就是通过一定的规则去书写我们的 classname,在 JavaScript 中则表现为按照一定规则去拼接字符串。
BEM 命名字符拼接函数:
//BEM命名字符拼接函数
const_bem=(
namespace:string,
block:string,
blockSuffix:string,
element:string,
modifier:string
)={
//默认是Block
letcls=`${namespace}-${block}`
//如果存在Block前缀,也就是Block里面还有Block,例如:el-form下面还有一个el-form-item
if(blockSuffix){
cls+=`-${blockSuffix}`
}
//如果存在元素
if(element){
cls+=`__${element}`
}
//如果存在修改器
if(modifier){
cls+=`--${modifier}`
}
returncls
}
这里值得注意的是 Block 也有可能有前缀,也就是 Block 里面还有 Block,例如:el-form下面还有一个el-form-item。
通过 BEM 命名字符拼接函数,我们就可以自由组合生成各种符合 BEM 规则的 classname 了。
exportconstuseNamespace=(block:string)={
//命名前缀也就是命名空间
constnamespace=computed(()=defaultNamespace)
//创建块例如:el-form
constb=(blockSuffix='')=
_bem(unref(namespace),block,blockSuffix,'','')
//创建元素例如:el-input__inner
conste=(element?:string)=
element?_bem(unref(namespace),block,'',element,''):''
//创建块修改器例如:el-form--default
constm=(modifier?:string)=
modifier?_bem(unref(namespace),block,'','',modifier):''
//创建前缀块元素例如:el-form-item
constbe=(blockSuffix?:string,element?:string)=
blockSuffixelement
?_bem(unref(namespace),block,blockSuffix,element,'')
:''
//创建元素修改器例如:el-scrollbar__wrap--hidden-default
constem=(element?:string,modifier?:string)=
elementmodifier
?_bem(unref(namespace),block,'',element,modifier)
:''
//创建块前缀修改器例如:el-form-item--default
constbm=(blockSuffix?:string,modifier?:string)=
blockSuffixmodifier
?_bem(unref(namespace),block,blockSuffix,'',modifier)
:''
//创建块元素修改器例如:el-form-item__content--xxx
constbem=(blockSuffix?:string,element?:string,modifier?:string)=
blockSuffixelementmodifier
?_bem(unref(namespace),block,blockSuffix,element,modifier)
:''
//创建动作状态例如:is-successis-required
constis:{
(name:string,state:boolean|undefined):string
(name:string):string
}=(name:string,...args:[boolean|undefined]|[])={
conststate=args.length=1?args[0]!:true
returnnamestate?`${statePrefix}${name}`:''
}
return{
namespace,
b,
e,
m,
be,
em,
bm,
bem,
is,
}
}
最后我们就可以在组件中引入 BEM 命名空间函数进行创建各种符合 BEM 命名规范的 classname 了,例如:
创建块 el-form、创建元素 el-input__inner、创建块修改器 el-form--default、创建前缀块元素 el-form-item、创建元素修改器 el-scrollbar__wrap--hidden-default、创建动作状态 例如:is-success is-required具体创建代码使用代码如下:
import{
useNamespace,
}from'@cobyte-ui/hooks'
//创建classname命名空间实例
constns=useNamespace('button')
然后就可以在 template 中进行使用了:
template
button
ref="_ref"
:class="[
ns.b()
]"
按钮/button
template
通过 SCSS 生成 BEM 规范样式我们在本专栏的第二篇《2. 组件库工程化实战之 Monorepo 架构搭建》中已经创建一个样式主题的目录theme-chalk。现在我们接着在这个目录下创建组件样式代码,我们在theme-chalk目录下创建一个src目录,在src目录下创建一个mixins目录。
Element Plus 的样式采用 SCSS 编写的,那么就可以通过 SCSS 的 @mixin 指令定义 BEM 规范样式。在mixins目录下新建三个文件:config.scss、function.scss、mixins.scss。 其中 config.scss 文件编写 BEM 的基础配置比如样式名前缀、元素、修饰符、状态前缀:
$namespace:'el'!default;//所有的组件以el开头,如el-input
$common-separator:'-'!default;//公共的连接符
$element-separator:'__'!default;//元素以__分割,如el-input__inner
$modifier-separator:'--'!default;//修饰符以--分割,如el-input--mini
$state-prefix:'is-'!default;//状态以is-开头,如is-disabled
在 SCSS 中,我们使用$+ 变量名:变量来定义一个变量。在变量后加入!default表示默认值。给一个未通过!default声明赋值的变量赋值,此时,如果变量已经被赋值,不会再被重新赋值;但是如果变量还没有被赋值,则会被赋予新的值。
mixins.scss 文件编写 SCSS 的 @mixin 指令定义的 BEM 代码规范。
定义 Block:
@mixinb($block){
$B:$namespace+'-'+$block!global;
.#{$B}{
@content;
}
}
$B表示定义一个一个变量,$namespace 是来自 config.scss 文件中定义的变量,!global表示其是一个全局变量,这样就可以在整个文件的任意地方使用。#{}字符串插值,类似模板语法。通过@content可以将include{}中传递过来的内容导入到指定位置。
定义 Element:
@mixine($element){
$E:$element!global;
$selector:
$currentSelector:'';
@each$unitin$element{
$currentSelector:#{$currentSelector+
'.'+
$B+
$element-separator+
$unit+
','};
}
@ifhitAllSpecialNestRule($selector){
@at-root{
#{$selector}{
#{$currentSelector}{
@content;
}
}
}
}@else{
@at-root{
#{$currentSelector}{
@content;
}
}
}
}
首先定义一个全局变量$E,接着定义父选择器$selector,再定义当前的选择器$currentSelector,再通过循环得到当前的选择器。接着通过函数 hitAllSpecialNestRule(hitAllSpecialNestRule 函数在 mixins 目录的 function.scss 文件中) 判断父选择器是否含有 Modifier、表示状态的.is-和 伪类,如果有则表示需要嵌套。@at-root的作用就是将处于其内部的代码提升至文档的根部,即不对其内部代码使用嵌套。
定义修改器:
@mixinm($modifier){
$selector:
$currentSelector:'';
@each$unitin$modifier{
$currentSelector:#{$currentSelector+
$selector+
$modifier-separator+
$unit+
','};
}
@at-root{
#{$currentSelector}{
@content;
}
}
}
这个非常好理解,就是定义了父选择器变量$selector和 当前选择器变量$currentSelector,并且当前选择器变量初始值为空,再通过循环传递进来的参数$modifier,获得当前选择器变量$currentSelector的值,再定义样式内容,而样式内容是通过@content将include{}中传递过来的内容。
定义动作状态:
@mixinwhen($state){
@at-root{
&.#{$state-prefix+$state}{
@content;
}
}
}
选择器就是 config.scss 文件中的变量$state-prefix加传进来的状态变量,而样式内容是通过@content将include{}中传递过来的内容。
接着我们再看下上面定义 Element 的时候说到的 hitAllSpecialNestRule 函数,这个函数是定义在 mixins 目录下的 function.scss 文件中。function.scss 文件内容如下:
@use'config';
//该函数将选择器转化为字符串,并截取指定位置的字符
@functionselectorToString($selector){
$selector:inspect(
$selector
//inspect(...)表达式中的内容如果是正常会返回对应的内容,如果发生错误则会弹出一个错误提示。
$selector:str-slice($selector,2,-2);//str-slice截取指定字符
@return$selector;
}
//判断父级选择器是否包含'--'
@functioncontainsModifier($selector){
$selector:selectorToString($selector);
@ifstr-index($selector,config.$modifier-separator){
//str-index返回字符串的第一个索引
@returntrue;
}@else{
@returnfalse;
}
}
//判断父级选择器是否包含'.is-'
@functioncontainWhenFlag($selector){
$selector:selectorToString($selector);
@ifstr-index($selector,'.'+config.$state-prefix){
@returntrue;
}@else{
@returnfalse;
}
}
//判断父级是否包含':'(用于判断伪类和伪元素)
@functioncontainPseudoClass($selector){
$selector:selectorToString($selector);
@ifstr-index($selector,':'){
@returntrue;
}@else{
@returnfalse;
}
}
//判断父级选择器,是否包含`--``.is-``:`这三种字符
@functionhitAllSpecialNestRule($selector){
@returncontainsModifier($selector)orcontainWhenFlag($selector)or
containPseudoClass($selector);
}
通过上述代码我们就可以知道 hitAllSpecialNestRule 函数是如何判断父选择器是否含有 Modifier、表示状态的.is-和 伪类的了。
测试实践 BEM 规范接下来,我们要把上面实现的 BEM 规范应用到真实组件中,通过写一个简易的测试组件进行测试实践。首先我们的样式是基于 SCSS 所以我们需要安装 sass。
我们在根目录下执行:
pnpminstallsass-D-w
接着我们把上新建的 hooks 模块也安装到根项目上:
我们在根目录下执行:
pnpminstall@cobyte-ui/hooks-D-w
而 theme-chalk 模块,我们在本专栏的第二篇的《2. 组件库工程化实战之 Monorepo 架构搭建》已经进行了安装,这里就不用再进行安装了。
我们在 packages 目录下的 components 目录创建一个 icon 目录,再创建以下目录结构:
├──packages
│├──components
││├──icon
│││├──src
││││└──icon.vue
│││└──index.ts
││└──package.json
index.ts 文件内容:
importIconfrom'./src/icon.vue'
exportdefaultIcon
icon.vue 文件内容:
template
i:class="bem.b()"
slot/
/i
/template
scriptsetuplang="ts"
import{useNamespace}from'@cobyte-ui/hooks'
constbem=useNamespace('icon')
/script
我们通过导入上面使用 JS 生成 BEM 规范名称 hooks 函数,然后创建对应的命名实例 bem,然后生成对应的 Block 块的 classname。接下来,我们把这个测试组件渲染到页面上,看看具体生成的效果。
我们去到 play 目录下的 src 目录中的 App.vue 文件中把上面写的测试组件进行引入:
template
div
c-iconIcon/c-icon
/div
/template
scriptsetuplang="ts"
importCIconfrom'@cobyte-ui/components/icon'
import'@cobyte-ui/theme-chalk/src/index.scss'
/script
stylescoped/style
我们也把theme-chalk目录中的样式也进行了导入。接着我们在 theme-chalk 目录下的 src 目录新建一个 icon.scss 文件,文件内容如下:
@use'mixins/mixins'as
@includeb(icon){
background-color:
color:#fff;
}
这里我们可以看到 SCSS 的@mixin、@include的用法:@mixin用来定义代码块、@include进行引入。
我们需要在 theme-chalk 目录下的 src 目录中的 index.scss 中导入 icon.scss 文件。
index.scss 文件内容:
@use'./icon.scss';
这样我们就实现了所有文件的闭环,最后我们把 play 项目运行起来,看看效果,要运行 play 项目,我们专栏的前面的文章中已经说过了,就是在根目录下执行pnpm run dev即可。
icon-play.png我们也看到已经成功实现了渲染并和如期一样,那么其他样式的测试,我们将在后续具体的组件实现上再进行测试。
经典 CSS 架构 ITCSS本小节我们继续通过学习经典 CSS 架构 ITCSS 来对比学习 Element Plus 的样式系统架构。我们可以向一些经典的 CSS 架构去学习取经,看看人家是怎么做架构的,从而可以在我们做自身的 CSS 架构的时候可以带来一些的启发,然后取长补短,从而可以更加优化自身的 CSS 架构。
ITCSS 基于分层的概念把项目中的样式分为七层,分别如下:
Settings 层:维护一些包含字体、颜色等的变量,主要是变量层。Tools 层:工具库,例如 SCSS 中的 @mixin 、@functionGeneric 层:重置和/或标准化样式等,例如 normalize.css、reset.css,主要解决浏览器的样式差异Elements 层:对一些元素进行定制化的设置,如 H1 标签默认样式,A 标签默认样式等Objects 层:类名样式中,不允许出现外观属性,例如 Color,遵循OOCSS方法的页面结构类,主要用来做画面的 layoutComponents 层:UI 组件Trumps 层:实用程序和辅助类,能够覆盖前面的任何内容,也就是设置important!的地方ITCSS 不是一个框架,只是组织样式代码的一种方案,ITCSS 的分层越在上面的分层,被复用性就越广,层的权重是层层递进,作用范围却是层层递减。除了 ITCSS 之外还有其他一些 CSS 架构,比如 SMACSS 、ACSS 等,但它们的核心思想并不是放之四海而皆准的,但是它维护项目样式的思想却是值得借鉴的。我们可以不完全遵守它们的规则,可以根据我们的项目需要进行删减或者保留。
那么根据上面 ITCSS 架构思想,Element Plus 也设置了Settings 层,在theme-chalk/src/common目录下的 var.scss 文件中就维护着各种变量。我们上文中实现的 BEM 规范的mixins目录下的 function.scss、mixins.scss 等则是Tools 层。Generic 层主要解决浏览器的样式差异,这些工作应该在具体的项目中进行处理,而 Element Plus 只是一个第三方的工具库,所以 Element Plus 在这一层不进行设置。Elements 层主要是对一些基础元素拓展一些样式,从而让我们的网站形成一套自己的风格,例如对 H1 标签默认样式,A 标签默认样式的设置。Element Plus 则在theme-chalk/src目录下的 reset.scss 文件进行了设置。Objects 层和Components 层其实就是 OOCSS 层,也就是我们上文所说的 BEM 样式规范,又因为组件库的样式使用一般都分为全量引入和按需引入,所以就根据组件名称分别设置各自样式文件进行维护各自的样式。Trumps 层在 Element Plus 中也是没有的,同样是因为 Element Plus 只是一个第三方工具库,权重是比较低,所以不需要设置权重层。
总结我们先从什么是 OOCSS 开始,OOCSS 主要运用了传统编程类中的封装和继承的特性,OOCSS 为我们提供了一种编写 CSS 代码的思维模型或者说方法论,后续则演化出更加具体的一种实现模式,也就是 BEM。
接着我们从 Element Plus 的 Tabs 组件进行讲解 BEM 的核心思想。BEM 本质上就是 OOCSS,通过 BEM 可以更加语义化我们的选择器名称。BEM 规范非常适用于公共组件,通过 BEM 命名规范可让组件的样式定制具有很高的灵活性。
接着我们介绍如何通过 JS 生成 BEM 规范名称。在编写组件的时候如果通过手写 classname 的名称,那么需要经常写el、-、__、--,那么就会变得非常繁琐,通过上文我们可以知道 BEM 命名规范是具有一定规律性的,所以我们可以通过 JavaScript 按照 BEM 命名规范进行动态生成。
接着学习如何通过 SCSS 生成 BEM 规范样式,并学习了 SCSS 的一些核心知识。
之后写了一个 demo 组件进行验证我们所写的 BEM 规范代码。
最后我们通过学习经典 CSS 架构 ITCSS 的思想,从而更加深入理解 Element Plus 的 CSS 架构设置思想。我们可以看到 Element Plus 的 CSS 架构并不是单一的使用了具体那一种架构,而是融合了多种架构的思想。
所以所谓的 CSS 架构,它们的核心思想并不是放之四海而皆准的,但是它维护项目样式的思想却是值得借鉴的。我们可以不完全遵守它们的规则,可以根据我们的项目需要进行删减或者保留。
Element Plus 的 CSS 样式架构中还有非常多少值得学习的知识,后续具体组件的实现,我们再继续探讨。
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线