CSS预处理器介绍

发表于更新于阅读时长 18 分钟

流行的 css 预处理器介绍和吐槽

0. CSS

CSS, 或者叫它的全称 Cascading Style Sheets, 是一种声明式的为浏览器渲染文档提供样式的语言, 它随着当代的浏览器生态一起, 已经远远偏离了它的初始意图, 也暴露了它的种种先天不足. 为了弥补这些缺陷, 前端社区中涌现出了各种工具, 其中最为出名的是这三种: Less, Sass 和 Stylus. 本文将在接下来几节中讨论它们

这些工具的主要解决的问题是 CSS 缺乏复用代码的能力, 具体说来则是提供了:

  • 变量
  • 函数和模块
  • 条件和循环
  • 嵌套

等等

为了讨论方便, 本文采用 MDN 所使用的术语翻译来描述 CSS

.p /* 这是选择器(序列) */ {
  /* 里面是声明 */
  color/* 这是属性 */: white; /* 这是值 */
  /* 这样的属性和值被称为一个名值对 */
}
/* 这是一条规则 */

1. Less

Less 的全称是 Leaner Style Sheets, 这名字非常令人困惑, 也给搜索造成了很大的麻烦. 据传, 它是在 Sass 只有基于缩进的语法时, 出于对这种语法的不满而被写出来的, 之后因为最著名的 CSS 库Bootstrap使用它而收到了大量的关注. 在这些工具中, Less 是提供的额外能力最少的, 它的实现几乎和一些宏一样简单(基本上都是基于字符串替换). 所以不妨从这里开始.

1.1 变量

在 Less 中的变量声明是如下的怪异形式

@property: color;

变量可以在选择器, 属性和值上, 当然也可以像其他编程语言一样用在语句中. 取值时, 若是在选择器, 属性或是字符串中, 需使用@{}语法, 否则使用@即可. Less 并不严格区分字符串和声明, 取值时@后面的接的东西可以是这两者中任意一个

Less 中的另一个值得注意的特性是"惰性求值". 这是说 Less 编译器在寻找变量的值的时候, 会在当前词法作用域中从前向后找并采用最后一个值, 并把这个值付给当前作用域中同名的每一个变量. 如果当前作用域中没有该变量, Less 会像普通的编程语言一样逐层向上寻找. 这意味着

text {
  @var: 10;
  line-height: @var;
  @var: 15;
}
@var: 99;

将会被编译成

text {
  line-height: 15;
}

这一特性乍一看有些奇怪, 但在编写复杂的 CSS 库时, 可以使用它来让用户覆盖全局变量, 只要在文件的最末尾引用user-defined.less即可

Less 也并没有浪费$这个前缀. 在 Less 中$var指的是同一个声明内的属性值, 形如

.widget {
  color: #efefef;
  background-color: $color;
}

Map 是 Less 3.5 中加入的新功能. 定义变量时, 可以这样

@sizes: {
  mobile: 320px;
  tablet: 768px;
  desktop: 1024px;
};

就像普通编程语言里的 object/table/dict 一样, 可以用[]取具体的属性

Less 当然也支持基础的数学运算, 但是 CSS 中的数字常常带有单位, 此时 Less 会试图保持表达式仍有意义. 如果单位能转换, 那就会被转换

@conversion-1: 5cm + 10mm; // result is 6cm

如果不能, Less 会假设所有的单位都是表达式中出现的第一个单位

@incompatible-units: 2 + 5px - 3%; // result is 4px
@base: 2cm * 3mm; // result is 6cm

1.2 嵌套

Less 提供基础的嵌套 CSS 功能, 比方说

a {
  color: blue;
  span {
    color: green;
  }
}

会被编译成

a {
  color: blue;
}
a span {
  color: green;
}

也就是说, 内部的选择器将被加上外部选择器作为前缀

Less 还支持 & 语法, 此时编译器不会自动把外部选择器加到前缀上, 而是把所有 & 都替换成外部选择器, 比如

.button {
  &-ok {
    background-image: url('ok.png');
  }
}

1.3 混合

虽然名字有些奇怪, 总之这就是在 Less 中使用函数的方式.

在 Less 中, 所有的规则天生就是函数, 可以在别的规则中调用它们以获得同样的样式, 大概这就是这个名字的来源. 另一个奇怪的设计则是函数名必须是类选择器或者 id 选择器

.a,
#b {
  color: red;
}
.mixin-class {
  .a();
}
.mixin-id {
  #b();
}

会被编译成

.a,
#b {
  color: red;
}
.mixin-class {
  color: red;
}
.mixin-id {
  color: red;
}

普通的函数和选择器的区别仅仅在于是否在选择器后面加上(), 函数不会被编译到结果中

Less 中的函数也可以被当作命名空间来使用, 如果要调用命名空间内的函数可以使用>或者空格(或者什么都不用)来连接命名空间和函数

就像普通的编程语言一样, Less 的函数可以接受参数, 也可以用形如.border-radius(@radius: 5px)提供参数的默认值. Less 的另一个奇怪设计则是参数分割符的设计: 逗号和分号都可以; 如果混用的话, 逗号会被当作列表风格符, 分号则还是参数风格符. 就像在 JS 里那样, 可以在函数用@arguments来访问所有参数, 也可以用...来表示剩余参数

Less 的 Mixin 还支持"模式匹配"(实际上只是会 fall through 的 switch 而已), 可以匹配参数值和参数数量

.mixin(dark; @color) {
  color: darken(@color, 10%);
}
.mixin(light; @color) {
  color: lighten(@color, 10%);
}
.mixin(@_; @color) {
  display: block;
}
@switch: light;

.class {
  .mixin(@switch; #888);
}

会被编译成

.class {
  color: #a2a2a2;
  display: block;
}

可以看到第二个分支因为参数值生效了, 而第三个分支因为会匹配所有值也生效了

Mixin 的另一个奇怪特性是在 Mixin 中, 如果属性前面加上了@, 那就可以像 Map 一样用[]取返回值的特定项. 如果[]没有写值, 那会取含有@的第一个属性

1.4 逻辑

因为 Less 的设计限制, 所以在 Less 中表达逻辑比较麻烦, 需要使用 Mixin Guard 这一特性.

所谓 Mixin Guard 指的是在 Mixin 后面可以接上 when 关键字, 只有当 when 后面的表达式值为真(在 Less 中除了 True 其他值都是 falsy 的)时函数才会执行, 比如说

.mixin(@a) when (lightness(@a) >= 50%) {
  background-color: black;
}
.mixin(@a) when (lightness(@a) < 50%) {
  background-color: white;
}
.mixin(@a) {
  color: @a;
}
.class1 {
  .mixin(#ddd);
}
.class2 {
  .mixin(#555);
}

会被编译成

.class1 {
  background-color: black;
  color: #ddd;
}
.class2 {
  background-color: white;
  color: #555;
}

在 when 语句中可以使用 and or 等逻辑运算符, 也可以使用iscolor isurl等内置函数

当然如果只要求值, 那可以使用内置函数if, 它的用法类似于普通编程语言中的三目运算符

Less 中的循环则更为反直觉一些, 需要依靠 Mixin Guard 和 递归调用手动模拟, 相信读者能想象出来

1.5 模块

Less 使用@import来导入其他模块. 如果导入的文件名后缀是 CSS, 编译器就会把他当作 CSS 来解析; 否则都会当作 Less 来解析.

在使用@import时, 可以使用形如@import (keyword1 keyword2) file.less来指定导入的方法, 可以使用的关键字包括

  • reference, 模块不写入结果仅作引用, 除非 Mixin 或者 extend 否则导入的代码不会出现在最后的结果中
  • inline, 导入代码时不做处理直接写入结果, 这是用于 CSS 一些和 Less 不一样的地方
  • lesscss, 指定语法解析方式
  • oncemultiple, 当多次导入代码时写入结果一次/多次.once是默认行为

1.6 其他杂项

虽然看起来更加奇怪, :extend() 也是 Less 中复用代码的一种方式

extend 可以被加在选择器后面, 或是在声明中; 如果是后面一种情况, 则要在前面加上 &. 无论怎么使用, 都会导致当前的选择器被加到 extend 的选择器后面, 从而实现代码的复用. 相比 Mixin, 这样做会少生成一些代码

.bucket {
  tr {
    color: blue;
  }
}
.some-class:extend(.bucket tr) {
}

会被编译成

.bucket tr,
.some-class {
  color: blue;
}

extend 的另一个用法则是在()内加入 all, 这会让当前的选择器加入当所有符合 all 之前选择器的选择器序列中, 例如

.a.b.test,
.test.c {
  color: orange;
}
.test {
  &:hover {
    color: green;
  }
}

.replacement:extend(.test all) {
}

会被编译成

.a.b.test,
.test.c,
.a.b.replacement,
.replacement.c {
  color: orange;
}
.test:hover,
.replacement:hover {
  color: green;
}

extend 的一个缺陷则是它的参数里不能有变量, 这让它比起 Mixin 来缺乏一些动态性. 另一条限制是 extend 只会查看同一个@meida规则内的声明, 无论是内层还是外层的选择器都会被忽视.

融合是 Less 提供给一些特殊 CSS 属性例如box-shadow等, 它们可以允许多个值同时生效. 在同一个声明里出现的属性, 只要在它后面加上+号, Less 就会把它编译到一起

2. Sass

在这些工具中, Sass 有着最长的历史和最活跃的社区. Sass 起源于 2000 年代时 web 领域最活跃的 Ruby 社区. 所以自然而然地, Sass 最初的版本是使用Ruby实现的, 之后出现了现在最常用也是性能最高的C++版本, 而在当代前端开发中最常用的node-sass就是在 C++ 版的基础上建立的, 当然出于种种原因也有其他版本的实现比如dart-sass.

相比 Less, Sass 更像普通的编程语言而非 CSS 的扩展, 它甚至还有自己的repl. 它支持两种语法: 一是基于缩进, 不用写分号和大括号的语法;

#main
  color: blue
  font-size: 0.3em

另一种则是类似于普通的 CSS 的语法. 按照惯例, 前者使用.sass 后缀名, 后者使用.scss 后缀名. 此外前者还支持把分号写在属性前而非属性和值中间

#main
  :color blue
  :font-size 0.3em

下文中将采用 scss 语法

2.1 变量

Sass 同样支持定义变量, 但是使用的前缀是 $

$width: 5em;

Sass 中的变量工作方式和普通的编程语言一样. 变量默认只在同一个代码块中定义后的行内有效. 如果要在全局环境中使用, 需要加上!global后缀. 一点小区别是 Sass 中的变量名不区分 _ 和 -.

如上所述, Sass 中的变量不会像 Less 中那样只取最后一个值, 这就使得在编写 CSS 库引入用户自定义变量时带来麻烦. 此时可以使用!default作为后缀, 此时只有在该值未被定义时此次赋值才会生效. 换言之, 只要在文件开头引入user-defined.scss, 会被覆盖的变量后加上!default即可. Sass 不区分 null 和未定义

Sass 共有七种数据类型:

  • 数字
  • 字符串, 在 Sass 中字符串可以不使用引号
  • 颜色, blue, #1453ad 和 rgba(255, 255, 255, 0.6) 都是颜色. 在输出成 CSS 时, Sass 会将它们输出成尽量短的形式
  • 布尔
  • 空值 null
  • 列表, 用空格或是逗号隔开. 列表可以包含列表, 此时需要用和父级不同的分割符隔开, 或者用()
  • map, 形如(key1: value1, key2: value2)

Sass 支持多种运算. 基础的数字之间可以进行基础的数学运算. Sass 要求 +, - 和 / 作用的变量有可转换的单位, * 作用的变量只能有一个有单位. 颜色之间可以进行类似的计算. 但是要求有 alpha 通道的颜色进行运算时, 只能在 alpha 通道值相同的变量之间进行.

然而令人尴尬的是 CSS 原生语法中也用到了 / 和 -, 因此可能造成歧义. 此时加上()即可.

  • 也可被用于字符串拼接. 和普通编程语言不同, Sass 默认使用空格连接字符串. 拼接时 null 会被视为空字符串. 可以使用#{var}来在字符串中使用变量. 同样地, 你可以在选择器和属性中使用#{var}来访问变量

2.2 嵌套

如同 Less 一样, Sass 也支持嵌套选择器. 同样地, Sass 也支持 &, 只不过 & 必须在某个选择器的开头,test-&这样的语法是不行的

在 Sass 中还支持选择器嵌套, 这一特性被称为属性命名空间. 有着同样前缀的选择器可以把前缀写在 : 前面, 内部的属性值都会被加上前缀. 该命名空间也可以有自己的值. 示例如下

.funky {
  font: 20px/24px fantasy {
    weight: bold;
  }
}

2.3 @规则和指令

Sass 支持所有 CSS 的 @ 规则(事实上相当多的 CSS @ 规则都来自 Sass),以及一些其他特定的指令. 因此扩展 Sass 的语法非常容易, 不用像 Less 那样使用各种 hack.

@import规则可以用来导入 Sass 或是 CSS 文件. 和导入 CSS 不同, 不能使用#{}来动态地导入 Sass. Sass 支持在声明中使用@import, 但不能在指令或是混入中使用. 非常神秘地是, Sass 时至今日也不支持在一个作用域中只导入一次文件, 这为开发带来了巨大的麻烦. 少数可行的解决方案包括类似于 C 中的 #ifdef 宏, 和一个跟 c++ 一样完全缺乏进展的 module 系统. 不得不说人类的愚蠢居然相似.

@media规则用法与 CSS 类似, 只不过支持 Sass 变量函数等等, 也可以用在嵌套选择器的内层.

@extend规则会把当前选择器加入到@extend的规则中, 和 Less 中类似. 同样地, @extend规则只支持同样的@media规则中的规则. 有时, 使用者只想在@extend中使用某条规则, 而不想把它输出到 CSS 中, 此时可以使用 % 开头的选择器.

Sass 会智能地排除重复的选择器, 也会略去不能匹配任何元素的选择器. Sass 允许 extend 一个选择器序列中的某一个选择器, 此时编译器识别两个序列中相同的部分, 剩下不同的部分会在生成的 CSS 中交替出现

#admin .tabbar a {
  font-weight: bold;
}
#admin .overview .fakelink {
  @extend a;
}

会被编译成

#admin .tabbar a,
#admin .tabbar .overview .fakelink,
#admin .overview .tabbar .fakelink {
  font-weight: bold;
}

@at-root规则可以让规则被输出在根层级上. 它可以当作选择器或属性的前缀, 此时该规则或属性会被输出到根层级; 或是当作单独的选择器使用, 此时@at-root内部的所有规则都会被输出到根层级

@at-root规则默认只排除选择器, @at-root(without: ...)可以让规则被输出到某个或多个指令之外(例如@media). 如果给 without 传递的参数为 rule, 则等价于不使用 without; 如果传递的是 all, 则会输出到全部指令和规则之外. 例如

@media print {
  .page {
    width: 8in;
    @at-root (without: media) {
      color: red;
    }
    @at-root height: 1em;
  }
}

会被编译成

@media print {
  .page {
    width: 8in;
    height: 1em;
  }
}

.page {
  color: red;
}

也可以在这条规则中使用 with 参数, 制定要保留而非应该忽视的规则

在撰写复杂的 Sass 规则时, 可以使用@warn, @debug@error来进行 debug

2.4 控制指令和表达式

在 Sass 中, 可以使用if(expr1, expr2, expr3)当作普通编程语言中的三值表达式使用. 对于较为复杂的控制流, Sass 也提供@if, @else if@else指令. 在 Sass 中 只有 false 和 null 是 falsy 的

p {
  @if 1 + 2 == 2 {
    border: 1px solid;
  }
  @if 5 - 5 {
    border: 2px dotted;
  }
  @if null {
    border: 3px double;
  }
}

会被编译成

p {
  border: 2px dotted;
}

@for指令则用于控制循环. Sass 很奇怪的支持两种语法:@for $var from <start> to/through <end>. 使用 through 时, 循环会到 end 时结束; 使用 to 时则会在前一个值结束. start 和 end 都必须是整数, 但并不限制 start 和 end 的大小关系. start 较小时, var 会递增; 否则会递减.

也可以使用形如@each $var in <list or map>的语句来对列表或 map 进行迭代. 可以在 var 处插入多个变量, 此时如果迭代的是列表, 则每个变量都会被赋上子列表中的值; 如果是 map, 则第一个变量会被赋值成键, 第二个会被赋值成 map 中的值.

@each $animal, $color, $cursor in (puma, black, default), (sea-slug, blue, pointer), (egret, white, move) {
  .#{$animal}-icon {
    background-image: url('/images/#{$animal}.png');
    border: 2px solid $color;
    cursor: $cursor;
  }
}

@each $header, $size in (h1: 2em, h2: 1.5em, h3: 1.2em) {
  #{$header} {
    font-size: $size;
  }
}

会被编译成

.puma-icon {
  background-image: url('/images/puma.png');
  border: 2px solid black;
  cursor: default;
}
.sea-slug-icon {
  background-image: url('/images/sea-slug.png');
  border: 2px solid blue;
  cursor: pointer;
}
.egret-icon {
  background-image: url('/images/egret.png');
  border: 2px solid white;
  cursor: move;
}

h1 {
  font-size: 2em;
}
h2 {
  font-size: 1.5em;
}
h3 {
  font-size: 1.2em;
}

@while则和普通编程语言中的 while 工作方式类似, 使用@while expr时直到表达式返回 false 时循环结束

2.5 混入和函数

虽然翻译不一样, 但 Sass 可以像 Less 中那样用 Mixin 定义"函数".

Sass 中使用混入的方式是用@mixin指令, 后面接上函数名和可选的参数列表(需要用()包起来). 例如:

@mixin large-text {
  font: {
    family: Arial;
    size: 20px;
    weight: bold;
  }
  color: #ff0000;
}

使用混合的方式则是使用@include指令, 同样是后面接上函数名和可选的参数列表. 可以在选择器内使用该指令, 如果调用的混成中没有直接定义属性, 那就可以在任何地方(包括文档根部)使用. 在较新的版本中, 混入可以递归.

Sass 中的混入在调用时也可以使用具名参数, 语法形如@include border($color: blue). 定义时可以使用剩余参数, 语法形如@mixin box-shadow($shadows...), 调用时也支持展开列表, 或者同时展开列表和 map, 只要列表在 map 前即可. 在调用有剩余参数的函数时, 也可以使用具名参数, 此时在混入内访问时可以用keywords($args)访问

可以用如下的语法像混入传入语句块, 在 混入内部可以用 @content规则访问它. 这种语法和 Web Components 中的slot

@mixin colors($color: blue) {
  background-color: $color;
  @content;
  border-color: $color;
}
.colors {
  $color: white;
  @include colors {
    color: $color;
  }
}

上述代码会被编译成

.colors {
  background-color: blue;
  color: white;
  border-color: blue;
}

注意传入的代码块的作用域仍然是调用者的作用域, 而非混入的作用域.

Sass 也支持用@function name(var)来定义函数, 这里的()和混入中不同, 不是可选的.

$grid-width: 40px;
$gutter-width: 10px;

@function grid-width($n) {
  @return $n * $grid-width + ($n - 1) * $gutter-width;
}

#sidebar {
  width: grid-width(5);
}

可以看到, Sass 的函数就像普通语言的函数那样, 返回值时需要使用@return指令, 调用时直接使用名字加上()即可.

3. Stylus

在这三种工具当中, Stylus 是最灵活的. 使用者可以省略{}, ; 甚至是 :, 此时可以得到非常干净漂亮的语法

body
  color               white
  background-color    black

在这三种工具中, Stylus 也有着最丰富的特性, 因此有着最强的表达力. 它不仅支持嵌套和 & 符号, 同时还在选择器在支持功能更加强大的其他符号, 比如^[N] ^[N..M]表示从第 N 级或是第 N 到 M 级选择器, 甚至还支持负数; 还有类似于 cd 的~/ .. /语法.

在 Stylus 中 ,定义变量也不需要丑陋的 $ 符号, 当然非要加上也是合法语法. 取值时也只需要使用{}而无需别的前缀. 条件语句和循环语句的用法也与 python 几乎一致, 不需要像 Sass 那样加上丑陋的 @ 前缀, 而又拥有 JS 灵活的对象字面量语法. Stylus 还从 CoffeeScript 中借鉴来了灵活的函数语法: 函数的返回值就是该函数中最后一个表达式的值. Stylus 还支持方便的require指令, 对于同一个文件只会在一个作用域中生效一次, 该指令也支持动态导入 Stylus 文件.

Stylus 是由 Node 社区知名大神TJ设计并编写的. 他不仅很有天赋, 而且精力非常充沛. 他不仅为社区共享了当前最为活跃的 Node 框架Express.js, 还开发了号称下一代 Node 框架的Koa, 非常流行的模板引擎Pug等等. 他一个人贡献的代码一度占到了 Node 社区的 3%.

到现在为止还不错, 是吗? 然而自从 TJ 大神转向 Go 语言之后, Stylus 逐渐陷入了缺乏投入和关注的恶性循环. 时至今日, 已经有三年多没有进行过一次发布, github repo 上可以看到还有多个严重的 issue 未得到解决. 除了在已经使用 Stylus 的项目之外, 不建议在任何地方使用它.

有的时候世界就是这么奇怪.

4. To infinity and beyond

相信读完了上面这么多内容的读者(应该没有吧), 相信已经对该用什么预处理器有了自己的看法(那当然是用全世界最好的 Sass 啦). 然而这并非终点. CSS 的语法是如此简单, 而它的效果又是如此复杂, 当然会不断涌现出各种各样的工具来解决 CSS 中会遇到的问题.

在当代前端开发中用到最多的可能是postcss. 就像它的名字所表示的那样, 它并不是一个预处理器而是一个"后处理器". 这意味着它会把你写的 CSS 代码编译成更"好"的 CSS 代码, 就像Babel对 JS 做的那样(事实上两者的设计思路确实很接近). 它可以单独使用, 提供把新 CSS 特性自动加上对应浏览器前缀的功能, 或是把新的 CSS 语法 诸如 CSS 变量转换为旧的 CSS. 当然也可以和前文提到的任何预处理器一起使用, 甚至可以使用某些 postcss 插件来使用编译预处理器的特殊语法.

从这个功能里相信读者能猜到 postcss 能做的并不只是后处理. 自然 postcss 社区中出现了各式各样的预处理插件, 其中最出名的是precss, 在其之上有人造出了基于缩进的sugarss. 当然, 只要读者愿意, 花上一定时间也可以使用丰富的 postcss 插件搭建出符合自己偏好的语法, 尽管这不过是把一个有问题的依赖换成了几十个有问题(还可能缺乏维护)的依赖.

随着当代前端社区以 JS 中心的思潮而兴起的另一项工具则是CSS in JS, 或者叫 JSS. 就像JSX一样, JSS 允许在 CSS 中使用表现力强大的 JS 来扩展 CSS 的功能. 很多人相信这是 CSS 的终极解决方案.

介绍了这么多历代前端对 CSS 的处理方案(和弯路), 尽管他们要处理的问题是类似的, 采取的手段也非常接近, 最后造成的成品却不尽相同. 希望读完之后能让读者对 CSS 的问题和处理方案有更完善的认识, 在选择工具时能做出更明智的选择.

© 2016 - 2022Austaras Devas