做前端的朋友们都知道,在项目中编写 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 版本。