做前端的朋友们都知道,在项目中编写 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"
}
}
这条命令会按顺序执行以下三步(以连接符 && 分开):
执行
build/bin/gen-cssfile中的代码,生成一个汇总的index.scss或index.css样式文件通过
gulp编译文件将样式文件从
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.scss 或 index.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,得到的将是下面的结果:

可以看到编译的结果产生了嵌套,这将导致部分清除浮动不生效。
@at-root 的作用就是将处于其内部的代码提升至文档的根部,即不对其内部代码使用嵌套:

这样编译出来的结果就没有问题了。
需要注意的是,这里定义的 $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
这个文件中的内容相对较多,但在我学习的时候发现有些 mixin 如 meb、configurable-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:

这两个按钮的样式很多是相同的,通过循环可以少写重复性代码。
/*...*/
@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,这里用括号将 increase 和 decrease 括起来表示其是一个值。
第 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 中的内容全是给按钮或类似按钮的组件样式准备的,用于批量设置按钮的相关颜色和尺寸。为什么说还有类似按钮呢?因为 checkbox 和 radio 可以设置成类似按钮的样式。

此文件的内容这里就不展开了,感兴趣的朋友们可以自行查看。
结语
ElementUI 在组织 CSS 上的方式是非常值得学习的,本文也只是分析了其中的一部分,学习的目的不仅在于复制,更重要的是融会贯通,甚至有所改进。
通过学习 ElementUI 的 CSS 编写,不仅学习了如何更好地组织 CSS,还学到了一些 SCSS 的高级特性,同时也对自动化构建有了一些了解,如果在工作中能够适时使用,相信一定很有帮助。
本文分析基于 ElementUI 2.12.0 版本。
