💨

静的サイトをNext.jsとvanilla-extractで作成する

2022/09/27に公開

私が今まで静的サイトを作成する際には、
インデント記法で簡単にHTMLコーディングができるPugや
PostCSSを使ってCSSでNested記法を使えるようにしたりという環境構成をとっていました。
ただ、近年この構成には周辺ツールのサポートが少なかったり、それ故にエディタにプラグインを追加したりなど設定が多くなりがちであるという点からも、あまり利点が感じられなくなってきました。
そこで今回、Next.jsvanilla-extractを使って静的サイトをコーディングすることにしてみました。

Next.js と vanilla-extract を静的サイト作成に使う理由

Next.jsは動的なサイトを作るときに使うイメージがあるかもしれませんが、
next exportを使うことで静的HTMLとして書き出す機能があります。
設定も next.config.js を少し記述するだけでよく、
TypeScriptを使いつつReactでコーディングが可能です。

近年はフロントエンドでエフェクトを実現するような各種ライブラリも
ReactやVueを前提に提供されたものがメンテナンスされ続けるケースが多いので、その点でも静的サイト構築とはいえNext.jsを使う方向へシフトするというのは決して過剰というわけでもないのではと考えています。

vanilla-extractはTypeScriptでCSSを記述可能にするライブラリです。
いわゆるCSS in JSに属するライブラリですが、
他のCSS in JSライブラリとは異なり、CSS Modulesと同じくビルド後はCSSファイルが出力されるため
パフォーマンス的にも通常のCSSファイルを読み込むのと同等のパフォーマンスが発揮できます。
また、TypeScriptであるという点からIDEで使うと型補完が効くというところと、
通常のCSSを使う際はLinterにstylelintを使ってきましたが、TSの型チェックでそれをほぼ補えるようになるため
stylelintが実質不要になるという利点もあります。(全てを補えるわけではないが必要十分ではある)

その上で、Next.jsと合わせて使うことで
Next.jsがデフォルトで有効にしているPostCSS設定を自動的に適用させることが可能になります。
もし細かく設定をカスタマイズしたくなったときは postcss.config.js を追加すればいいだけなので、
webpackの設定を追加する必要もないので非常にセットアップが簡単になります。

というわけで、セットアップやメンテの簡潔さからも Next.js + vanilla-extract で静的サイトのコーディングを行うことにしました。

Next.jsと vanilla-extract のセットアップ

Next.js はこの記事を執筆時点の最新版である v12.3.1 です。
プロジェクトは以下のコマンドで簡単に作成できます。

npx create-next-app example-static-site --typescript

tsconfig.json の compileOptions 配下に以下の設定を追加し、絶対パスによるimportを出来るようにします。
Next.jsでは tsconfig.json にこれを記述するだけであとは自動的にビルド時にもこの絶対パスの設定を元にJSを生成してくれます。

{
  "compileOptions": {
     ...

    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  }
}

次に、作成したプロジェクトでvanilla-extractに必要な依存関係をインストールします。

npm i --save-dev @vanilla-extract/css @vanilla-extract/next-plugin

最後に、next.config.js を以下のように設定します。

const {
  createVanillaExtractPlugin
} = require('@vanilla-extract/next-plugin');
const withVanillaExtract = createVanillaExtractPlugin();
const { PHASE_DEVELOPMENT_SERVER } = require('next/constants');

module.exports = (phase, { defaultConfig }) => {

  /**
   * @type {import('next').NextConfig}
   */
  const nextConfig = {
    compiler: {
      removeConsole: PHASE_DEVELOPMENT_SERVER ? false : {
        exclude: ['error'],
      },
    },
    images: {
      // next export では画像の最適化が使えないので無効にする
      unoptimized: true,
    },
    reactStrictMode: true,
    swcMinify: true,
  };

  return withVanillaExtract(nextConfig);

};

これだけで必要な設定は完了です。

ちなみに、vanilla-extractのサイトには babel-plugin を使う手順もありますが、
現在のバージョンでは特にセットアップは不要です。
Next.jsでBabelを使うとSWCによるコンパイルが使えなくなって開発パフォーマンスが低下してしまうため、基本的には使わない方がよいと思います。

vanilla-extractによるCSSの基本的な記述方法

では、準備が整ったので気になるCSSの記述方法を紹介します。

vanilla-extractは基本的には以下のように記述します。
なお、ファイルの拡張子は *.css.ts となるようにします。

  • style.css.ts
export const headerStyle = {
  header: style({
    display: 'flex',
    width: '100%',
  }),
} as const;

この辺は好みの問題ですが、私の場合はnamespaceとなるオブジェクトの中に、
複数のstyleオブジェクトを記述していくようにしています。
この記述の仕方だとスタイルの関連性がわかりやすいので、オススメです。
あとはこれをTSX側で読み込んで、classNameに直接指定します。

  • index.tsx
import { headerStyle } from './style.css.ts';

...

<header className={headerStyle.header}>
  ...
</header>

CSSに指定する画像の cachebuster 対応について

CSSでbackground-imageに指定する画像に、cachebusterとなる文字列を付与したいケースがあるかと思います。
CDNに静的コンテンツを配置するケースが多いことからも、cachebusterの付与は出来る限りした方が良いでしょう。

これを実現する機能は vanilla-extract 自体には存在しませんが、
Next.jsで画像をimportすることでdigest hashを付与した画像ファイル名を取得することが可能になります。
そのため、これを利用します。

import headerBackImage from '@/assets/images/header_background.png';

export const headerStyle = {
  header: style({
    display: 'flex',
    width: '100%',
    backgroundImage: `url(${headerBackImage.src})`,
  }),
} as const;

このように記述すると、以下のようなCSSが出力されます。

.style__srvjuv0 {
  display: flex;
  width: 100%;
  background-image: url(/_next/static/media/image_header_background.e5f0b45e.png);
}

これで cachebuster についても問題なく付与することが可能となりました。

CSSのNormalizeやMedia Queriesでよく使う条件の記述方法について

normalize.cssやsanitize.cssでCSSを正規化した上で利用をすることが多いかと思います。
これについては、vanilla-extractで無理に処理しようとはせず、
Next.jsの _app.tsx で対象のCSSを import することで対処します。

私は sanitize.css を使うことが多いのでこれを追加する例を紹介します。

npm i sanitize.css
  • _app.tsx
import 'sanitize.css';

...

次に、Media Queriesの指定についてですが、
vanilla-extractではCSSの Custom Media Queries を使った Media Queries の指定にはそのままでは対応していません。
PostCSSの設定を追加すれば Custom Media Queries を使えるようにはなりますが、タイプセーフな記述ができるわけではないので微妙なところですし、何よりPostCSSの設定をそれだけのためにカスタマイズしたくありません。

これについてはTypeScriptである点を利用し、
単によく使う条件式は定数化しておいて、Media Queriesを記述するときに
その定数を使って条件を指定する方法がおすすめです。
以下に例を紹介します。

  • media.ts
export const mediaQueries = {
  tablet: 'screen and (min-width: 768px)',
  desktop: 'screen and (min-width: 1024px)',
} as const;
  • index.tsx
import headerBackImage from '@/assets/images/header_background.png';
import { mediaQueries } from '@/styles/media';

export const headerStyle = {
  header: style({
    display: 'flex',
    width: '100%',
    backgroundImage: `url(${headerBackImage.src})`,
    '@media': {
      [mediaQueries.tablet]: {
        display: 'block',
        maxWidth: '960px',
      },
    }
  }),
} as const;

公式サイトをみると、以下のような指定方法もあるようです。

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

const responsiveStyle = ({ tablet, desktop }) => ({
  '@media': {
    'screen and (min-width: 768px)': tablet,
    'screen and (min-width: 1024px)': desktop
  }
});

const container = style([
  {
    display: 'flex',
    flexDirection: 'column'
  },
  responsiveStyle({
    tablet: { flex: 1, content: 'I will be overridden' },
    desktop: { flexDirection: 'row' }
  }),
  {
    '@media': {
      'screen and (min-width: 768px)': {
        content: 'I win!'
      }
    }
  }
]);

ただこのケースでは tablet, desktop の双方を常に記述しなければならないのと、
tabletとdesktopの型が any となってしまい、タイプセーフにはならないのであまりお勧めできません。

これで、Media Queriesの指定も問題なくできるようになりました。

スタイルごとにVariantsを指定する方法

BEMでいうところのModifier、RSCSSでいうところのVariantsといった
あるスタイルについて一部のプロパティだけを変えたい、ということがあります。

<BEMのModifierの例>

.headerArea {
  display: 'flex';
  width: '100%';

  &__text {
    display: inline;
    text-align: center;
    color: #000;

    &--white {
      color: #fff;
    }
  }
}
<header class="headerArea">
  <p class="headerArea__text">normal text</p>
  <p class="headerArea__text headerArea__text--white">white text</p>
</header>

<RSCSSのVariantsの例>

.headerArea {
  display: 'flex';
  width: '100%';

  & > .text {
    display: inline;
    text-align: center;
    color: #000;

    &.-white {
      color: #fff;
    }
  }
}
<header class="headerArea">
  <p class="text">normal text</p>
  <p class="text -white">white text</p>
</header>

vanilla-extractでこれに相当する書き方をするには selectors を使います。

selectorsの使い方

記述方法は単純で、以下のように clsx と合わせて利用します。

export const headerStyle = {
  headerArea: style({
    display: 'flex',
    width: '100%',
  }),
  text: style({
    display: 'inline',
    color: '#000',
    selectors: {
      '&.-white': {
        color: '#fff',
      },
    },
  })
}
<header className={headerStyle.headerArea}>
  <p className={headerStyle.text()}>normal text</p>
  <p className={clsx(headerStyle.text, '-white')}>white text</p>
</header>

ほぼRSCSSのVariantsそのままですが、同じことが実現できました。
但しスタイルの名称として文字列を直に記述しているためタイプセーフというわけではなく、ただの文字列としての扱いになってしまうところは気になる方も多いかと思います。

他にも、似たような機能を実現する方法としては、recipes があります。
こちらはスタイルに記述したVariantsしか指定ができないので、よりタイプセーフな記述を目指すならこちらの機能を利用してみるのがおすすめです。

recipesの使い方

まずは以下のコマンドで recipes のパッケージをインストールします。

npm i --save-dev @vanilla-extract/recipes

次に、スタイルを記述します。

<vanilla-extractの例>

export const headerStyle = {
  headerArea: style({
    display: 'flex',
    width: '100%',
  }),
  text: recipe({
    base: {
      display: 'inline',
      color: '#000',
    },
    variants: {
      color: {
        white: {
          color: '#fff',
        },
      },
    },
  })
}
<header className={headerStyle.headerArea}>
  <p className={headerStyle.text()}>normal text</p>
  <p className={headerStyle.text({ color: 'white' })}>white text</p>
</header>

baseに基本となるスタイルを記述し、variantsに上書きをしたいスタイルを記述し、これをTSX側で指定します。
recipesはコンポーネントのpropsからテーマを与えて、それによってスタイルを変えたい場合には非常に適してそうです。

selectors の利用例と比較すると記述量は少し多くなってしまいますが、
variantsに名称が必須で、かつスタイルに定義した色(上記の例ならwhite)しか指定ができなくなるのは良い点です。
また、明確に何のプロパティを変更しようとしているのかがわかりやすくなるのもとても良さそうに思えます。

このように、vanilla-extractはBEMやRSCSSを使ってきたような方が使っていたテクニックが関数化されているので、その点は非常に良いと思います。
ただ一方で、次のような問題を現状は抱えています。

vanilla-extractのCSS変数を利用時にディレクトリ構造によって参照エラーになる問題がある

【追記 2022/09/29】
なんとこの記事を公開してから数日で修正いただけました。
以下のリリースノートにある通り、@vanilla-extract/next-plugin を 2.1.1 以降にすることで改善されます!
https://github.com/vanilla-extract-css/vanilla-extract/releases/tag/%40vanilla-extract%2Fnext-plugin%402.1.1

まとめ

というわけで、Next.jsとvanilla-extractを利用した静的サイト作成の構成について紹介しました。
静的サイトといっても規模や内容は様々ですが、ちょっとした動きやエフェクトを利用したサイトを作成する上では結局JSをゴリゴリ使うことになるケースがあるかと思います。
そのような場合は最初からReactを使っておく方が作りやすいケースも多いですので、もし当てはまりそうな場合は参考にしてみてください。

Discussion