Docker + Storybookで作るReactコンポーネント開発環境

Docker上にStorybookを使ったReactコンポーネント開発環境を作ります。ついでにnpmへの公開も行います。

# ゴール

- WindowsやMac上に、DockerとStorybookを使ったReactコンポーネント開発環境を作る
- Storybookの利点であるページリロード無しでのCSS/JS更新を実現する
- コンポーネントはnpmに公開できるようにする

# 前提

- npmの知識がある
- Reactの知識がある
- Dockerで簡単な環境構築ができる

# Dockerにサーバーを準備する

## Dockerをインストールする

ホストOSに[Docker](https://www.docker.com/)をインストールした状態から開始。(個人、教育機関、中小企業は無償でDocker Desktopを使えます。大企業の場合Docker Desktopは有償なので注意。)

## プロジェクトフォルダにDocker用ファイルを作る

今回はドキュメントフォルダの下にQiitaフォルダを作り、storybooknpmフォルダを作ります。
作り終わったら、そのフォルダの中にDocker用ファイルを作っていきましょう。
なお、開発が複雑化したときに対応しやすいよう、docker-compose.ymlとDockerfileを分ける構成にします。

```docker-compose.yml
services:
  storybook-env:
    container_name: "storybook-env"
    build:
      context: ./docker/node
    ports:
      - 6006:6006
    volumes:
      - ./:/src
      - /src/node_modules
```

いくつかポイントを抑えます。

今回はStorybookで開発を進めるので、Storybookで使うポート6006をDocker外から見えるようにします。

ソースコードをホストOSのVisual Studio Codeなどで編集できるようにボリュームのマウントを行いますが、``node_modules``だけは対象から外します。これにより、Storybookによるコンパイル（実際にはViteによるコンパイル）やホットリロードが高速動作します。

## node.jsのDockerfile

Dockerfileを``/docker/node``フォルダに作ります。

```Dockerfile
FROM node:20
EXPOSE 6006
RUN apt-get update
RUN apt-get install -y xdg-utils
WORKDIR /src
ENTRYPOINT ["tail", "-f", "/dev/null"]
```

ここでStorybookで使うポート6006を外に見せていることに注意してください。

``xdg-utils``をインストールしているのは、Storybookに必須であるためです。

また、最後のENTRYPOINTの記述は、起動したコンテナがすぐ終了してしまうのを防ぐためのものです。

## Dockerの起動テストをする

ターミナル（WindowsならPowerShell）を起動し、プロジェクトフォルダに移動します。

たとえばWindowsのPowerShellを起動して、ドキュメントフォルダのQiitaフォルダに作ったstorybooknpmプロジェクトフォルダに移動した場合は：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/d27e0d6a-b9af-1b8c-0586-63d40a993926.png)

Dockerを起動しましょう。

```bash
> docker compose up -d --build
```

``--build``オプションはサーバーを構築することを指定するものなので、初回だけ指定すればよいです。ただし``docker-compose.yml``や``Dockerfile``に変更をした場合にも指定が必要です。

うまく行けば以下のようにコンテナが起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/cb712aad-23bd-7c0d-c314-faceb9c1f4c1.png)

## コンテナに入る

```bash
> docker compose exec storybook-env bash
```

以下のように入れたらOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/d17257d1-4869-1ebf-eea6-f3ee7b68d939.png)

ここからは「`>`」がホストOS（ここではWindows）、「`$`」がDockerコンテナ内のOSとして表記します。
上記/src#の#部分が$だと思って読み替えてください。

# React + Vite の開発プロジェクトを作る

あとでnpmにパッケージを公開することを考えるので、ビルド環境をつくっておきます。

## プロジェクト作成

カレントフォルダにプロジェクトを作りたいですが、``npm create vite .``するとファイルがすでに存在すると怒られるので、一旦子フォルダにプロジェクトを作ります。

```bash
$ npm create vite storybooknpm
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/9daaf05e-fac9-6664-5c47-878693b9e0b3.png)

Reactを選択してEnter。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/0a42f62f-9208-9eaa-56d9-a6db9116cd19.png)

言語を選択してEnter、ここではJavaScriptとします。

サブフォルダ``storybooknpm``にすべてのファイルが準備されたので、カレントフォルダに移動します。

```bash
$ mv storybooknpm/* .
$ mv storybooknpm/.* .
```

2行目を実行しないと``.gitignore``が残ります。。。

最後にサブフォルダを削除。

```bash
$ rm -rf storybooknpm
```

結果はこんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/11d33d4f-ff94-580a-9561-92900b63250a.png)

## コンポーネント開発に不要なファイルの削除

以下青ハイライトのファイルとフォルダはコンポーネント開発に不要なので削除

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/c913c49b-434d-61b8-d1b4-cdd87d96bb06.png)

```bash
$ rm -rf src/*
$ rm public/*
$ rm index.html
```

# Storybookを導入する

## Storybookをインストール

```bash
$ npx storybook@latest init
```

※バージョン指定したい場合は``storybook@7.6.10``とか``storybook@7``です。

すでに作ったプロジェクトを自動認識してStorybookの設定をそれに合わせて作ってくれます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/361224ca-116e-a2e6-62da-6d16ed82da8c.png)

そして勝手に起動状態になる。（停止はCtrl+C）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/5e97c427-b2cc-2b4f-356d-fd14b9fce8a7.png)

## Storybook起動状態の確認

この状態でブラウザを開いて``http://localhost:6006``にアクセスすると、Storybookのフロントが見えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/9bb0164f-cb23-2946-665c-84b54c664a57.png)

# Reactコンポーネントを作る

いよいよ実装です。ここでは「Green monster」コンポーネントを作りましょう。

## コンポーネントを実装

``src``フォルダに``components``フォルダを作って、その下に``GreenMonster``フォルダを作り、その中に``GreenMonster.jsx``と``greenmonster.css``を作ります。

```greenmonster.css
.green-monster{
    padding: 20px;
    border: 1px solid green;
    background-color: green;
    color: white;
}
.green-monster-outline{
    background-color: white;
    color: green;
}

```

```GreenMonster.jsx
import { useState, useEffect } from 'react'
import './greenmonster.css';

export const GreenMonster = (props) => {
    const [isOutline, setIsOutline] = useState(true)

    useEffect(() => {
        setIsOutline(props.isOutline)
    }, [])

    const onClickMonster = () => {
        setIsOutline(!isOutline)
        props.onClick()
    }

    return (
        <>
            <div className={"green-monster " + (isOutline ? "green-monster-outline" : null)} onClick={onClickMonster}>
                Monster!
            </div>
        </>
    )
}

export default GreenMonster
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/280d8453-415e-9c6b-59b8-09220d314ddd.png)

## コンポーネントをStorybookでテスト

ストーリー``GreenMonster.stories.js``を作ります。

```GreenMonster.stories.js
import { fn } from '@storybook/test';

import { GreenMonster } from '../components/GreenMonster/GreenMonster';

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
export default {
  title: 'Monsters/GreenMonster',
  component: GreenMonster,
  parameters: {
    // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
    layout: 'centered',
  },
  // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
  tags: ['autodocs'],
  // More on argTypes: https://storybook.js.org/docs/api/argtypes
  argTypes: {
    backgroundColor: { control: 'color' },
  },
  // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
  args: { onClick: fn() },
};

// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Outline = {
  args: {
    isOutline: true,
  },
};

export const Solid = {
  args: {
    isOutline: false,
  },
};
```

Storybookを起動（すでに起動中ならCtrl+Cで止めてから）

```bash
$ npm run storybook
```

フロントには、ストーリーで作ったOutlineとSolidが表示されています。ここでコンポーネントをクリックすると、デザイン変更の動作を確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/9f8681ac-876c-055f-816b-4d37d92f5b28.png)

また、Outlineのページ内でコンポーネントをクリックすると、親のonClickがコールバックされている様子が確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/c73b2288-e1a1-6635-5f8d-a8c19e005fa2.png)

## ホットリロードが動くようにする

[詳細記事](https://qiita.com/hitotch/items/466ff9cd0e463dfc47d7)にあるように、``.storybook/main.js``に以下を追記します。

```main.js
  viteFinal: async (config, options) => {
    config.server.watch = {
      usePolling: true,
    }
    return config;
  }
```

# コンポーネントのビルド

## Index.jsxを作成する

``src``フォルダに``Index.jsx``を作って、exportしたいコンポーネントを定義します。

```index.jsx
export { default as GreenMonster } from "./components/GreenMonster/GreenMonster";
```

## vite.config.jsにビルド情報

以下の1行目とbuild部分を追加します。

```vite.config.js
import path from "path"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],

  build: {
    //Specifies that the output of the build will be a library.
    lib: {
      //Defines the entry point for the library build. It resolves 
      //to src/index.ts,indicating that the library starts from this file.
      entry: path.resolve(__dirname, "src/index.jsx"),
      name: "【ここにnpmjsサーバーに登録するリポジトリ名】",
      //A function that generates the output file
      //name for different formats during the build
      fileName: (format) => `index.${format}.js`,
    },
    rollupOptions: {
      external: ["react", "react-dom"],
      output: {
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
        },
      },
    },
    //Generates sourcemaps for the built files,
    //aiding in debugging.
    sourcemap: true,
    //Clears the output directory before building.
    emptyOutDir: true,
  },

})
```

これで``dist``フォルダにビルドできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/cc3fa545-4307-b4a4-2a99-7daa33b0d446.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315589/8bbc8112-d6cf-5d2c-9e69-93595c5484d9.png)

## package.jsonにnpmパッケージ化情報を追記

```package.json
  "name": "【ここにnpmjsサーバーに登録するリポジトリ名】",
  "private": false,
  "description": "【ここに説明文】",
  "version": "0.0.0",
  "type": "module",
  "main": "dist/index.umd.js",
  "module": "dist/index.es.js",
  "exports": {
    ".": {
      "import": "./dist/index.es.js",
      "require": "./dist/index.umd.js"
    },
    "./styles": "./dist/storybooknpm.css"
  },
  "files": [
    "/dist"
  ],
  "publishConfig": {
    "access": "public"
  },

  【以下省略】
```

これで``npm``に``publish``できます。(詳細は省略)