RAKSUL TechBlog

ラクスルグループのエンジニアが技術トピックを発信するブログです

ダンボールワンのフロントエンドでゼロランタイムCSS in JSを採用してみた

ダンボールワンのフロントエンドでゼロランタイムCSS in JSを採用してみた

こんにちは、ラクスルの宮崎(@miyahkun)です。現在はラクスル株式会社の「ダンボールワン」という、ダンボールや梱包資材を扱うサービスの運用・開発を行っています。我々のチームではNext.jsを用いたサービス開発をしています。今回はその中で採用したvanilla-extractというツールについて紹介します。

背景と課題

我々がこのプロジェクトを始めた当初は、ラクスルでの採用実績が長いCSS Modulesの導入を検討していました。Next.jsのドキュメントで最初に記載されているのがCSS Modulesであり、当時は強く推奨されていたことを覚えています。しかし、Next.jsの環境構築を進めていく中で、他のプロジェクトで利用していたPostCSSのcustom mediaというpluginがうまく動作しないことに気づきました。PostCSSのメジャーバージョンアップ(v7 → v8)に伴い、PostCSS plugin周りに入った大きな変更が原因のようです。

いくつか代替案はありましたが、1からのプロジェクトという折角の機会なので、ラクスルでは導入していない新しいCSSツールにも視野を広げてみようという話になりました。

技術選定の要件

CSSツールの技術選定をするにあたって、求める要件は以下の通りです。

  • エディター上での開発体験:クラス名やプロパティの補完が効くこと
  • React Server Component対応:当時はまだexperimentalでしたが、Next.js App Routerの採用を見据えて、React Server Component上でも動作すること(現在はApp Routerを利用しています)
  • パフォーマンス:ブラウザランタイム上での動作が不要なもの。また、CDNによるCSSファイルのキャッシュが可能なもの
  • 未使用のスタイル定義が追える:長期運用していく上で、使われているか分からないCSSを増やさないため

これらの要件を満たす候補を探してチームで議論した結果、選ばれたのはvanilla-extractでした。

vanilla-extractとは

vanilla-extractがどういうものか簡単に紹介します。

vanilla-extract.style

一般的なCSS in JSライブラリと同様にJavaScriptを用いてスタイルを記述する一方で、ビルド時にはCSSファイルを出力します。

ファイル名は .css.ts のような命名規則に従い、パッケージから提供されているstyle 関数を利用します。JavaScriptのオブジェクトとしてCSSプロパティを記述します。

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

export const container = style({
  display: 'flex',
  padding: 10,
});

コンポーネント内では以下のようにスタイルを適用します。

// SomeComponent.tsx
import * as styles from './hoge.css';

export const SomeComponent = () => (
  <div className={styles.container}>
    // some contents here...
  </div>
);

ビルド時にはバンドラーによりCSSファイルとして出力されます。

満足している点

補完による実装スピードの向上

ラクスルではVue.js SFC + CSS Modulesを採用しているプロダクトが多いため、補完が効きづらく書きづらいなと個人的に感じていました。vanilla-extractでは純粋なTypeScriptとして型が付くため、IDEの拡張機能は不要です。内部的にはcsstypeが利用されておりCSSプロパティの補完も効くため、実装スピードもかなり早くなりました。また、TypeScriptのオブジェクトとして正確に参照が追えるため、定義元ジャンプを簡単にできる点も嬉しいです。

参照の追跡により安全にCSSを削除できる

私は普段VSCodeを利用していますが、”TypeScript > References Code Lens” をオンにすると参照カウントが表示されます。スタイルを書きながら、消し忘れているスタイルに気付きやすいので非常に便利です。(どこで使われているか分からない迷子CSSをビクビクしながら削除する作業とはおさらばです 👋)

未使用のCSSの削除に関してはPurgeCSSなどのライブラリでも可能ですが、TypeScriptの参照を追えた方がより確実だろうと思っています。

VSCodeの設定画面で「TypeScript, Reference Code Lens」機能にチェックを入れている

vanilla-extractを用いたスタイル定義行の上部に「1 reference」という参照カウントが表示されている

また、プロジェクト全体で参照されていないコードを見つけるために、CLIとして実行できるts-pruneも活用しています。vanilla-extractに限らず、TypeScriptファイル全体で未使用のコードがないかチェックできます。我々のチームでは気が向いたらローカル環境で実行する程度のゆるい運用にとどめています。

活用方法やTipsなど

最小限の機能のみ利用

vanilla-extractにはいくつかのパッケージが提供されています。SprinklesはTailwindCSSのようなユーティリティ形式の定義をサポートし、 Recipesはランタイム上でスタイルを切り替えるAPIを提供しています。導入した当初はこういった機能を活用した方が良いのだろうかと悩みましたが、基本的なAPIの利用だけで十分実装できています。ダンボールワンのECサイトではダークモード対応や、デザインルールが明確に定まっていないことも、シンプルな実装になっている要因かと思います。大抵のことはミニマムに始めるのが吉ですね 👍

サイト全体で利用するカスタムプロパティの定義

サイト全体で利用するカラーバリエーションやフォントサイズ、z-indexなどをカスタムプロパティとして定義することが多いと思います。vanilla-extractでは createGlobalTheme というAPIを活用するのがおすすめです。例えば、セレクター :root に対してカスタムプロパティを定義したい場合は以下のように書きます。

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

export const color = {
  gray100: '#fafafa',
  // ...
  gray700: '#391f0d',
} as const;

export const vars = createGlobalTheme(':root', {
  color,
  // other variables...
});

ビルド結果は --color-gray100__yahcht2 のようにハッシュが付き、名前の衝突が起きない仕組みになっています。

固定文字を含んだセレクターが欲しいとき

vanilla-extract は style 関数を利用するとセレクターにハッシュが自動で付与されます。しかし、外部のライブラリなどと連携するために決められたセレクターを生成したい場合があります。具体例の一つとしてreact-transition-groupがあげられます。このライブラリはアニメーションの開始・途中・終了などのタイミングで適用されるセレクターは xxx-enterxxx-enter-active のようにsuffixが決められています。このような場面では style 関数と globalStyle 関数を組み合わせると良いです。以下はポップアップのUIでアニメーションを適用する例です。

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

export const popup = style({});

globalStyle(`${popup}-enter`, {
  opacity: 0,
  transform: 'translate3d(0, -6px, 1px)',
  display: 'block',
});

globalStyle(`${popup}-enter-active`, {
  opacity: 1,
  transition: `opacity 300ms ease-in-out, transform 300ms ease-in-out`,
});
// BasePopup.tsx
import { CSSTransition } from 'react-transition-group';
import * as styles from './BasePopup.css';

export const BasePopup = ({ children }) => {
  return (
    <CSSTransition classNames={styles.popup}>
      <div>{children}</div>
    </CSSTransition>
  );
};

popup という空のセレクターを作成し、globalStyle 関数の第一引数で使用している点が肝です。 popupstyle 関数によりハッシュ付きのセレクターとなっています。一方、globalStyle 関数は第一引数で指定された文字列がそのままセレクターになります。これにより、ハッシュによるスコープ化を効かせながら、固定のsuffixを持つセレクターを定義することができます。

導入してから気づいた点

半年ほどvanilla-extractを利用してきた中で気づいたことを2つ紹介します。

親コンポーネントから子コンポーネントのスタイルを上書きできないときがある

親コンポーネントから子コンポーネントのスタイルを上書きしようとしたときに、意図した通りに上書きしてくれないことがあります。そもそも、コンポーネントのスタイルを外部から上書きするようなことがあまり推奨されないかもしれませんが、時々そういった場面は訪れます。

// child.css.ts
const text = style({
  color: 'red'
});

// child.tsx
const ChildComponent = ({ className }) => (
  <div className={clsx(className, styles.text)}>This text can be red...</div>
);

// parent.css.ts
const textFromParent = style({
  color: 'blue'
});

// parent.tsx
const ParentComponent = () => (
  <ChildComponent className={styles.textFromParent} />
);

CSSの定義順がバンドラー依存のため、意図しないスタイルが適用されてしまうことがあります。私は他のCSSツールを利用してきた中でこのような問題に出会ったことはありませんでした。しかし、バンドラーを通してCSS定義の順番を決定する仕組み上、潜在的に抱えている問題のようです。

対策として、以下のようなことを心がけています。

  • CSSで上書きするようなスタイル設計を避ける
  • コンポーネントの props として外部から指定し、上書きが発生しないようにする
  • 最悪の場合、!important を指定する(今のところ出番はありません 😊)

プロパティの記述順序をソートできない

vanilla-extractではStylelintのプラグインが存在しないため、Stylelintエコシステムの恩恵を受けることができません。一番影響が大きいのはCSSプロパティの記述順をソートできなくなったことです。これに関しては、チーム内でどのような順序を良しとするかの認識がある程度揃っているため、大きな問題にはなっていません。後述するLinariaというCSS in JSライブラリでは公式プラグインが提供されているので、vanilla-extractにも提供されてほしいなと思います。いくつかのIssueではvanilla-extractの構文の関係で難しいとのコメントも見かけました。完璧ではなくとも、部分的に自動整形してくれるようになれば嬉しいですね(OSSコミットチャンス!)

採用しなかった技術

チーム内で候補に上がったものの採用されなかったCSSツールの技術を一部紹介します。どんな観点で技術選定しているかの参考になれば嬉しいです。

CSS Modules + typed-css-modules

github.com

typed-css-modulesはCSSファイルからTypeScriptの型定義を自動で生成してくれるライブラリです。純粋なCSS記法で書きつつ、型補完も効くため学習コストゼロで書き始められます。冒頭で述べたメディアクエリ周りの問題がなければこちらが最も有力な候補でした。

TailwindCSS

tailwindcss.com

“utility-first CSS framework” と呼ばれるような、事前に定義されたセレクターを組み合わせてスタイルを適用する方式です。人気のあるツールですが、以下の理由から我々には向いていないと判断しました。

ダンボールワンのECサイトはデザインルールが明確に定まっていません。例えば、marginの大きさや文字サイズはUIごとに細かく調整しており、事前に決められたサイズ(デザイントークン)の中から組み合わせるようなデザインにはなっていません。TailwindCSSを使いこなすためには、デザイントークン + それを元に定義したTailwindCSSのutilityを厳密に運用することが不可欠だと思っています。しかしながら、現状のチーム体制でそこまでやり切ることは難しいと判断しました。

Linaria

linaria.dev

タグ関数を用いたテンプレートリテラルでスタイル定義します。テンプレート内は素のCSSと同じ記法のため、既存のCSS実装を移行するのは簡単そうです。一方で、テンプレート記法のため、シンタックスハイライトがエディターの拡張機能に依存します。vanilla-extractとは立ち位置がかなり似ていると思います。当時はありませんでしたが、現在ではStylelintのプラグインが公式で提供されているのは地味に嬉しいポイントです。

まとめ

今回はラクスルの「ダンボールワン」開発チームで導入したvanilla-extractについて紹介しました。ダンボールワンではモダンな技術を取り入れたプロダクト開発を行っています。直近ではNext.js App RouterやChromaticなどの知見を共有していく予定です。ぜひ楽しみにしていてください!