在TypeScript中使用vanilla-extract的CSS

1,521 阅读6分钟

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 的名字,但知道它是以 "字体 "开头的,你就可以输入它,然后滚动浏览选项。在VS Code中,你不需要下载任何额外的工具来实现这一目标。

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

这也意味着你的编辑器在你的肩膀上看着你,确保你没有犯任何可能导致令人沮丧的bug的拼写错误。

vanilla-extract类型还在其类型定义中提供了一个语法解释*,以及*一个指向你正在编辑的CSS属性的MDN文档的链接。这消除了在样式表现出乎意料的情况下疯狂地搜索的步骤。

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>`;

或者你也可以使用Theming 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来促进重用性。Recipes 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还允许你编写条件速记,使用实用类创建响应式风格。