【脱create-react-app】React開発環境を1から作ってみる

# はじめに

この記事は限界開発鯖 Advent Calendar 2020の7日目です。
何気にAdvent Calendar初挑戦です。

もともと「高速化のためのWebpack」というタイトルでWebpackのチューニングについて書こうと思っていたのですが、あまりにもニッチ過ぎたのでやめました。

# 1コマンドで環境構築ができる`create-react-app`

皆さんReactの開発はやったことありますか？Reactのチュートリアルでは環境構築として`create-react-app`が紹介されています。この`create-react-app`は以下のコマンドを叩くだけですぐにReactの開発環境を作れる夢のようなアイテムです。

```shell
npx create-react-app my-app
```

このコマンドはたしかにReact初心者の方にはおすすめのコマンドですが、React(Webフロント)中級者になってくると、ビルド時のコンフィグをいじったり新しいツールチェインを導入したくなると思います。
`create-react-app`はコンフィグをほとんど隠してしまうため、いじることのできる箇所が非常に少ないです。

一応`npm run eject`で`create-react-app`が隠しているコンフィグをすべてファイルにエクスポートすることができますが、今度は膨大なコンフィグファイルの量で逆にわからなくなります。

今回はそんな`create-react-app`から卒業するために各種ツールチェインの設定を1から丁寧に解説していこうと思います。

# 今回の構築する開発環境

### Webpack

複数のソースコードを1ファイルにまとめる(バンドルと言います)バンドラーと呼ばれるツールです。バンドルする際に各種ツールにソースコードを通したり画像のファイルパスを自動的に解決してくれたりと万能です。「モダンなWebフロント開発=バンドラーを使った開発」 とも言えるでしょう。

### TypeScript

JavaScriptに安全性をもたらす上位互換な言語です。JavaScriptは他言語だとエラーになるようなことも当たり前のようにエラーにならなかったりするので型やnull安全性をもたらしてくれるTypeScriptはほぼ必須の存在です。

### Babel

新しいJS/TSの文法を古い文法に変換してくれるツールです。Webフロントでは実行環境がブラウザに依存するため様々なブラウザ・バージョンをサポートしなければなりません。新しいJSの文法が使いたいのに古いブラウザへのサポートもしたい…！そんなときにはぜひともBabelを使いましょう。

### SCSS

CSSに変数やfor文、if文を追加したCSSの上位互換な言語です。他にもスタイルをネストして書けたりするので快適にCSSを書くことができます。

### PostCSS

CSSに対して色々な処理を行うことができるツールです。今回は自動的にベンダープレフィックスを付けてくれるautoprefixerとCSSのコードサイズを縮小するcssnanoを導入します。

※ESLintやPrettierも導入したいのですが、そこまで解説するととんでもない文字数になってしまいそうなので今回は見送らせていただきます。

# 環境構築

## Node.js

はじめにNode.jsプロジェクトのセットアップを行います。今回はnpmではなくyarnを用いて解説します。npmでもpnpmでもお好きなパッケージマネージャをお使いください。

```shell
yarn init -y
```

`package.json`が作成されます。デフォルトで問題ありませんが、必要に応じて変更してください。

## TypeScript

```shell
yarn add -D typescript
# TypeScriptのヘルパーライブラリです。
yarn add tslib
```

次に`tsconfig.json`を作成します。古いバージョンへのトランスパイルはbabelに任せるので、TypeScriptで出力するコードはesnextにします。

```json-doc:tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "jsx": "react",
    "sourceMap": true,
    "removeComments": true,
    "importHelpers": true,
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

## Webpackの初期設定

Webpackの設定ファイルはJavaScriptで記述することが多いですが、どうせなら型による安全性を設定ファイルにも導入しましょう。いくつかのTypeScriptのパッケージを導入してWebpackの設定を行います。

```shell
yarn add -D webpack webpack-cli @types/webpack ts-node @types/node
```

`webpack.config.ts`を作成する前に、`tsconfig.json`に少し手を加えます。`ts-node`を用いて実行したときのみ反映される設定を追加します。

```json-doc:tsconfig.json
{
  "compilerOptions": {
    // ...
  },
  "ts-node": {
    "compilerOptions": {
      "target": "es2020",
      "module": "commonjs",
      "lib": ["es2020"]
    }
  }
}
```

`webpack.config.ts`を作成します。loaderやpluginの設定は後回しにして、ベースとなる設定のみをここで行います。

`src/index.tsx`をエントリーポイントにして、`dist/`ディレクトリを出力とします(お好みで変更してください)。
Webpackにはmodeという概念があり、production modeとdevelopment modeの2種類がありますが、今回は`NODE_ENV`という環境変数を読み込んで判断することにします(未設定の場合はdevelopment modeになります。)。同様に、ベースとなるパス関しても`BASE_URL`という環境変数を読み込んで設定します(未設定の場合は`/`になります。)

```typescript:webpack.config.ts
import { Configuration } from "webpack";
import * as path from "path";

const isProduction = process.env.NODE_ENV === "production";
const isDevelopment = !isProduction;

const baseURL = process.env.BASE_URL ?? "/";

const config: Configuration = {
  target: "web",
  mode: isProduction ? "production" : "development",
  entry: {
    index: path.join(__dirname, "src", "index.tsx"),
  },
  output: {
    path: path.join(__dirname, "dist"),
    publicPath: baseURL,
    filename: "assets/scripts/[name].[contenthash:8].js",
    chunkFilename: "assets/scripts/chunk.[contenthash:8].js",
  },
};

export default config;
```

まだloaderを一つも設定していないので`src/index.tsx`というファイルを作って実行してもバンドルに失敗します。

## Babel

Babelでは過去の文法への変換は行ってくれますが、最新の標準ライブラリの補完は行ってくれません。そこで`core-js`も導入します。

```shell
yarn add -D @babel/core @babel/preset-env
yarn add core-js
```

Babelの設定ファイルである`.babelrc`を作成します。

```json:.babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ]
}
```

Babelでは`.browserslistrc`というファイルを読み込んでどのバージョンの文法へトランスパイルするかを判断する機能があります。基本的にデフォルト設定で構いませんが、「IE許すまじ」の意志を込めてIEをサポートから外しておきます。

```text:.browserslistrc
defaults
not ie 11
```

具体的にどんなブラウザをサポートする設定になっているかは、以下のコマンドで確認することができます。必要に応じてサポートするブラウザやバージョンを変更しましょう。

```shell
npx browserslist
```

## loaderの設定

とりあえずTypeScriptとBabelの設定が行えたのでWebpackに各種loaderを設定します。
TypeScriptとBabelのloaderを導入します。

```shell
yarn add -D ts-loader babel-loader
```

`webpack.config.ts`にloaderの設定を追加します。

```typescript:webpack.config.ts
const config: Configuration = {
  // ...
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
  },
  // ...
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "babel-loader",
          },
          {
            loader: "ts-loader",
          },
        ],
      },
    ],
  },
  // ...
};
```

## Reactの導入

```shell
yarn add react react-dom
yarn add -D @types/react @types/react-dom
```

ここまでの動作確認も含めていくつかファイルを作って実際に動作させてみます。

```tsx:src/index.tsx
import React, { StrictMode } from "react";
import { render } from "react-dom";

import App from "./App";

render(
  <StrictMode>
    <App />
  </StrictMode>,
  document.getElementById("root"),
);
```

```tsx:src/App.tsx
import React, { FC } from "react";

const App: FC = () => (
  <div>
    <h1>Hello, world!</h1>
  </div>
);

export default App;
```

```json-doc:package.json
{
  // ...
  "scripts": {
    "build": "webpack"
  }
  // ...
}
```

```shell
yarn build
```

`dist/`下にファイルが出力されたら成功です。

## SCSS

SCSSのプロセッサーにはC++製の`node-sass`とDart製の`dart-sass`の2種類がありますが、公式の推奨が`dart-sass`のため、こちらを導入します。

```shell
yarn add -D sass fibers @types/sass @types/fibers style-loader css-loader sass-loader
```

`webpack.config.ts`にCSS系のloader設定を追加します。CSS Moduleを使用したいため、`[name].module.(css|sass|scss)`だった場合はCSS Moduleを有効にするようにします。

```typescript:webpack.config.ts
// ...
import sass from "sass";
import fibers from "fibers";

const config: Configuration = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.(?:c|sa|sc)ss$/,
        use: [
          {
            loader: "style-loader",
          },
          {
            loader: "css-loader",
            options: {
              sourceMap: isDevelopment,
              importLoaders: 1,
              modules: {
                auto: true,
                localIdentName: isProduction ? "[hash:base64:8]" : "[path][name]__[local]",
                exportLocalsConvention: "dashesOnly",
              },
            },
          },
          {
            loader: "sass-loader",
            options: {
              sourceMap: isDevelopment,
              implementation: sass,
              sassOptions: {
                fiber: fibers,
              },
            },
          },
        ],
      },
      // ...
    ],
  },
  // ...
};
```

このままではTypeScriptのコンパイル時に、「CSS/Sass/SCSSを読み込むことができない」というエラーが発生してしまうので`src/global.d.ts`を作成して読み込めるようにします。

```typescript:src/global.d.ts
declare module "*.module.css" {
  const classes: { readonly [key: string]: string };
  export default classes;
}
declare module "*.module.scss" {
  const classes: { readonly [key: string]: string };
  export default classes;
}
declare module "*.module.sass" {
  const classes: { readonly [key: string]: string };
  export default classes;
}
```

動作確認のためにSCSSのファイルを作成します。

```scss:src/App.module.scss
.title {
  font-size: 3rem;
  color: lightblue;
}
```

`src/App.tsx`を変更してSCSSを適用させます。

```tsx:src/App.tsx
import React, { FC } from "react";

import styles from "./App.module.scss";

const App: FC = () => (
  <div>
    <h1 className={styles.title}>Hello, world!</h1>
  </div>
);

export default App;
```

`yarn build`を実行してエラーが発生しなければ成功です。

## PostCSS

今回はPostCSSのプラグインとしてautoprefixerとcssnanoを導入します。

```shell
yarn add -D postcss autoprefixer cssnano postcss-loader
```

`.postcssrc`を作成します。

```json:.postcssrc
{
  "plugins": ["autoprefixer", "cssnano"]
}
```

`webpack.config.ts`にPostCSSのloaderを追加します。

```typescript:webpack.config.ts
const config: Configuration = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.(?:c|sa|sc)ss$/,
        use: [
          // ...
          {
            loader: "css-loader",
            options: {
              // ...
              importLoaders: 2,
              // ...
            },
          },
          {
            loader: "postcss-loader",
            options: {
              sourceMap: isDevelopment,
            },
          },
          {
            loader: "sass-loader",
            // ...
          },
        ],
      },
      // ...
    ],
  },
  // ...
};
```

## プラグインの導入

Webpackのプラグインを導入します。今回は、バンドルしたJavaScriptをHTMLタグとして注入してくれる`html-webpack-plugin`とWebpackの出力先にファイルをそのままコピーする`copy-webpack-plugin`を導入します。

```shell
yarn add -D html-webpack-plugin copy-webpack-plugin @types/html-webpack-plugin @types/copy-webpack-plugin
```

`webpack.config.ts`にプラグインの設定を追加します。

```typescript:webpack.config.ts
const config: Configuration = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      inject: "head",
      minify: isProduction,
      template: path.join(__dirname, "src", "index.html"),
      scriptLoading: "defer",
    }),
    new CopyWebpackPlugin({
      patterns: [
        {
          from: path.join(__dirname, "public"),
        },
      ],
    }),
  ],
  // ...
};
```

`html-webpack-plugin`で用いるHTMLを作成します。

```html:src/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Hello, world!</title>
  </head>
  <body>
    <noscript>
      <strong>This site uses JavaScript. Please enable it.</strong>
    </noscript>
    <div id="root"></div>
  </body>
</html>
```

`copy-webpack-plugin`の動作確認のために`public/`ディレクトリを作成し、`robots.txt`を作成します。

```text:public/robots.txt
user-agent: *
```

`yarn build`を実行して動作確認をします。

## 開発用のローカルサーバーのセットアップ

`webpack-dev-server`を導入します。ホットリロード付きなので開発中の動作確認が楽になります。

```shell
yarn add -D webpack-dev-server @types/webpack-dev-server
```

`webpack.config.ts`に設定を追加します。

```typescript:webpack.config.ts
const config: Configuration = {
  // ...
  devServer: {
    historyApiFallback: true,
  },
  // ...
};
```

`package.json`にscriptを新しく追加します。

```json-doc:package.json
{
  // ...
  "scripts": {
    // ...
    "dev": "webpack serve"
  }
  // ...
}
```

`yarn dev`を実行してWebページがきちんと表示されれば成功です。

## ソースマップの設定

ソースマップがあると開発時のデバッグがやりやすくなります。

```typescript:webpack.config.ts
const config: Configuration = {
  // ...
  devtool: isDevelopment ? "eval-source-map" : "nosources-source-map",
  // ...
};
```

これで終了です。お疲れさまでした。

# おわり

最後に何度も書き換わった`package.json`と`webpack.config.ts`の全体像を示しておきます。

```json:package.json
{
  "scripts": {
    "build": "webpack",
    "dev": "webpack serve"
  },
  "dependencies": {
    "core-js": "^3.8.1",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "tslib": "^2.0.3"
  },
  "devDependencies": {
    "@babel/core": "^7.12.9",
    "@babel/preset-env": "^7.12.7",
    "@types/copy-webpack-plugin": "^6.3.0",
    "@types/fibers": "^3.1.0",
    "@types/html-webpack-plugin": "^3.2.4",
    "@types/node": "^14.14.10",
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "@types/sass": "^1.16.0",
    "@types/webpack": "^4.41.25",
    "@types/webpack-dev-server": "^3.11.1",
    "autoprefixer": "^10.0.4",
    "babel-loader": "^8.2.2",
    "copy-webpack-plugin": "^6.3.2",
    "css-loader": "^5.0.1",
    "cssnano": "^4.1.10",
    "fibers": "^5.0.0",
    "html-webpack-plugin": "^4.5.0",
    "postcss": "^8.1.14",
    "postcss-loader": "^4.1.0",
    "sass": "^1.30.0",
    "sass-loader": "^10.1.0",
    "style-loader": "^2.0.0",
    "ts-loader": "^8.0.11",
    "ts-node": "^9.1.0",
    "typescript": "^4.1.2",
    "webpack": "^5.10.0",
    "webpack-cli": "^4.2.0",
    "webpack-dev-server": "^3.11.0"
  }
}
```

```typescript:webpack.config.json
import { Configuration } from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import CopyWebpackPlugin from "copy-webpack-plugin";
import sass from "sass";
import fibers from "fibers";
import * as path from "path";

const isProduction = process.env.NODE_ENV === "production";
const isDevelopment = !isProduction;

const baseURL = process.env.BASE_URL ?? "/";

const config: Configuration = {
  target: "web",
  mode: isProduction ? "production" : "development",
  entry: {
    index: path.join(__dirname, "src", "index.tsx"),
  },
  output: {
    path: path.join(__dirname, "dist"),
    publicPath: baseURL,
    filename: "assets/scripts/[name].[contenthash:8].js",
    chunkFilename: "assets/scripts/chunk.[contenthash:8].js",
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "babel-loader",
          },
          {
            loader: "ts-loader",
          },
        ],
      },
      {
        test: /\.(?:c|sa|sc)ss$/,
        use: [
          {
            loader: "style-loader",
          },
          {
            loader: "css-loader",
            options: {
              sourceMap: isDevelopment,
              importLoaders: 2,
              modules: {
                auto: true,
                localIdentName: isProduction ? "[hash:base64:8]" : "[path][name]__[local]",
                exportLocalsConvention: "dashesOnly",
              },
            },
          },
          {
            loader: "postcss-loader",
            options: {
              sourceMap: isDevelopment,
            },
          },
          {
            loader: "sass-loader",
            options: {
              sourceMap: isDevelopment,
              implementation: sass,
              sassOptions: {
                fiber: fibers,
              },
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      inject: "head",
      minify: isProduction,
      template: path.join(__dirname, "src", "index.html"),
      scriptLoading: "defer",
    }),
    new CopyWebpackPlugin({
      patterns: [
        {
          from: path.join(__dirname, "public"),
        },
      ],
    }),
  ],
  devServer: {
    historyApiFallback: true,
  },
  devtool: isDevelopment ? "eval-source-map" : "nosources-source-map",
};

export default config;
```

# 最後に

明日は @Siketyan による[謎のコミュニティ「限界開発鯖」を支える技術](https://qiita.com/Siketyan/items/aa5ae2804bf925860416)です。私の所属している限界開発鯖の雰囲気がわかる(？)と思うのでぜひご覧ください！