🧑‍🍳

Emotionからvanilla-extractへの移行!Recipesスタイリングで最適化

2024/08/04に公開

はじめに

前回の記事では、vanilla-extract の基本とCSSの型定義について説明しました。

今回は、App Routerから使用できなくなった Emotion から vanilla-extract への移行を軸として解説していき、vanilla-extract のRecipesパッケージにDeepDiveしていきます🏊🏻

https://zenn.dev/blueish/articles/d989369fe4a220

vanilla-extractの基本

vanilla-extract は、Next13以降で登場したApp Routerに対応しており、Zero-Runtimeで型安全なCSSを実現するスタイリング方法です。
従来のCSS-in-JSライブラリとは異なり、Build時にCSSを生成するため、Runtimeでのパフォーマンスへの影響がありません。
また、型安全な設計により、コンパイル時にエラー検出が可能になり、開発効率がアップします。

インストール方法と設定方法、基本的な使用方法、型周りについては今回は説明を省略します。
過去の記事内で説明していますので、必要な方はご覧ください。
https://zenn.dev/blueish/articles/d989369fe4a220#インストールと設定方法の解説

Recipesパッケージの活用

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

Recipesパッケージの概要

Create multi-variant styles with a type-safe runtime API, heavily inspired by Stitches.

As with the rest of vanilla-extract, all styles are generated at build time.

引用: vanilla-extract公式より

Recipesは vanilla-extract のパッケージで、コンポーネントの様々な見た目や状態(マルチバリアント)を型安全かつ効率的に作成するためのパッケージです。

例えば、ボタンの大きさ、色、スタイルなど、複数の特性を組み合わせてさまざまなデザインのパターンを簡単に管理することができます。

Recipesパッケージの利点

Recipesを使うことによって、vanilla-extractをもっと便利に使うことができます。

【利点①】 動的で柔軟なスタイル生成

basevariantsdefaultVariantscompoundVariants、など、様々なオプションを使って細かくスタイルを定義できます。
これにより、ボタンやカードなど、状態によって複数のスタイルを出しわけを簡単に行うことができます。

【利点②】 スタイルの継承

レシピの設定時に、すでに定義されている変数、クラス、スタイルを使用できるので、一貫性のあるデザインを維持することができます。

【利点③】 型安全性

vanilla-extract と Recipes パッケージは、どちらもTypeScriptを使用するので、コンパイル時にエラー検出が可能になり、型安全な開発が効率よく行えます。

▼ vanilla-extractのRecipesについて(公式ドキュメント)
https://vanilla-extract.style/documentation/packages/recipes/#recipevariants

Recipesパッケージの基本的な使い方

1. インストール

Recipes使用するには、まず@vanilla-extract/recipesをインストールする必要があります。

npm install @vanilla-extract/recipes

2. スタイルの定義

スタイルの定義に関して、今回は例として、「青」「緑」「灰色」の3色から選択できる buttonRecipeを定義してみます。

import { recipe } from '@vanilla-extract/recipes';

export const buttonRecipe = recipe({
  base: {
    // 基本的なスタイルを定義
    borderRadius: '4px',
    cursor: 'pointer',
  },
  variants: {
    color: {
      primary: { backgroundColor: 'blue', color: 'white' },
      secondary: { backgroundColor: 'green', color: 'white' },
      warning: { backgroundColor: 'yellow', color: 'black' },
    },
    size: {
      small: { fontSize: '14px', padding: '4px 10px' },
      medium: { fontSize: '16px', padding: '10px 20px' },
      large: { fontSize: '18px', padding: '16px 30px' },
    },
  },
  defaultVariants: {
    color: 'primary',
    size: 'large',
  },
});

baseでは、ボタンの基本的なスタイルを定義しています。
ここでは、borderRadius, cursorという3つのプロパティが設定されているので、すべてのボタンは共通して「4pxの角丸」、「ポインタカーソル」を持つことになります。

variantsは、状態に応じて変化するスタイルを定義する部分です。
ここでは、colorというバリアントが定義されています。
colorバリアントには、primary, secondary, warningの3つの状態があり、それぞれ異なる背景色と文字色を設定しています。

3. スタイルの適用

スタイルを適用するには、recipe関数で定義されたスタイルを使用して、要素にクラス名を付与します。
今回は3パターン用意しました。

import { buttonRecipe } from './styles';

export const ExampleButtons = () => {
  return (
    <>
      {/* パターン1: colorもsizeも指定 */}
      <button className={buttonRecipe({ color: 'warning', size: 'large' })}>
        Large Warning Button
      </button>

      {/* パターン2: 何も指定しない(defaultVariantsが適用される) */}
      <button className={buttonRecipe()}>
        Default Button
      </button>

      {/* パターン3: colorのみを指定(sizeはdefaultVariantsが適用される) */}
      <button className={buttonRecipe({ color: 'secondary' })}>
        Secondary Button
      </button>
    </>
  );

パターン1のボタンは、color: 'warning'size: 'large'を明示的に指定しているので、大きな黄色のボタンが表示されます。

パターン2のボタンは何も指定していないので、defaultVariantsで設定された値が適用されます。
先ほどdefaultVariants: { color: 'primary', size: 'medium' }と定義していたため、このボタンは自動的に中サイズの青色ボタンとして表示されます。

パターン3のボタンはcolor: 'secondary'のみを指定しています。
サイズは未指定のため、defaultVariantsで設定された値が適応されるので、中サイズのグレーのボタンが表示されます。

compoundVariants

basevariantsdefaultVariantsの他に、compoundVariantsというオプションも設定できます。
compoundVariantsは、特定のパターンのvariantsが指定されたときのスタイルを定義することができます。

例えば、「variantswhitetextColorblackroundedtrue」という条件が揃った場合のみに borderを設定したいといったきは、以下のようなコードになります。

 },
  compoundVariants: [
    {
      variants: { color: 'primary', outlined: true },
      style: {
        color: 'blue',
        borderColor: 'blue',
        ':hover': { backgroundColor: 'rgba(0, 0, 255, 0.1)' },
      },
    },
    {
      variants: { color: 'warning', size: 'large' },
      style: {
        fontWeight: 'bold',
        textTransform: 'uppercase',
      },
    },
  ],

Emotionとvanilla-extractの比較

性能ベースでの比較

「Pages RouterではEmotion使ってたんだけど、App Routerでどうすればいいか悩んでる…」という方、案外多いのではないでしょうか?

Emotionは柔軟性が高く、動的なスタイリングが容易で、かつReactとの統合が優れているため、多くの開発者に人気がありました。
特に、コンポーネントの状態やpropsに基づいて動的にスタイルを変更できる点や、JavaScriptの表現力を活かしたスタイリングが可能な点が高く評価されていました。

しかし、Emotionをはじめとする、クライアントサイドでのJavaScript実行に依存するCSS-in-JSライブラリは、 App RouterのServer Componentsでは直接使用することができません。
Server ComponentsがサーバーサイドでHTMLをレンダリングする仕組みであり、クライアントサイドでのJavaScript実行に依存するCSS-in-JSライブラリとの互換性がないためです。

今回はそんなEmotion とZero-Runtime CSS-in-JSライブラリである vanilla-extract の性能の比較を行いました。

このように圧倒的にvanilla-extractの方が優位性が高い事がわかります。
Next.jsがこのパフォーマンスを意識する為にApp Routerに切り替えた、ということが伝わる表ですね!

Emotionからvanilla-extractへの移行

ここでは、Emotion からvanilla-extract への移行に向けてのコードベースでの比較を行います。

【vanilla-extractへの移行: その①】 ファイル定義とprops指定

まずは、基本的なボタンコンポーネントを例に挙げてみます。
実際に Emotion から vanilla-extract に切り替える時に、どのようなコードの差分があるのかみていきましょう。

Emotion

Emotion を使ったボタンコンポーネントの作成例です。

import { css } from '@emotion/react';

const buttonStyle = ({ bgColor }) => css({
  padding: '10px 20px',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer',
  fontSize: '16px',
  color: 'white',
  backgroundColor: 
    bgColor === 'primary' ? 'blue' :
    bgColor === 'secondary' ? 'green' :
    bgColor === 'danger' ? 'red' :
    'gray'
});

export const EmotionButton = ({ bgColor }) => (
  <button css={buttonStyle({ bgColor })}>Button</button>
);

Emotionでは「同一」ファイル内でスタイルを定義しており、propsを「直接」使用していますね。

vanilla-extract

つづいて vanilla-extract を使ったボタンコンポーネントの作成例です。

基本的に同一ファイル内で定義し、propsを直接使用していたEmotion と違い、vanilla-extract では、「スタイルの定義」と「コンポーネントへの定義」は基本的に別ファイルで行い、かつ、propsの使用も、「variant」での使用となります。

ここでのstyleの指定には、RecipesパッケージのvarientdefaultVariantsを使用しています。
vanilla-extract 独自の定義方法なので、最初は少し慣れが必要かもしれませんね。

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

export const buttonStyle = recipe({
  base: {
      padding: '10px 20px',
      border: 'none',
      borderRadius: '4px',
      cursor: 'pointer',
      fontSize: '16px',
      color: 'white',
  },
  variants: {
    bgColor: {
      primary: { backgroundColor: 'blue' },
      secondary: { backgroundColor: 'green' },
      danger: { backgroundColor: 'red' },
      default: { backgroundColor: 'gray' },
    },
  },
  defaultVariants: {
    bgColor: 'default',
  },
});
export const VanillaExtractButton = ({ bgColor }: Props) => (
  <button className={buttonStyle({ bgColor })}>Button</button>
);

【vanilla-extractへの移行: その②】 擬似クラスの指定

ここのカードコンポーネントの例では、Emotion と vanilla-extract の擬似クラスの指定について比較していきます。

Emotion

Emotionではhoverやfocusは、ネストされたセレクタ構文(&:hoverなど)を使用して、CSSのような文字列ベースの構文を使って定義します。

import { css } from '@emotion/react';

const cardStyle = css({
  padding: '20px',
  border: '1px solid #ccc',
  borderRadius: '4px',
  boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
  '&:hover': {
    boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)'
  },
  '&:focus': {
    outline: '2px solid blue'
  }
});

export const EmotionCard = ({ children }) => (
  <div css={cardStyle}>{children}</div>
);

vanilla-extract

vanilla-extractでは、擬似クラスをオブジェクトのキーとしてstyleオブジェクト内で直接定義(':hover':など)し、JavaScriptオブジェクト構文を使用します。

この擬似クラスの指定では、Emotion とvanilla-extract とではほぼ違いがなく、移行しやすい内容となっています。

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

export const cardStyle = style({
  padding: '20px',
  border: '1px solid #ccc',
  borderRadius: '4px',
  boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
  ':hover': {
    boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)',
  },
  ':focus': {
    outline: '2px solid blue',
  },
});
export const VanillaExtractCard = ({ children }: Props) => {
  return (
    <div className={cardStyle}>
      {children}
    </div>
  );
};

【Emotionへの移行: その③】 ファイル名の違い

  • Emotionでのファイル定義の拡張子のルールは.tsx
  • vanilla-extractでのファイル定義の拡張子のルールは.css.ts

まとめ

いかがでしたでしょうか。

もともとEmotion を触っていた私が、新規プロジェクトで vanilla-extract に初めて触れた際は、そこまで違和感を感じることなく扱うことができました。
※既存のEmotion プロジェクトの場合は、Recipe 関係の差分が発生するので、移行には工数がかかると思います。

「Pages RouterではEmotion を使ってたんだけど、App RouterではEmotion の代わりに何を使ったらいいのか悩んでる…」
こんなお悩みのお持ちの方は、この記事を参考に vanilla-extract に触れてみてはいかがでしょうか?

最後までお読みいただきありがとうございました。

Discussion