做前端的朋友们都知道,在项目中编写 CSS 其实是一件比较痛苦的事,在还原设计图的同时,既要防止过度(甚至无意义的)嵌套,又要防止样式覆盖,还要防止命名冲突,很让人头大。这篇文章就来聊一聊 ElementUI 中是如何编写 CSS 样式的。

从自动化构建出发

本文并不会深究每个组件的样式是怎样的,而是从组织结构的角度去分析如何更好地编写 CSS,因为组件的样式是很灵活的,ElementUI 提供的也只是一种通用的样式,所以我们从自动化构建的角度出发,一点一点向内分析。

这是 ElementUI 的项目结构:

element-dev
├── build
├── CHANGELOG.en-US.md
├── CHANGELOG.es.md
├── CHANGELOG.fr-FR.md
├── CHANGELOG.zh-CN.md
├── components.json
├── element_logo.svg
├── examples
├── FAQ.md
├── LICENSE
├── Makefile
├── package-lock.json
├── package.json
├── packages
├── README.md
├── src
├── test
├── types
└── yarn.lock













 






打开 package.json,在 scripts 中找到下面这条用于构建 CSS 文件的命令:

{
    "scripts": {
          "build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk"
    }
}

这条命令会按顺序执行以下三步(以连接符 && 分开):

  1. 执行 build/bin/gen-cssfile 中的代码,生成一个汇总index.scssindex.css 样式文件

  2. 通过 gulp 编译文件

  3. 将样式文件从 packages/theme-chalk/lib 复制到 lib/theme-chalk

生成汇总样式文件

在第一步中执行的代码如下:

var fs = require('fs');
var path = require('path');
var Components = require('../../components.json');
var themes = [
  'theme-chalk'
];
Components = Object.keys(Components);
var basepath = path.resolve(__dirname, '../../packages/');

function fileExists(filePath) {
  try {
    return fs.statSync(filePath).isFile();
  } catch (err) {
    return false;
  }
}

themes.forEach((theme) => {
  var isSCSS = theme !== 'theme-default';
  var indexContent = isSCSS ? '@import "./base.scss";\n' : '@import "./base.css";\n';
  Components.forEach(function(key) {
    if (['icon', 'option', 'option-group'].indexOf(key) > -1) return;
    var fileName = key + (isSCSS ? '.scss' : '.css');
    indexContent += '@import "./' + fileName + '";\n';
    var filePath = path.resolve(basepath, theme, 'src', fileName);
    if (!fileExists(filePath)) {
      fs.writeFileSync(filePath, '', 'utf8');
      console.log(theme, ' 创建遗漏的 ', fileName, ' 文件');
    }
  });
  fs.writeFileSync(path.resolve(basepath, theme, 'src', isSCSS ? 'index.scss' : 'index.css'), indexContent);
});

components.json 文件中存有所有组件的名称及其相对路径,通过循环将每个组件的样式引入代码写入到 ../../packages/theme-chalk/src 下的 index.scssindex.css 中以供第二步的编译使用,令人好奇的是在判断是否为 SCSS 类型的时候使用的比对名称是 theme-default,不知用意。

官方目前只提供了一个默认主题,名称是 theme-chalk,所以这里的 themes 数组中只有一个值,也许将来会有不同的主题吧。

在循环的过程中同时也会检查是否每个组件都有独立的样式文件,对于“遗漏”的组件则创建一个,但新建的样式文件实际上是没有内容的。

事实上,这里的检测只是针对特定组件的,这部分组件(如 checkbox-button)的样式也并没有写到独立的样式文件中,而是和其主分类组件(姑且这样称呼吧)的样式在一个文件中,所以不知检测和创建的意义。

编译样式文件

经过第一步后,现在每个组件都有了对应的独立样式文件,同时还有一个汇总的 index.scss 文件(或 index.css 文件),这一步的作用就是对 scss 文件进行编译。

ElementUI 使用的编译工具是 gulp,为什么不用 webpack 呢?因为要考虑按需加载的情况。当用户使用按需加载的时候,真正需要的样式只是使用到的组件的样式,所以需要每个组件都要有独立的样式文件,像这样的多文件一对一处理,用 gulp 比 webpack 简单得多。

packages/theme-chalk/gulpfile.js 中的代码如下:

'use strict';

const { series, src, dest } = require('gulp');
const sass = require('gulp-sass');
const autoprefixer = require('gulp-autoprefixer');
const cssmin = require('gulp-cssmin');

function compile() {
  return src('./src/*.scss')
    .pipe(sass.sync())
    .pipe(autoprefixer({
      browsers: ['ie > 9', 'last 2 versions'],
      cascade: false
    }))
    .pipe(cssmin())
    .pipe(dest('./lib'));
}

function copyfont() {
  return src('./src/fonts/**')
    .pipe(cssmin())
    .pipe(dest('./lib/fonts'));
}

exports.build = series(compile, copyfont);

在这一步编译的时候还对样式添加了浏览器前缀,并对样式和图标进行了压缩。

复制文件

经过上面两步后,在 packages/theme-chalk/lib 就生成了最终的完整版 CSS 样式文件 index.css 及每个组件的独立样式文件。

回想一下我们引入全部 ElementUI 样式文件时的写法:

import 'element-ui/lib/theme-chalk/index.css';

可以看到,样式文件是在 element-ui/lib/theme-chalk/ 中,而在生产版代码包中,lib 文件夹下的文件才是真正提供给用户使用的,所以 ElementUI 使用了 cp-cli 来将最终的样式文件复制到这个目录下。

至此,ElementUI 样式文件的自动化构建就结束了。

SCSS 结构

ElementUI 的样式是使用 SCSS 编写的,运用了 SCSS 中很多的高级特性,非常值得研究。

ElementUI 的样式文件都在 packages/theme-chalk/ 文件夹下,除去每个组件的独立样式文件外还有四个重要的目录:

src
├── common
│   ├── popup.scss
│   ├── transition.scss
│   └── var.scss
├── date-picker
│   ├── date-picker.scss
│   ├── date-range-picker.scss
│   ├── date-table.scss
│   ├── month-table.scss
│   ├── picker-panel.scss
│   ├── picker.scss
│   ├── time-picker.scss
│   ├── time-range-picker.scss
│   ├── time-spinner.scss
│   └── year-table.scss
├── fonts
│   ├── element-icons.ttf
│   └── element-icons.woff
├── mixins
│   ├── config.scss
│   ├── function.scss
│   ├── mixins.scss
│   ├── utils.scss
│   └── _button.scss

 



 










 


 





  • common 文件夹中包含了三个文件,分别是所有变量的 var.scss 文件、通用过渡的 transition.scss 文件和弹出样式 popup.scss 文件。

  • date-pikcer 文件夹中的文件都是和时间日期选择器相关的样式,从这里可以看到时间日期选择器是一个非常复杂的组件。

  • fonts 文件夹下存放的是图标文件。

  • mixins 文件夹中的文件都是起到辅助作用的,是编写组件样式的基石,也是本文重要重点分析的对象。

config

config 中的内容比较简单,定义了一些变量:

$namespace: 'el';
$element-separator: '__';
$modifier-separator: '--';
$state-prefix: 'is-';

utils

对于 utils.scss 中的代码,我们选择一个具有代表性的 mixin

...
@mixin utils-clearfix {
  $selector: &;

  @at-root {
    #{$selector}::before,
    #{$selector}::after {
      display: table;
      content: "";
    }
    #{$selector}::after {
      clear: both
    }
  }
}
...

这是一个非常常用的功能,用于清除浮动,这里的难点在于如何选中需要清除浮动的“选择器”。

ElmentUI 的做法是先在这个 mixin 中定义一个变量 $seletor,并将其值设为 &,用于引用父选择器;然后利用插值器(Interpolation#{} 选中父选择器的前后伪类进而实现清除浮动。

@at-root 又有什么用呢?

& 可以引用父选择器,但是它有一个问题,就是它始终是嵌套的:

&

因此如果没有使用 @at-root,得到的将是下面的结果:

without_at_root

可以看到编译的结果产生了嵌套,这将导致部分清除浮动不生效。

@at-root 的作用就是将处于其内部的代码提升至文档的根部,即不对其内部代码使用嵌套:

with_at_root

这样编译出来的结果就没有问题了。

需要注意的是,这里定义的 $selector 变量并不是必须的,直接在 #{} 中使用 & 也是生效的:

without_selector

functions

functions 中定义的函数从作用上分为两类,除了 selctorToString 是为了去除选择器的前缀外,其余的都是由 hitAllSpecialNestRule 用于检测:

@import "config";

/* BEM support Func
 -------------------------- */
@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;
  }
}

@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
  }
}

@function hitAllSpecialNestRule($selector) {

  @return containsModifier($selector) or containWhenFlag($selector) or containPseudoClass($selector);
}

这个 hitAllSpecialNestRule 会在 mixins 使用到,稍后再分析。

mixins

这个文件中的内容相对较多,但在我学习的时候发现有些 mixinmebconfigurable-m 等并没有使用。因此,这里我们重点分析 BEM 命名规范的实现,其他的部分感兴趣的朋友们可以自行查看。

Block

Block 的实现比较简单,代码如下:

@mixin b($block) {
  $B: $namespace+'-'+$block !global;

  .#{$B} {
    @content;
  }
}

 

 



需要关注的是第 2 行定义了一个变量 $B,但并不是因为第 4 行需要使用这个变量,而是因为 Element 要使用,所以这个变量的后面加跟上了 !global,表示其是一个全局变量,这样就可以在整个文件的任意地方使用。

Element

Element 的逻辑相对复杂一些,代码如下:

@mixin e($element) {
  $E: $element !global;
  $selector: &;
  $currentSelector: "";
  @each $unit in $element {
    $currentSelector: #{$currentSelector + "." + $B + $element-separator + $unit + ","};
  }

  @if hitAllSpecialNestRule($selector) {
    @at-root {
      #{$selector} {
        #{$currentSelector} {
          @content;
        }
      }
    }
  } @else {
    @at-root {
      #{$currentSelector} {
        @content;
      }
    }
  }
}

 


 
 
 

 















第 2 行中定义了一个变量 $E,且后面跟上了 !global,是为 Modifier 的使用而准备的。

5-7 行使用了循环,多数情况下这并不是一个必选项,除了一小部分情况,比如 input-number

input_number

这两个按钮的样式很多是相同的,通过循环可以少写重复性代码。

/*...*/
@include e((increase, decrease)) {
  position: absolute;
  z-index: 1;
  top: 1px;
  width: $--input-height;
  height: auto;
  text-align: center;
  background: $--background-color-base;
  color: $--color-text-regular;
  cursor: pointer;
  font-size: 13px;

  &:hover {
    color: $--color-primary;

    &:not(.is-disabled) ~ .el-input .el-input__inner:not(.is-disabled) {
      border-color: $--input-focus-border;
    }
  }

  &.is-disabled {
    color: $--disabled-color-base;
    cursor: not-allowed;
  }
}
/*...*/

需要注意的是 SCSS 中用于循环的对象是以逗号分隔的值或 map,这里用括号将 increasedecrease 括起来表示其是一个值。

第 9 行的条件语句用于判断是否需要嵌套,hitAllSpecialNestRule 在前面已经提到过,它用于检测父选择器是否含有 Modifier、表示状态的 .is- 和 伪类,如果有则表示需要嵌套。

值得一提的是 ElementUI 并没有把这里的条件语句放到循环中,而是在循环中将多个选择器用逗号连接在一起(可以看到,连接的时候使用到了在 mixin 中定义的全局变量 $B),并用 $currentSelector 保存,然后再进行判断,这样做能够让最终编译出来的代码只有一份,起到减小体积的作用。

Modifier

在分析了 Element 的实现之后再来看 Modifier 的实现就比较简单了,基本上和 Modifier 一样,只是少了判断而已。

@mixin m($modifier) {
  $selector: &;
  $currentSelector: "";
  @each $unit in $modifier {
    $currentSelector: #{$currentSelector + & + $modifier-separator + $unit + ","};
  }

  @at-root {
    #{$currentSelector} {
      @content;
    }
  }
}

_button

_button 中的内容全是给按钮或类似按钮的组件样式准备的,用于批量设置按钮的相关颜色和尺寸。为什么说还有类似按钮呢?因为 checkboxradio 可以设置成类似按钮的样式。

checkbox

此文件的内容这里就不展开了,感兴趣的朋友们可以自行查看。

结语

ElementUI 在组织 CSS 上的方式是非常值得学习的,本文也只是分析了其中的一部分,学习的目的不仅在于复制,更重要的是融会贯通,甚至有所改进。

通过学习 ElementUI 的 CSS 编写,不仅学习了如何更好地组织 CSS,还学到了一些 SCSS 的高级特性,同时也对自动化构建有了一些了解,如果在工作中能够适时使用,相信一定很有帮助。

本文分析基于 ElementUI 2.12.0 版本。

最近更新:
作者: MeFelixWang