Closed24

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 なら上の問題大体気にならなそうだなと思って興味をもった

きむそんきむそん

少し格闘したけど、とりあえず以下の設定ファイルで一通り動く感じになった

.babelrc
{
  "presets": ["next/babel"],
  "plugins": ["@vanilla-extract/babel-plugin"]
}
next.config.js
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 でホットリロード設定をオーバーライドできるなにかが提供されてないか探してたけどそういうのは提供されてなさそう、代わりに

https://github.com/hashicorp/next-remote-watch

こんなのがあったので試してみる (あまり気乗りするやり方ではないけど、そういうオプションが公式で提供されてないので仕方ない、、)

きむそんきむそん

苦しいことを書かなきゃ行けないかと思ったけど、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 とかで呼ばれる層に使うような、タグに直接セレクタを当てたりするやつ

base.css.ts
import { globalStyle } from '@vanilla-extract/css';

globalStyle('html, body', {
  margin: 0
});
_app.tsx
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 できない
  • 設定は以下
必要なパッケージのインストール
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
next.config.js
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",
        }),
      ],
    })
  },
}
.babelrc
{
  "presets": ["next/babel"],
  "plugins": ["@vanilla-extract/babel-plugin"]
}
package.json
{
  "scripts": {
    "dev": "PORT=3000 next-remote-watch ./**/*.css.ts",
  },
}
このスクラップは2021/08/19にクローズされました