Next.js + Tailwind + CSS Modulesの環境にStorybookを入れる
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として処理する挙動を実現することができない。
そのため、上記のプラグインは利用せずに、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を除き、個別のルールを追加する。
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のビルドの設定は概ね以下の箇所で定義されている。
-
https://github.com/storybookjs/storybook/blob/v6.4.14/app/react/src/server/preset.ts
- reactに対応する設定
-
https://github.com/storybookjs/storybook/blob/v6.4.14/lib/builder-webpack5/src/preview/base-webpack.config.ts
- フレームワークによらず共通で適用される設定
Next.jsのビルドの設定は以下。
他の注意点としては、Next.jsはコンパイル済みのwebpackを含んでおり、通常のwebpackがインストールされている状態ではないため、別途webpackをインストールすることが必要となる。
Discussion