🔨

Next.js + Tailwind + CSS Modulesの環境にStorybookを入れる

2022/01/25に公開

Next.jsで作ったプロジェクトにStorybookを追加する方法について。
基本的にはStorybookの公式が公開しているポストGet started with Storybook and Next.jsの通りに設定すれば良いが、CSS ModulesやPostCSSといったCSS関連の部分などはWebpackの調整が必要になる。

webpackFinalを用いたWebpackの設定

StorybookでPostCSSを利用するためのプラグインとしてstorybookjs/addon-postcssが存在する。
これはStorybookのWebpackの設定にpostcss-loaderを追加し、postcss-loaderやcss-loader、style-loaderにそれぞれ任意のオプションを渡すことができるようにするプラグインである。
しかし以下のコードのように、loaderを適用するファイル名のパターンを指定するtestプロパティを変更することができないため、Next.jsが提供する.module.cssのパターンの場合はCSS Modulesとして処理する挙動を実現することができない。
https://github.com/storybookjs/addon-postcss/blob/v2.0.0/src/index.ts#L60-L82

そのため、上記のプラグインは利用せずに、webpackFinalからwebpackの設定の変更を行う。
なお、module.rules内の順番はStorybookのバージョンやプロジェクトの状態によって変わる可能性があるため、console.logなどで確認した上で変更することが望ましい。

既存のCSSに対するルールの変更

{
  ...config.module.rules[7],
  exclude: [path.resolve(__dirname, '../src')],
},

webpackFinalの設定はStorybookのiframe内にレンダリングされるコードのみを対象とするため、既存のルールは削除してしまってもおそらく問題はないが、プロジェクトのコードが配置されるsrcディレクトリをexcludeに追加する形にしている。

グローバルなCSSに対するルールの追加

{
  test: /\.css$/,
  include: [path.resolve(__dirname, '../src')],
  exclude: [/\.module\.css$/],
  use: [
    {
      loader: 'style-loader',
      options: {
        // globalなスタイル(tailwind)を先に定義するための設定
        // https://github.com/webpack-contrib/style-loader#function
        insert: function insertAtTop(element) {
          var parent = document.querySelector('head')
          // eslint-disable-next-line no-underscore-dangle
          var lastInsertedElement =
            window._lastElementInsertedByStyleLoader

          if (!lastInsertedElement) {
            parent.insertBefore(element, parent.firstChild)
          } else if (lastInsertedElement.nextSibling) {
            parent.insertBefore(element, lastInsertedElement.nextSibling)
          } else {
            parent.appendChild(element)
          }

          // eslint-disable-next-line no-underscore-dangle
          window._lastElementInsertedByStyleLoader = element
        },
      },
    },
    {
      loader: 'css-loader',
      options: {
        importLoaders: 1,
        sourceMap: true,
      },
    },
    {
      loader: 'postcss-loader',
      options: {
        postcssOptions: {
          plugins: ['tailwindcss', ['autoprefixer', { grid: true }]],
        },
      },
    },
  ],
},

.storybook/preview.jsでインポートされるグローバルなCSSに適用されるルールとなる。
Get started with Tailwind CSSのTailwindのディレクティブを追加したmain.cssに該当するCSSをインポートする形となる。
ポイントはexclude.module.cssを対象外とすること、 style-loaderのinsertオプションの設定。
insertオプションを設定しないと、CSS ModulesのスタイルよりもTailwindのスタイルが後に追加されることで優先度が上がり、結果個別のスタイルが反映されないという事象が発生する場合がある。

CSS Modulesに対するルールの追加

{
  test: /\.module\.css$/,
  include: [path.resolve(__dirname, '../src')],
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 1,
        sourceMap: true,
        modules: {
          auto: true,
          localIdentName: '[name]__[local]___[hash:base64:5]',
        },
      },
    },
    {
      loader: 'postcss-loader',
      options: {
        postcssOptions: {
          plugins: [['autoprefixer', { grid: true }]],
        },
      },
    },
  ],
},

.moudle.cssのパターンの場合、css-loaderのmodulesオプションを有効にし、CSS Modulesとして扱えるようにする。localIdentNameは変換後のクラス名を指定する項目であり、Next.jsのビルドされたものとは異なるが、挙動自体に影響は与えない。
Next.jsの設定を適用すれば同じクラス名とすることはできると思われるが、今回はそこまで行わなかった。

svgファイルに対するルールの変更と追加

// @svgrを用いているためsvgを既存の対象から外す
{
  ...config.module.rules[8],
  test: /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/,
},
{
  test: /\.svg$/,
  use: ['@svgr/webpack'],
},

プロジェクトでは@svgr/webpackを用いているため、以下のアセットとして扱う対象からsvgを除き、個別のルールを追加する。
https://github.com/storybookjs/storybook/blob/v6.4.14/lib/builder-webpack5/src/preview/base-webpack.config.ts#L60-L68

最終的なwebpackFinal

webpackFinal: async (config) => {
  // tsconfigのpathsを用いていることに対する設定
  config.resolve.modules = [
    ...(config.resolve.modules || []),
    path.resolve(__dirname, '../src'),
  ]
  config.resolve.plugins = [
    ...(config.resolve.plugins || []),
    new TsconfigPathsWebpackPlugin(),
  ]

  config.module.rules = [
    ...config.module.rules.slice(0, 7),
    // storybookのcss設定の変更(src以下にはデフォルトの設定を適用しない)
    {
      ...config.module.rules[7],
      exclude: [path.resolve(__dirname, '../src')],
    },
    // src内のcss設定の追加
    {
      test: /\.css$/,
      include: [path.resolve(__dirname, '../src')],
      exclude: [/\.module\.css$/],
      use: [
        {
          loader: 'style-loader',
          options: {
            // globalなスタイル(tailwind)を先に定義するための設定
            // https://github.com/webpack-contrib/style-loader#function
            insert: function insertAtTop(element) {
              var parent = document.querySelector('head')
              // eslint-disable-next-line no-underscore-dangle
              var lastInsertedElement =
                window._lastElementInsertedByStyleLoader

              if (!lastInsertedElement) {
                parent.insertBefore(element, parent.firstChild)
              } else if (lastInsertedElement.nextSibling) {
                parent.insertBefore(element, lastInsertedElement.nextSibling)
              } else {
                parent.appendChild(element)
              }

              // eslint-disable-next-line no-underscore-dangle
              window._lastElementInsertedByStyleLoader = element
            },
          },
        },
        {
          loader: 'css-loader',
          options: {
            importLoaders: 1,
            sourceMap: true,
          },
        },
        {
          loader: 'postcss-loader',
          options: {
            postcssOptions: {
              plugins: ['tailwindcss', ['autoprefixer', { grid: true }]],
            },
          },
        },
      ],
    },
    {
      test: /\.module\.css$/,
      include: [path.resolve(__dirname, '../src')],
      use: [
        'style-loader',
        {
          loader: 'css-loader',
          options: {
            importLoaders: 1,
            sourceMap: true,
            modules: {
              auto: true,
              localIdentName: '[name]__[local]___[hash:base64:5]',
            },
          },
        },
        {
          loader: 'postcss-loader',
          options: {
            postcssOptions: {
              plugins: [['autoprefixer', { grid: true }]],
            },
          },
        },
      ],
    },
    // localeファイルに対するloaderの追加
    {
      test: /\.(yml|yaml)$/,
      include: [path.resolve(__dirname, 'src/locales')],
      use: [{ loader: 'json-loader' }, { loader: 'yaml-flat-loader' }],
    },
    // @svgrを用いているためsvgを既存の対象から外す
    {
      ...config.module.rules[8],
      test: /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/,
    },
    {
      test: /\.svg$/,
      use: ['@svgr/webpack'],
    },
    config.module.rules[9],
  ]

  return config
},

tsconfigでpathsを用いることで絶対パスでのインポートを行えるようにしているので、それに対応するため tsconfig-paths-webpack-plugin を追加している。

...config.module.rules.slice(0, 7)はJavaScript(TypeScript)に対するルールであり、ビルド・実行に問題がない、ドキュメント生成に必要なルールが含まれている、Next.jsの設定を流用することが困難である、といった理由からそのまま利用している。

参考までにNext.jsのプロジェクトに追加されるStorybookのビルドの設定は概ね以下の箇所で定義されている。

Next.jsのビルドの設定は以下。

他の注意点としては、Next.jsはコンパイル済みのwebpackを含んでおり、通常のwebpackがインストールされている状態ではないため、別途webpackをインストールすることが必要となる。

Discussion