弥合 CSS 和 JavaScript 之间的差距:CSS 模块、PostCSS 和 CSS 的未来

Avatar of Matija Marohnić
Matija Marohnić

DigitalOcean 提供适合您旅程每个阶段的云产品。从 200 美元的免费信贷 开始!

在这个由两部分组成的系列的 上一篇文章 中,我们探讨了 CSS-in-JS 的现状,我们意识到 CSS-in-JS 不仅可以生成关键样式,而且有些库甚至没有运行时。我们看到,通过添加巧妙的优化可以显著改善用户体验,这就是本系列侧重于开发人员体验(编写样式的体验)的原因。

在本部分中,我们将通过重构我们现有示例中的 Photo 组件来探索“普通 CSS”的工具。

争议和 #hotdrama

CSS 最著名的辩论之一是,该语言是否就这样很好。我认为这场辩论之所以一直存在,是因为双方都有一定的道理。例如,虽然 CSS 最初是用来设计文档而不是应用程序组件的,但即将推出的 CSS 功能将彻底改变这一点,而且许多 CSS 错误都是因为将样式视为事后才想到的东西,而不是花时间正确学习它或雇佣擅长它的人。

我认为 CSS 工具本身并不是争议的根源;我们可能总是至少在某种程度上使用它们。但是,CSS-in-JS 这样的方法不同,因为它们用客户端 JavaScript 修补了 CSS 的不足之处。但是,CSS-in-JS 并不是唯一的解决方案;它仅仅是最新的解决方案。还记得我们以前曾经就预处理器(例如 Sass)进行过类似的辩论吗?Sass 有诸如 mixin 之类的功能,这些功能不是基于任何 CSS 提案(更不用说整个 缩进语法)。但是,Sass 出生在一个截然不同的时代,并且已经发展到不再公平地将其纳入辩论的程度,因为辩论本身已经发生了变化——所以我们开始批评 CSS-in-JS,因为它更容易成为目标。

我认为我们应该使用允许我们今天使用提案中的语法的工具。让我们以 JavaScript Promises 作为类比。Internet Explorer 不支持此功能,因此许多人为此包含了一个 polyfill。polyfill 的作用是让我们假装该功能在所有地方都受支持,通过用补丁替换本机浏览器实现。同样,使用诸如 Babel 之类的工具来转译新的语法也是如此。我们可以今天使用它,因为代码将被编译成更旧、更受支持的语法。这是一个好方法,因为它使我们能够在今天使用未来的功能,同时推动 JavaScript 向前发展,就像 Sass 这样的预处理工具已经 推动 CSS 向前发展 一样。

我对 CSS 争议的看法是,我们应该使用能够让我们在今天使用未来 CSS 的工具。

预处理器

我们已经稍微谈到了 CSS 预处理器,所以值得更详细地讨论它们以及它们如何融入 CSS-in-JS 的对话。我们有 Sass、Less 和 PostCSS(以及其他)可以为我们的 CSS 代码赋予各种新功能。

对于我们的示例,我们只关注嵌套,这是预处理器最常见和最强大的功能之一。我建议使用 PostCSS,因为它可以让我们对添加的功能进行细粒度的控制,这正是我们在这种情况下所需要的。PostCSS 插件 我们将使用的是 postcss-nesting,因为它遵循 原生 CSS 嵌套的实际提案

在我们的编译工具 webpack 中使用 PostCSS 的最佳方法是在配置中在 css-loader 之后添加 postcss-loader。在 css-loader 之后添加加载器时,务必通过设置 importLoaders 来考虑它们,该值应设置为后续加载器的数量,在本例中为 1

{
  test: /\.css$/,
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 1,
      },
    },
    'postcss-loader',
  ],
}

这样可以确保从其他 CSS 文件导入的 CSS 文件也会使用 postcss-loader 进行处理。

设置好 postcss-loader 后,我们将安装 postcss-nesting 并将其包含在 PostCSS 配置中

yarn add postcss-nesting

配置 PostCSS 的 方法很多。在本例中,我们将在项目的根目录添加一个 postcss.config.js 文件

module.exports = {
  plugins: {
    "postcss-nesting": {},
  },
}

现在,我们可以为 Photo 组件编写一个 CSS 文件。我们称之为 Photo.css

.photo {
  width: 200px;
  &.rounded {
    border-radius: 1rem;
  }
}

@media (min-width: 30rem) {
  .photo {
    width: 400px;
  }
}

我们还可以添加一个名为 utils.css 的文件,其中包含一个用于视觉隐藏元素的类,正如我们在本系列的第一部分中介绍的那样

.visuallyHidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
  white-space: nowrap;
}

由于我们的组件依赖于此实用程序,因此让我们将 utils.css 包含到 Photo.css 中,方法是在顶部添加一个 @import 语句

@import url('utils.css');

这将确保 webpack 要求 utils.css,这得益于 css-loader。我们可以将 utils.css 放在任何我们想要的地方,并调整 @import 路径。在本例中,它是 Photo.css 的同级文件。

接下来,让我们将 Photo.css 导入到我们的 JavaScript 文件中,并使用这些类来设置我们组件的样式

import React from 'react'
import { getSrc, getSrcSet } from './utils'
import './Photo.css'

const Photo = ({ publicId, alt, rounded }) => (
  <figure>
    <img
      className={rounded ? 'photo rounded' : 'photo'}
      src={getSrc({ publicId, width: 200 })}
      srcSet={getSrcSet({ publicId, widths: [200, 400, 800] })}
      sizes="(min-width: 30rem) 400px, 200px"
    />
    <figcaption className="visuallyHidden">{alt}</figcaption>
  </figure>
)

Photo.defaultProps = {
  rounded: false,
}

export default Photo

虽然这会起作用,但我们的类名过于简单,并且它们肯定会与与我们的 .photo 类完全无关的其他类名冲突。解决此问题的方法之一是使用命名方法(例如 BEM)来重命名我们的类名(例如 photo_roundedphoto__what-is-this--i-cant-even),以帮助防止冲突的发生,但是组件很快就变得复杂,并且类名往往会变长,具体取决于项目的总体复杂性。

认识 CSS 模块。

CSS 模块

简而言之,CSS 模块 是 CSS 文件,其中所有类名和动画默认情况下都在本地范围内。它们看起来非常像普通的 CSS。例如,我们可以使用我们的 Photo.cssutils.css 文件作为 CSS 模块,而无需对它们进行任何修改,只需将 modules: true 传递给 css-loader 的选项即可

{
  loader: 'css-loader',
  options: {
    importLoaders: 1,
    modules: true,
  },
}

CSS 模块是一项不断发展的功能,可以进行更深入的讨论。Robin 关于它的 三部分系列 是一个很好的概述和介绍。

虽然 CSS 模块本身看起来非常像普通的 CSS,但我们使用它们的方式却大不相同。它们被导入到 JavaScript 中作为对象,其中键对应于编写的类名,而值是为我们自动生成的唯一类名,这些类名将范围限制在组件中

import React from 'react'
import { getSrc, getSrcSet } from './utils'
import styles from './Photo.css'
import stylesUtils from './utils.css'

const Photo = ({ publicId, alt, rounded }) => (
  <figure>
    <img
      className={rounded
        ? `${styles.photo} ${styles.rounded}`
        : styles.photo}
      src={getSrc({ publicId, width: 200 })}
      srcSet={getSrcSet({ publicId, widths: [200, 400, 800] })}
      sizes="(min-width: 30rem) 400px, 200px"
    />
    <figcaption className={stylesUtils.visuallyHidden}>{alt}</figcaption>
  </figure>
)

Photo.defaultProps = {
  rounded: false,
}

export default Photo

由于我们将 utils.css 用作 CSS 模块,因此我们可以删除 Photo.css 顶部 @import 语句。此外,请注意,使用 camelCase 格式化类名使它们在 JavaScript 中更容易使用。如果我们使用连字符,则需要完全写出它们,例如 stylesUtils['visually-hidden']

CSS 模块具有其他功能,例如组合。现在,我们将 utils.css 导入到 Photo.js 中以应用我们组件的样式,但假设我们想将设置字幕样式的责任转移到 Photo.css 中。这样一来,就 JSX 代码而言,styles.caption 只是一个类名;它碰巧视觉上隐藏了该元素,但将来可能会以不同的方式设置样式。无论哪种方式,Photo.css 都将做出这些决定。

因此,让我们向 Photo.css 中添加一个字幕样式,以使用 composes 扩展 visuallyHidden 实用程序的属性

.caption {
  composes: visuallyHidden from './utils.css';
}

我们可以同样向该类中添加更多规则,但这对于本例来说已经足够了。现在,我们不再需要将 utils.css 导入到 Photo.js 中;我们可以简单地使用 styles.caption 代替

<figcaption className={styles.caption}>{alt}</figcaption>

它是如何工作的?visuallyHidden 中的样式是否被复制到 caption 中?让我们检查 styles.caption 的值——哇,两个类!没错:一个是来自 visuallyHidden,另一个将应用我们添加到 caption 中的任何其他样式。CSS-in-JS 使使用诸如 polished 之类的库来重复样式变得过于容易,但 CSS 模块鼓励您重用现有样式。无需创建一个新的 VisuallyHidden React 组件来仅仅应用几个 CSS 规则。

让我们更进一步,研究一下这种令人不舒服的类组合

rounded
  ? `${styles.photo} ${styles.rounded}`
  : styles.photo

对于这种情况,有一些库,例如 classnames,它们对于更复杂的类组合很有用。然而,在我们的示例中,我们可以继续使用 composes 并将 .rounded 重命名为 .roundedPhoto

.photo {
  width: 200px;
}

.roundedPhoto {
  composes: photo;
  border-radius: 1rem;
}

@media (min-width: 30rem) {
  .photo {
    width: 400px;
  }
}

.caption {
  composes: visuallyHidden from './utils.css';
}

现在,我们可以以更具可读性的方式将类名应用到我们的组件中

rounded ? styles.roundedPhoto : styles.photo

但是等等,如果我们不小心将 .roundedPhoto 规则集放在 .photo 之前,并且 .photo 中的一些规则最终会由于特殊性而覆盖 .roundedPhoto 中的规则怎么办?别担心,CSS 模块会通过抛出类似这样的错误来防止我们在定义了当前类之后的类之间进行组合

referenced class name "photo" in composes not found (2:3)

  1 | .roundedPhoto {
> 2 |   composes: photo;
    |   ^
  3 |   border-radius: 1rem;
  4 | }

请注意,为 CSS 模块使用文件命名约定(例如使用扩展名 .module.css)通常是一个好主意,因为通常我们还想应用一些全局样式。

动态样式

到目前为止,我们一直在有条件地应用预定义的样式集,这被称为**条件样式**。如果我们还想能够微调圆形照片的边框半径怎么办?这被称为**动态样式**,因为我们事先不知道值将是什么;它可以在应用程序运行时更改。

动态样式的用例并不多——通常我们只是有条件地设置样式,但如果我们需要这样做,我们该怎么做呢?虽然我们可以使用内联样式来解决问题,但针对这类问题的原生解决方案是自定义属性(又称 CSS 变量)。此功能的一个非常有价值的方面是,浏览器将在 JavaScript 更改自定义属性时使用它们来更新样式。我们可以通过内联样式在元素上设置自定义属性,这意味着它将只作用于该元素,不会影响其他元素。

style={typeof borderRadius !== 'undefined' ? {
  '--border-radius': borderRadius,
} : null}

Photo.css 中,我们可以使用 var() 和将默认值作为第二个参数传递来使用此自定义属性。

.roundedPhoto {
  composes: photo;
  border-radius: var(--border-radius, 1rem);
}

就 JavaScript 而言,它只是将动态参数传递给 CSS,然后当 CSS 接管时,它可以按原样应用该值,使用 calc() 等从中计算新值。

备用

在撰写本文时,浏览器对自定义属性的支持……好吧,你自己决定。对于实际应用来说,不支持这些浏览器(可能)是不可能的,但请记住,有些样式比其他样式不那么重要。在本例中,如果 IE 上的边框半径始终为 1rem,则没什么大不了的。应用程序不必在每个浏览器上看起来都一样。

我们可以自动为所有自定义属性提供备用的方法是安装 postcss-custom-properties 并将其添加到我们的 PostCSS 配置中。

yarn add postcss-custom-properties
module.exports = {
  plugins: {
    'postcss-nesting': {},
    'postcss-custom-properties': {},
  },
}

这将为我们的 border-radius 规则生成一个备用。

.roundedPhoto {
  composes: photo;
  border-radius: 1rem;
  border-radius: var(--border-radius, 1rem);
}

不理解 var() 的浏览器将忽略该规则并使用之前的规则。不要让插件的名称欺骗你;它只是通过提供静态备用方案来部分改善对自定义属性的支持。动态方面无法进行填充。

将值暴露给 JavaScript

在本系列的上一部分中,我们探讨了 CSS-in-JS 如何允许我们使用媒体查询作为示例在 CSS 和 JavaScript 之间共享几乎任何内容。这里不可能实现这一点,对吗?

感谢 Jonathan Neal,你可以!

首先,了解 postcss-preset-env,它是 cssnext 的继任者。它是一个 PostCSS 插件,充当类似于 @babel/preset-env 的预设。它包含像 postcss-nesting、postcss-custom-properties、autoprefixer 等等插件,因此我们可以使用未来的 CSS。

yarn add postcss-preset-env
module.exports = {
  plugins: {
    'postcss-preset-env': {
      features: {
        'nesting-rules': true,
        'custom-properties': true, // already included in stage 2+
        'custom-media-queries': true, // oooh, what's this? :)
      },
    },
  },
}

注意,我们替换了现有的插件,因为此 postcss-preset-env 配置包含它们,这意味着我们现有的代码应该与以前一样工作。

在媒体查询中使用自定义属性是无效的,因为这不是它们的用途。相反,我们将使用 自定义媒体查询

@custom-media --photo-breakpoint (min-width: 30em);

.photo {
  width: 200px;
}

@media (--photo-breakpoint) {
  .photo {
    width: 400px;
  }
}

即使此功能处于实验阶段,因此在任何浏览器中都不受支持,但由于 postcss-preset-env,它可以正常工作!一个问题是 PostCSS 在每个文件的基础上运行,因此,这样只有 Photo.css 可以使用 --photo-breakpoint。我们来解决这个问题。

Jonathan Neal 最近在 postcss-preset-env 中实现了 importFrom 选项,该选项也传递给其他支持它的插件,例如 postcss-custom-properties 和 postcss-custom-media。它的值可以是 很多东西,但为了我们的示例,它是一个将被导入到 PostCSS 处理的文件的路径。我们将其称为 global.css 并将我们的自定义媒体查询移动到那里。

@custom-media --photo-breakpoint (min-width: 30em);

……并且让我们定义 importFrom,提供指向 global.css 的路径。

module.exports = {
  plugins: {
    'postcss-preset-env': {
      importFrom: 'src/global.css',
      features: {
        'nesting-rules': true,
        'custom-properties': true,
        'custom-media-queries': true,
      },
    },
  },
}

现在,我们可以删除 Photo.css 顶部的 @custom-media 行,我们的 --photo-breakpoint 值仍然可以工作,因为 postcss-preset-env 将使用 global.css 中的值来编译它。自定义属性和自定义选择器也是如此。

现在,如何将其暴露给 JavaScript?当自定义媒体查询等实验性功能被标准化并在主要浏览器中实现时,我们将能够从 CSS 中本地检索它们。例如,这就是我们访问在 :root 上定义的名为 --font-family 的自定义属性的方式。

const rootStyles = getComputedStyle(document.body)
const fontFamily = rootStyles.getPropertyValue('--font-family')

如果自定义媒体查询被标准化,我们可能会以类似的方式访问它们,但在目前,我们必须找到替代方案。我们可以使用 exportTo 选项生成 JavaScript 或 JSON 文件,我们将在 JavaScript 中导入它。然而,问题是 webpack 会在它被生成之前尝试要求它。即使我们在运行 webpack 之前生成它,对 global.css 的每次更新都会导致 webpack 重新编译两次,一次是生成输出文件,另一次是导入它。我想要一个不受其实现影响的解决方案。

对于本系列,我专门为你创建了一个全新的 webpack 加载器,名为 css-customs-loader!这使得这项任务变得很容易:我们所需要做的就是在我们的 webpack 配置中将其包含在 css-loader 之前。

{
  test: /\.css$/,
  use: [
    'style-loader',
    'css-customs-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 1,
      },
    },
    'postcss-loader',
  ],
}

这将自定义媒体查询以及自定义属性暴露给 JavaScript。我们可以通过简单地导入 global.css 来访问它们。

import React from 'react'
import { getSrc, getSrcSet } from './utils'
import styles from './photo.module.css'
import { customMedia } from './global.css'

const Photo = ({ publicId, alt, rounded, borderRadius }) => (
  <figure>
    <img
      className={rounded ? styles.roundedPhoto : styles.photo}
      style={
        typeof borderRadius !== 'undefined'
          ? { ['--border-radius']: borderRadius }
          : null
      }
      src={getSrc({ publicId, width: 200 })}
      srcSet={getSrcSet({ publicId, widths: [200, 400, 800] })}
      sizes={`${customMedia['--photo-breakpoint']} 400px, 200px`}
    />
    <figcaption className={styles.caption}>{alt}</figcaption>
  </figure>
)

Photo.defaultProps = {
  rounded: false,
}

export default Photo

就是这样!

我创建了一个演示本系列中讨论的所有概念的存储库。它的自述文件还包含一些关于本文中描述的方法的高级技巧。

查看存储库

结论

可以肯定地说,CSS 模块、PostCSS 和即将推出的 CSS 功能等工具能够解决 CSS 的许多挑战。无论你在 CSS 辩论中的立场如何,这种方法都值得探索。

我拥有丰富的 CSS-in-JS 背景,但我很容易受到炒作的影响,因此跟上那个世界对我来说非常困难。虽然将样式放在行为旁边可能很简洁,但这实际上是在混合两种截然不同的语言——与 JavaScript 相比,CSS 非常冗长。这促使我编写更少的 CSS,因为我想避免让文件过于拥挤。这可能是一个个人喜好问题,但我不想让它成为一个问题。使用一个单独的文件来存放 CSS 最终让我的代码获得了一些喘息的空间。

虽然精通这种方法可能不像 CSS-in-JS 那样简单,但我相信从长远来看,它更有益。它将提高你的 CSS 技能,让你更好地为它的未来做好准备。

文章系列

  1. CSS-in-JS
  2. CSS 模块、PostCSS 和 CSS 的未来(本文)