📖

CRA 風のボイラープレートを自作する【自分用メモ】

25 min read

はじめに

Create React Appeject するといきなり地獄と化すのが嫌なので自分でボイラープレートを作っておきたい。

目標

  • TypeScript に対応
  • 最低限のリント・フォーマット環境も用意
  • Sass にも対応
  • webpack-dev-server によるホットリロード
  • 最低限のテストも実行可能にする
  • GitHub Actions によるテストとデプロイまで

前提

以下のソフトウェアはインストール済み。

  • Node.js 14+
  • Git Bash または何らかの UNIX シェルと Git コマンド

手順

1. プロジェクトフォルダの作成

zsh
% mkdir zenn
% cd zenn

% npm init --yes
Wrote to /Users/zenn/Downloads/zenn/package.json:

{
  "name": "zenn",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

2. 必要なパッケージをインストール

React 本体と ReactDOM

zsh
% npm install react react-dom

added 6 packages, and audited 7 packages in 1s

found 0 vulnerabilities

TypeScript 関連

zsh
// 本体
% npm install --save-dev typescript

// 型定義ファイル
% npm install -D @types/node

// TS ファイルを直接実行するコマンド
% npm install -D ts-node

React の型定義ファイル

zsh
% npm install -D @types/react @types/react-dom

TypeScript の設定 (tsconfig.json)

プロジェクトフォルダ直下に tsconfig.json を作成。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ES2020",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "lib": ["DOM", "ES2020"],
    "jsx": "react",
    "strict": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "forceConsistentCasingInFileNames": true
  },
  "ts-node": {
    "compilerOptions": {
      "target": "ES2015",
      // ts-node 実行環境では CommonJS が必要
      "module": "CommonJS"
    }
  }
}

3. リンター・フォーマッターの準備

リンター: ESLint
フォーマッター: Prettier

インストール

zsh
// 本体
% npm install -D eslint prettier

// prettier と被る(または不要な) eslint のルールを無効化するパッケージ
% npm install -D eslint-config-prettier

React 用のプラグイン

zsh
% npm install -D eslint-plugin-react eslint-plugin-react-hooks

TypeScript 用のパーサーとプラグイン

zsh
// パーサー
% npm install -D @typescript-eslint/parser

// プラグイン
% npm install -D @typescript-eslint/eslint-plugin

VSCode 拡張をインストール

ESLint 設定ファイル

プロジェクトフォルダ直下に .eslintrc.json を作成。

.eslintrc.json
{
  // 適用する環境
  "env": {
    "es6": true,
    "node": true,
    "browser": true,
    "commonjs": true
  },
  // React のバージョンは自動検出に
  "settings": {
    "react": {
      "version": "detect"
    }
  },
  // TypeScript のパーサーを指定
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 2018,
    "ecmaFeatures": {
      // React JSX(TSX) を使う
      "jsx": true
    },
    // import 文でモジュールを使う
    "sourceType": "module"
  },
  // TypeScript 用と React 用のプラグインを指定
  "plugins": ["@typescript-eslint", "react", "react-hooks"],
  // ルールをインポート
  // 基本的に recommended に従う
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    // prettier は配列の最後尾に置かなければ機能してくれない
    "prettier"
  ],
  "rules": {
    // お好みのカスタムルールをここで指定
    "react/prop-types": "off"
  }
}

Prettier の設定

プロジェクトフォルダ直下に .prettierrc.json を作成。
オプションはこちらからお好みで。

.prettierrc.json
{
  "singleQuote": true,
  "jsxBracketSameLine": true
}

Ignore ファイルの設定

eslint や prettier を適用したくないファイルやフォルダを指定する。

.eslintignore
node_modules
coverage
public
.prettierignore
node_modules
coverage
public

エディタ VSCode の設定

プロジェクトフォルダ直下に .vscode フォルダを作成し、settings.json を配置する。

.vscode/settings.json
{
  // デフォルトフォーマッターを prettier に
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll": true // <-- eslint でリント
  },
  "editor.formatOnSave": true // <-- prettier で整形
}

参考記事

https://zenn.dev/sprout2000/articles/9f20902d394aa2

4. バンドラー Webpack の準備

(モジュール)バンドラーとは、複数のファイルを 1 つにまとめて出力してくれるツールのこと。ここでは webpack を利用する。
他にも parcelrollup といったバンドラーが存在する。

インストール(その1)

zsh
// 本体とコマンドライン用の実行ファイル
% npm install -D webpack webpack-cli

// 開発用ローカルサーバとその型定義ファイル
% npm install -D webpack-dev-server @types/webpack-dev-server

インストール(その2)

JavaScript ファイルへ画像やスタイルシートをバンドル(=複数ファイルをまとめること)するためには、その目的別にローダープラグイン を用意しなければならない。

zsh
// TypeScript 用のローダー
% npm install -D ts-loader

// JavaScript の中にある CSS の文字列を DOM に挿入するローダー
% npm install -D style-loader

// CSS 用ローダー(CSS ファイルを JavaScript へバンドルする)
% npm install -D css-loader

// Sass 用ローダー
% npm install -D sass-loader

// Sass を CSS へ変換するコンパイラ
% npm install -D sass

// HTML ファイルを出力するプラグイン
% npm install -D html-webpack-plugin

設定ファイル (webpack.config.ts) を作成

プロジェクトフォルダ直下に webpack.config.ts を作成。
公式ドキュメント: configuration

webpack.config.ts
/** https://webpack.js.org/configuration/ */

// エディタで補完が効くように設定の型定義ファイルをインポート
import { Configuration } from 'webpack';

// プラグインのインポート
import HtmlWebpackPlugin from 'html-webpack-plugin';

// Node.js モジュール (path) をインポート
import path from 'path';

const config: Configuration = {
  /**
   * entry:
   * https://webpack.js.org/configuration/entry-context/#entry
   * エントリーファイル
   */
  entry: './src/index.tsx',
  /**
   * mode: 'development' | 'production'
   * https://webpack.js.org/configuration/mode/
   * 開発時には development モードにする
   */
  mode: 'development',
  /**
   * output:
   * 出力先とファイル名
   */
  output: {
    // https://webpack.js.org/configuration/output/#outputpath
    path: path.resolve(__dirname, 'public'),
    // https://webpack.js.org/configuration/output/#outputfilename
    filename: 'bundle.js',
  },
  /**
   * module:
   * https://webpack.js.org/configuration/module/#modulerules
   * ファイル種別ごとに適用するローダーやプラグインを指定する
   */
  module: {
    rules: [
      {
        // 拡張子 .ts または .tsx のファイル(正規表現)
        test: /\.tsx?$/,
        // node_modules フォルダは除外する
        exclude: /node_modules/,
        // ts-loader を使ってバンドルする
        loader: 'ts-loader',
      },
      {
        // css または scss ファイル
        test: /\.s?css$/,
        /**
         * ローダーは use 配列の *最後尾* から順に適用されることに注意
         */
        use: ['style-loader', 'css-loader', 'sass-loader'],
      },
      {
        /**
         * 画像やフォントなどのアセット類
         * https://webpack.js.org/guides/asset-modules/#root
         */
        test: /\.(ico|gif|jpe?g|png|svg|ttf|otf|eot|woff?2?)$/,
        type: 'asset/inline',
      },
    ],
  },
  /**
   * resolve:
   * https://webpack.js.org/configuration/resolve/
   * webpack に依存関係を解決させるファイルの拡張子を指定
   */
  resolve: {
    extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
  },
  /**
   * plugins:
   */
  plugins: [
    /**
     * バンドルされた JS を <script></script> タグへ
     * 注入した HTML を出力してくれるプラグイン
     */
    new HtmlWebpackPlugin({
      // テンプレート
      template: './src/index.html',
      // ファビコン
      favicon: './src/favicon.ico',
      // script タグの注入先
      inject: 'body',
      // ロードのタイミング
      scriptLoading: 'defer',
    }),
  ],
  /**
   * devServer:
   * https://webpack.js.org/configuration/dev-server/#devserver
   * ローカルサーバの設定
   */
  devServer: {
    // output の path に設定されたフォルダ
    contentBase: path.resolve(__dirname, 'public'),
    port: 3030,
    open: true,
  },
  /**
   * devtool:
   * https://webpack.js.org/configuration/devtool/#devtool
   * 開発時にはソースマップを付ける
   */
  devtool: 'inline-source-map',
};

export default config;

5. ソースコードを配置

ソースコードの置き場所を src にする。

zsh
% mkdir src

コードの例: npx create-react-app myapp --template typescript のものを流用

src/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
src/@types/resources.d.ts
declare module '*.svg';
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';

import { App } from './App';

ReactDOM.render(<App />, document.getElementById('root'));
src/App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.scss';

export const App: React.FC = () => {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer">
          Learn React
        </a>
      </header>
    </div>
  );
};
src/App.scss
@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;

  .App {
    text-align: center;

    .App-header {
      background-color: #282c34;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      font-size: calc(10px + 2vmin);
      color: white;

      code {
        font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
          monospace;
      }

      .App-link {
        color: #61dafb;
      }

      .App-logo {
        height: 40vmin;
        pointer-events: none;
        animation: App-logo-spin infinite 20s linear;
      }
    }
  }
}

ここまでのファイル・フォルダ構成

zsh
% tree -a -I 'node_modules'
.
├── .eslintignore
├── .eslintrc.json
├── .prettierignore
├── .prettierrc.json
├── .vscode
│   └── settings.json
├── package-lock.json
├── package.json
├── src
│   ├── @types
│   │   └── resources.d.ts
│   ├── App.scss
│   ├── App.tsx
│   ├── favicon.ico
│   ├── index.html
│   ├── index.tsx
│   └── logo.svg
├── tsconfig.json
└── webpack.config.ts

3 directories, 16 files

6. NPM スクリプトの設定

package.jsonscripts プロパティでは、スクリプト(NPM スクリプト)を登録しておくことで長いコマンドのエイリアスとすることが出来る。

package.json
{
  "scripts": {
    "start": "webpack serve",
    "eslint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix",
    "prettier:fix": "prettier . --write"
  },
}

それぞれのスクリプトは、

zsh
% npm run スクリプト名

で実行できる。ただし、例外的に starttest では途中の run を省略できる。

zsh
% npm start
% npm test

実行テスト

zsh
% npm start

7. 最低限のテスト環境の構築

以下のツールを利用する。

参考記事

インストール

zsh
// Jest 本体と型定義ファイル
% npm install -D jest @types/jest

// トランスパイラ
% npm install -D ts-jest

// jest.config を TypeScript で書くための型定義ファイル
% npm install -D @jest/types

// React Testing Library
% npm install -D @testing-library/react
% npm install -D @testing-library/jest-dom

設定ファイル (jest.config.ts) の作成

プロジェクトフォルダ直下に jest.config.ts を作成。

jest.config.ts
// 設定の型定義ファイルをインポート
import { Config } from '@jest/types';

const config: Config.InitialOptions = {
  // トランスパイラに ts-jest を使う
  preset: 'ts-jest',
  // ブラウザで実行される DOM をテストする
  testEnvironment: 'jsdom',
  /**
   * テストファイルの配置場所を指定
   * ここではプロジェクト直下の tests フォルダとする
   */
  roots: ['<rootDir>/tests'],
  /**
   * モックファイルの配置場所を指定
   */
  moduleNameMapper: {
    // 画像ファイルなどのアセット類
    '\\.(ico|gif|jpe?g|png|svg|ttf|otf|eot|woff?2?)$':
      '<rootDir>/mocks/fileMock.ts',
    // (S)CSS ファイル
    '\\.(css|scss)$': '<rootDir>/mocks/styleMock.ts',
  },
};

export default config;

モックファイルの作成

プロジェクトフォルダ直下に mocks フォルダを作成し、それぞれモックファイルを作成する。

mocks/fileMock.ts
export default {};
mocks/styleMock.ts
export default 'test-file-stub';

テストファイルの作成

jest.config.ts に指定したテストファイル置き場フォルダへ *.test.tsx を作成する。
ここではレンダリングされた DOM の中に Learn React という文字列が存在することのみをテストしている。

tests/App.test.tsx
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';

// テストするコンポーネントをインポート
import { App } from '../src/App';

describe('App component', () => {
  test('test App component', () => {
    render(<App />);

    expect(screen.getByText(/Learn React/)).toBeInTheDocument();
  });
});

NPM スクリプトの追加

package.json
   "scripts": {
     "start": "webpack serve",
+    "test": "jest",
     "eslint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix",
     "prettier:fix": "prettier . --write"
   },

テスト実行

zsh
% npm test

> zenn@1.0.0 test
> jest

 PASS  tests/App.test.tsx
  App component
    ✓ test App component (16 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.43 s
Ran all test suites.

8. コミットするたびにテストとリント&フォーマットが走るようにする

huskylint-staged を使う。

zsh
% npm install -D husky lint-staged

Git レポジトリ化する

当然ながら Git レポジトリでないと husky は機能しない。

zsh
// git による管理から除外するファイルやフォルダを .gitignore へ記載する
% echo 'node_modules' > .gitignore
% echo 'public' >> .gitignore

// レポジトリ初期化
% git init
Initialized empty Git repository in /Users/zenn/Downloads/zenn/.git/

husky の初期設定コマンドを実行する

zsh
% npx husky-init && npm install
husky - updated package.json
husky - Git hooks installed
husky - created .husky/pre-commit

NPM スクリプトに prepare が追加され、

package.json
   "scripts": {
     "start": "webpack serve",
     "test": "jest",
     "eslint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix",
     "prettier:fix": "prettier . --write",
+    "prepare": "husky install"
   },

プロジェクトディレクトリ直下に .husky フォルダが作成される。

bash
  % tree -a -I 'node_modules|.git'
  .
  ├── .eslintignore
  ├── .eslintrc.json
  ├── .gitignore
+ ├── .husky
+ │   ├── .gitignore
+ │   ├── _
+ │   │   └── husky.sh
+ │   └── pre-commit
  ├── .prettierignore
  ├── .prettierrc.json

pre-commit スクリプトの編集

コミットのたびに(正確には git commit が実行される前に)テストと lint-staged (NPM スクリプト) が実行されるように追記。

.husky/pre-commit
  #!/bin/sh
  . "$(dirname "$0")/_/husky.sh"

  npm test
+ npm run lint-staged

NPM スクリプトの追加

package.json
   "scripts": {
     "start": "webpack serve",
     "test": "jest",
     "eslint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix",
     "prettier:fix": "prettier . --write",
-    "prepare": "husky install"
+    "prepare": "husky install",
+    "lint-staged": "lint-staged"
   },
+  "lint-staged": {
+    "*.{js,ts,jsx,tsx}": [
+      "npx eslint . --fix",
+      "npx prettier . --write"
+    ]
+  },
   "keywords": [],

最初のコミット

zsh
// Git 管理対象ファイルをすべてステージ化
% git add -A

// コミット
% git commit -m "first commit"

> zenn@1.0.0 test
> jest

 PASS  tests/App.test.tsx
  App component
    ✓ test App component (15 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.573 s, estimated 1 s
Ran all test suites.

> zenn@1.0.0 lint-staged
> lint-staged

⚠ Skipping backup because there’s no initial commit yet.

✔ Preparing...
✔ Running tasks...
✔ Applying modifications...
[main (root-commit) e674b5a] first commit
 23 files changed, 24040 insertions(+)

~ snip ~

ここでは、まだリント&フォーマットの対象ファイル (js,ts,jsx,tsx) がステージ化されていないのでテストだけが走っている。

9. 本番デプロイ向け環境の構築

方針

  • JS, CSS, HTML ファイルはそれぞれ webpack の production モードによって minify する
  • JS のバンドルサイズを抑えるため、CSS やアセット類はバンドルせずに出力する

追加パッケージのインストール

zsh
// style-loader の代わりに(JS へバンドルしないで) CSS ファイルを出力するプラグイン
% npm install -D mini-css-extract-plugin @types/mini-css-extract-plugin

デプロイ向け webpack.config の作成

プロジェクトフォルダ直下に webpack.config.prod.ts を作成する。

webpack.config.prod.ts
import path from 'path';
import { Configuration } from 'webpack';

// プラグインのインポート
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';

const config: Configuration = {
  entry: './src/index.tsx',
  // デプロイ時には production モードにする
  mode: 'production',
  output: {
    path: path.resolve(__dirname, 'public'),
    // https://webpack.js.org/configuration/output/#outputpublicpath
    publicPath: '',
    filename: 'bundle.js',
    // https://webpack.js.org/configuration/output/#outputassetmodulefilename
    assetModuleFilename: 'assets/[name][ext]',
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: 'ts-loader',
      },
      {
        test: /\.s?css$/,
        /**
         * style-loader (JS へバンドル) ではなく、
         * CSS ファイルとして出力するプラグインを使う
         */
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
      },
      {
        test: /\.(ico|gif|jpe?g|png|svg|ttf|otf|eot|woff?2?)$/,
        /**
         * JS へバンドル (asset/inline) せず、
         * output.assetModuleFilename に指定した
         * ファイル(パス)名で配置する
         */
        type: 'asset/resource',
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
  },
  /**
   * optimization:
   * https://webpack.js.org/configuration/optimization/
   */
  optimization: {
    minimize: true,
  },
  plugins: [
    /**
     * MiniCssExtractPlugin.loader のために
     * インスタンスを作成する
     */
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({
      template: './src/index.html',
      favicon: './src/favicon.ico',
      inject: 'body',
      // minifty する
      minify: true,
      scriptLoading: 'defer',
    }),
  ],
  // デプロイ時にはソースマップを付けない
  devtool: undefined,
};

export default config;

NPM スクリプトの追加

webpack の設定ファイルには webpack.config.prod.ts を指定する。

package.json
   "scripts": {
     "start": "webpack serve",
+    "build": "webpack --config webpack.config.prod.ts",
     "test": "jest",
     "eslint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix",
     "prettier:fix": "prettier . --write"
   }

10. GitHub Actions を利用して GitHub Pages へデプロイする

GitHub Pages action を利用する。

packages.json へ homepage プロパティを設定する

URL は、https://ユーザ名.github.io/レポジトリ名 となる。

package.json
   "name": "zenn",
   "version": "1.0.0",
   "description": "",
+  "homepage": "https://sprout2000.github.io/zenn",
   "scripts": {

GitHub Actions ワークフローの作成

プロジェクトフォルダ直下に .github フォルダを作成し、その中に workflows フォルダを作成する。

zsh
% mkdir -p .github/workflows

設定ファイル ghpages.ymlworkflows フォルダ内に配置する。

.github/workflows/ghpages.yml
name: GitHub Pages

# 'v*' 付きのタグを push した時のみに実行する
on:
  push:
    tags:
      - v*
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # 初期化
      - uses: actions/checkout@master

      # Node@14 でビルドする
      - name: Use Node.js 14.x
        uses: actions/setup-node@master
        with:
          node-version: '14.x'

      # 依存するパッケージをインストール
      - name: Install
        run: npm ci
      # ビルド
      - name: Build
        run: npm run build

      # GitHub Pages action を使ってデプロイ
      # https://github.com/peaceiris/actions-gh-pages
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public

11. CI (GitHub Actions) を利用してテストを自動化する

ワークフローファイルの追加

.github/workflows/test.yml
name: GitHub CI

# git push のたびに起動する
on: [push]

jobs:
  test:
    runs-on: macos-10.15
    steps:
      - uses: actions/checkout@master

      - name: Use Node.js 14.x
        uses: actions/setup-node@master
        with:
          node-version: '14.x'
      - name: Install
        run: npm install
      - name: Test
        run: npm test

12. 最終的なファイル・フォルダ構成

zsh
% tree -a -I 'node_modules|.git'
.
├── .eslintignore
├── .eslintrc.json
├── .github
│   └── workflows
│       ├── ghpages.yml
│       └── test.yml
├── .gitignore
├── .husky
│   ├── .gitignore
│   ├── _
│   │   └── husky.sh
│   └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│   └── settings.json
├── jest.config.ts
├── mocks
│   ├── fileMock.ts
│   └── styleMock.ts
├── package-lock.json
├── package.json
├── src
│   ├── @types
│   │   └── resources.d.ts
│   ├── App.scss
│   ├── App.tsx
│   ├── favicon.ico
│   ├── index.html
│   ├── index.tsx
│   └── logo.svg
├── tests
│   └── App.test.tsx
├── tsconfig.json
├── webpack.config.prod.ts
└── webpack.config.ts

9 directories, 27 files

13. 今回作成したレポジトリ

https://github.com/sprout2000/react-typescript

使い方

sh
% git clone git@github.com:sprout2000/react-typescript.git

% cd react-typescript
% npm install && npm start