浅谈 element-ui 中 css 的部分代码规范
简介
css 作为前端开发的重要一环,其代码量随着项目规模的增加,也是越发复杂;而且,由于 css 并不属于传统意义上的“编程语言”,其组织形式与编程语言也会有所区别。
若只是用于个人开发,选择符合个人习惯的方式即可。但由于多人开发更为常见,因此,很多组织都提出了专业建议,可以帮助开发者更好地组织 css 代码。
此处以 element-ui 为例尝试分析。
element 样式代码规范
组件样式
- 所有选择器都不超过三层嵌套
- 遵循 BEM 规范,且:
- 使用了类似“构造函数+插槽”的形式定义符合 BEM 规范的样式。其内部会定义一组变量(可能会用到判断或循环等方式),变量的值会使用到 @include 传入的参数,类似通过构造函数传值;同时,使用 @include 时写在
{}
中的样式会直接传入 @mixins 内部的 @content,类似视图层框架常见的插槽语法:@mixins b($block) { } @mixins e($element) { } @mixins m($modifier) { }
- 对于通过 Element 和 Modifier 定义的样式块,始终通过 @at-root 输出到 .css 文件的顶层,也就是不保留外层的嵌套选择器;但
@include e()
/@include m()
规则始终位于对应的@include b()
样式块内部 - 对于每个 Block / Element / Modifier,
@include b()
/@include e()
/@include m()
规则仅有一条;若需要在其他 Modifier 内使用已声明过的 Block 或 Element ,则直接声明完整的 class ;关于这一点,或许可以通过定义全新的 @mixins 规则,比如@mixins setE($element)
,这样既能避免重复通过@include e()
声明元素,同时又能避免此类代码块继续重复书写选择器,减少手打失误
- 使用了类似“构造函数+插槽”的形式定义符合 BEM 规范的样式。其内部会定义一组变量(可能会用到判断或循环等方式),变量的值会使用到 @include 传入的参数,类似通过构造函数传值;同时,使用 @include 时写在
- 对于拥有多条属性的样式块,大部分情况下,严格按照先布局属性,后样式属性的顺序排列
严格嵌套与非严格嵌套
css 预处理都支持简化的嵌套语法(也就是将组合选择器替换为嵌套或 & 嵌套等形式,避免重复书写),使得开发和维护具有层级关系的样式更方便。但滥用嵌套会导致嵌套层级过深,查找不便;而且最终产出的 .css 文件也会更大,因为多了很多不必要的嵌套选择器。
以下是对严格嵌套和非严格嵌套的优缺点和适合的使用场景的比较(此处的严格与非严格只是个人说法)。
- 严格嵌套根据 html 结构嵌套选择器,这种方式的优点是结构完整,所有选择器都能与 html 一一对应;但缺点是结构层级较大时(> 3 层),选择器的层级同样增大,可能不便于快速定位到特定的样式;同时,也会导致产生的 .css 文件变大。而且,并非所有样式都是与结构对应,很多样式只是针对各部分单独设置的颜色、边框、背景等,此时其实没必要完全对应 html 的结构书写选择器。
- 非严格嵌套则是针对特定的样式,去除中间不必要的选择器,仅保留与样式相关的核心选择器。这种方式的理念是,只负责自身权限范围内的选择器和样式。
而 element-ui 的源码由于采用了 BEM 命名规范,因此可以省略部分嵌套结构,直接通过 class 的命名就能知道元素间的嵌套关系,而且所有 Element / Modifier 样式块都通过 @at-root 规则导出到顶层,避免了嵌套层级过深的问题。
样式变量
- 事先定义好了大部分全局统一的样式变量,因此各组件的样式文件中基本没有再定义私有变量(对于基础组件而言,全局的样式变量已经能满足开发需求了,而且更方便统一维护,很少需要自定义的私有变量)
- 先定义通用变量,再定义组件变量
- 变量名使用统一的命名格式且有固定的词汇表,这与其他编程语言的命名规范是相通的
- 命名格式:
命名主体-属性主体-状态修饰
,如:border-color-base。大部分属于这类结构,小部分有特殊结构,比如文本是--color-text-regular
的格式 - 词汇,如:base | primary | mini/small/middile/large/extra-large
- 命名格式:
- 注释代码区域使用固定格式:
// 注释组件 /* 组件名 -------------------------- */ // 例如 /* Select -------------------------- */ // 注释变量值的类型 /// 属性主体||变量类型|变量序号 序号从 0 开始计算 // 例如 /// fontSize||Font|1 表示从 Font 系列变量中取第 1 个值 /// height||Other|4 这里的 Other 表示取值不属于通用变量,但 4 代表什么暂时没看懂,大部分 height 都是这种注释格式,除了一处是 Other|3
以下内容的规律倒是没发现:
- 同样是子级选择器,有的使用了 & 代表父元素,有的未使用
element 工具类、配置类源码分析
样式源码中包括了 common(项目内的变量和动画)、fonts(字体)、mixins(工具类及其配置) 以及各组件单独的样式文件。
变量部分在上面已经介绍过了。动画部分就是按照 vue 的要求定义的多个状态下的属性。字体暂不了解。
下面主要介绍 mixins,包括:_button(按钮相关)、config(全局的自定义变量)、function(BEM 辅助函数)、mixins(声明了关键的混入工具)
其中 _button 不是今天的重点,暂时跳过。
config
一共 4 各变量,定义了 element 类名选择器的前缀和连接符,用于 mixins / function 中的选择器生成或判定。
$namespace: 'el';
$element-separator: '__';
$modifier-separator: '--';
$state-prefix: 'is-';
function
// 由于 inspect 返回的字符串会带双引号,因此通过 str-lice 掐头去尾
@function selectorToString($selector) {
$selector: inspect($selector);
$selector: str-slice($selector, 2, -2);
@return $selector;
}
// 选择器是否包含修饰符连接符 --
@function containsModifier($selector) {
$selector: selectorToString($selector);
@if str-index($selector, $modifier-separator) {
@return true;
} @else {
@return false;
}
}
// 选择器是否包含状态前缀 .is
@function containWhenFlag($selector) {
$selector: selectorToString($selector);
@if str-index($selector, '.' + $state-prefix) {
@return true
} @else {
@return false
}
}
// 选择器是否包含伪类选择器
@function containPseudoClass($selector) {
$selector: selectorToString($selector);
@if str-index($selector, ':') {
@return true
} @else {
@return false
}
}
// 选择器是否包含 -- 或 .is 或伪类选择器
@function hitAllSpecialNestRule($selector) {
@return containsModifier($selector) or containWhenFlag($selector) or containPseudoClass($selector);
}
BEM 相关
// 声明块
@mixin b($block) {
// 将 block 选择器定义为全局变量;注意,后声明的 block 会覆盖先声明的 block 定义的 $B 变量,因为 $B 被提升到全局作用域
$B: $namespace + '-' + $block !global;
.#{$B} {
@content;
}
}
// 声明元素
@mixin e($element) {
$E: $element !global; // 将 element 选择器定义为全局变量
$selector: &;
$currentSelector: '';
// 当参数为一组选择器时,遍历并输出所有选择器;如: @include e((inner, body)) => .el-input__inner, .el-input__body {}
@each $unit in $element {
// 将所有选择器拼接为分组选择器的形式
$currentSelector: #{$currentSelector +
'.' +
$B +
$element-separator +
$unit +
','};
}
// 符合特定规则时保留父级选择器,否则忽略
@if hitAllSpecialNestRule($selector) {
@at-root {
#{$selector} {
#{$currentSelector} {
@content;
}
}
}
} @else {
@at-root {
#{$currentSelector} {
@content;
}
}
}
}
// 声明修饰符;原理同上 @mixin e() {}
@mixin m($modifier) {
$selector: &; // 这个变量未被使用,可以删去
$currentSelector: '';
@each $unit in $modifier {
$currentSelector: #{$currentSelector +
& +
$modifier-separator +
$unit +
','};
}
@at-root {
#{$currentSelector} {
@content;
}
}
}
其他 mixin 中比较重要的还有声明状态的 when 和声明伪类的 psuedo。其中, when 跟 m 有些类似,都用于表示特定选择器的一种特殊状态,区别是:
// 声明状态,如 .is-active
@mixin when($state) {
@at-root {
&.#{$state-prefix + $state} {
@content;
}
}
}
// 定义伪类;虽然写起来比较麻烦,但可以规范化伪类的书写格式
@mixin pseudo($pseudo) {
@at-root #{&}#{':#{$pseudo}'} {
@content;
}
}
上面的 when 和 pseudo 工具并不支持分组选择器,实际使用中会不方便,因此,可以参考上面的 element 和 modifier 的定义,改成如下形式:
@mixin when($state) {
$currentSelector: '';
@each $unit in $state {
$currentSelector: #{$currentSelector + #{&}#{'.#{$state-prefix}#{$unit},'}};
}
@at-root {
#{$currentSelector} {
@content;
}
}
}
@mixin pseudo($pseudo) {
$currentSelector: '';
@each $unit in $pseudo {
$currentSelector: #{$currentSelector + #{&}#{':#{$unit},'}};
}
@at-root {
#{$currentSelector} {
@content;
}
}
}
剩下的 mixin 与 BEM 规范无关,包括一个滚动条样式、一个 placeholder 的浏览器兼容规则,以及一系列暂不清楚用途的工具(可能是用于调试样式代码)。
正式的组件中大多时候都是用 b / e / m / when / psuedo 等工具定义各个部分的样式结构,减少了手动编写带来的命名格式不统一的问题。
在项目中使用 BEM 规范
借用 element-ui 对于 BEM 规范的实现,在实际项目中使用该模式改造老代码。
准备工作
与 element 一样,先创建四个公共文件:config、functions、mixins、vars。此处将 config 中的 $namespace 改为项目独有前缀。若基础组件与项目前缀不同,可以分别创建两个 config、functions、和 bem-mixins (与 element 中写法相同,只是引用了不同的 config 变量;sass 变量是有作用域的,所以使用到的地方必须引入,如果想省略这一步,使用全局的 vars.scss,就需要安装其他插件了)。并且,由于 function 和 bem-mixin 都用到了 config 文件,如果与 element 一样是将 function 和 bem-mixins 分开两个文件的,在 function 中可以使用 @use 指令,而在 bem-mixins 中需要 @use 'function'; @import 'config';
的形式,若是都使用 @use
或都使用 @import
会报错。或者,可以将两个文件甚至三个文件合并,毕竟这些文件同属于 BEM 模块。
开发规范
以下是使用该模式改造代码后的一些经验:
- 若是使用 css 与 vue 文件独立的形式,为了保证各模块以及当前模块下的子模块(子级业务组件)的样式独立,可以在样式的最外层使用一个单独的标签命名该(子)模块,并在使用 @include b/e/m 等工具时,将其他选择器嵌套在根标签下面;或者可以直接在每个选择器前加一个模块名,以避免多余的嵌套;模块名通常只需 1-2 个单词即可
- 基于 element-ui 对 BEM 规范的实现原理,嵌套选择器时需遵循以下规范:
基本原理:element 的 BEM 实现是在各样式文件中,全局定义了 sass 变量来表示当前的 Block 或 Element 的名称,因此后定义的名称会覆盖之前的
- 定义 Modifiler 时最好不要出现同级的 Block;如有需要,Modifiler 必须在前
- 同理,定义 Element 必须位于同级 Block 之前
- 若 Element 同级存在 Block ,且需要使用到后面的 Block 内的元素,需要小心,因为嵌套后选择器的权重可能相同,导致后面定义的基础样式反而覆盖了前面的特殊样式;此时最好将同级的 Block 移到父级选择器外面,且最好移到原先父级选择器的 @include b() 规则之前,这与通过普通的嵌套模式开发是相同的,即先定义基础样式,再使用特殊样式覆盖
- 选择器的命名:
- 常用的父级选择器命名(Block):container、group、list,常用的中间层选择器命名(Element):wrap、inner、head(er)、content、body、foot(er),常用的子级选择器命名(嵌套的 Block):item
- 对于某个元素之下的唯一元素,可以优先作为父级元素的 Element,而不是作为 Modifier 或 State (.is-*)或新的 Block;当嵌套层级较多时,会存在多个上级选择器,此时,需要综合判断当前元素与各级选择器的关系紧密程度以及嵌套层级的远近,选择最为合适的一个作为其父选择器——嵌套层数距离过大时,可能后期维护找起来麻烦(虽然可以通过字符串直接定位到父选择器的位置,但切换选择器的位置不方便,除非使用可视化的工具或插件)
比较 Modifier——@include m() 和 State——@include when()
Modifier 和 State 很像,命名时可能不容易选择。个人的理解是:
- Modifier 更偏向于选择器所指向元素本身的性质,通常是静态的状态,常用于实现非交互型样式,当然也可用于实现交互。如常见的尺寸状态:mini | small | middle | large,一般是静态的,但也可以通过增加按钮来切换尺寸状态
- 而 State 更偏向于动态状态,通常是一组真假子状态的集合,基本都是用于实现交互型样式,比如:active | disable | hover