👏

CSS Modules-in-TypeScript派に俺はなる

2022/03/09に公開

はじめに

最近やっているプロダクトで vanilla-extract を導入をしたので記事にします。
vanilla-extract は CSS Modules-in-TypeScript と言って、 TypeScript で書ける CSS Modules というのを知り「良さそう!」と思ったのがきっかけです。
まずは現在のスタイリング事情を振り返ってみます。

昨今の React のスタイリング事情

昨今のスタイリング事情としては CSS Modules に分があるっぽい。state of css 2021 を見てみても明らかですね。

https://2021.stateofcss.com/ja-JP/technologies/css-in-js/

The-State-of-CSS-2021-CSS-in-JS

そもそも Next.js も CSS Modules をビルドインサポートしていたりしていて CSS Modules 押し?っぽい節もあります。が、その反面 CSS in JS として Vercel 謹製の Styled JSX もあったりしてますね。
Next.js だけで判断することはできませんが、正直フロントエンド界隈としてもどっちが正解なのかはっきりしていない印象。個人的にも使いやすいものとして、より TypeScript との相性が良い CSS in JS をよく使っていました。

ところが CSS in JS はパフォーマンス的に難があるという記事もあり、CSS Modules に傾いたというのもあるのかなと思います。
いや、そもそも導入のしやすさ学習コストを考えると CSS Modules 一択かもしれません。

https://www.infoq.com/jp/news/2020/01/css-cssinjs-performance-cost/

  • パフォーマンス面、学習コストを考慮すると CSS Module
  • 開発体験を考慮すると CSS in JS

なのかなと個人的には思っています。

そんな中  state of css 2021  で vanilla-extract が 1 位になっていて、調べてみると Zero-runtime Stylesheets in TypeScript. と書かれているではないですか。
これは良さそう!!

ってことですぐにでも導入したかったのですが、なかなか新規のプロジェクトに参加する機会がなくて導入を見送っていました。そこでタイミング良く今回新しいプロジェクトで導入することができたので記事にしてみた感じです。

そもそも vanilla-extract とは

CSS Modules-in-TypeScript というものらしく。簡単に説明すると TypeScript で書ける CSS Modules です。
特徴としては・・・

  • TypeScript との相性が良く、TypeScript の静的解析に頼れる
  • ビルド時に静的な CSS ファイルを生成するため CSS-in-JS よりもパフォーマンスに優れる

CSS Modules と CSS in JS のいいとこ取りをした感じですね。

vanilla-extract の基本

書き方の基本は下記のように style を書いてコンポーネント側に className で指定をします。CSS Modules と同じですね。
が、backgroundColor のように JS ライクな書き方をしなければいけないのでちょっと慣れが必要。

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

export const [themeClass, vars] = createTheme({
  color: {
    brand: "blue",
  },
  font: {
    body: "arial",
  },
});

export const exampleStyle = style({
  backgroundColor: vars.color.brand,
  fontFamily: vars.font.body,
  color: "white",
  padding: 10,
});
import { themeClass, exampleStyle } from "./styles.css.ts";

export const Component = () => {
  return (
    <section className="${themeClass}">
      <h1 className="${exampleStyle}">Hello world!</h1>
    </section>
  );
};

他にも Sprinkles API、Recipes API、Dynamic API、Utility Functions などがあるので公式ドキュメントを見ておくと良いと思います。
https://vanilla-extract.style/documentation

プロダクトにどう落とし込んだか

では実際にプロダクトではどのような使い方をしているかというと、まずは下記のようなディレクトリ構成にしています。

components 配下に Button ディレクトリがあって、その中にコンポーネントの定義をする index.tsx とスタイルを定義する style.css.ts を配置しています。
この辺りは CSS Modules のパターンと同じですね。

components
  +-- /Button
        +-- index.tsx
        +-- style.css.ts

style.css.ts

スタイルについては vanilla-extract の recipe を使うようにしました。理由としては variants にプロパティを定義することで例えば色や大きさなどは使う時にある程度指定できるものにしたかったからです。コンポーネントを汎用的に使うことができますね。

そして export type ButtonVariants = VEX.RecipeVariants<typeof ButtonRecipe>; の部分で型定義をしています。
これは本当に便利でスタイルの定義とともにコンポーネントで使うプロパティの型定義も完了してしまいます。

import * as VEX from "@vanilla-extract/recipes";
import { vars } from "~/src/styles/theme.css";
import { pxToRem } from "~/src/styles/utils.css";

export const ButtonRecipe = VEX.recipe({
  base: {
    borderRadius: 99,
    fontSize: "1rem",
    fontWeight: "bold",
    display: "inline-flex",
    border: "none",
    alignItems: "center",
    justifyContent: "center",
    transition: "all 0.5s",
    boxShadow:
      "0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08)",
    ":hover": {
      opacity: 0.7,
    },
  },

  variants: {
    color: {
      primary: { background: vars.color.primary, color: "white" },
      secondary: { background: vars.color.secondary, color: "white" },
      caution: { background: vars.color.caution, color: "white" },
    },
    size: {
      small: { padding: ".5rem 2rem" },
      medium: { padding: ".6rem 3rem" },
      large: { padding: ".8rem 3rem", fontSize: `${pxToRem(18)}` },
    },
  },
  defaultVariants: {
    color: "primary",
    size: "medium",
  },
});

export type ButtonVariants = VEX.RecipeVariants<typeof ButtonRecipe>;

index.tsx

コンポーネント側は下記のようになっていて先ほど定義した ButtonVariantsPropsに詰めることで colorsize が型チェックとサジェストがされるようになります。

import React, { FCX } from "react";
import { ButtonRecipe, ButtonVariants } from "./style.css";

type Props = {
  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
  type: "submit" | "button";
} & ButtonVariants;

export const Button: FCX<Props> = ({
  color,
  size,
  onClick,
  children,
  className,
  type,
}) => {
  return (
    <button
      className={`${ButtonRecipe({
        color,
        size,
      })} ${className || ""}`}
      onClick={onClick}
      type={type}
    >
      {children}
    </button>
  );
};

scaffdog でテンプレートを作ってしまう

コンポーネントはいくつもいくつも作るのでこの辺りはテンプレートとして scaffdog を使ってコマンドで生成されるようにしています。

https://blog.wadackel.me/2019/scaffdog/

---
name: "ui"
root: "src/components"
output: "."
ignore: []
questions:
  value: "Please enter any text."
---

# `{{ inputs.value }}/index.tsx`

```javascript
import React, { FCX } from 'react'
import { {{ inputs.value }}Recipe, {{ inputs.value }}Variants } from './style.css'

type Props = {{ inputs.value }}Variants

export const {{ inputs.value }}: FCX<Props> = ({
  children,
  className
}) => {
  return (
    <div
      className={`${ {{ inputs.value }}Recipe({

      })} ${className || ''}`}
    >
      {children}
    </div>
  )
}

```

# `{{ inputs.value }}/style.css.ts`

```javascript
import * as VEX from '@vanilla-extract/recipes'

export const {{ inputs.value }}Recipe = VEX.recipe({
  base: {

  },

  variants: {

  }
})

export type {{ inputs.value }}Variants = VEX.RecipeVariants<typeof {{ inputs.value }}Recipe>

```

まとめ

あくまで CSS Modules の一種というものであり、Vanilla-extract がデファクトスタンダードになりうるかというと疑問があります。
とは言いつつも TS の相性の良さを考えると開発体験をとても良いですね。ちなみにパフォーマンスとしては人間が体感でわかるレベルの違いはないというのが率直な感想。が、styled-components のように head タグ内にブバーっと style が書かれるわけではなく css ファイルとして吐き出されるので良いなと思います。

Discussion