来自实战的 Sass 技术

Avatar of Buddy Reno
Buddy Reno 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

在网页开发行业工作了 14 年多,我见过也写过不少好坏参半的 CSS 代码。五年前,当我开始在 Ramsey Solutions 工作时,接触到了 Sass。它强大的功能让我感到震惊!我立即投入其中,并渴望学习关于它的所有知识。在过去的五年里,我使用了许多不同的 Sass 技术和模式,并爱上了一些,借用苹果公司的一句话来说,就是好用的技术。

在这篇文章中,我将探讨广泛的主题

根据我的经验,找到简单和复杂之间的平衡是开发优秀软件的关键。软件不仅应该易于用户使用,还应该易于您和其他开发人员在未来进行维护。我将这些技术视为高级技术,但并非故意使其变得巧妙或复杂!

“每个人都知道,调试比编写程序本身要困难一倍。因此,如果您在编写程序时尽可能地聪明,那么您将如何调试它呢?”

——《编程与风格要素》(第二版),第二章

考虑到这一点,让我们首先看看 Sass 的与符号。


与符号的强大功能

您可以使用许多不同的命名约定来组织您的 CSS。我最喜欢使用的是 SUIT,它是 BEM(即Block、Element、Modifier 的缩写)的一种变体。如果您不熟悉 SUIT 或 BEM,我建议您在继续之前先了解一下它们。在本文的其余部分,我将使用 SUIT 约定。

无论您选择哪种命名约定,基本思想都是每个样式化元素都获得自己的类名,并在类名前加上组件名称。这个想法对于以下一些组织方式的运作至关重要。此外,本文仅描述性,而非规定性。每个项目都不同。您需要根据您的项目和团队的需求选择最合适的方法。

我之所以喜欢使用 SUIT、BEM 和类似的约定,主要原因在于 与符号。它允许我在不产生选择器冲突的情况下使用嵌套和作用域。举个例子,如果不使用与符号,我需要创建单独的选择器来创建-title-content元素。

.MyComponent {
  .MyComponent-title {}
}

.MyComponent-content {}

// Compiles to
.MyComponent .MyComponent-title {} // Not what we want. Unnecessary specificity!
.MyComponent-content {} // Desired result

在使用 SUIT 时,我希望-content的第二个结果成为我编写所有选择器的方式。为此,我需要在整个过程中重复组件的名称。这增加了我在编写新样式时打错组件名称的可能性。它也很冗长,因为它最终会忽略许多选择器的开头,这可能导致忽略明显的错误。

.MyComponent {}
.MyComponent-title {}
.MyComponent-content {}
.MyComponent-author {}
// Etc.

如果这是普通的 CSS,我们会被迫编写上述代码。由于我们使用的是 Sass,因此有一种更好的方法可以使用与符号。与符号非常棒,因为它包含对当前选择器以及任何父级的引用。

.A {
  // & = '.A'
  .B {
    // & = '.A .B'
    .C {
      // & = '.A .B .C'
    }
  }
}

您可以在上面的示例中看到,与符号如何在嵌套代码中深入时引用链中的每个选择器。通过利用此功能,我们可以创建新的选择器,而无需每次都重写组件的名称。

.MyComponent {
  &-title {}
  
  &-content {}
}

// Compiles to
.MyComponent {}
.MyComponent-title {}
.MyComponent-content {}

这很棒,因为我们可以利用与符号来编写一次组件的名称,然后在整个过程中简单地引用组件名称。这降低了组件名称被错误输入的可能性。此外,整个文档也更容易阅读,而无需在代码中重复.MyComponent


有时,组件需要一个变体修饰符,就像在 SUIT 和 BEM 中所说的那样。使用与符号模式可以更容易地创建修饰符。

<div class="MyComponent MyComponent--xmasTheme"></div>
.MyComponent {
  &--xmasTheme {}
}

// Compiles to
.MyComponent {}
.MyComponent--xmasTheme {}

“但是,如何修改子元素呢?”您可能会问。“这些选择器是如何创建的?修饰符并不需要在每个元素上使用,对吧?”

这就是变量可以提供帮助的地方!

变量和作用域

过去,我曾以几种不同的方式创建修饰符。大多数时候,我会重写我想在修改元素时应用的特殊主题名称。

.MyComponent {
  &-title {
    .MyComponent--xmasTheme & {
    }
  }
  
  &-content {
    .MyComponent--xmasTheme & {
    }
  }
}

// Compiles to
.MyComponent-title {}
.MyComponent--xmasTheme .MyComponent-title {}
.MyComponent-content {}
.MyComponent--xmasTheme .MyComponent-content {}

这可以完成工作,但我又回到了在多个地方重写组件名称,更不用说修饰符名称了。当然有更好的方法。让我们看看 Sass 变量。

在探索 Sass 变量与选择器之前,我们需要了解它们的作用域。Sass 变量具有作用域,就像在 JavaScript、Ruby 或任何其他编程语言中一样。如果在选择器外部声明,则该变量在声明之后可用于文档中的每个选择器。

$fontSize: 1.4rem;

.a { font-size: $fontSize; }
.b { font-size: $fontSize; }

在选择器内部声明的变量的作用域仅限于该选择器及其子元素。

$fontSize: 1.4rem;

.MyComponent { 
  $fontWeight: 600;
  font-size: $fontSize; 
  
  &-title {
    font-weight: $fontWeight; // Works!
  }
}

.MyComponent2 { 
  font-size: $fontSize; 
  
  &-title {
    font-weight: $fontWeight; // produces an "undefined variable" error
  }
}

我们知道变量可以存储字体名称、整数、颜色等。您知道它还可以存储选择器吗?使用字符串插值,我们可以使用变量创建新的选择器。

// Sass string interpolation syntax is #{VARIABLE} 
$block: ".MyComponent";

#{$block} {
  &-title {
    #{$block}--xmasTheme & {
    }
  }
}

// Compiles to
.MyComponent {}
.MyComponent-title {}
.MyComponent--xmasTheme .MyComponent-title {}

这很酷,但变量是 全局作用域的。我们可以通过在组件声明内部创建$block变量来解决这个问题,这将将其作用域限制在该组件中。然后,我们可以在其他组件中重复使用$block变量。这有助于使主题修饰符更加 DRY

.MyComponent {
  $block: '.MyComponent';
  
  &-title {
    #{$block}--xmasTheme & {
    }
  }
  
  &-content {
    #{$block}--xmasTheme & {
    }
  }
}

// Compiles to
.MyComponent {}
.MyComponent-title {}
.MyComponent--xmasTheme .MyComponent-title {}
.MyComponent-content {}
.MyComponent--xmasTheme .MyComponent-content {}

这更接近了,但我们仍然必须一遍遍地写主题名称。让我们也将其存储在变量中!

.MyComponent {
  $block: '.MyComponent';
  $xmasTheme: '.MyComponent--xmasTheme';
  
  &-title {
    #{$xmasTheme} & {
    }
  }
}

这好多了!但是,我们还可以进一步改进它。变量还可以存储与符号的值!

.MyComponent {
  $block: &;
  $xmasTheme: #{&}--xmasTheme;
  
  &-title {
    #{$xmasTheme} & {
    }
  }
}

// Still compiles to
.MyComponent {}
.MyComponent-title {}
.MyComponent--xmasTheme .MyComponent-title {}

现在这才是我想要的!使用与符号“缓存”选择器允许我们在顶部创建修饰符,并将主题修改与它所修改的元素一起保留。

“当然,这在顶层有效,”你说。“但是如果你嵌套得很深,比如嵌套了八层呢?”你问的问题很棒。

无论嵌套深度如何,此模式始终有效,因为主组件名称从未附加到任何子元素上,这要归功于 SUIT 命名约定和与符号组合。

.MyComponent { 
  $block: &;
  $xmasTheme: #{&}--xmasTheme;
  
  &-content {
    font-size: 1.5rem;
    color: blue;
    
    ul {
      li {
        strong {
          span {
            &::before {
              background-color: blue;
              
              #{$xmasTheme} & {
                background-color: red;
              }
            }
          }
        }
      }
    }
  }
}

// Compiles to 
.MyComponent-content {
  font-size: 1.5rem;
  color: blue;
}

.MyComponent-content ul li strong span::before {
  background-color: blue;
}

/*
* The theme is still appended to the beginning of the selector!
* Now, we never need to write deeply nested Sass that's hard to maintain and 
* extremely brittle: https://css-tricks.cn/sass-selector-combining/
*/
.MyComponent--xmasTheme .MyComponent-content ul li strong span::before {
  background-color: red;
}

代码组织是我喜欢使用此模式的主要原因。

  • 它相对 DRY
  • 它支持“选择加入”方法,该方法将修饰符与其修改的元素一起保留
  • 命名事物很难,但这使我们能够重用常见的元素名称,如“title”和“content”
  • 通过将修饰符类放在父组件上,添加修饰符到组件的成本很低

“嗯……但是创建了许多不同的组件后,这是否会变得难以阅读?当所有内容都命名为&-title&-content时,您如何知道自己在哪里?”

您继续问一些很棒的问题。谁说Sass 必须在一个文件中?我们可以导入这些组件,所以让我们转向这个话题!

导入的重要性

鸣谢:@Julien_He

Sass 最好的功能之一是@import。我们可以创建单独的 Sass 文件(部分文件)并将它们导入到其他 Sass 文件中,这些文件与导入的文件一起编译,导入的文件位于其导入的位置。这使得将相关的组件、实用程序等的样式打包在一起并将其提取到单个文件中变得容易。如果没有@import,我们需要链接到单独的 CSS 文件(创建大量网络请求,这 不好)或在单个样式表中编写所有内容(这很难导航和维护)。

.Component1 {
  &-title {}
  &-content {}
  &-author {}
}

.Component2 {
  &-title {}
  &-content {}
  &-author {}
}

.Component3 {
  &-title {}
  &-content {}
  &-author {}
}

.Component4 {
  &-title {}
  &-content {}
  &-author {}
}

.Component5 {
  &-title {}
  &-content {}
  &-author {}
}

// A couple of hundred lines later...

.Component7384 {
  &-title {}
  &-content {}
  &-author {}
}

// WHERE AM I?

组织 Sass 文件的一种更流行的方法是 7-1 模式。即七个不同的文件夹包含 Sass 文件,这些文件被导入到单个 Sass 文件中。

这些文件夹是

  • 抽象
  • 基础
  • 组件
  • 布局
  • 页面
  • 主题
  • 供应商

使用@import将这些文件夹中的每个 Sass 文件提取到一个主 Sass 文件中。我们希望按照以下顺序导入它们,以保持良好的作用域并在编译期间避免冲突

  1. 抽象
  2. 供应商
  3. 基础
  4. 布局
  5. 组件
  6. 页面
  7. 主题
@import 'abstracts/variables';
@import 'abstracts/functions';
@import 'abstracts/mixins';

@import 'vendors/some-third-party-component';

@import 'base/normalize';

@import 'layout/navigation';
@import 'layout/header';
@import 'layout/footer';
@import 'layout/sidebar';
@import 'layout/forms';

@import 'components/buttons';
@import 'components/hero';
@import 'components/pull-quote';

@import 'pages/home';
@import 'pages/contact';

@import 'themes/default';
@import 'themes/admin';

您可能希望或可能不希望使用所有这些文件夹(我个人不使用主题文件夹,因为我将主题与其组件一起保留),但是将所有样式分离到不同的文件中这一想法使代码更容易维护和查找。

使用此方法的更多好处

  • 小组件更容易阅读和理解
  • 调试变得更简单
  • 更清楚地确定何时应创建新组件——例如,当单个组件文件变得太长或选择器链过于复杂时
  • 这强调了重用——例如,将三个本质上执行相同操作的组件文件概括为一个组件可能是有意义的

说到重用,最终会有一些经常使用的模式。这时我们可以使用混合。

混合(Mixin)的使用

混合是整个项目中重用样式的好方法。让我们逐步创建简单的混合,然后为其添加一些智能。

我经常合作的设计师总是将font-sizefont-weightline-height设置为特定值。我发现每次需要调整组件或元素的字体时,都要手动输入这三个属性,因此我创建了一个mixin来快速设置这些值。它就像一个小的函数,我可以使用它来定义这些属性,而无需完整地编写它们。

@mixin text($size, $lineHeight, $weight) {
  font-size: $size;
  line-height: $lineHeight;
  font-weight: $weight;
}

目前,这个mixin非常简单——类似于JavaScript中的函数。它有一个mixin名称(text),并接收三个参数。每个参数都与一个CSS属性关联。当调用mixin时,Sass会复制这些属性并将传入的参数值应用到它们。

.MyComponent {
  @include text(18px, 27px, 500);
}

// Compiles to
.MyComponent {
  font-size: 18px;
  line-height: 27px;
  font-weight: 500;
}

虽然这是一个很好的演示,但这个特定的mixin有点局限性。它假设我们每次调用它时,都希望使用font-sizeline-heightfont-weight属性。所以让我们使用Sass的if语句来控制输出。

@mixin text($size, $lineHeight, $weight) {
  // If the $size argument is not empty, then output the argument
  @if $size != null {
    font-size: $size;
  }
  
  // If the $lineHeight argument is not empty, then output the argument
  @if $lineHeight != null {
    line-height: $lineHeight;
  }
  
  // If the $weight argument is not empty, then output the argument
  @if $weight != null {
    font-weight: $weight;
  }
}

.MyComponent {
  @include text(12px, null, 300);
}

// Compiles to
.MyComponent {
  font-size: 12px;
  font-weight: 300;
}

这样好多了,但还不够完美。如果我尝试在不使用null作为不想使用或提供的参数值的情况下使用mixin,Sass会生成错误。

.MyComponent {
  @include text(12px, null); // left off $weight
}

// Compiles to an error:
// "Mixin text is missing argument $weight."

为了解决这个问题,我们可以为参数添加默认值,从而允许我们在函数调用中省略它们。所有可选参数都必须声明在任何必需参数之后。

// We define `null` as the default value for each argument
@mixin text($size: null, $lineHeight: null, $weight: null) {
  @if $size != null {
    font-size: $size;
  }
  
  @if $lineHeight != null {
    line-height: $lineHeight;
  }
  
  @if $weight != null {
    font-weight: $weight;
  }
}

.MyComponent {
  &-title {
    @include text(16px, 19px, 600);
  }
  
  &-author {
    @include text($weight: 800, $size: 12px);
  }
}

// Compiles to
.MyComponent-title {
  font-size: 16px;
  line-height: 19px;
  font-weight: 600;
}

.MyComponent-author {
  font-size: 12px;
  font-weight: 800;
}

默认参数值不仅使mixin更容易使用,而且我们还可以为参数命名并赋予它们可能常用的值。在上面第21行,mixin的参数顺序被打乱了,但由于也明确指定了参数值,因此mixin知道如何应用它们。


我每天都会使用一个特定的mixin:min-width。我更喜欢先创建移动端优先的网站,或者说先考虑最小的视口。随着视口变宽,我定义断点来调整布局和代码。这就是我使用min-width mixin的地方。

// Let's name this "min-width" and take a single argument we can
// use to define the viewport width in a media query.
@mixin min-width($threshold) {
  // We're calling another function (scut-rem) to convert pixels to rem units.
  // We'll cover that in the next section.
  @media screen and (min-width: scut-rem($threshold)) {
    @content;
  }
}

.MyComponent {
  display: block;
  
  // Call the min-width mixin and pass 768 as the argument.
  // min-width passes 768 and scut-rem converts the unit.
  @include min-width(768) {
    display: flex;
  }
}

// Compiles to 
.MyComponent {
  display: block;
}

@media screen and (min-width: 48rem) {
  .MyComponent {
    display: flex;
  }
}

这里有几个新想法。mixin包含一个名为@content的嵌套函数。因此,在.MyComponent类中,我们不再单独调用mixin,而是还调用了一段代码块,这段代码块将在生成的媒体查询中输出。生成的代码将在调用@content的地方编译。这允许mixin处理@media声明,并仍然接受该特定断点的自定义代码。

我还将mixin包含在.MyComponent声明中。有些人提倡将所有响应式调用放在一个单独的样式表中,以减少样式表中@media的写入次数。就我个人而言,我更喜欢将组件可能经历的所有变化和修改都与其组件的声明放在一起。这往往更容易跟踪正在发生的事情,并且如果出现问题,可以帮助调试组件,而不是筛选多个文件。

你注意到里面的scut-rem函数了吗?这是一个来自名为Scut的Sass库的Sass函数,由David The Clark创建。让我们看看它是如何工作的。

开始使用函数

函数与mixin的不同之处在于,mixin旨在输出常见的属性组,而函数则根据返回新结果的参数修改属性。在本例中,scut-rem接收像素值并将其转换为rem值。这允许我们以像素为单位思考,同时在幕后使用rem单位来避免所有计算。

在这个示例中,我简化了scut-rem,因为它有一些额外的功能使用了循环和列表,这些内容超出了我们这里讨论的范围。让我们看看整个函数,然后一步一步地分解它。

// Simplified from the original source
$scut-rem-base: 16 !default;

@function scut-strip-unit ($num) {
  @return $num / ($num * 0 + 1);
}

@function scut-rem ($pixels) {
  @return scut-strip-unit($pixels) / $scut-rem-base * 1rem;
}

.MyComponent {
  font-size: scut-rem(18px);  
}

// Compiles to
.MyComponent {
  font-size: 1.125rem;
}

首先要注意的是第2行的声明。在声明变量时使用了!default,这告诉Sass将值设置为16,除非该变量已定义。因此,如果在样式表中较早地声明了具有不同值的变量,则这里不会覆盖它。

$fontSize: 16px;
$fontSize: 12px !default;

.MyComponent {
  font-size: $fontSize;
}

// Compiles to
.MyComponent {
  font-size: 16px;
}

接下来的部分是scut-strip-unit。此函数接收px、rem、百分比或其他带后缀的值,并删除单位标签。调用scut-strip-unit(12px)将返回12而不是12px。它是如何工作的?在Sass中,相同类型的单位除以另一个单位将去除单位并返回数字。

12px / 1px = 12

现在我们知道了这一点,让我们再次看看scut-strip-unit函数。

@function scut-strip-unit ($num) {
  @return $num / ($num * 0 + 1);
}

该函数接收一个单位并将其除以相同单位的1。因此,如果我们传入12px,则该函数将如下所示:@return 12px / (12px * 0 + 1)。按照运算顺序,Sass首先计算括号内的内容。Sass巧妙地忽略了px标签,计算表达式,并在完成后重新添加px12 * 0 + 1 = 1px。现在的等式是12px / 1px,我们知道它返回12。

这对scut-rem为什么重要?让我们再看看它。

$scut-rem-base: 16 !default;

@function scut-rem ($pixels) {
  @return scut-strip-unit($pixels) / $scut-rem-base * 1rem;
}

.MyComponent {
  font-size: scut-rem(18px);  
}

在第4行,scut-strip-unit函数从参数中删除px并返回18。base变量等于16,这将等式转换为:18 / 16 * 1rem。请记住,Sass会忽略任何单位,直到等式的末尾,因此18 / 16 = 1.125。该结果乘以1rem得到1.125rem。由于Scut从参数中删除了单位,因此我们可以使用无单位的值调用scut-rem,例如scut-rem(18)

我不写很多函数,因为我尽量使我创建的东西尽可能简单。不过,能够使用像scut-rem这样的东西进行一些复杂的转换还是很有帮助的。

占位符打乱的选择器顺序

最终出现在我认为它应该出现的地方了吗,那个CSS?

小心扩展的内容

我尝试编写一些示例来演示为什么使用@extend可能存在问题,但我使用它们的次数很少,无法创建任何像样的示例。当我第一次学习Sass时,我周围的队友已经经历了各种各样的挑战和磨难。我的朋友Jon Bebee写了一篇非常优秀的文章,介绍了@extend如何让你陷入困境。这是一篇简短易读的文章,值得一读,所以我会稍后再读。

关于那些占位符……

Jon建议使用占位符作为他概述的问题的解决方案:占位符在与@extend一起使用之前不会输出任何代码。

// % denotes an extended block
%item {
  display: block;
  width: 50%;
  margin: 0 auto;
}

.MyComponent {
  @extend %item;
  color: blue;
}

// Compiles to
.MyComponent {
  display: block;
  width: 50%;
  margin: 0 auto;
}

.MyComponent {
  color: blue;
}

好的,等等。所以它输出.MyComponent两次?为什么它没有简单地合并选择器?

这些是我第一次开始使用占位符(然后随后停止使用)时遇到的问题。线索是名称本身。占位符只是保存对声明它们的位置的引用。虽然mixin会将属性复制到使用它的位置,但占位符会将选择器复制到定义占位符的位置。因此,它会复制.MyComponent选择器并将其放置在声明%item的位置。考虑以下示例

%flexy {
  display: flex;
}

.A {
  color: blue;
}

.B {
  @extend %flexy;
  color: green;
}

.C {
  @extend %flexy;
  color: red;
}

// Compiles to
.B, .C {
  display: flex;
}

.A {
  color: blue;
}

.B {
  color: green;
}

.C {
  color: red;
}

即使B和C在样式表中更靠后声明,占位符也会将扩展的属性一直向上放置到它最初声明的位置。在这个示例中,这并不是什么大问题,因为它非常靠近使用它的源代码。但是,如果我们遵循前面介绍的7-1模式,那么占位符将定义在抽象文件夹中的一个部分中,它是第一个导入的文件之一。这会在扩展的预期位置和实际使用位置之间放置很多样式。这也很难维护,也很难调试。

Sass指南(当然)对占位符和扩展进行了很好的介绍,我建议阅读它。它不仅解释了扩展功能,而且最后还反对使用它。

关于@extend的优点和问题,意见似乎存在极大的分歧,以至于包括我在内的许多开发人员一直反对它,……


Sass还有许多其他功能我没有在这里介绍,例如循环和列表,但我老实说并没有像本文中介绍的功能那样依赖这些功能。浏览Sass文档,即使是为了看看这些功能的作用。你可能不会立即找到所有功能的用途,但可能会遇到这种情况,而掌握这些知识则非常宝贵。

如果我错过了什么或弄错了什么,请告诉我!我始终乐于接受新想法,并希望与您讨论!

进一步阅读