クックパッドのフロントエンド CSS in JS をゼロランタイムに切り替えました

こんにちは。レシピ事業部のkaorun343です。我々のチームではレシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログにて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回は、この新システムのCSS in JSをEmotionからゼロランタイムのvanilla-extractへ変更した話です。

vanilla-extract.style

背景

以前書いた レシピサービスのフロントエンドに CSS in JS を採用した話 - クックパッド開発者ブログでは、CSS in JSライブラリとして Emotion(@emotion/react)を採用した経緯と開発環境整備を紹介しました。採用理由としては以下の通りでした。

  • セレクタに一意なIDが割り振られるので、スタイルを適用した要素とは別の要素への、意図しないスタイル適用を防ぐことができる。
  • ESLintやTypeScriptコンパイラといったJavaScriptの静的解析ツールの恩恵を受けることができ、タイポや機能削除時の削除漏れに気づきやすくなる。
  • styled-componentsのようなスタイルではJSXのツリーを見たときに、機能を持つコンポーネントなのか装飾されたコンポーネントなのかわからず、コードレビューがしにくい。
  • 通常のCSSの記法に慣れたメンバーが多いので、String Styles、すなわちタグ付きテンプレートリテラルを採用する。

このような方針でEmotionの導入を決め、stylelintやeslintを導入し、必要に応じてカスタムルールを作成して機能開発を進めました。

しかしながら、Emotionを導入してから2年ほど経った結果、以下のような課題や懸念を抱えるようになりました。

  1. ページサイズ:SSR時には初期表示用のCSSをEmotionが作るわけですが、このCSSは .css ファイルとしてブラウザに届くのではなく Next.jsから配信されるHTMLに埋め込まれた状態でブラウザに届きます。そのため、ロードバランサーを通過するHTMLのサイズが増加してしまいます。CSSのデータがCDNを通らないため、パフォーマンスの面でもコストの面で問題です。実際、background-imageにbase64の画像URLを埋め込んだときには、その影響が強く出てしまいました。
  2. 動的生成による肥大化:Next.jsのSSR時にうっかりCSSのバリエーションを増やしてしまうと、Next.jsプロセスのメモリ使用量が増大し、アプリケーションが落ちてしまいます。これは、Emotionがインメモリのキャッシュ機構を備えており、一度生成したCSSデータを保持し続けるためです。過去の事例では、レシピごとに異なるbackground-imageを設定するCSSをEmotionで書いたときにこの問題が生じました*1
  3. クライアント側のオーバーヘッド:CSS生成のためにブラウザ上でJavaScriptが実行されるため、ページのパフォーマンスへ影響しうる懸念があります。EmotionはCSSの記述内容の解析、古いブラウザ向けの記述の追加、CSSの合成、そしてスタイルのDOMへの挿入をブラウザ上で実行します。Emotionのcss関数を使えば使うほどCSSに関する処理の実行時間が増えていきます。また、これらの処理をブラウザ上で実行するためのJSのコードが必要となるため、バンドルサイズが増加してしまいます。新システムがホストしているページはスマートフォン向けのページであり、パフォーマンスやバンドルサイズは特に注視しています。

そこで、Emotionから別の CSS 環境への移行を検討しました。

技術選定

上記の課題を踏まえ、以下の要件で新しいCSS 環境を検討しました。

  • Emotionに近い開発体験:Emotionと同様に、CSS クラス名を自分でつける必要がないこと
  • CDNの活用:ビルド時に CSS ファイルが生成されて CDN から静的に配信できること
  • 低いオーバーヘッド:ゼロランタイムであること(ビルド時にCSSを生成し、ブラウザに送られるJavaScriptにはCSSを生成するコードを含まないこと)
  • 将来性:Server Components導入を見据えて、Server Componentsに対応していると嬉しい

検討した結果、これらの要件を満たすライブラリとしてvanilla-extractが挙がりました。 vanilla-extractではCSSをJavaScriptのオブジェクトとして .css.[jt]s という拡張子のファイルに記述します。これをvanilla-extractの各種バンドラに対応したプラグインがCSSに変換し、CSSファイルを生成します。また、それぞれのスタイルは一意なクラス名をセレクタとしており、Emotionと同じように意図しないスタイル適用を防ぐことができます。

Emotionとvanilla-extractの比較を表にすると以下のようになり、技術選定で重視した項目を満たしています。

比較項目 Emotion vanilla-extract
クラス名の自動付与
CDNから配布可能 (HTMLに埋め込まれる)
ゼロランタイム (ブラウザ)
Server Components対応 (CSRのみ)
コンポーネントと同じファイルに書ける (.css.[jt]sに書く必要がある)
CSSの書き方 String Styles、Object Styles Object Stylesのみ
Stylelint (未対応)
スナップショットテスト (なし)
ベンダープレフィクスの自動付与 (なし)

(比較当時、@emotion/reactはv11.10.0、@vanilla-extract/cssはv1.9.1でした)

筆者がvanilla-extractを提案した際は、記述方法の違いやビルド成果物の差がわかるように、実際のページを書き換えたプルリクエストを例示しました。CSSのコード量が小さいOGP画像生成用のページを対象にしました。

vanilla-extractのデメリット

一方で、vanilla-extractにはデメリットも存在します。

CSSの書き方

まず、これまで通りString Stylesで記述することができなくなりました。この点について懸念点がないかデザイナーの方に伺ったところ、「CSSを書ければ問題ない」とのことでした。vanilla-extractは .css.[jt]s に記述する必要がありますが、この点についても、チームメンバーから合意をもらいました。

Stylelint

加えて記法が変わったことによりStylelintで検査できなくなりました。しかしながら、CSSのプロパティや値のタイポ・プロパティの重複はTypeScriptで見つけられますし、我々のアプリケーションでは詳細度に関連して困るような書き方をしていないので、Stylelintを廃止するデメリットは小さいと判断しました。

スナップショットテスト

Emotionでは@emotion/jestがスナップショットテストにCSSの記述を表示する仕組みを提供していました。しかしvanilla-extractでは提供されておらず、スナップショットテストでCSSの記述を確認することもできなくなりました。スナップショットテストについては、運良くバグを検知できるほどのメリットしかないと判断し、使えなくなるデメリットは小さいと判断しました。

ベンダープレフィクスの自動付与

Emotionはベンダープレフィクスを自動で付与してくれるのですが、Emotionではライブラリ利用者がブラウザのバージョンを指定できないため、クックパッドの推奨環境より古いブラウザを対象としたプロパティも追加されていました。クックパッドの推奨環境も鑑み、自動付与がなくなるデメリットは小さいと判断しました。

vanilla-extractへの移行

CSSの記述をvanilla-extractへ移行することを決定した後、移行作業にとりかかりました。

最初はすべて手作業で書き換えていたのですが、途中から正規表現を使った簡素な変換ツールを導入して移行作業がスピードアップしました。 Emotionとvanilla-extractは共存できたため、手が空いているときに手分けをして少しずつ移行していきました。また、Next.jsアプリケーション本体だけではなく、共通コンポーネントパッケージ、そして社内のデザインシステムのReactライブラリもvanilla-extractに移行しました。

// Emotion

import { css } from '@emotion/react'

const linkStyle = css`
  flex: 1;
  box-sizing: border-box;
  background-color: white;
`

const linkDisableStyle = css`
  ${linkStyle}
  background-color: gray;
`

export const MyComponent = () => {
  return (
    <section>
      <a href="#" css={linkStyle}>
        Link
      </a>
      <a href="#" css={linkDisableStyle}>
        Disabled Link
      </a>
    </section>
  )
}
// vanilla-extract
// MyComponent.css.js

export const linkStyle = style({
  flex: 1,
  boxSizing: 'border-box',
  backgroundColor: 'white',
})

export const linkDisabledStyle = style([
  linkStyle,
  {
    backgroundColor: 'gray',
  },
])

// MyComponent.js

import { linkDisabledStyle, linkStyle } from './MyComponent.css.js'

export const MyComponent = () => {
  return (
    <section>
      <a href="#" className={linkStyle}>
        Link
      </a>
      <a href="#" className={linkDisabledStyle}>
        Disabled Link
      </a>
    </section>
  )
}

動的にスタイルを生成していた箇所については、 @vanilla-extract/dynamic や @vanilla-extract/recipesを利用して問題なく置き換えられました。

// Emotion

import { css } from '@emotion/react'

const linkStyle = (size) => css`
  width: ${size};
  height: ${size};
`

export const MyComponent = () => {
  return (
    <section>
      <a href="#" css={linkStyle('100px')}>
        Link 1
      </a>
      <a href="#" css={linkStyle('200px')}>
        Link 2
      </a>
    </section>
  )
}
// vanilla-extract
// MyComponent.css.js

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

export const sizeVar = createVar()

export const linkStyle = style({
  width: sizeVar,
  height: sizeVar,
})

// MyComponent.js

import { assignInlineVars } from '@vanilla-extract/dynamic'
import { sizeVar, linkStyle } from './MyComponent.css.js'

export const MyComponent = () => {
  return (
    <section>
      <a 
        href="#"
        className={linkStyle} 
        style={assignInlineVars({ [sizeVar]: '100px' })}
      >
        Link 1
      </a>
      <a
        href="#"
        className={linkStyle}
        style={assignInlineVars({ [sizeVar]: '200px' })}
      >
        Link 2
      </a>
    </section>
  )
}

移行した結果、Emotionで課題や懸念に感じていたことを解消できました。

  1. ページサイズ:CSSファイルにページ全体のCSSが含まれるようになり、CDNから配布できるようになりました。background-imageとしてbase64の画像ファイルを埋め込んだ場合でもロードバランサーを通るHTMLのサイズが大きくなることはありません。
  2. 動的生成による肥大化:メモリ使用量が増加してNext.jsプロセスが落ちることはなくなりました。
  3. クライアント側のオーバーヘッド:CSSの生成はビルド時にのみおこなわれるようになり、生成のためのJavaScriptは@vanilla-extract/dynamicや@vanilla-extract/recipesだけになりました。また、Emotionのランタイム削除によりバンドルサイズはgzipで10kB弱減少しました。

エンジニアやデザイナーからも、特にネガティブな意見は出ていません。初めてvanilla-extractを触るメンバーも、問題なくCSSを変更できています。

さいごに

今回はレシピサービスの新システムにおける ゼロランタイムCSS in JS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。

*1:このケースではstyle属性に直接background-imageを指定するCSSを付与して問題を回避しましたが、開発者がこの特性を気にし続けるのは難しいです