vanilla-extract を試す
モチベーション
CSS にも型があると嬉しいよねと思いつつ
- JS の中で CSS を書こうとする CSS in JS の思想があまり好きじゃない
- Runtime ありの CSS in JS だと、SSR/SSG 時にサーバー側でビルドしてセレクタをかき集めてみたいなことをする
- これがクライアント側とサーバー側で実行する JSX を出し分けたりしてると添え字が合わなくなって、ハイドレーションに失敗したりする(フルSSR なら問題にならないが、クライアントでしかレンダリングしない要素にセレクタを当てると起きる)
- 大体の問題は JS で動的な値をいれる必要なんてなくて、CSS 的なアプローチで説いたほうがコストが低い
辺りの理由で、CSS in JS を毛嫌いしてたけど、JSer info で Replacing Sass · Discussion #44 · Shopify/polaris · GitHub が流れてきて、ゼロランタイムの CSS in JS なら上の問題大体気にならなそうだなと思って興味をもった
vanilla-extract も CSS Modules in TypeScript +α って謳ってる
Next で試す
まずは環境づくり
Next で vanilla-extract 動かしてる例はいくつか見つかった
- vanilla-extract / next.js - CodeSandbox
- Usage with Nextjs · Issue #4 · seek-oss/vanilla-extract · GitHub
この辺り参考に設定ファイル準備する
- .babelrc
- next.config.js (書くのは webpack config)
を準備すれば良い
少し格闘したけど、とりあえず以下の設定ファイルで一通り動く感じになった
{
"presets": ["next/babel"],
"plugins": ["@vanilla-extract/babel-plugin"]
}
const { VanillaExtractPlugin } = require("@vanilla-extract/webpack-plugin")
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const { merge } = require("webpack-merge")
module.exports = {
webpack: (config) => {
return merge(config, {
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [
new VanillaExtractPlugin(),
new MiniCssExtractPlugin({
filename: "static/chunks/[contenthash].css",
chunkFilename: "static/chunks/[contenthash].css",
}),
],
})
},
}
一部パッケージを追加する必要があった
yarn add -D @vanilla-extract/css @vanilla-extract/babel-plugin @vanilla-extract/webpack-plugin // 公式で求められるやつ
yarn add -D webpack css-loader mini-css-extarct-plugin webpack-merge // 足りなくて追加したやつ
TSでかけるのでいろいろな書き方ができそうだけど、とりあえず CSS Modules と同じ感じで書いてみる
実際に適用されるスタイルはこんな感じ
${fileName}_${selectorName}__${hash}
になってる、ハッシュ値はファイルごとに共通になっているので、スコープもちゃんと閉じてる
すごく好感触だけど、ホットリロードが効かない。リロードすればスタイルは置き換わるけど、保存してもリロードが走らない
もうちょっと触ってみたらこの辺も少しおってみる
mini-css-extract-plugin でスタイル読んでるからそりゃそうだよなって感じ
style-loader を試してみたけど、SSRに対応してなかった
next-style-loader も試してみたがダメ(繋ぎの問題っぽいので、loader 周りの設定をちゃんとすればいけるかも、ちゃんと調べてない)
isomorphic-style-loader を使えば解決しそうだけど、それるのでとりあえず 今回はやらない
isomorphic-style-loader も一応ざっと追ってみたけど、必要なスタイルシートをかき集めて Provider に渡してみたいなことをする必要があってかなり苦しい、.css.ts
拡張子を watch して個別に reload 走らせるようなアプローチを取ったほうが良さげ
next の config でホットリロード設定をオーバーライドできるなにかが提供されてないか探してたけどそういうのは提供されてなさそう、代わりに
こんなのがあったので試してみる (あまり気乗りするやり方ではないけど、そういうオプションが公式で提供されてないので仕方ない、、)
苦しいことを書かなきゃ行けないかと思ったけど、npm scripts 書き換えるだけでOKだった
-"dev": "PORT=3000 next dev",
+"dev": "PORT=3000 next-remote-watch ./**/*.css.ts",
ちゃんとリロードされてくれるので、開発体験的にも悪くない
とはいえ、next のプライベートなAPIを無理やり叩いてる感じっぽいので next がこの辺りの内部仕様を変更したら next-remote-watch 側が対応するまでアップデートできなくなるみたいなリスクがある
公式でこういうオプション提供してくれるとありがたいんだけどなあ、、mdx で書くブログとかでも *.mdx
を見てリロードしたいみたいな記事・ Issueがいくつかヒットしたので需要はあると思うし
ビルドの挙動をざっとみてみると
- 単一の css ファイルにビルドされる (動的 import で chunk を分ければ別ファイルになる)
- ビルドされるセレクタとされないセレクタがある
- import していて使っている → ビルドされる
- import していて、typeof window === undefined のとき使っている → ビルドされる
- import していて、使っていない → ビルドされない
- import してない → ビルドされない
- 1セレクタでも上のビルド条件を満たしていれば(named-export でもOK)そのファイルに存在するすべてのセレクタがビルドされる
const FooComp = dynamic(async () => {
const mod = await import("../components/Foo")
return mod.Foo
})
dynamic import はどうだろ
ReferenceError: document is not defined
at findStylesheet (/Users/kaito/Playground/pg-vanila-extract/.next/server/webpack-runtime.js:157:36)
at /Users/kaito/Playground/pg-vanila-extract/.next/server/webpack-runtime.js:174:17
at new Promise (<anonymous>)
at loadStylesheet (/Users/kaito/Playground/pg-vanila-extract/.next/server/webpack-runtime.js:171:20)
at Object.__webpack_require__.f.miniCss (/Users/kaito/Playground/pg-vanila-extract/.next/server/webpack-runtime.js:187:58)
at /Users/kaito/Playground/pg-vanila-extract/.next/server/webpack-runtime.js:70:40
at Array.reduce (<anonymous>)
at Function.__webpack_require__.e (/Users/kaito/Playground/pg-vanila-extract/.next/server/webpack-runtime.js:69:67)
at exports.modules.942.dynamic_default.loadableGenerated.webpack (/Users/kaito/Playground/pg-vanila-extract/.next/server/pages/index.js:109:41)
at LoadableSubscription.load [as _loadFn] (/Users/kaito/Playground/pg-vanila-extract/node_modules/next/dist/next-server/lib/loadable.js:22:111)
うひー、、。Foo コンポーネントに vanilla-extract のセレクタ当ててるとビルドに失敗する
const FooComp = dynamic(
async () => {
const mod = await import("../components/Foo")
return mod.Foo
},
{ ssr: false }
)
なら問題なくビルドできて、非同期コンポーネントで使っているセレクタもきちんとビルドされてくれた
「split chunks のために dynamic import で読むが、初回表示でもテンプレートはレンダリングしておきたい」はできないが、dynamic import の使い所は、(初回表示では使わないのでパフォーマンス向上のために) chunk を分けたい or サーバー側で実行したくないくらいだと思うので、{ ssr: true }
で使いたい場面があまり浮かばない
基本的には問題なさそう
結構グダったけど、ハマリポイントはあるもののちゃんと設定すれば問題なく使えそうだったので vanilla-extract の API を追ってみる
style
基本的なやつ。あまり複雑にするとつらいので、これだけあれば良いのではという気がしてる
import { style } from '@vanilla-extract/css';
export const className = style({
display: 'flex'
});
疑似セレクタとかメディアクエリとか
export const container = style({
":hover": {
color: "red",
},
selectors: {
"&:nth-child(2n)": {
background: "#fafafa",
},
},
"@media": {
"screen and (min-width: 768px)": {
padding: 10,
},
},
"@supports": {
"(display: grid)": {
display: "grid",
},
},
})
._1vme6il0:hover {
color: red;
}
._1vme6il0:nth-child(2n) {
background: #fafafa;
}
@media screen and (min-width: 768px) {
._1vme6il0 {
padding: 10px;
}
}
@supports (display: grid) {
._1vme6il0 {
display: grid;
}
}
hover はルートに置くのに、&:nth-child(2n)
は selectors に置くのに違和感があったけど、selectors では &
が必須になっていた。&:hover
で書くなら selectors においても良い
💡 To improve maintainability, each style block can only target a single element. To enforce this, all selectors must target the & character which is a reference to the current element. For example, '&:hover:not(:active)' is considered valid, while '& > a' and [
& ${childClass}
] are not.
同意するし個人的には嬉しい挙動だけど、押し付けがましい感じもする
styleVariants
import { style, styleVariants } from "@vanilla-extract/css"
export const variant = styleVariants({
primary: { background: "blue" },
secondary: { background: "aqua" },
})
._1vme6il0 {
background: #00f;
}
._1vme6il1 {
background: #0ff;
}
公式に書いてあるように、<button className={styles.variant[props.variant]}>
で使うのに良さそう
型的にも勝手が良い
import { selector, variant } from 'path/to/style.css'
type Props = { kind: keyof typeof variant }
const Comp: React.VFC<Props> = ({ kind }: Props) => {
return <div className={`${selector} ${variant[kind]}`}></div>
}
globalStyle
base とか foundation とかで呼ばれる層に使うような、タグに直接セレクタを当てたりするやつ
import { globalStyle } from '@vanilla-extract/css';
globalStyle('html, body', {
margin: 0
});
import "~/styles/globals/base.css"
const MyApp = // ...
export default MyApp
body,
html {
color: red;
}
scss とかでの & > a
に当たるようなスタイルは上に書いた理由でこっちに書いてねとも書いてある
composeStyles
import { style, composeStyles } from '@vanilla-extract/css';
const button = style({
padding: 12,
borderRadius: 8
});
export const primaryButton = composeStyles(
button,
style({ background: 'coral' })
);
export const secondaryButton = composeStyles(
button,
style({ background: 'peachpuff' })
);
セレクタを結合するやつ (scss なら extend)
たしかにこれでも良いけど、個人的には CSS 的なアプローチから離れないほうが良いと思うので styleVariants を使うほうが良いと思う。まあ API 自体はわかりやすくて良さそう
createTheme, createGlobalTheme
theme と書いてあった少し困惑したけど、要は CSS 変数(カスタムプロパティ)を扱うやつ
import { createTheme } from "@vanilla-extract/css"
export const [themeClass, vars] = createTheme({
color: {
brand: "blue",
},
font: {
body: "arial",
},
})
export const selector = {
color: vars.color.brand,
}
._1vme6il0 {
--_1vme6il1: #00f;
--_1vme6il2: arial;
}
たぶん
<div className={themeClass}>
<p className={selector}>hello</p>
</div>
こう使えということだと思うけど、手元だと、themeClass のセレクタ名が一致しない(home__createTheme__1vme6il0
が正しいが、themeClass の値はhome_themeClass__1vme6il0
になってしまっている)問題があって、まともに使えそうになかった。createGlobalTheme は:root
に登録するので問題なさそうなのと、用途的にはそっちが主流だと思う
でまあ、カスタムプロパティはIE11が非対応なので、なるべく使いたくはない(切れるなら切っても良いけど、変数に依存してる部分がすべておかしくなると考えると結構消極的になる)
カスタムプロパティだと、JSから動的に変数値を切り替えたりできる(vanilla-extract でも assingVars でサポートしてそうなのであとで見てみる)ので、そういう用途がないときは普通に TS の変数を使ったほうがいいと思う
createThemeContract, assingVars
createThemeContract は変数のスキーマを作るようなもの
import { createThemeContract, style, assignVars } from '@vanilla-extract/css';
export const vars = createThemeContract({
space: {
small: null,
medium: null,
large: null
}
});
export const responsiveSpaceTheme = style({
vars: assignVars(vars.space, {
small: '4px',
medium: '8px',
large: '16px'
}),
marginRight: vars.space.small, // みたいな感じで使う
'@media': {
'screen and (min-width: 1024px)': {
vars: assignVars(vars.space, {
small: '8px',
medium: '16px',
large: '32px'
})
}
}
});
書き方が違うだけで createTheme とやってることは同じ
media クエリ下ではスキーマは同じだが値は異なるものを使う的なことができるのと、実際そういうようとで useful ですよって書いてある
CSS 変数の書き換え
CSS 変数の書き換えは JS がないとできないので、Zero Runtime ではなくなる。Runtime ありでも良いので変数書き換えるくらいはしたいよねって感じで使いたければ @vanilla-extract/dynamic を追加してこの辺を扱えるっぽい
他の CSS in JS と違って変数書き換えるだけならお行儀も良いので、JS の値でテーマ変えたいとかのモチベーションで使う分には良さそう
まとめ
- next で一通り使うことができる
- SSR 周りの問題を気にしてたけど問題なさそう
- ビルドについて
- 使われてる (import して実際に当てられてる) セレクタが存在するファイルのみビルドされる
- dynamic import でしか読んでないコンポーネントでも OK
- 使い勝手
- API もわかりやすく十分
- 型付きの CSS Modules って感じだし、型も結構しっかりつけてくれるので良い
- トラブルシュート
- next/dynamic で動的にコンポーネントを呼ぶなら
{ ssr: false }
を指定する必要あり - サーバーの起動は
next-remote-watch
でやらないとスタイルシートが hot reload できない
- next/dynamic で動的にコンポーネントを呼ぶなら
- 設定は以下
yarn add -D @vanilla-extract/css @vanilla-extract/babel-plugin @vanilla-extract/webpack-plugin
yarn add -D webpack css-loader mini-css-extarct-plugin webpack-merge
const { VanillaExtractPlugin } = require("@vanilla-extract/webpack-plugin")
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const { merge } = require("webpack-merge")
module.exports = {
webpack: (config) => {
return merge(config, {
module: {
rules: [
{
test: /\.css$/,
use: [
{
MiniCssExtractPlugin.loader,
"css-loader",
],
},
],
},
plugins: [
new VanillaExtractPlugin(),
new MiniCssExtractPlugin({
filename: "static/chunks/[contenthash].css",
chunkFilename: "static/chunks/[contenthash].css",
}),
],
})
},
}
{
"presets": ["next/babel"],
"plugins": ["@vanilla-extract/babel-plugin"]
}
{
"scripts": {
"dev": "PORT=3000 next-remote-watch ./**/*.css.ts",
},
}