🍨

Next.js AppRouterの新規プロダクトのCSSライブラリにvalilla-extractを採用して約1年が経ったので振り返る

2024/12/13に公開

この記事は株式会社カオナビ Advent Calendar 2024の13日目の記事です。
https://qiita.com/advent-calendar/2024/kaonavi

はじめに

株式会社カオナビのフロントエンドエンジニアの@shinji_beckyです。

株式会社カオナビでは2024年4月にヨジツティクスという予実管理プロダクトをローンチしました。
https://www.yojitsutics.jp/

私はヨジツティクスの立ち上げ直後に開発メンバーとしてジョインし、フロントエンド領域の開発を担当しています。

先日はてなニュースでヨジツティクスのプロダクトの話やフロントエンドの技術選定の話などのインタビュー記事が出たのでこちらも見ていただけると幸いです。
https://hatenanews.com/articles/2024/12/04/103000

このプロダクトはCSSライブラリとしてvanilla-extractを採用しており、採用から1年以上経過したので採用経緯や運用方法、つまづきポイントなど振り返りたいと思います。

ヨジツティクスの技術構成・変遷

vanilla-extractの話の前に簡単にヨジツティクスの技術構成の話をしようと思います。

最初にモックを作った時の主な構成は以下になります。

  • TypeScript
  • React
  • Next.js(PageRouter)
  • styled-components

モックを作っていたのが2022/09~2022/12頃だったのでまだAppRouterがまだexperimentalだった時期かと思います。
そこからしばらく開発が止まり、開発が本格的に再開したのが2023/07になります。
この時すでにAppRouterがStableになっており、モックから正式なものを開発する段階だったため以下の構成に変更しました。

  • TypeScript
  • React
  • Next.js(AppRouter)
  • vanilla-extract

AppRouterの採用に伴いvanilla-extractへ移行

AppRouterを採用したことによりRuntimeCSSであるstyled-componentsはServerComponentでは利用できないという制約がありビルド時にCSSファイルを生成するタイプのCSSライブラリ、フレームワークに乗り換える必要がありました。

幸いモックの段階ではページ数は1~2しかなく共通コンポーネントもそれほど用意しておらず、CSSライブラリの移行は容易だったため移行コストそれほどかからない状態でした。

移行コストという観点は無視して最終的にvanilla-extractを採用しました。

vanilla-extractの概要

https://vanilla-extract.style/

https://github.com/vanilla-extract-css/vanilla-extract

採用した理由の前にvanilla-extractがどのようなCSSライブラリか簡単に紹介したいと思います。

vanilla-extractのgithubのREADMEの記述がわかりやすかったのでまず紹介します。

Zero-runtime Stylesheets-in-TypeScript.

Write your styles in TypeScript (or JavaScript) with locally scoped class names and CSS Variables, then generate static CSS files at build time.

Basically, it’s “CSS Modules-in-TypeScript” but with scoped CSS Variables + heaps more.

「CSS Modules-in-TypeScript」という表現がありますが、CSS Modulesの要素を取り入れているライブラリになっています。

CSS Modulesのようにcssファイルを別で用意するところや、通常のCSSに近い感覚で記述できるところなど実際にコードを書いてみるとCSS Modulesっぽさを感じます。

実際のコードは以下になります。

CSS Modulesでは .module.cssという拡張子ですがvanilla-extractは .css.ts という拡張子を利用します。

import { style } from '@vanilla-extract/css';

export const textStyle = style({
  color: 'red',
  fontWeight: 'bold',
})

このように style という関数の引数にスタイルオブジェクトを記述するとスタイルが生成されます。

利用する際はコンポーネント側でスタイルをimportし、classNameに渡すことでスタイルを表現することができます。

import { textStyle } from './style.css';

export const Text = ({children}) => {
  return <span className={textStyle}>{children}</span>
}

また生成したスタイルはローカルスコープになっている点やZero-RuntimeのCSS in JSなのでビルド時に静的なCSSファイルを生成する仕組みになっています。

vanilla-extractの採用理由

vanilla-extractの概要をざっくり説明したところで採用した理由について書きたいと思います。

ライブラリ選定するにあたって以下の観点で精査しました。

  • Zero-Runtime
  • ローカルスコープ
  • 学習コストが低い
  • 開発体験を損なわない
  • 別のライブラリに置き換えやすい

vanilla-extractはこれらにマッチしていました。
最初の2つに関しては概要の方で説明したので割愛します。

学習コスト

CSS Modules-in-TypeScriptと謳っているだけあってCSS Modulesに近い感覚でコードを書くことができます。

CSS Modulesは通常のCSSの知識があれば比較的簡単に書くことができます。
vanilla-extractもスタイルを書くだけであればそれに近い感覚で記述することができます。

開発体験を損なわない

vanilla-extractはTypeScriptでスタイルを記述します。
スタイルのキーや値部分はコード補完されるので非常に開発体験は良いです。

別のライブラリに置き換えやすい(かも)

CSSに限った話ではないですがフロントエンドの技術は変化が早い領域です。
何かしらの理由でライブラリを捨てないといけない場面が来るかもしれません。

実際このプロダクトにおいてもServerComponentsという新しい技術に適用するためにstyled-componentsを諦める必要がありました。

vanilla-extractに関してはCSS Modulesへの置き換えというのは選択肢の1つとしては現実的だと思います。

どのCSSライブラリへの置き換えもある程度のコストがかかるという前提ではありますが、CSS Modulesはライブラリとしては安定していますし、スタイルを記述するファイルが分かれているという点や記述方法など類似点が多く、ASTを用いてある程度の範囲はカバーできそうです。

どのように運用しているか

スタイルファイルの運用

まず基本的なコンポーネントの構造に関しては概要にも記載していますが1コンポーネントに対して1つのスタイルファイルを用意する形をとっています。

ファイルの命名は style.css.ts とう命名に統一しています。

Button/
  index.tsx
  style.css.ts

style.css.ts 内では概要ででてきた style 関数を利用してスタイリングをします。

またパターンが決まっている動的なスタイル変更などはvanilla-extractが提供している @vanilla-extract/recipe というパッケージをimportして利用できる recipe 関数を用いて表現しています。

https://vanilla-extract.style/documentation/packages/recipes/

例えばinputタグを利用したTextFieldコンポーネントを実装する際にmedium,smallという2つのサイズをpropsで変更できるようにしたい場合は以下のように recipe の variants というプロパティにsizeのパターンを記述すると、利用側で渡したpropsによってスタイルを切り替えることができます。

const base = style({
  // ベースのスタイルを記述
})
export const inputStyle = recipe({
  base,
  variants: {
    size: {
      medium: {
        height: '30px',
        ...THEME.typography.medium,
      },
      small: {
        height: '26px',
        ...THEME.typography.small,
      },
    },
  },
});

export const TextField = ({size = 'medium'}) => {
  return (
    <input className={inputStyle({size})} />
  )
}

アプリケーション全体に影響のあるスタイルの運用

アプリケーション全体に影響のあるスタイルに関連するモジュールやデザイントークン、global cssなどはappディレクトリ直下の _styles というディレクトリにまとめてあります。

_styles は以下の構造になっています。

app/_styles
  design_tokens/
  fonts/
  global.css.ts
  sprinkles.css.ts
  theme.ts
  utils.ts

design_tokensにはデザインシステムとして定められているデザイントークンの定義を格納しておりtheme.tsはdesign_tokensの定義をまとめたオブジェクトを定義しています。
fontsにはフォントの定義ファイルを格納しています。

global.css.ts

global.css.ts はアプリケーション全体に適用されるスタイルを定義しています。

vanilla-extractには globalStyle という関数が用意されており、これを利用することでグローバルスタイルを定義することができます。

https://vanilla-extract.style/documentation/global-api/global-style/

このファイルをappディレクトリ直下の layout.tsx にimportすることでアプリケーションにグローバルスタイルを適用しています。

global.css.ts
import { globalStyle } from '@vanilla-extract/css';

globalStyle('button', {
  cursor: 'pointer',
});

sprinkles.css.ts

sprinkles.css.ts にはvanilla-extractが提供している vanilla-extract/sprinkles というパッケージの機能を用いた定義を格納しています。

https://vanilla-extract.style/documentation/packages/sprinkles/

sprinkles.css.ts のコードは以下のようなモジュールを定義しています。

sprinkles.css.ts
const styles = defineProperties({
  properties: {
    // 利用したいスタイルのユーティリティを定義
  }
});

export const sprinkles = createSprinkles(styles);

export type Sprinkles = Parameters<typeof sprinkles>[0];

sprinkles はスタイルのユーティリティを定義できるものです。
例えばcolorのスタイルにデザインシステムで定義したcolorのみを適用したい場合などに便利です。
以下のように defineProperties という関数にcolorにデザインシステムで定義しているcolorのオブジェクトを設定し、 createSprinkles でsprinklesを生成します。

const styles = defineProperties({
  properties: {
    color: THEME.color,
  },
});

export const sprinkles = createSprinkles(styles);

このsprinklesを用いるとデザインシステムで定義しているカラーの種類を選択することができます。

style.css.ts
export const textStyle = sprinkles({ color: 'gray-900' })

また、コンポーネントのpropsにsprinklesで定義したユーティリティを利用することも簡単にできます。

以下のようにtypeを定義するとdefinePropertiesで定義したユーティリティの型を定義できます。

export type Sprinkles = Parameters<typeof sprinkles>[0];

ここで定義したSprinklesを用いてcolor propsに Sprinkles[’color’] を設定すると利用側はcolorにデザインシステムで定義したcolor名を指定することができるようになります。

コンポーネント側では受け取ったcolorを先ほど定義したsprinklesに指定してやるとclassNameが生成されます。

interface Props {
  color: Sprinkles['color'];
  children?: React.ReactNode;
}

export const Text: React.FC<Props> = ({ color, children }) => {
  const sprinklesClass = sprinkles({ color });

  return <p className={sprinklesClass}>{children}</p>
}

この仕組みを利用することでデザインシステムを簡単にアプリケーションに統合することができました。

utils.ts

utils.ts にはスタイル関連で利用する汎用関数を格納しています。
1つは複数のclassNameを統合する classNames という関数です。

clsx と似たような使い方ができますが、clsx のようにオブジェクトや配列を渡すようなユースケースには対応していません。
オブジェクトや配列を渡すケースを想定しておらず、現在もそのケースが必要になった場面がないので自作の関数を使い続けています。

export const Text = ({ children }) => {
  return <p className={classNames(styleA, styleB)}>{children}</p>
}

2つ目は splitProps という関数です。

これはsprinklesを利用する際に合わせて利用することが多い関数です。

ユースケースとしてはsprinklesで定義したユーティリティをpropsに定義しているコンポーネントで、propsの中からユーティリティpropsとそれ以外のpropsを分けたい場合に利用します。

例えば Box というコンポーネントを定義するとしましょう。

ユーティリティをpropsとして受け取れるようにしてスタイリングできるようにし、div要素の属性も合わせてpropsに指定できるようなinterfaceにしています。

この時にコンポーネント内でユーティリティpropsを抽出してsprinkesに渡してclassNameを生成したいですがユーティリティのみを抜き出して1つずつ渡していくのは面倒ですし、変更しづらいです。

splitProps を利用すると渡したpropsからユーティリティprops(styleProps)とそれ以外のprops(elementProps)を分割して返してくれます。

stylePropsの方をsprinklesに渡せば全てのユーティリティpropsからclassNameを生成することができ非常に便利です。

type Props = {
  display?: Sprinkles['display'];
  alignItems?: Sprinkles['alignItems'];
  justifyContent?: Sprinkles['justifyContent'];
  gap?: Sprinkles['gap'];
  backgroundColor?: Sprinkles['backgroundColor'];
  color?: Sprinkles['color'];
  children?: React.ReactNode;
  className?: string;
} & React.ComponentPropsWithoutRef<'div'>;

export const Box = ({ className, children, ...props }) => {
  const { styleProps, elementProps } = splitProps(props);
  const sprinklesClass = sprinkles(styleProps);

  return (
    <div
      className={classNames(sprinklesClass, className)}
      {...elementProps}
    >
      {children}
    </div>
  );
},

つまづきポイント

動的なstyle propsを利用できない

vanilla-extractはRuntime CSS in JSではないのでパターンの決まっていない動的なスタイルを表現することができません。

例えば width: number のようなpropsを受け取りCSSに反映させたい、みたいなケースです。

ヨジツティクスにおいてはこのような実装をするパターンはほぼありませんがどうしても利用したいケースも出てきます。

この場合はシンプルにstyleタグを利用して動的なスタイルを表現しましょう。

const Container = ({ width, height, children }) => {
  return <div style={{ width: `${width}px`, height: `${height}px` }}>{children}</div>
}

また、ヨジツティクスでは利用していませんがvanilla-extractから提供されている

@vanilla-extract/dynamic というパッケージで利用できる

assignInlineVars という関数でも同じようなことができるのでこちらを検討するのも良いでしょう。

https://vanilla-extract.style/documentation/packages/dynamic/

recepi, sprinklesを利用しているとJestを利用したコンポーネントテストが実行できない

vanilla-extractのrecepi, sprinklesを用いたコンポーネントのテストをJestで実行すると

TypeError: (0 , _stylecss.textStyle) is not a function

というようなエラーが発生します。
issueにも同様の事象が報告されています。(解決はまだされていない模様)

https://github.com/vanilla-extract-css/vanilla-extract/issues/1131

Next.jsから提供されている next/jest.js というNext.jsでJestを利用する際に簡単にセットアップできるパッケージとvanilla-extractが提供している @vanilla-extract/jest-transform というjestのセットアップに利用するパッケージの相性が悪いために発生しています。

原因としてはJestのセットアップの際のtransform,moduleNameMapperの設定によるものです。

transformはNext.js側の設定とvanilla-extract側の設定の順番、moduleNameMapperはNext.jsのmoduleNameMapperの設定によりvanilla-extractのスタイルファイル( .css.ts)が機能しなくなることが原因です。

next/jest.js側の moduleNameMapper.css の拡張子が styleMock.js に置換される設定になっております。

https://github.com/vercel/next.js/blob/05c102ca154b8f985b21d6f2f017df47e9d41a50/packages/next/src/build/jest/jest.ts#L129

この設定により本来使えるvanilla-extractの機能が利用できなくなります。
公式ドキュメントにも記載がありますがどうやらJestが .css.css.ts のimportを区別できないようになっているらしく、 .css に対するstyle mockは避ける必要があるらしいです。

https://vanilla-extract.style/documentation/test-environments/#remove-style-mocking

jest.config.js でその辺りの設定をうまく組み換えて動くようになりましたが、Next.jsでvanilla-extractを用いたコンポーネントテストにJestと利用したいというケースではこのような罠があることを念頭においていただけるといいかなと思います。

まとめ

vanilla-extractの概要や実際の運用、つまづきポイントなど紹介しました。
導入してから1年以上経ちますが安定して開発できており途中から参画したメンバーもつまづくことなく利用してもらえてます。
この記事でvanilla-extractの良さを少しでも知ってもらえると嬉しいです。

Discussion