Open46

新しくnext.jsで作るプロダクトで CSS Modules か CSS in JS か決める調査や思考のログ

現状

社内の他のプロダクトは styled-components/astroturf を使っている
next.js * styled-componentsのプロダクトが既に1つある
今回、next.jsで新たなプロダクトを1つ作る
next.jsが組み込みでサポートしている最適は CSS Modules なのでこのままCSS in JSを使うか悩んでいる状態
また、CSS in JSにするとしても zero runtimeなものにするか悩んでいる状態

前提

  • 実行速度は可能な限り上げたいものの、作ろうとしているのは実行速度を極力 上げる必要があるアプリケーションではない
  • チームメンバーは今のところCSS in JSの知見しかない
  • よほどメリットが上回らない限り、なるべくwebpackやbabelやpostcssなどの設定を書きたくない

感じていた課題

書きやすいが読みづらい

幾つかの記事で述べられているように、ただスタイルを付与して別名を付けたコンポーネントなのか、処理上/atomic design上の意味のあるReact Componentなのか判別しづらい

styled-componentsをnext.js SSRで動かすための対処

https://medium.com/swlh/server-side-rendering-styled-components-with-nextjs-1db1353e915e

のように _doucment.tsx に一手間挟む必要がある

CSS in JSとstylelintの相性の悪さ

2020年秋頃だと babel設定が特定の条件を満たしているときに
https://github.com/stylelint/stylelint/issues/4732 のような問題が起きるので "stylelint": "13.3.0" に固定する必要があった

また、幾つかのルールを無効化する必要があった

  rules: {
    'plugin/no-low-performance-animation-properties': true,
    'value-keyword-case': null, // CSS in JSのcamelCase変数が壊れる
    'function-name-case': null, // CSS in JSで使うfunctionが壊れる
    'declaration-empty-line-before': null, // CSS in JSで宣言なくても怒る
  },

みたいな回避をする必要があった

また、 const a = styled... 同士が空白行で空いていないとstylelintがかからないことがあるという問題も観測した

このようなCSS in JS * stylelint の怪しい挙動に付き合うのが嫌だったし、 問題はまだ山積みのように見える

sass-extract が deprecated なlib-sassに依存

開発事情としてSCSSがあり、SCSSに定義したデザイン用定数(breakpointや色やフォントサイズなど)をCSS in JSで使うために sass-extract を使っているのだが、これがnode-sass(libsass)に依存していて、 lib-sassはもうdeprecatedである
READMEにdart-sassを使えと書いてある

ちなみに next.js は node-sass と sass の両方が入っているとwarnを出す

その状態がちょっと嫌だった

Cybozuの

styled-components を使う上で定めたのは次のようなルールです。

styledはコンポーネントを引数に取ってスタイルをあてる以外の使い方をしない
styledは同一ファイル内で使用してファイルを分割しない

は良さそうなんだけど、lintやformatterで強制できないルールを設けたくないなという気持ちは少しあるし、renderする構造が大きくなったらスタイルが書きづらくならないか気になる

パフォーマンスについて言及している記事には、「多くの場合はCSS-in-JSのコストは知覚できないが、コンポーネントツリーが巨大な場合は無視できなくなるかもしれない」というようなことが書いてあるので、ほとんどの場合は気にすることではない

https://www.infoq.com/news/2020/01/css-cssinjs-performance-cost/

CSS Modulesの利点

  • CSSコーダーにとっては書き味がほぼ変わらない
  • React Componentでないものはhtmlタグがそのままなので読みやすい
  • パフォーマンスはCSS in JSよりいい

CSS Modulesの欠点・懸念

astroturf * next.js 触った感想

  • css で書くと(おそらく個別の)cssファイルにextractされる ようなことが書いてあり、そこがlinariaよりパフォーマンス上 優位に思える
  • 公式のexample を最新にしたりして試した
    • styles.chunk.*.css みたいなファイルが1つできて、全ページでそれが <link rel="preload" href="/_next/static/css/styles.54eb5106.chunk.css" as="style"> のように読み込まれていた
  • Warning: Built-in CSS support is being disabled due to custom CSS configuration being detected. が出る
  • cssstyled でタグにスタイルつける書き方より書きやすいのか不明。React Componentと判別が難しい問題はなくなる
  • まだbetaなのが不安
  • シンプルなツールなので自分でたくさんwebpack設定しなきゃいけない

2021/3/30 追記

以下のような感じに書けば普通に出来たし、next.jsの暗黙で持ってるpostcssなども効いたので特に設定が増えることもなかった

next.config.js
module.exports = {
  webpack: (config) => {
    config.module.rules.push({
      test: /\.(tsx|ts|js|mjs|jsx)$/,
      use: [
        {
          loader: 'astroturf/loader',
          options: {
            extension: '.module.css',
            enableCssProp: true,
            enableDynamicInterpolations: true
          }
        }
      ]
    });

    return config;
  }
};

cssもページごとに分かれていた

astroturfがlinariaと違うのはcomposesやscssをサポートしている(というかライブラリ的には何もせずパスする思想なので使える)のでclassnameの嵐にならずに書けることである
最高に思えるが、これの唯一の懸念としては、(linaria * next.jsもそうだが)結局css-loaderのmodule: trueに依存してnext.jsのcss最適化に乗っけていることか

astroturfほぼ完全に求めているものなんだけど、内部的にcomposesを使ってたりしてcss-modulesに完全依存しているのが、css-modules依存したくない前提だと本末転倒感あってウーン
linariaでnext-linaria 使って拡張子だけmodule.cssにしてnext.jsに食わせているのと依存レベルがちょっと違う

linaria * next.js 触った感想

  • https://zenn.dev/meijin/articles/a8163992c8e845fb382f#追記 には 全部同じ CSS ファイルに出力されるようです とあるが、 この記事 に従って next-linaria を使って 公式のexample を若干書き換えてみると以下のようにcssがページごとに分割されることが確認できた
    • 共通で使っているComponentのcssは分離してくれず、それぞれに同じ内容が入っていた
    • .linalia-cacheには *.linaria.module.css がページやコンポーネントごとに別々に生成されていた
    • .linalia-cacheにはvendor prefixが付いてるのに .next/static/cssの方にはついてない…なんでや
    • HTMLでは <link rel="preload" href="/_next/static/css/bf12abf93e56e542a67f.css" as="style"> のように読み込めていた
├ ○ /fuga                                                      418 B          67.3 kB
├   └ css/bf12abf93e56e542a67f.css                             180 B
└ ○ /hoge                                                      415 B          67.3 kB
    └ css/edee91bf344d91b9abfd.css  
  • ページごとに分割される
    • 大きなCSSになると思われる。styleがjsの中に入っているのとどちらがパフォーマンス上よいのか不明
    • next.jsだとjsもpreloadで読まれているのでloadタイミング的な優位は特に無い。CSSOMツリーの構築とか、エンジンの解釈は早くなるかもしれない
    • CSS Modules使ったときの出力と同じだから多分next.jsにおいて最適なのだろう

.linalia-cacheにはvendor prefixが付いてるのに .next/static/cssの方にはついてない…なんでや

以下の要因で勘違いしており、 .browserslistrcに 'ie10', 'firefox 10', 'safari 8' みたいなのを入れたら vendor prefixがちゃんとついていることが確認できた

  • next.jsデフォルトのbrowserslist設定は結構新しいので滅多にvendor prefixがつかない
  • .linaria-cacheはlinariaの依存しているstylisでprefixつけたlinariaの出力であって、.next/static/css には使われないっぽいことを分かっていなかった
    • そしてlinariaの出力はbrowserslistを見ずに雑にカバーするように大量のvendor prefixがついているぽい?
  • .next/static/css はnext.jsがCSS in JSコードの方を直接postcss-preset-env経由でautoprefixerをかけていることを分かっていなかった
  • 古いブラウザを指定するのを試す方法としてやってた、package.jsonのbrowserslist指定がなぜかpostcssに反映されない!
    • .broserslistrcに書くとcss出力に反映されたが、気づくまで時間がかかってハマった

冒険せずにCSS Modulesと(おそらく)同じ最適化の出力が得られるなら、これが良い気がしている
あとは実装中に特有の不具合を踏まなければ…

動的な値について

  • styled ならPropsが渡せる
  • css なら custom propertyが使える
    • Props方式に比べて型が弱いので style={{ '--box-size': isSuccess ? '40px' : '10px' } as never} のように潰す必要がある

生成されたCSSは両方custom propertiesで実装されていて、webpackが複雑に生成したjsの部分はよくわからないが、jsがそれを変えることでcssに反映されるっぽい

そういえば styled-jsx ってなんで使われてる例が目立たないのか

next.js組み込みのはずで、パフォーマンスも考えられているはずなのに、なぜか最近の記事であまり選定対象として議論されてなくて、存在感がないように感じる

CSS Modules * next.js 触った感想

  • 設定なしでvendor prefixもついて望む最適化が得られるのは非常に楽
  • ページごとに1枚に分割されていて、そこにそのページのためのスタイルが全部入っていて、preloadもちゃんとあるのはlinariaと同じ結果
    • linariaもそうだが、共通 componentのchunkが分割される条件(サイズが肥大したときとか?)があるのかまでは不明
┌ ○ /                                                          702 B            68 kB
├   └ css/f4803cd91d8adae24141.css                             222 B
└ ○ /hoge                                                      495 B          67.8 kB
    └ css/9ec4e8d80e4624b73801.css                             176 B
  • clsxによる条件分けとVSCode上での typescript-plugin-css-modules による補完がうまく動いているので、書きにくくはない

大量のファイルがあり色んな所からcomposesすると順序不定問題にぶち当たるのは前職で苦しんだので、そこの不安を抱えたり、解決策を考えながら開発するのもなぁ CSS in JSの方が楽なんだよなぁという感じ

暫定結論

linariaにしてみる

理由

  • (多分)CSS Modules使ったときの出力と同じなのでパフォーマンスがよさそう
  • 駄目だったらstyled-componentsに戻しやすい
  • あんまり設定が必要ない
  • チームの既存技術スタック的にCSS Modulesを採用する冒険はやっぱり避けたい

その他

styled にするか css にするか
また、 styled のときにコンポーネントにだけ使う縛りにするか
これはちょっと静的に縛りづらいので結論が出せない
(emotionだと書き方を設定できるのかな)

css はライブラリによって微妙に渡し方違うので移行時の手間になりそうだし、メッチャクチャに困ってるわけじゃないので普通にstyledをいっぱい書く方針で良いんじゃないかと思えてきた

stylelintの課題も、まぁ…我慢できる
sass-extractはsassを共有変数定義のために使わない方針にすれば何とかなる

既に1プロダクトで使ってるastroturfが同じ出力ならlinariaよりsimple寄りということもあり採用したかったんだけど、 @zeit/next-css 起因なのかbuilt in CSSのwarnが出る上に1枚しか出力されないし、next-linariaみたいなのを自分で書いてメンテする気力と知識がまだないので見送る

linaria使ってて気づいた(他のライブラリも)の機能不足な点
styled-componentsやemotionでできる「 csscssstyled にmixin,compositionする機能」がない

例: https://emotion.sh/docs/composition

また、以下のリンクを見ると

https://github.com/callstack/linaria/issues/244
https://github.com/callstack/linaria/pull/615

反対意見もありサポートされる気配がしばらくない
なので

https://qiita.com/kondei/items/6032bd086f52ccf1d297#3-mixinする

のような隠蔽ができずclassnameでmixinするしかなさそう

css断片を css を用いず文字列として定義して、それを ${} で埋め込む方法でもうまく行った
静的なcssであればそれでもいいかもしれないが、ちょっと逸脱したハック感が強い

決定的な方法ではないが、優先度をアンパサンドで上げる 方法はそこそこ一般的なので、だいたい優先することが多い断片に適用するのはよさそう

    && {
      font-size: 16px;
      font-weight: 600;
      line-height: calc(26 / 16);
    }

styled と className と style の併用で優先スタイルを当てる手もあるが、なるべく避けて1つで統一したい
classnameが並んでるときの優先度がdeterministicでない問題はもともとcssにつきものだし、compositionで上書きしたいスタイルはそんなに多くないことを考えると、ノー対策でしばらく様子をみたい

css modulesほど思想に一貫性があって、かつ使われてしまったものが消えるとは思えないので、css loaderがサポートやめてもcss modules loaderみたいな感じに分離して生き残るんじゃないかと思ってはいる

https://zenn.dev/kondei/scraps/1058662429ad9b#comment-7d514f82e121dc
でも思ったけど、next.jsでcss最適化に乗せたいと思うと、何のno runtime CSS in JSを使っても結局css-loaderのcss modules機能に依存することになるのが何とも将来の不確定要因になってしまっている

そこを絶対避けたいならstyled-componentsとかemotionとかを使うことになる

CSS Modulesになくてno runtime CSS in JSにある利点は、 CSS custom properties等を利用した動的な値を使うことが、(ライブラリが連携を隠蔽していることで)極めてしやすいことである

『styledつけただけのComponentなのかどうか判別しづらい問題』は namespace Styledconst Styled = {} で囲えば解決しそう
namespace使うと全部export constしなきゃいけないので後者が良さそう

constで囲う方式は

const Styled = {
  Hoge: styled.div``,
  Fuga: styled(Styled.Hoge)``,
}

みたいに兄弟を参照して拡張できないのでこれも完璧ではない

zero runtimeのCSS in JSでcss-modulesに依存せずdeterministicにstyleが決まるcompositionがほしい件についての調査

前提を繰り返すが、classnameの羅列はdeterministicでないし、composesも別ファイルからだとdeterministicでない
scss のmixinは求めているものだけど、以下の理由でSCSS in JSに定義してもmixin困難

  • astroturf: 同じファイル内だろうとcssブロックは全部自動で命名される別のファイルになるのでimportできない
  • linaria: css出力はjsファイル単位だが、mixinが出力されない

妥協してscssの @extendconst css の結果のclassnameを指定できないか?

linaria

mixin使うときと同じで、next-linariaのここを変更して .module.scss を吐くようにすれば使える

astroturf

const css の中身はスペース区切りのclassnameが2つ取れるのでそのままだと無理
また、適当に文字列を分割して@extendしてもloaderの順番の問題でclassnameがまだ定まっていないのかscss errorが出て無理だった


どちらも、使えてもそうまでして使いたくない

結論

  • styled-components使う
    • やっぱりfragmentをdeterministicにcompositionできるのが強い
    • 別にemotionでもいいが既存技術スタックと合わせる

所感

linaria, astroturf, (s)css modulesはどれも一長一短でかゆいところに手が届かなかった
linariaがcompositionに対応さえしてくれれば…

  • linariaでも良いかもしれないケース
    • non deterministicなclassname羅列方式でも許容できるなら
  • astrturfでも良いかもしれないケース
    • non deterministicなclassname羅列/composes方式や、内部的に完全にcss modulesに依存していることが許容できるなら
  • css modulesでも良いかもしれないケース
    • css modulesへの依存と、動的な値のつなぎにくさを許容できるなら
    • 更にscss使っていいなら、composesの代わりにscss mixinを使えばdeterministicにできる

最近 next.js + MUI でstyled-components がうまく動かない状態 になっていて、docに

Warning: Using styled-components as an engine at this moment is not working when used in a SSR projects. The reason is that the babel-plugin-styled-components is not picking up correctly the usages of the styled() utility inside the @mui packages. For more details, take a look at this issue. We strongly recommend using emotion for SSR projects.

と書かれたりして不穏な感じになっている
MUIと合わせるならemotionが無難そう

stylelint/postcss-css-in-js がずっとバグめいていて、ついにライブラリごとに分割路線になりそうだが動きが遅いのも、CSS in JSのString Stylesの保守性面で不安要素がずっとつきまとっている

https://github.com/stylelint/postcss-css-in-js/issues/225
ログインするとコメントできます