どう足掻こうとNULL

nullに対してのメソッドコール? なんだかしらんがとにかくヨシ!

TypeScript+Reactで小規模SPAの開発環境を構築する①――実行環境編

要約

  • これは技術記事である。
    • ただし参照する際はこちらを確認してリスクを理解した上で扱うこと。
  • TypeScriptとReactで開発基礎環境を整える。
    • lint、test等は次の記事でする。
  • バックエンドも同じリポジトリにある。

目次

始めに

当記事では、小規模なSPAを開発する上での環境の構築について解説する。
create-react-appを用いず基礎的なもののみで構成するので、nodeのライブラリ文化と親しむことにも重点を置いている。
記事の対象としては、環境に以下のような条件を求める人物が当てはまるだろう。

  • 纏まりあるプロジェクトをできるだけ素早く進めたい。
  • 開発体験を向上させたい。
  • SPAを触りだけでも書いてみたい。

これから解説する環境はこれらを満たすもので、筆者自身も利用している形となる。 そしてこの環境についての機能要件としては、以下のようになる。

  • フロントとバック双方のホットリロード
  • 開発から本番へのシームレスな移行
  • 入力補完とバグ抑制のための型の導入

これらを満たす技術選定をした上で、環境を構築していく。
なお、CSS関連に関しては言及しないが、後述するフロントエンドの環境からCSS in JS系列との親和性は高いだろう。

では以下からがこの記事の本文だ。

マシン環境

筆者の環境は以下のように、操作用のWindowsと開発及び実行用の仮想環境であるUbuntuの二つとなる。エディタはVSCodeで、SSH接続を利用することでWindowsからテキストの操作を行っている。参考の一助として欲しい。

role CPU cores memory OS
host Ryzen 5 6 16GB Windows
gest ---- 4 6GB Ubuntu 20.04

ところで、当記事でのコードのパス等は全て開発環境であるUbuntuに準拠していることに留意されたい。

代表的な技術選定

利用する大まかな技術、ライブラリ及びフレームワークは以下となる。

  • React
  • TypeScript
  • Webpack
  • Express

なお、バックエンドはts-nodeで動作させるとするので、ビルドは行わない。こちらも依存関係などが気になる場合にはWebpackとts-loaderで一つのJSファイルに固めてしまうとよいだろう。
以下に、それぞれの技術に関しての注釈を入れていく。

React

言わずと知れたフロントエンドにおける代表的なライブラリだ。
今回は小規模とあってReduxは用いないが、しかしグローバルな状態を持っておいたほうが開発のしやすさも最終的な利便性も大きく向上することが見込める。そこでreact-trackedというReact hooksとProxyで実装されたグローバルなステートメントを扱うライブラリを採用する。Qiitaの記事にてある程度の解説が日本語で為されているので、参照するのもよいだろう。react-trackedの開発者のブログでも英語ながら詳細な解説を読むことができるので、そちらも合わせて目を通しておきたい。
なお、Reduxでなくreact-trackedを採用した明確な理由としては他にも、学習コスト面と将来性が挙げられる。Reactは将来concurrent modeという非同期の要素をもつライフサイクルを実装する予定であり、記事を書いている現在、Reduxはそれに対応中である様子がこのIssueから分かる。
Reduxにはある程度のAPIの変更の可能性があるのだ。一方でreact-trackedは対応済みであるアドバンテージがあり、件のIssueで改善案を提言しているのもまた、react-trackedの開発者なのである。これらの要素も非常に重要だと筆者は考えている。

TypeScript

型の導入を行うことで、入力補完を強めることが主目的だ。ビルド時における潜在的なバグの検出の魅力もある。
型のドキュメント性やコードの安全性というのも重要だが、本環境ではそれほど重視しない。規模の小さいプロジェクトを進めるための環境であるので、複雑な型を組むコストが相対的に大きくなっているからだ。あくまでライブラリ層でなくアプリケーション層を構築するのが目的となっている。

Webpack

ロード順問題を解決しつつ、ホットリロード等のエコシステムが優秀な面から採用した。基本的にはts-loaderと組み合わせてTSファイルをトランスパイルするのに利用する。後述するExpressのプラグインとしてWebpackのホットリロードを用いてHTMLを配信する機能があり、本環境ではこれを利用する。
なお、利用するバージョンは5だ。

Express

特にこれといった目的があって選出したのではなく、先述した通りWebpackとの相性がよいこと、扱いやすさ、情報の多さから選択した。たとえばWebpackのProxyなどを自力で解決でき、Expressの他に使い慣れたフレームワークがあるのならそちらを採用してもよいだろう。

環境構築

必要なライブラリのインストール

git clone sshUrlOfSomeRepository && cd someRepozitory && yarn init -yといういつもの儀式を終えた後に、以下のコマンドを叩く。

yarn add -D \
  @types/react \
  react \
  @types/react-dom \
  react-dom \
  react-tracked \
  ts-node-dev `#nodemonとts-nodeを同時にやってくれる。バックエンドのホットリロード用` \
  webpack \
  webpack-cli \
  react-refresh `#HMRの代替ライブラリ`\
  @pmmmwh/react-refresh-webpack-plugin \
  type-fest `#react-refreshのTSサポート用` \
  @types/webpack-dev-middleware \
  webpack-dev-middleware `#Expressをdev-serverとしても利用できるように` \
  @types/webpack-hot-middleware \
  webpack-hot-middleware `#フロントエンドのHMR用` \
  html-webpack-plugin \
  tsconfig-paths-webpack-plugin `#パス解決が上手く行かないとき用` \
  @types/dotenv-webpack \
  dotenv-webpack `#環境変数をフロントに埋め込むため` \
  source-map-loader `#TSのSourceMapをJSに付与するため` \
  ts-loader \
  immutability-helper `#ネストしたステートを安全に変更するために必要` \
  check-peer-dependencies `#peer dependenciesを一括でインストール` \
  sort-package-json

yarn add \
  ts-node \
  @types/node \
  typescript \
  @types/express \
  express \
  tsconfig-paths `#パス解決` \
  dotenv

なおコメントはこちらのStackOverFlowの回答を参考にしている。
インストールが終わった後は、yarn run check-peer-dependencies --installでその他必要なライブラリを一括で追加インストールする。
このとき、追加でインストールされるライブラリ群は全てdependenciesとしてpackage.jsonに配置される。しかしWebpackで纏めるフロントエンドの技術に関しては稼働のときに必要となる訳ではないので、全てdevDependenciesに移しておく。この際、ソート順などは気にせずに配置して問題ない。その後yarn run sort-package-jsonでpackage.jsonを整えて、このステップは終了だ。

TypeScriptの設定

以下のようにtsconfig.jsonに記述することで設定している。

{
  "compilerOptions": {
    "target": "ES5",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "lib": ["ES6", "DOM", "ES2019"],
    "typeRoots": ["node_modules/@types"],
    "removeComments": true,
    "resolveJsonModule": true,
    "noImplicitAny": true,
    "downlevelIteration": true,
    "forceConsistentCasingInFileNames": true,
    "jsx": "react",
    "rootDir": ".",
    "baseUrl": ".",
    "sourceMap": true,
    "paths": {
      "frontend/*": ["*", "src/frontend/*"],
      "backend/*": ["*", "src/backend/*"]
    }
  },
  "include": ["./**/*.ts*", "./**/*.js", "./**/.*.ts", "./**/.*.js"],
  "exclude": ["node_modules", "public"],
  "ts-node": {
    "files": true
  }
}

各項目については各自で調べて欲しいので、ここでは意図だけ解説する。
include項目に関して含むディレクトリの幅が広いのは、VSCodeなどのエディタがコンフィグファイルを読み込む際、自動でtsconfig.jsonから設定をロードしてしまうためだ。ここを狭めた際に発生する、TypeScriptで記述したコンフィグファイルやテストが上手くlintされない問題を回避することができる。なお、これによって本来含んで欲しくないコードが取り込まれていたときは、webpackの設定で調整する。

Webpackの設定

Webpackのconfigファイルは、開発用と製品用で二つ用意する。これらを以下に示す。

webpack.config.dev.ts

import path from "path";
import { Configuration, HotModuleReplacementPlugin } from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin";
import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin";
import DotEnvPlugin from "dotenv-webpack";

const webpackConfig: Configuration = {
  mode: "development",
  entry: [
    "webpack-hot-middleware/client?reload=true&timeout=1000",
    "./src/frontend/index.dev.tsx",
  ],
  resolve: {
    extensions: [".ts", ".tsx", ".js"],
    plugins: [new TsconfigPathsPlugin() as any],
  },
  output: {
    path: path.join(__dirname, "/public"),
    filename: "bundle.js",
    publicPath: "/",
  },
  devtool: "inline-source-map",
  target: "web",
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
        options: {
          configFile: path.resolve(__dirname, "./tsconfig.json"),
        },
        exclude: /public/,
      },
      {
        test: /\.js/,
        enforce: "pre",
        loader: "source-map-loader",
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({ template: "src/frontend/index.html" }),
    new HotModuleReplacementPlugin(),
    new ReactRefreshWebpackPlugin(),
    new DotEnvPlugin(),
  ],
  stats: "errors-warnings",
  externals: {
    react: "React",
    "react-dom": "ReactDOM",
  },
};

export default webpackConfig;

webpack.config.ts

import path from "path";
import { Configuration } from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin";
import DotEnvPlugin from "dotenv-webpack";

const webpackConfig: Configuration = {
  mode: "production",
  entry: "./src/frontend/index.tsx",
  resolve: {
    extensions: [".ts", ".tsx", ".js"],
    plugins: [new TsconfigPathsPlugin() as any],
  },
  output: {
    path: path.join(__dirname, "/public"),
    filename: "bundle.js",
    publicPath: "/",
  },
  target: "web",
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
        options: {
          configFile: path.resolve(__dirname, "./tsconfig.json"),
        },
        exclude: /public/,
      },
      {
        test: /\.js/,
        enforce: "pre",
        loader: "source-map-loader",
      },
    ],
  },
  plugins: [
    new DotEnvPlugin(),
    new HtmlWebpackPlugin({ template: "src/frontend/index.html" }),
  ],
  externals: {
    react: "React",
    "react-dom": "ReactDOM",
  },
};

export default webpackConfig;

解説すべき点としては、バージョン5から導入されたファイルキャッシュが挙げられる。これによってリビルド時の速度が向上する。しかし大規模なライブラリを導入しているとやはり目立って遅くなるので、その際は別途高速化を行うべきだろう。
またバージョン5からWebpackに型が付属するようになったが、TsConfigPathsPluginに関してはそれに対応できていないようだ。しかしanyを付けて強引に導入したところ、しっかり稼働したので今回はこれで良しとしている。
他注意点としては、configの型に注目してもらいたい。Webpackのバージョン5での型について筆者が調べたところ、型をWebpackOptionsNormalizedとしているものもあったが、確認したところConfigurationが正しいようだった。根拠はソースコード上でWebpackに設定を渡す際の引数の型がConfigurationだったことから来ている。正式なドキュメントから読み取ったのではなく型を参照したに過ぎないので、読者がこれを信用するかはよく吟味してもらいたい。

Expressの設定

開発用のコードとして、index.dev.tsを用意する。内容は以下に示す。

import webpack from "webpack";
import WebpackDevMiddleware from "webpack-dev-middleware";
import WebpackHotMiddleware from "webpack-hot-middleware";
import webpackConfigDev from "webpack.config.dev";
import { app } from "./app";
import { constValue } from "./constValue";

const compiler = webpack(webpackConfigDev);
app
  .use(
    WebpackDevMiddleware(compiler, {
      stats: webpackConfigDev.stats,
      publicPath: webpackConfigDev.output?.publicPath as string,
    }),
  )
  .use(WebpackHotMiddleware(compiler))
  .listen(constValue.PORT, () => {
    console.log("development mode");
    console.log(`start listening at ${constValue.PORT}`);
  });

webpackを利用できるようにするExpressのプラグインを利用し、フロントエンドをオートリロードされている。またHotMiddlewareとreact-refreshのの効力によりHMRをコンポーネントにおける主だった記述なしに利用できる。

開発

上記の設定を行った上で、yarn run ts-node-env -r tsconfig-paths/register index.dev.tsxを実行することで、柔軟な開発が可能だ。
なお、この記事の設定等を完遂した上でlintやtestに関しても調整したリポジトリをテンプレートとして用意している。詳しくはこちらを参照されたい。

というところで、今回の記事は以上である。

終わりに

最低限の環境についてであって、更にハンズオン形式でなく淡々と項目を解説していったに過ぎないにも関わらず、記事の文量が非常に多くなってしまった。勿論コードを複数載せたので仕方なく、これを更に削ろうとすれば分かりづらさが出てしまうので、今回に関しては納得しておくことにする。
なお次回の記事ではlintとtest、それから装飾の要素としてchakra uiを用いて本環境を更に整えるものを投稿する。
これはこぼれ話だが、当初は次の記事でreact-jssを用いる予定であった。実際、筆者はreact-jssを用いて複数個のテーマを扱うことができるライブラリのようなものを作ったことがあるし、Reactの型であるCSSPropertiesを用いて、ネストしたJSSに対する入力補完を疑似的に再現したこともある。疑似的、ということについてはCSSPropertiesを再帰して宛がうような型を引数に取る関数を作った。
閑話休題。react-jssを利用するため、件のテンプレートリポジトリではstylelintを導入していた。しかし試しにchakra uiに触れてみたところ、思いのほか扱いやすく、いたく感動して急遽こちらに変更することとなった。なのでコミットログを遡って表示してみると、stylelint用の設定ファイルの削除されたコミットが見つかることだろう。
ところで、HMRに関してWindowsでは上手く動作しない場合があるようだ。知人にこの環境を試してもらったところ、Windows上でHMRのみ上手く動作しなかった。
そして文末にはなってしまったが、当記事のレビュアーとして、バックエンド及びインフラを包括的に扱うエンジニアである、goochy氏に協力して頂いた。迷惑になってしまうやもしれないので、githubでのアカウントのURL等は記載しないが、非常に有意義なレビューを受けることができた。この場を借りて感謝したい。