使用 Vanilla Extract 在 TypeScript 中编写 CSS

Avatar of Hugh Haworth
Hugh Haworth

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

vanilla-extract 是一个新的、与框架无关的 CSS-in-TypeScript 库。 它是编写样式的一种轻量级、健壮且直观的方式。 vanilla-extract 不是一个规范性的 CSS 框架,而是一个灵活的开发人员工具。 在过去的几年里,CSS 工具一直是一个相对稳定的领域,PostCSSSassCSS Modulesstyled-components 都在 2017 年之前发布(有些早在很久以前),并且它们 至今仍然很受欢迎Tailwind 是在过去几年中撼动 CSS 工具的少数工具之一。

vanilla-extract 旨在再次掀起波澜。 它于今年发布,并受益于能够利用一些最新的趋势,包括

  • JavaScript 开发人员转向 TypeScript
  • 浏览器支持 CSS 自定义属性
  • 实用优先样式

vanilla-extract 中有很多巧妙的创新,我认为这使其成为一件大事。

零运行时

CSS-in-JS 库通常在运行时将样式注入文档中。 这有一些好处,包括 关键 CSS 提取和动态样式。

但作为一般经验法则,单独的 CSS 文件将具有更好的性能。 这是因为 JavaScript 代码需要经过更昂贵的解析/编译,而单独的 CSS 文件可以被缓存,同时 HTTP2 协议降低了额外请求的成本。 此外,自定义属性 现在可以免费提供许多动态样式。

因此,vanilla-extract 借鉴了 Linariaastroturf,而不是在运行时注入样式。 这些库允许您使用 JavaScript 函数编写样式,这些函数在构建时会被提取出来并用于构建 CSS 文件。 尽管您在 TypeScript 中编写 vanilla-extract,但它不会影响生产 JavaScript 包的整体大小。

TypeScript

vanilla-extract 的一个重要价值主张是,您可以获得类型安全。 如果保持代码库的类型安全非常重要,那么为什么不也对样式做同样的事情呢?

TypeScript 提供了许多好处。 首先,有自动完成功能。 如果您键入“fo”,那么在支持 TypeScript 的编辑器中,您会在下拉列表中获得字体选项列表——fontFamilyfontKerningfontWeight或其他匹配项——供您选择。 这使得 CSS 属性可以从编辑器的舒适环境中被发现。 如果您不记得 fontVariant 的名称,但知道它将以“font”开头,您可以键入它并滚动浏览选项。 在 VS Code 中,您无需下载任何其他工具即可实现此功能。

这确实加快了样式编写的速度。

这也意味着您的编辑器会监视您的操作,确保您不会犯任何拼写错误,这些错误可能导致令人沮丧的错误。

vanilla-extract 类型还在其类型定义中提供了语法说明以及您正在编辑的 CSS 属性的 MDN 文档 链接。 当样式行为异常时,这省去了疯狂搜索 Google 的步骤。

Image of VSCode with cursor hovering over fontKerning property and a pop up describing what the property does with a link to the Mozilla documentation for the property

在 TypeScript 中编写意味着您正在使用驼峰式命名法来表示 CSS 属性,例如 backgroundColor。 对于习惯使用常规 CSS 语法(如 background-color)的开发人员来说,这可能是一个小小的改变。

集成

vanilla-extract 为所有最新的打包器提供了第一方集成。 以下是它目前支持的 集成 的完整列表。

  • webpack
  • esbuild
  • Vite
  • Snowpack
  • NextJS
  • Gatsby

它也完全与框架无关。 您只需从 vanilla-Extract 中导入类名,这些类名将在构建时转换为字符串。

用法

要使用 vanilla-Extract,您可以编写一个 .css.ts 文件,您的组件可以导入它。 对这些函数的调用会在构建步骤中转换为散列和作用域的类名字符串。 这听起来可能与 CSS Modules 类似,这并非巧合:vanilla-Extract 的创建者之一 Mark Dalgleish 也是 CSS Modules 的共同创建者。

style()

您可以使用 style() 函数创建一个自动作用域的 CSS 类。 您传入元素的样式,然后导出返回值。 在您的用户代码中的某个地方导入此值,它会被转换为作用域的类名。

// title.css.ts
import {style} from "@vanilla-extract/css";

export const titleStyle = style({
  backgroundColor: "hsl(210deg,30%,90%)",
  fontFamily: "helvetica, Sans-Serif",
  color: "hsl(210deg,60%,25%)",
  padding: 30,
  borderRadius: 20,
});
// title.ts
import {titleStyle} from "./title.css";

document.getElementById("root").innerHTML = `<h1 class="${titleStyle}">Vanilla Extract</h1>`;

媒体查询和伪选择器也可以包含在样式声明中。

// title.css.ts
backgroundColor: "hsl(210deg,30%,90%)",
fontFamily: "helvetica, Sans-Serif",
color: "hsl(210deg,60%,25%)",
padding: 30,
borderRadius: 20,
"@media": {
  "screen and (max-width: 700px)": {
    padding: 10
  }
},
":hover":{
  backgroundColor: "hsl(210deg,70%,80%)"
}

这些 style 函数调用是对 CSS 的一种薄抽象——所有属性名称和值都映射到您熟悉的 CSS 属性和值。 需要习惯的一个变化是,值有时可以声明为数字(例如 padding: 30),默认为像素单位值,而某些值需要声明为字符串(例如 padding: "10px 20px 15px 15px")。

样式函数内部的属性只能影响单个 HTML 节点。 这意味着您不能使用嵌套来为元素的子元素声明样式——这在 SassPostCSS 中您可能习惯使用的方法。 相反,您需要分别为子元素设置样式。 如果子元素需要根据父元素的不同样式,您可以使用 selectors 属性添加依赖于父元素的样式。

// title.css.ts
export const innerSpan = style({
  selectors:{[`${titleStyle} &`]:{
    color: "hsl(190deg,90%,25%)",
    fontStyle: "italic",
    textDecoration: "underline"
  }}
});
// title.ts
import {titleStyle,innerSpan} from "./title.css";
document.getElementById("root").innerHTML = 
`<h1 class="${titleStyle}">Vanilla <span class="${innerSpan}">Extract</span></h1>
<span class="${innerSpan}">Unstyled</span>`;

或者,您还可以使用主题 API(我们将在后面介绍)在父元素中创建自定义属性,这些属性会被子节点使用。 这听起来可能具有限制性,但它被有意地这样设计,以提高大型代码库的可维护性。 这意味着您将准确地知道在项目中每个元素的样式在哪里声明。

主题

您可以使用 createTheme 函数在 TypeScript 对象中构建变量。

// title.css.ts
import {style,createTheme } from "@vanilla-extract/css";

// Creating the theme
export const [mainTheme,vars] = createTheme({
  color:{
    text: "hsl(210deg,60%,25%)",
    background: "hsl(210deg,30%,90%)"
  },
  lengths:{
    mediumGap: "30px"
  }
})

// Using the theme
export const titleStyle = style({
  backgroundColor:vars.color.background,
  color: vars.color.text,
  fontFamily: "helvetica, Sans-Serif",
  padding: vars.lengths.mediumGap,
  borderRadius: 20,
});

然后,vanilla-extract 允许您创建主题的变体。 TypeScript 可以帮助它确保您的变体使用相同的属性名称,因此如果您忘记将 background 属性添加到主题中,您会收到警告。

Image of VS Code where showing a theme being declared but missing the background property causing a large amount of red squiggly lines to warn that the property’s been forgotten

以下是如何创建常规主题和暗模式的示例。

// title.css.ts
import {style,createTheme } from "@vanilla-extract/css";

export const [mainTheme,vars] = createTheme({
  color:{
    text: "hsl(210deg,60%,25%)",
    background: "hsl(210deg,30%,90%)"
  },
  lengths:{
    mediumGap: "30px"
  }
})
// Theme variant - note this part does not use the array syntax
export const darkMode = createTheme(vars,{
  color:{
    text:"hsl(210deg,60%,80%)",
    background: "hsl(210deg,30%,7%)",
  },
  lengths:{
    mediumGap: "30px"
  }
})
// Consuming the theme 
export const titleStyle = style({
  backgroundColor: vars.color.background,
  color: vars.color.text,
  fontFamily: "helvetica, Sans-Serif",
  padding: vars.lengths.mediumGap,
  borderRadius: 20,
});

然后,使用 JavaScript,您可以动态应用 vanilla-extract 返回的类名来切换主题。

// title.ts
import {titleStyle,mainTheme,darkMode} from "./title.css";

document.getElementById("root").innerHTML = 
`<div class="${mainTheme}" id="wrapper">
  <h1 class="${titleStyle}">Vanilla Extract</h1>
  <button onClick="document.getElementById('wrapper').className='${darkMode}'">Dark mode</button>
</div>`

这在幕后是如何工作的? 您在 createTheme 函数中声明的对象将转换为附加到元素类的 CSS 自定义属性。 这些自定义属性会被散列以防止冲突。 我们的 mainTheme 示例的输出 CSS 如下所示。

.src__ohrzop0 {
  --color-brand__ohrzop1: hsl(210deg,80%,25%);
  --color-text__ohrzop2: hsl(210deg,60%,25%);
  --color-background__ohrzop3: hsl(210deg,30%,90%);
  --lengths-mediumGap__ohrzop4: 30px;
}

而我们的 darkMode 主题的 CSS 输出如下所示。

.src__ohrzop5 {
  --color-brand__ohrzop1: hsl(210deg,80%,60%);
  --color-text__ohrzop2: hsl(210deg,60%,80%);
  --color-background__ohrzop3: hsl(210deg,30%,10%);
  --lengths-mediumGap__ohrzop4: 30px;
}

因此,我们只需在用户代码中更改类名即可。 将 darkmode 类名应用于父元素,mainTheme 自定义属性就会被 darkMode 自定义属性替换。

配方 API

stylecreateTheme 函数本身提供了足够的强大功能来为网站设置样式,但 vanilla-extract 提供了一些额外的 API 来提高可重用性。 配方 API 允许您为元素创建许多变体,您可以在标记或用户代码中从中选择。

首先,它需要单独安装。

npm install @vanilla-extract/recipes

以下是它的工作原理。 您导入 recipe 函数,并传入一个包含 basevariants 属性的对象。

// button.css.ts
import { recipe } from '@vanilla-extract/recipes';

export const buttonStyles = recipe({
  base:{
    // Styles that get applied to ALL buttons go in here
  },
  variants:{
    // Styles that we choose from go in here
  }
});

base内部,你可以声明将应用于所有变体的样式。在variants内部,你可以提供自定义元素的不同方法。

// button.css.ts
import { recipe } from '@vanilla-extract/recipes';
export const buttonStyles = recipe({
  base: {
    fontWeight: "bold",
  },
  variants: {
    color: {
      normal: {
        backgroundColor: "hsl(210deg,30%,90%)",
      },
      callToAction: {
        backgroundColor: "hsl(210deg,80%,65%)",
      },
    },
    size: {
      large: {
        padding: 30,
      },
      medium: {
        padding: 15,
      },
    },
  },
});

然后你可以在标记中声明要使用的变体。

// button.ts
import { buttonStyles } from "./button.css";

<button class=`${buttonStyles({color: "normal",size: "medium",})}`>Click me</button>

并且vanilla-extract利用TypeScript为你自己的变体名称提供自动完成功能!

你可以根据自己的喜好命名变体,并在其中添加任何你想要的属性,例如这样。

// button.css.ts
export const buttonStyles = recipe({
  variants: {
    animal: {
      dog: {
        backgroundImage: 'url("./dog.png")',
      },
      cat: {
        backgroundImage: 'url("./cat.png")',
      },
      rabbit: {
        backgroundImage: 'url("./rabbit.png")',
      },
    },
  },
});

你可以看到这对于构建设计系统非常有用,因为你可以创建可重用的组件并控制它们变化的方式。这些变化通过TypeScript变得很容易发现——你只需要输入CMD/CTRL + Space(在大多数编辑器中),你就会得到一个下拉列表,其中包含自定义组件的不同方法。

基于实用程序的Sprinkles

Sprinkles是一个构建在vanilla-extract之上的基于实用程序的框架。这是vanilla-extract文档对其的描述

基本上,它就像构建你自己的零运行时、类型安全的TailwindStyled System等版本。

所以,如果你不喜欢命名事物(我们都做过这样的噩梦:创建一个outer-wrapper div,然后意识到我们需要用一个. . . outer-outer-wrapper 来包裹它),Sprinkles可能是你使用vanilla-extract的首选方式。

Sprinkles API也需要单独安装。

npm install @vanilla-extract/sprinkles

现在我们可以为我们的实用程序函数创建一些构建块来使用。让我们通过声明几个对象来创建一个颜色和长度列表。JavaScript键名可以是任何我们想要的。值需要是我们计划使用的CSS属性的有效CSS值。

// sprinkles.css.ts
const colors = {
  blue100: "hsl(210deg,70%,15%)",
  blue200: "hsl(210deg,60%,25%)",
  blue300: "hsl(210deg,55%,35%)",
  blue400: "hsl(210deg,50%,45%)",
  blue500: "hsl(210deg,45%,55%)",
  blue600: "hsl(210deg,50%,65%)",
  blue700: "hsl(207deg,55%,75%)",
  blue800: "hsl(205deg,60%,80%)",
  blue900: "hsl(203deg,70%,85%)",
};

const lengths = {
  small: "4px",
  medium: "8px",
  large: "16px",
  humungous: "64px"
};

我们可以使用defineProperties函数声明这些值将应用于哪些CSS属性。

  • 传递一个包含properties属性的对象。
  • properties中,我们声明一个对象,其中是用户可以设置的CSS属性(这些需要是有效的CSS属性),是我们之前创建的对象(我们的colorslengths列表)。
// sprinkles.css.ts
import { defineProperties } from "@vanilla-extract/sprinkles";

const colors = {
  blue100: "hsl(210deg,70%,15%)"
  // etc.
}

const lengths = {
  small: "4px",
  // etc.
}

const properties = defineProperties({
  properties: {
    // The keys of this object need to be valid CSS properties
    // The values are the options we provide the user
    color: colors,
    backgroundColor: colors,
    padding: lengths,
  },
});

然后最后一步是将defineProperties的返回值传递给createSprinkles函数,并导出返回值。

// sprinkles.css.ts
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles";

const colors = {
  blue100: "hsl(210deg,70%,15%)"
  // etc.
}

const lengths = {
  small: "4px",
  // etc. 
}

const properties = defineProperties({
  properties: {
    color: colors,
    // etc. 
  },
});
export const sprinkles = createSprinkles(properties);

然后我们可以通过在类属性中调用sprinkles函数并在每个元素中选择我们想要哪些选项,在组件内部内联开始设置样式。

// index.ts
import { sprinkles } from "./sprinkles.css";
document.getElementById("root").innerHTML = `<button class="${sprinkles({
  color: "blue200",
  backgroundColor: "blue800",
  padding: "large",
})}">Click me</button>
</div>`;

JavaScript输出为每个样式属性保存一个类名字符串。这些类名与输出CSS文件中的单个规则匹配。

<button class="src_color_blue200__ohrzop1 src_backgroundColor_blue800__ohrzopg src_padding_large__ohrzopk">Click me</button>

如你所见,此API允许你使用一组预定义的约束在标记内部设置元素的样式。你还可以避免为每个元素想出类名的困难任务。结果是感觉非常像Tailwind的东西,但也受益于围绕TypeScript构建的所有基础设施。

Sprinkles API还允许你编写条件简写,以使用实用程序类创建响应式样式。

总结

vanilla-extract感觉是CSS工具方面的一大进步。人们在将其构建成一个直观的、强大的样式解决方案方面投入了大量思考,该解决方案利用了静态类型提供的所有功能。

进一步阅读