ミツカリのたなしゅん(@tanashun_dev)です。
皆さんは、emotionなどのCSS-in-JSライブラリを使っていて、ランタイムのスタイル生成オーバーヘッドが気になったことはありませんか?
昨今のエンドユーザの端末はスペックがどんどん高くなっているので、msレベルのオーバーヘッドであることが多く、人が検知できるレベルでパフォーマンスが悪いということはあまりないですが、LPのようなSEO評価のされるページだったりするとパフォーマンスの検査員は人ではなく機械なので、ページ読み込み時にJavaScriptでスタイルを生成・注入する処理が、評価に影響を与えることがあります。
最近のフロントエンド界隈では、ゼロランタイムCSSが主流になってきており、ビルド時にCSSを生成することでランタイムオーバーヘッドをゼロにできる選択肢が増えています。
ミツカリではこれまでemotionをスタイリングライブラリとして採用してきましたが、パフォーマンス改善とより型安全なスタイリングを目指して、ゼロランタイムCSS-in-JSへの移行を検討することにしました。
ただし、既存のコードベースは大規模で、emotionで書かれたコンポーネントが多数存在します。一度に全てを書き換えるのは現実的ではありません。そこで、emotionと新しいライブラリを共存させながら段階的に移行する必要がありました。
移行先ライブラリの選定
移行先の条件は以下です。
- (must)ゼロランタイムであること
- (want)CSS-in-JSであること(emotionからの移行がしやすいと嬉しいから)
ゼロランタイムCSSは結構選択肢が広いです。
最終的にtailwindとStyleXに絞り、それらを詳細に比較して検討しました。
tailwindは人気のあるユーティリティファーストのCSSフレームワークで、ゼロランタイムという点では魅力的です。しかし、実際に書いてみて以下の懸念がありました。
- className属性が冗長になる: 複雑なスタイルを適用しようとすると、
className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-500 to-purple-600 ..."のように非常に長くなり、可読性が下がる - emotionとの親和性: 既存のCSS-in-JSで書かれたコードベースから移行する際、スタイルの書き方が大きく変わってしまう。emotionでは
backgroundColor: colors.primaryのように書けていたものが、tailwindではclassName="bg-primary"のように別の記法になる
一方、StyleXはCSS-in-JSの記法を維持しながらゼロランタイムを実現できます。
// emotionの書き方(既存コード) const styles = css` background-color: ${colors.primary}; padding: 16px; `; // StyleXの書き方(移行後) const styles = stylex.create({ container: { backgroundColor: colors.primary, padding: 16 } });
このように、既存のCSS-in-JSの知識や経験を活かしながら、段階的に移行できることがStyleXの大きなメリットでした。チームメンバーもCSS-in-JSの書き方に慣れており、学習コストが低く抑えられることも決め手になりました。
StyleXは先に引用したCSSトレンドでもまだまだ使用率下位のライブラリですが、Meta社製ということもあり、Reactとの今後の親和性なども高そうだし信頼性も高そうだと判断しました。
ただし、導入を進めてみると、Next.js、Storybook、Jest、Docker環境のそれぞれで異なる課題に直面したので、それぞれの解消についてまとめてみます。
苦労した点
Next.jsでのSWC設定
globals.cssの設定などは公式ガイドに書かれているだけなので割愛します。
Next.jsの本番ビルドではSWCプラグインを使用する形で設定を追加しました。
// next.config.js const stylexPlugin = require("@stylexswc/nextjs-plugin"); module.exports = stylexPlugin({ rsOptions: { aliases: { "@/*": [path.join(__dirname, "*")], }, unstable_moduleResolution: { type: "commonJS", }, }, })();
設定はガイドを見てもこれだけのはずですが、上手く動きませんでした。
結論、設定は確かにこれだけで良かったのですが、Turbopack周りに問題がありました。
最新のNext.jsはデフォルトでTurbopackでビルドされますが、上記の書き方はTurbopackでは動かないので、Turbopackでは別の書き方をする必要があるとのことでした。
https://www.npmjs.com/package/@stylexswc/nextjs-plugin
[!IMPORTANT] Turbopack Limitation: Turbopack does not support webpack plugins (see Next.js docs). When using Turbopack, the loader only compiles StyleX code but does not extract CSS.
[!重要] Turbopackの制限事項: Turbopackはwebpackプラグインをサポートしていません(Next.jsのドキュメントを参照)。Turbopackを使用する場合、ローダーはStyleXコードのみをコンパイルし、CSSを抽出しません。
Turbopack用のプラグインもあるみたいですが、そっちだとpostcssのプラグインも別途使用する必要があるようです。
設定はシンプルな方が嬉しかったので、起動やビルドのオプションで --webpack を指定してWebpackを使うようにして解決しました。
StorybookでのBabel設定
Storybookでは、emotionがBabelに依存している関係で、SWCを使用することができませんでした。
そのため、Storybook環境では以下の対応を行いました。
Babel設定の追加
StyleX公式のドキュメントを参考に、BabelプラグインでStyleXをコンパイルする設定を追加しました:
// .storybook/main.js module.exports = { // ... babel: async (config) => { config.plugins = config.plugins || []; config.plugins.push([ "@stylexjs/babel-plugin", { dev: true, test: false, runtimeInjection: false, aliases: { "@/*": [path.join(__dirname, "../*")], }, unstable_moduleResolution: { type: "commonJS", rootDir: path.join(__dirname, ".."), }, }, ]); return config; } };
PostCSSによるCSS生成
StyleXをBabelで使用する場合、PostCSSという仕組みでコンパイルしたCSSをglobals.cssへ反映する必要があります。@storybook/addon-styling-webpackプラグインを使用してこれを実現しました。
// .storybook/main.js const { webpackFinal } = require("@storybook/addon-styling-webpack"); module.exports = { addons: [ { name: "@storybook/addon-styling-webpack", options: { rules: [ { test: /\.css$/, use: [ "style-loader", "css-loader", { loader: "postcss-loader", options: { postcssOptions: { plugins: [ require("@stylexjs/postcss-plugin")({ // コンパイル後のCSSをglobals.cssに出力 }) ] } } } ] } ] } } ] };
そして、preview.jsxでコンパイル後のCSSを読み込むようにしました。
// .storybook/preview.jsx import "../styles/globals.css"; // コンパイル後のCSSクラスが展開される
globals.cssには、ビルド時に以下のようなエイリアスを記述しておき、ここにStyleXのCSSが展開されます:
/* globals.css */
@stylex;
このアプローチにより、StorybookでもStyleXのスタイルを正しく表示できるようになりました。
JestでのBabel設定
テスト環境でもStyleXを正しくコンパイルする必要がありました。
// jest.config.ts import type { Config } from "jest"; const config: Config = { // ... transform: { "^.+\\.(js|jsx|ts|tsx|mjs)$": [ "jest-chain-transform", { transformers: [ [ "@stylexswc/jest", { rsOptions: { aliases: { "@/*": [path.join(__dirname, "*")], }, unstable_moduleResolution: { type: "commonJS", }, styleResolution: "application-order", }, }, ], [ "@swc/jest", { jsc: { transform: { react: { runtime: "automatic", importSource: "@emotion/react", }, }, }, }, ], ], }, ], }, }; export default config;
元々emotionをtransformするための設定が @swc/jest の設定として存在したので、それと共存させる必要がありました。
jest-chain-transform を使うとtransformを複数定義できるので、これを活用しました。
Docker環境での問題
ここまでローカルで動作確認が完了していましたが、最後にDockerビルドで以下のエラーが発生しました。
Error: Cannot find module '@stylexswc/rs-compiler-linux-arm64-musl'
このエラーの原因は、StyleXのSWCコンパイラがAlpine Linux(musl libc)に対応していないことでした。
Alpine LinuxとDebian系の違い
Linuxディストリビューションには様々な種類がありますが、Docker環境でよく使われるのは以下の2つです。
- Alpine Linux: 軽量で人気があるが、Cライブラリとして
muslを使用 - Debian(bookworm等): より一般的で、Cライブラリとして
glibcを使用
StyleXのSWCコンパイラはglibcには対応していますが、muslには対応していませんでした。
解決策
ビルド用のイメージをAlpine LinuxからDebian系(bookworm)に変更しました。
# Before: Alpine Linuxを使用(muslなのでStyleXが動かない) FROM node:24-alpine AS builder # After: Debian系のbookwormを使用(glibcなのでStyleXが動く) FROM node:24-bookworm-slim AS builder
ただし、実行環境のイメージは軽量化したいので引き続きAlpineを使用しています。bookwormでビルドしたものをalpineで動かしても特に問題はありませんでした。
まとめ
設定が色々と大変でしたが、CSS-in-JSの書き方を維持しながらゼロランタイムを実現できる環境が整いました。
あとはやっぱり新しい技術ってなんだかワクワクしますね。やっていて楽しかったです。
移行作業はまだ残っていますが、本腰を入れてやっていきます!
現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!