Closed22

NextJS11-TypeScriptにStorybook6.4を導入する。

Yutaka FujiiYutaka Fujii

作業ログとしてgithubを記述しておく。

cssmodule-sass-storybook-sample

create-next-appでNextJSを導入する。

yarn create next-app --typescript cssmodule-sass-storybook-sample
yarn create v1.22.10

依存関係

  "dependencies": {
    "next": "11.0.1",
    "react": "17.0.2",
    "react-dom": "17.0.2"
  },
  "devDependencies": {
    "@types/react": "17.0.14",
    "eslint": "7.30.0",
    "eslint-config-next": "11.0.1",
    "typescript": "4.3.5"
  }
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info  - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5

立ち上がることを確認

Yutaka FujiiYutaka Fujii
yarn storybook

上記で起動すると下記エラーがでる。

Error: Cannot find module 'webpack/lib/util/makeSerializable.js'

このエラーはWebpack5を入れれば解決する。
Storybook build fails with "Cannot find module 'webpack/lib/util/makeSerializable.js" after upgrading storybook packages to 6.3.0

yarn storybook

yarn run v1.22.10
$ start-storybook -p 6006
info @storybook/react v6.3.4
~~~~~
info => Using implicit CSS loaders
info => Using default Webpack5 setup

Webpack5で動いていることが確認できる。

Yutaka FujiiYutaka Fujii

Sassを使っていないのにも関わらず、起動したStorybookで
Addon controls: Control of type color only supports string, received "undefined" insteadとエラーが出ているが気にしない。

ここまでの作業で一旦保存する。
Github

Yutaka FujiiYutaka Fujii

いよいよStorybookのSassおよびCSS moduleに取り組む。
StroybookはCSS-in-JS推しなのに対し、NextJSはCSS module推しなのが原因だと思われる。

CSS moduleにするにあたり、下記のファイルを整形しようとすると初手でTypeScriptエラーになる。

// storis/Button.stories.tsx
export default {
  title: 'Example/Button',
  component: Button,
  argTypes: {
    backgroundColor: { control: 'color' },
  },
} as ComponentMeta<typeof Button>;

エラー内容は下記のとおり、
TS4082: Default export of the module has or is using private name 'ButtonProps'.
だからexportすれば大丈夫。

// storis/Button.tsx
export interface ButtonProps 
Yutaka FujiiYutaka Fujii

button.cssをButton.module.scssに名前変更する。
それに伴いclass名も全部変更する。

.storybook-button {}
.storybook-button--primary {}
.storybook-button--secondary {}
.storybook-button--small {}
.storybook-button--medium {}
.storybook-button--large {}
.storybookButton {}
.storybookButtonPrimary {}
.storybookButtonSecondary {}
.storybookButtonSmall {}
.storybookButtonMedium {}
.storybookButtonLarge {}

css moduleに関してはあまり詳しくないのでよくわからないが、色々やってこうやったら動いたので書いていく。

Yutaka FujiiYutaka Fujii

多分だけどこんな書き方ができる。

// Button.tsx
<button
 type="button"
 className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
 style={{ backgroundColor }}
 {...props}
>
{label}
</button>
// Button.tsx
<button
type="button"
className={
 clsx(
  styles.storybookButton,
  size === "small" && styles.storybookButtonSmall,
  size === "medium" && styles.storybookButtonMedium,
  size === "large" && styles.storybookButtonLarge,
  primary ? styles.storybookButtonPrimary : styles.storybookButtonSecondary
 )
}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
Yutaka FujiiYutaka Fujii

yarn storybookを実行してもErrorが出て失敗する。

ModuleNotFoundError: Module not found: Error: Can't resolve 'stories/Button.module.scss' in '/Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/stories'
意訳:「stories/Button.module.scss」なんていうファイルは見つからない。

Parsed request is a module
using description file: /Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/package.json (relative path: ./stories)
意訳:ちょっと何言ってるかわからないですね。package.jsonにもありませんし......。

looking for modules in /Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/node_module /Users/yutakaf/Desktop/git/cssmodule-sass-storybook-sample/node_modules/stories doesn't exist
意訳:node_modulesの中にも無いですよ。

// .storybook/main.js

module.exports = {
  "stories": [
    "../stories/**/*.stories.mdx",
    "../stories/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  "core": {
    "builder": "webpack5"
  }
}

上記にCSS moduleの設定がないからである。(それ以前にTypeScriptの"baseUrl": "."が関係していると思う。)

Yutaka FujiiYutaka Fujii

baseUrl対応

const path = require('path')

module.exports = {
  "stories": [
    "../stories/**/*.stories.mdx",
    "../stories/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  "core": {
    "builder": "webpack5"
  },
  webpackFinal: async (config) => {
   config.resolve.modules = [
    ...(config.resolve.modules || []),
    path.resolve(__dirname, "../"),
   ];
   return config;
  },
}

上記のように設定すると
意訳:loaderの設定が間違ってるんじゃない?と言われる。

ModuleParseError: Module parse failed: Unexpected token (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders

ちなみに、この人のいうようにやってもうまく行かなかった。
なんかtsconfig-paths-webpack-pluginがうまく動作してくれない。
詳しい人教えてください!

const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  "stories": [
    "../stories/**/*.stories.mdx",
    "../stories/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  "core": {
    "builder": "webpack5"
  },
  webpackFinal: async (config) => {
    config.resolve.plugins.push(new TsconfigPathsPlugin({}));
    return config;
  },
}
Yutaka FujiiYutaka Fujii

loader対応

yarn add -D @storybook/preset-scss css-loader sass-loader

const path = require('path')

module.exports = {
  "stories": [
    "../stories/**/*.stories.mdx",
    "../stories/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    {
      name: "@storybook/preset-scss",
        options: {
        cssLoaderOptions: {
        modules: true,
    },
    scssLoaderOptions: {
     sourceMap: true,
    },
   },
  },
 ],
  "core": {
    "builder": "webpack5"
  },
 webpackFinal: async (config) => {
  config.resolve.modules = [
   ...(config.resolve.modules || []),
   path.resolve(__dirname, "../"),
  ];
  return config;
 },
}

yarn storybookで確認する。

Yutaka FujiiYutaka Fujii

結局使わなかったtsconfig-paths-webpack-pluginなどは削除しておく。
Github

ここまで調べるのにほぼ丸一日かかった。
時間かけすぎかもしれないが、webpack周りなどの動作や、仕組みがわかってとても勉強になった。
なにかあれば教えてください。

Yutaka FujiiYutaka Fujii

そもそも最初に困ったのは下記エラー

Error: Module build failed (from ./node_modules/sass-loader/dist/cjs.js): TypeError: this.getOptions is not a function

このときwebpackのバージョンがVersion: webpack 4.46.0で起動していた。
upgradeを試すupgradeを試してからいろいろな問題にぶちあたった。

・sass-loaderがversion 11からwebpack5からのサポートになった問題
・TypeScriptのbaseUrl問題
・Loader周りの問題
・scssでcss moduleってどうやって使うんだろう問題
・clsxって何?問題

など。

感想としてはStorybookもNextJSもwebpack周りがうまいこと隠れているからこそ、その配下の設定をいじろうとか、バージョンの互換性からずれたことをやろうとすると、結構大変なんだなぁということ。

バージョン合わせは飽きたので、明日はコードを書きたい。

Yutaka FujiiYutaka Fujii

storybookではnext/imageがうまく読み取れていないようだ。

<img alt="dashboard" src="/_next/image?url=%2Fuser%2Favatar.jpeg&amp;w=96&amp;q=75" decoding="async" srcset="/_next/image?url=%2Fuser%2Favatar.jpeg&amp;w=48&amp;q=75 1x, /_next/image?url=%2Fuser%2Favatar.jpeg&amp;w=96&amp;q=75 2x" style="position: absolute; inset: 0px; box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;">

storybook上で画像を読み込みたい場合には下記のようにpreview.jsに設定を読み込ませる。

// .storybook/preview.js

import * as nextImage from 'next/image'

Object.defineProperty(nextImage, 'default', {
  configurable: true,
  value: (props) => <img {...props} />,
})

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}
// package.json
    "storybook": "start-storybook -s ./public -p 6006",

これでyarn storybookで呼び出せる。

<img src="/user/avatar.jpeg" alt="dashboard" width="40" height="40">

参考
https://xenox.dev/next-image-with-storybookjs/

Yutaka FujiiYutaka Fujii
// package.json
    "storybook": "start-storybook -s ./public -p 6006",

info => Loading presets
info => Serving static files from ././public at /

// package.json
    "storybook": "start-storybook -s public -p 6006",

info => Loading presets
info => Serving static files from ./public at /

上二つ、どちらでも問題ないが、ログで表示されるパスが気持ちわるいので、自分は下でいこうと思う。

    "storybook": "start-storybook -s public -p 6006",
Yutaka FujiiYutaka Fujii

Test周り storiesの作成

ComponentMeta

For the common case where a component's stories are simple components that receives args as props
コンポーネントのストーリーが、引数をプロップとして受け取るシンプルなコンポーネントである場合、一般的にはexport default { ... } as ComponentMeta<typeof Button>;

ComponentStory

For the common case where a story is a simple component that receives args as props
ストーリーが、引数をプロップとして受け取るシンプルなコンポーネントであるという一般的なケースでは、const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />

サンプル

// Button.stories.tsx

import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'

import { Button } from './Button'

export default {
  title: 'Example/Button',
  component: Button,
  argTypes: {
    backgroundColor: { control: 'color' },
  },
} as ComponentMeta<typeof Button>

const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />

export const Primary = Template.bind({})
Primary.args = {
  primary: true,
  label: 'Button',
}

export const Secondary = Template.bind({})
Secondary.args = {
  label: 'Button',
}

export const Large = Template.bind({})
Large.args = {
  size: 'large',
  label: 'Button',
}

export const Small = Template.bind({})
Small.args = {
  size: 'small',
  label: 'Button',
}
// Button.module.scss

.storybookButton {
  font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-weight: 700;
  border: 0;
  border-radius: 3em;
  cursor: pointer;
  display: inline-block;
  line-height: 1;
}
.storybookButtonPrimary {
  color: white;
  background-color: #1ea7fd;
}
.storybookButtonSecondary {
  color: #444;
  background-color: transparent;
  box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybookButtonSmall {
  font-size: 12px;
  padding: 10px 16px;
}
.storybookButtonMedium {
  font-size: 14px;
  padding: 11px 20px;
}
.storybookButtonLarge {
  font-size: 16px;
  padding: 12px 24px;
}
// Button.tsx
import React from 'react';
import clsx from "clsx"
import styles from 'stories/Button.module.scss';

export interface ButtonProps {
  /**
   * Is this the principal call to action on the page?
   */
  primary?: boolean;
  /**
   * What background color to use
   */
  backgroundColor?: string;
  /**
   * How large should the button be?
   */
  size?: 'small' | 'medium' | 'large';
  /**
   * Button contents
   */
  label: string;
  /**
   * Optional click handler
   */
  onClick?: () => void;
}

/**
 * Primary UI component for user interaction
 */

export const Button = ({
  primary = false,
  size = 'medium',
  backgroundColor,
  label,
  ...props
}: ButtonProps) => {
  return (
<button
	type="button"
	className={
		clsx(
			styles.storybookButton,
			size === "small" && styles.storybookButtonSmall,
			size === "medium" && styles.storybookButtonMedium,
			size === "large" && styles.storybookButtonLarge,
			primary ? styles.storybookButtonPrimary : styles.storybookButtonSecondary
		)
	}
	style={{ backgroundColor }}
	{...props}
>
	{label}
</button>
  );
};
Yutaka FujiiYutaka Fujii

引数なしの場合はどうするのかと探していたら公式のやり方でできた。
この型が厳密に何を示しているかは調査中(複数要素があり、確認しているところ)だが、storiesの書式にエラーがあると通知してくれるみたい。

https://storybook.js.org/docs/react/writing-stories/args

// Original/Tab.tsx

import { Meta, Story } from '@storybook/react'
import Tab from 'components/dashboard/Tab'

export default {
  title: 'components/dashboard/Tab',
  component: Tab,
} as Meta

/**
 * 引数なしの場合 Meta & Story
 * @param {() => JSX.Element} args
 * @return {JSX.Element}
 * @constructor
 */
const Template: Story<typeof Tab> = (args) => <Tab {...args} />

export const Default = Template.bind({})
Default.args = {}
Yutaka FujiiYutaka Fujii

UIのテストはstorybookで良いのではないかと思う。

ちなみに自分は初期にchromaticと動機させちゃう。
共有する人はいないけどPRと連動させてその内容であっているかを確かめるのに便利。

chromatic doc

前に練習で作ったやつ(Reactとstyled-components)

PR
Storybook

UIに追加されている機能テストはjestとreact-testing-libraryとかで対応する感じなのかな?
E2Eテストとしてはcypressが良いのかな?

今作っているのはZennを参考にしているため、外観がとても似ている。
いろいろ考えてGithubでprivate設定にしてしまった。
将来的には自分なりの設定にしてpublicにしようかと考えている。
また気づきがあればスクラップをopenにしようと思う。

Yutaka FujiiYutaka Fujii

そういえばだけど書いていて思ったのは、storiesの作成は基本ベースだけであれば自動化できるんじゃないかな?と思った。

  1. 作成したい元になるcomponentファイルを指定する。
  2. 1で指定したファイルを解析し、propsが必要か不要か判定。
  3. propsが必要ならComponentStory, ComponentMeta / propsが不要ならStory, Meta
  4. 生成データの基本は下記の型を使う。
  5. 生成するパスはrootにjsonファイルかなんか作るように指定して、そこからの相対パスで設定できるとかすれば汎用性は高そう。
export default {
  title: 'Example/Header',
  component: Header,
} as ComponentMeta<typeof Header>;

const Template: ComponentStory<typeof Header> = (args) => <Header {...args} />;

export const Default = Template.bind({});
Default.args = {};

nodeからjsx/tsxを指定して解析する方法がわからない(調べていない)ので今回はできないけど、WebStormのライブテンプレートを使っても幾分か楽ができそうなので、ひとまずそれで試してみよう。

Yutaka FujiiYutaka Fujii

下記のライブテンプレートを使ったら楽だった。

// LiveTemplate
import { Meta, Story } from '@storybook/react'

export default {
  title: ,
  component: $component$,
} as Meta

const Template: Story<typeof $component$> = (args) => <$component$ {...args} />

export const $storyType$ = Template.bind({})
$storyType$.args = {}
// LiveTemplate
import { ComponentStory, ComponentMeta } from '@storybook/react'

export default {
  title: ,
  component: $component$,
} as ComponentMeta<typeof $component$>

const Template: ComponentStory<typeof $component$> = (args) => <$component$ {...args} />

export const $storyType$ = Template.bind({})
$storyType$.args = {}

使い方:

  1. componentと記載している箇所にはcomponentの名前を記載する。
  2. component名を記載してタブを押下すれば、exportの設定が対象componentに入っている際にWebStormの機能で自動補完が走る。
  3. storyTypeはお好みでつける。
  4. titleには自動補完で記載されたpathの設定をそのまま書く。
import { ComponentStory, ComponentMeta } from '@storybook/react'
import SectionTitle from 'components/layout/SectionTitle'

export default {
  title: 'components/layout/SectionTitle',
  component: SectionTitle,
} as ComponentMeta<typeof SectionTitle>

const Template: ComponentStory<typeof SectionTitle> = (args) => <SectionTitle {...args} />

export const Default = Template.bind({})
Default.args = {
  title: 'Articles',
  src: 'https://twemoji.maxcdn.com/v/13.1.0/svg/1f4dd.svg',
}

この程度であればとても楽。

Yutaka FujiiYutaka Fujii

global.scssの読み込ませ方

基本NextJSではpageフォルダの_app.tsxにglobal環境に適用するCSSファイルを設置する。
storybookは読み込まれ方がNextJSのものと根本から異なるため、storybookにも同じようにimportしてやる必要がある。

// ./page/_app.tsx

import Head from 'next/head'
import type { AppProps } from 'next/app'
import 'styles/globals.scss'
import Layout from 'components/layout/layout'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Layout>
      <Head>
        <title>PlayWell</title>
        <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
        <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
        <link rel="manifest" href="/site.webmanifest" />
        <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
        <meta name="msapplication-TileColor" content="#ffffff" />
        <meta name="theme-color" content="#ffffff" />
        <meta name={'description'} content={'This is PlayWell Blog'} />
        <meta name={'viewport'} content={'initial-scale=1.0, width=device-width'} />
      </Head>
      <Component {...pageProps} />
    </Layout>
  )
}

export default MyApp

下記のように設置することができる。

// ./storybook/preview.js
import * as nextImage from 'next/image'
import 'styles/globals.scss'

Object.defineProperty(nextImage, 'default', {
  configurable: true,
  value: (props) => <img {...props} />,
})

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}

参考
https://storybook.js.org/docs/react/writing-stories/decorators#global-decorators

Yutaka FujiiYutaka Fujii

今日作業をしていて気づいたのは、NextJSでのnext/imageでsrcに画像を設定すると、chromaticでは読み取られない。
どうやら、storybook -s publicのような設定がないようで、読み取れなかった。
普通にReactで作成するならちゃんとアップロードできるんだけど、うまくいかない。
これについては調べてみたけどよくわからないので諦めることにする。
ちなみに、svgであればコンポーネント化して読み取りが可能。

このスクラップは2021/07/16にクローズされました