LoginSignup
13
16

More than 3 years have passed since last update.

開発コンテナで快適!<del>ひきこもり生活</del>フロントエンド開発

Last updated at Posted at 2020-03-23

どうも、よこけんです。
Web アプリ開発の現場から離れて10年くらい経つのですが、思うところあって最近のフロントエンド開発についてプライベートで勉強しました。今日はその成果をアウトプットしようと思います。

本記事では主に、VSCode の Remote-Container を使ったフロントエンド (React) 開発を行うための環境構築方法を解説します。
オールインワンのためかなり長い記事になってしまいましたが、大半の作業はファイルのコピペとコマンドのコピペなので作業自体はシンプルです。ただし、各要素の理解こそが重要なので、この記事をきっかけに各要素の理解を深めていっていただければと思います。

本記事では特に下記の要素を押さえています。

  • 開発環境のコンテナ化
    • 常にコンテナの中で 生活 開発していきます。
    • 開発者間での開発環境の統一ができます。
    • 開発環境のリセットが容易です。
    • 本番環境に (構成の面で) 近い環境を使用して開発できます。
  • 本番環境のコンテナ化
    • Docker in Docker によって、開発コンテナからも気軽に起動できます。
    • 本番環境と (構成の面で) 同一の環境を使用して動作確認できます。
  • React 開発を始めるにあたって重要になってくる (手堅い) 周辺技術
    • 技術選定を簡略化もしくは省略できます。
    • create-react-app を使わないので、各技術がブラックボックス化されず制御しやすくなります。
    • 導入時に手を焼くであろうポイントを回避できます。
    • 概略を押さえることで各要素の学習のハードルが下がり、スムーズに進めやすくなります。
  • デバッグ方法
    • 取っつきにくくややこしい設定に手を焼くことなく、容易にデバッグを行えます。

反対に、下記については本記事では扱いません。

  • 言語について
  • React そのものの詳細な開発テクニック・テストテクニック
  • バージョン管理 (Git) の詳細
  • ひきこもりの是非
  • アトミックデザインなどのコンポーネント設計手法
  • マテリアルデザインなどの Web デザイン手法
  • Cloud などへのデプロイメント

余談ですが、個人的にはアトミックデザインについてはやや懐疑的です。
コンポーネントの再利用性を高めること自体は重要と思いますが、ボトムアップなアプローチは過剰設計を招きがちです。
フロントエンド開発においても、トップダウンなアプローチで必要に応じてコンポーネントの再利用性を高めていく進化的設計が望ましいと考えています。

まぁそれはさておき、本題に入りましょう。
完成品は GitHub にあげてありますのでご活用ください。

開発環境構成

まずはこの記事で構築する開発環境の構成を見ていきます。

ホスト構成

  • OS
    • Windows 10 Pro (※)
  • IDE
    • VSCode
      • Remote Development (Remote-Container)
      • Docker
      • EditorConfig
      • Git Lens (この記事では扱いませんがお勧めです)
      • Git Graph (この記事では扱いませんお勧めです)
  • バージョン管理システム
    • Git
  • コンテナツール
    • Docker Desktop

※ 私の環境が Windows 10 Pro なので、Windows 10 Home や Mac だとどうなるのかはよくわかりません。特に Windows 10 Home は Hyper-V 非対応のため Docker Desktop が使えないかと思います。(Virtual Box + Docker Toolbox や WSL2 + Docker Desktop で Docker が動かせるという噂ですが。)

開発用コンテナ構成

  • OS
    • Debian
  • ランタイム
    • Node.js
  • パッケージマネージャー
    • npm
  • コンテナツール
    • Docker CE CLI
    • Docker Compose
  • IDE
    • VSCode (ホスト環境からリモート接続)
      • language-stylus
      • Debugger for Chrome
      • Debugger for Edge
      • ESLint
      • Stylint
      • Manta's Stylus Supremacy
      • Jest Runner
      • EditorConfig 1
      • Git Lens 1
      • Git Graph 1
  • バージョン管理システム
    • Git 1

コンテナ化した本番環境を Docker in Docker で起動できるよう、コンテナツールもインストールしておきます。 (基本的には本番環境は起動せず開発環境内で直接アプリを実行しますが。)

開発用パッケージ構成

  • クライアントサイド
    • 言語
      • HTML
      • TypeScript
      • Stylus
    • Lint
      • ESLint
        • ESLint Plugin React
      • Stylint
    • フレームワーク
      • React
        • React DOM
        • React Hooks
        • React Router DOM
        • React Hot Loader
    • モジュールバンドラ
      • WebPack
        • WebPack CLI
        • WebPack Merge
        • TS Loader
        • CSS Loader
        • Style Loader
        • Stylus Loader
        • URL Loader
        • File Loader
        • Clean WebPack Plugin
        • HTML WebPack Plugin
    • テストフレームワーク
      • Jest
      • Jest CSS Modules
      • Fetch Mock
      • React Testing Library
    • その他
      • concurrently
  • サーバーサイド
    • ランタイム
      • ts-node
      • ts-node-dev
    • 言語
      • TypeScript
    • Lint
      • ESLint
    • Web サーバー
      • Express
      • WebPack Dev Server
    • ロギング
      • log4js
    • リバースプロキシ
      • node-fetch

多過ぎ…

うん。多いですね。
これだけ見ると、フロントエンド開発をこれから学ぼうとしている方は尻込みしてしまうかもしれません。
ただ、これらは大きなものから小さなものまで全て洗い出して記載しており、主要技術としては太字で記載したものに限定されます。
下記は主要技術を抜き出したリストとなります。

  • IDE
    • VSCode
      • Remote Development (Remote-Container)
  • バージョン管理システム
    • Git
  • ランタイム
    • Node.js
  • パッケージマネージャー
    • npm
  • コンテナツール
    • docker-ce-cli
    • docker-compose
  • 言語
    • TypeScript
    • Stylus
  • Lint
    • ESLint
    • Stylint
  • Web サーバー
    • Express
    • WebPack Dev Server
  • フレームワーク
    • React
  • モジュールバンドラ
    • WebPack
  • テストフレームワーク
    • Jest
    • React Testing Library
  • ロギング
    • log4js

まぁ、それでも多いんですが。
だからこそ、まとめて押さえられるようにこの記事を書こうと思い立ったわけです。

といっても、この記事だけで全て完璧に習得できる、なんてことは全くありません。特にバージョン管理、言語、フレームワーク、テストフレームワーク、そして CSS フレームワークについては、確実に追加学習が必要となります。

何を学べば良いかがある程度固まって、その取っ掛かりになるくらいの情報は得られる、というのがこの記事の目指すところです。

本番環境構成

続いて本番環境です。

本番用コンテナ構成

  • OS
    • Debian
  • ランタイム
    • Node.js
  • パッケージマネージャー
    • npm

本番用パッケージ構成

  • クライアントサイド
    • 言語
      • (HTML)
      • (JavaScript)
      • (CSS)
  • サーバーサイド
    • ランタイム
      • ts-node
    • 言語
      • TypeScript
    • Web サーバー
      • Express
    • ロギング
      • log4js
    • リバースプロキシ
      • node-fetch

クライアントサイドはブラウザ上で実行されますので HTML + JavaScript + CSS になっています。TypeScript や Stylus がビルド時に自動的にこれらにトランスパイル (変換) されます。
React などのライブラリ群は、ビルド時にモジュールバンドラによってまとめられるため本番環境へのインストールは不要です。

事前準備

では、ここからは実際に作業を行っていきます。

VSCode のインストール

VSCode をダウンロードしてインストールします。

インストールしたら起動して、拡張機能をインストールします。

image.png

以下の拡張機能をそれぞれ名前で検索して [Install] ボタンでインストールしてください。

  • Remote Development (Remote-Container)
  • Docker
  • EditorConfig
  • Git Lens (この記事では扱いませんがお勧めです)
  • Git Graph (この記事では扱いませんお勧めです)

Git のインストール

Git をダウンロードしてインストールします。

重要な設定は改めて行うので、インストール時に行う設定はとりあえず適当で良いです。よくわからない項目はそのままで。

インストールが完了したら Git Bash を開き、下記のように設定を行います。
{User Name}{Mail Address} は自分の情報で置き換えてください。

git config --global user.name "{User Name}"
git config --global user.email "{Mail Address}"
git config --global push.default "simple"
git config --global core.autocrlf "false"
git config --global core.ignorecase "false"
git config --global core.quotepath "false"

Hyper-V の設定確認

デフォルトで有効化されていると思いますが、一応確認しておいてください、

仮想マシンを自分で作成する必要はありません。

Docker Desktop のインストール

Docker Desktop (Community Edition) をダウンロードしてインストールします。インストーラのオプション設定は変更せずにインストールします。

インストールが完了すると自動で起動します。Docker アカウントのログイン画面が表示されますが、アカウント登録やログインは不要ですので閉じてしまってください。

ワークスペース

VSCode にはマルチルートワークスペースという機能があります。これは一つのワークスペースに、関連する複数のプロジェクトフォルダーをまとめる機能です。
この記事では一つのプロジェクトフォルダーしか作成しませんが、下記の理由からこのマルチルートワークスペースを採用します。

  • 常にマルチルートワークスペースで構築することで、一貫して同じ操作方法・動作となる。
    • プロジェクトフォルダー を直接開く場合はプロジェクトフォルダー = ワークスペースだが、マルチルートワークスペースではプロジェクトフォルダー ≠ ワークスペースとなり、一部の操作方法や動作に違いがある。
    • フロントエンドは Node.js、バックエンドは Python や C# というように、プロジェクトフォルダーを分けることは多い。後からプロジェクトフォルダーが増えることもよくある。

ということで、ワークスペースを作成しましょう。

マルチルートワークスペースの作成

  1. Windows Explorer で任意の場所にワークスペースフォルダー (本記事ではC:\Workspaces\MoroMoro.Sample) を作成します。
  2. VSCode を起動します。
  3. メニューバーの [File] - [Close Folder] が無効化されていることを確認します。有効化されていたり [Close Workspace] が表示されている場合はそれを押してフォルダー(もしくはワークスペース) を必ず閉じてください。
  4. メニューバーの [File] - [Save Workspace As...] を押します。
  5. ファイル保存ダイアログが開かれるので、ワークスペースフォルダーに移動し、ファイル名にワークスペースフォルダーと同じ名前 (本記事では MoroMoro.Sample) を入力して [Save] ボタンで保存します。

<注意>
本記事ではワークスペース名やプロジェクト名を MoroMoro.Sample.Frontend のようにアッパーキャメルケースで命名していますが、Node.js パッケージ (後述) の命名規則に合わせて moromoro.sample.frontend のように全て小文字で命名しても構いません。(本記事でも Node.js パッケージ名については全て小文字で命名します。)

Git Init

バージョン管理はワークスペースレベルで行います。
Git Bash を開きワークスペースに移動してから次のコマンドを実行します。

git init

<注意>
「使い捨てだからバージョン管理しなくていいや」という人も必ず行ってください。
非常に厄介なことに、Git Init の有無で Remote-Container の挙動の一部が大きく変わってしまうためです。(後述)

プロジェクト

続いて、ワークスペースにフロントエンドのプロジェクトを作成します。

プロジェクトフォルダーの追加

  1. Windows Explorer でワークスペースフォルダーにプロジェクトフォルダー (本記事では MoroMoro.Sample.Frontend) を作成します。
  2. VSCode のサイドメニューバーから image.png (Explorer) を開き、[Add Folder] ボタンを押します。
  3. フォルダー選択ダイアログが開かれるので、プロジェクトフォルダーを選択して [Add] ボタンで追加します。

EditorConfig 設定

EditorConfig はファイルの文字コードや改行コード、インデントなどのエディタ設定をファイルにまとめる仕組みです。設定ファイルをソースコードと一緒に管理することで、メンバー間でのエディタ設定を統一することができます。
VSCode では直接はこの仕組みをサポートしていませんが、事前準備でインストールした EditorConfig 拡張機能によってこの仕組みが利用できるようになります。
VSCode の settings.json などでも同様のことを実現できるのですが、下記の理由から EditorConfig を採用することにします。

  • EditorConfig をサポートする別のエディタを併用できる
  • 適用対象ファイルをパターンで指定することができる
  • フォルダ単位で設定ファイルを用意することができる (基本的にはルートフォルダーで全体設定するだけで事足りますが)

では、プロジェクトフォルダーに .editorconfig というファイルを作成し、下記の内容で保存してください。 (必要に応じて独自にカスタマイズしてください。)

root = true

[*]
end_of_line = lf
charset = utf-8
indent_size = 4
indent_style = space
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

一行目の root = true という記述は特別な設定です。これによって「この設定ファイルはルートフォルダーに配置されている」ということが宣言され、これより上位のフォルダーに EditorConfig 設定ファイルがあったとしても無視されるようになります。
あとはシンプルでわかりやすいと思うので説明は省きます。

なお、VSCode 独自の設定や拡張機能の設定は EditorConfig では設定できませんので settings.json で管理します。(後述)

Git Ignore の設定

Git Ignore は下記の gitignore.io という Web サイトで作成するのが手っ取り早いです。

今回は Node, react, Linux, VisualStudioCode を入力して作成しました。 (Stylus を含めると *.css が登録されてしまうのであえて除外)

では、プロジェクトフォルダーに .gitignore というファイルを作成し、下記の内容で保存してください。 (必要に応じて独自にカスタマイズしてください。)

内容
# Created by https://www.gitignore.io/api/node,react,linux,visualstudiocode
# Edit at https://www.gitignore.io/?templates=node,react,linux,visualstudiocode

### Linux ###
*~

# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*

# KDE directory preferences
.directory

# Linux trash folder which might appear on any partition or disk
.Trash-*

# .nfs files are created when an open file is removed but is still being accessed
.nfs*

### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.test

# parcel-bundler cache (https://parceljs.org/)
.cache

# next.js build output
.next

# nuxt.js build output
.nuxt

# rollup.js default build output
dist/

# Uncomment the public line if your project uses Gatsby
# https://nextjs.org/blog/next-9-1#public-directory-support
# https://create-react-app.dev/docs/using-the-public-folder/#docsNav
# public

# Storybook build outputs
.out
.storybook-out

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# Temporary folders
tmp/
temp/

### react ###
.DS_*
**/*.backup.*
**/*.back.*

node_modules

*.sublime*

psd
thumb
sketch

### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

### VisualStudioCode Patch ###
# Ignore all local history of files
.history

# End of https://www.gitignore.io/api/node,react,linux,visualstudiocode

開発コンテナ

さあ、お待ちかねの コンテナハウス 開発コンテナです。
快適な環境を整えていきましょう。

開発コンテナの作成

開発コンテナは Remote-Container 拡張機能が用意しているコマンドで手っ取り早く作成することもできるのですが、下記の理由から本記事では手作業で作成します。

  • ベースイメージは本番環境と揃えたい
  • 拡張機能が用意する Node.js 開発用 Dockerfile と Docker in Docker 用 Dockerfile の2つの良いとこどりをしたい
  • 拡張機能が用意する Node.js 開発用 Dockerfile では、今回使用する一部の Node.js モジュールとの相性が悪い
  • 拡張機能が用意する Docker in Docker 用 Dockerfile では、イメージサイズが無駄に大きくなる

やはり 生活空間 開発環境はこだわらないと。
まずはプロジェクトフォルダーに .devcontainer フォルダーを作成してください。
大事なことなので画像貼っておきます。
image.png
このフォルダーの中に Dockerfiledevcontainer.json を作成します。
image.png

Dockerfile

Dockerfile は下記の内容で保存してください。

FROM node:12.16-buster-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
    #
    # Verify git, process tools installed
    && apt-get -y install --no-install-recommends git openssh-client iproute2 procps \
    #
    #####
    # https://github.com/Microsoft/vscode-dev-containers/tree/master/containers/docker-in-docker#how-it-works--adapting-your-existing-dev-container-config
    # Note that no recommended packages are required, except for gnupg-agent.
    #
    # Install Docker CE CLI
    && apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl software-properties-common lsb-release jq \
    && apt-get install -y gnupg-agent \
    && curl -fsSL https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/gpg | apt-key add - 2>/dev/null \
    && add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]') $(lsb_release -cs) stable" \
    && apt-get update \
    && apt-get install -y --no-install-recommends docker-ce-cli \
    #
    # Install Docker Compose
    && curl -sSL "https://github.com/docker/compose/releases/download/$(curl https://api.github.com/repos/docker/compose/releases/latest | jq .name -r)/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose \
    && chmod +x /usr/local/bin/docker-compose \
    #
    #####
    # Clean up
    && apt-get autoremove -y \
    && apt-get clean -y \
    && rm -rf /var/lib/apt/lists/*
ENV DEBIAN_FRONTEND=dialog

これが開発コンテナの素です。
Docker がこの定義に従ってイメージを構築し、コンテナとして立ち上げてくれるのです。

ベースイメージは node:12.16-buster-slim です。後述の本番環境のベースイメージと揃えています。buster は Debian 10 のことで、Docker イメージ向けにスリムになったものが buster-slim です。これに Node.js 12.16 がインストールされています。

開発コンテナには更に、Git など開発に必要となるツールと Docker CE CLI、Docker Compose を追加インストールします。これらの追加ツールは、製品コードに直接影響しない補助ツールなのでバージョン指定を行っていません。Docker Compose についても、下記の記事を参考に最新版が自動で選択されるよう細工しています。

devcontainer.json

devcontainer.json は下記の内容で、{Workspace Name} 1箇所と {Project Name} 2箇所を適切に置き換えた上で保存してください。この際、大文字・小文字もしっかり合わせる必要があります。
本記事の場合、{Workspace Name}MoroMoro.Sample に、{Project Name}MoroMoro.Sample.Frontend になります。

// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.101.1/containers/javascript-node-12
{
    "name": "Node.js 12",
    "dockerFile": "Dockerfile",
    // Set *default* container specific settings.json values on container create.
    "settings": {
        "terminal.integrated.shell.linux": "/bin/bash"
    },
    // Add the IDs of extensions you want installed when the container is created.
    "extensions": [
        "editorconfig.editorconfig",
        "mhutchie.git-graph",
        "eamodio.gitlens",
        "sysoev.language-stylus",
        "msjsdiag.debugger-for-chrome",
        "msjsdiag.debugger-for-edge",
        "dbaeumer.vscode-eslint",
        "haaleo.vscode-stylint",
        "thisismanta.stylus-supremacy",
        "firsttris.vscode-jest-runner"
    ],
    // Use 'forwardPorts' to make a list of ports inside the container available locally.
    "forwardPorts": [
        3000, // Main server
        8080, // HMR server
    ],
    "mounts": [
        // Use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-in-docker.
        "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind",
        // https://code.visualstudio.com/docs/remote/containers-advanced#_use-a-targeted-named-volume
        "source={Project Name}-node_modules,target=/workspaces/{Workspace Name}/{Project Name}/node_modules,type=volume"
    ],
    // Use 'postCreateCommand' to run commands after the container is created.
    "postCreateCommand": "npm install"
}

重要な設定項目についてだけ詳しく説明しておきます。

extensions

インストールする VSCode の拡張機能です。
コンテナイメージ作成時に自動でインストールしてくれます。
ホスト側にはインストールされませんので開発コンテナ内でのみ使用可能です。
拡張機能は ID で指定する必要がありますが、拡張機能サイドバーで拡張機能を右クリックして Copy Extension Id を実行すれば簡単に ID をコピーできます。

forwardPorts

ポートフォワーディング設定です。
コンテナ内でポート3000を使用してサーバーを立ち上げますので、ホスト側のブラウザからアクセスできるようにこの設定が必要となります。また、直接アクセスすることはありませんが、後述の HMR 用補助サーバーがポート8080を使用してこっそりブラウザとやり取りしますのでこちらも設定が必要です。

mounts

マウント設定です。二つ設定しています。

一つ目のマウント設定
Docker in Docker を実現するために必要です。
これにより、開発コンテナ内の Docker CLI がホストの Docker Desktop と接続され、正常に動作するようになります。

二つ目のマウント設定
node_modules フォルダー (Node モジュールが大量にインストールされるフォルダ) を Docker Desktop の名前付きボリュームという特別な領域にマウントするための設定です。
通常、ワークスペース内のファイルはホストとコンテナの間で自動的に共有されるのですが、ファイルアクセス速度はやや遅めです。基本的には全く問題無いレベルの遅延なのですが、node_modules フォルダーには大量のファイル作成が行われるため、この遅延によるパフォーマンス低下が顕著に現れてしまいます。そこで、node_modules フォルダーだけは例外的にホストではなく名前付きボリュームにマウントしてしまうわけです。

名前付きボリュームはコンテナの外の領域なので、コンテナを再作成しても削除されません。その代わり、別コンテナからも同じ名前付きボリュームにマウントすることができてしまいます。なので、名前が衝突しないよう MoroMoro.Sample.Frontend-node_modules というように面倒な名前付けを行う必要があります。

ちなみに、マウント先のパスの先頭 (パーティション名) は workspaces 固定です。workspaceFolder 設定及び workspaceMount 設定で変更することもできますが、非常にややこしいことになるのでやらないでください。本記事の、『マルチルートワークスペース』+『ワークスペース丸ごと Git 管理』+『コンテナ内から Git 操作』という構成は恐らく成立しなくなります。
ホスト側のワークスペース直下で Git Init を行い、workspaceFolder 設定及び workspaceMount 設定を変更していない2場合に限り、ホストのプロジェクトフォルダではなくワークスペースフォルダがマウントされ、コンテナ内から Git 操作できるようになります。

postCreateCommand

コンテナイメージ作成後に自動実行されるコマンドです。コンテナイメージはコンテナを初めて開くときに作成されます。
npm install というコマンドは package.json に従って Node モジュールのインストールを行うコマンドです。この後コンテナを開きますが、その時点では package.json がないのでこのコマンドはあまり意味がありません。しかし、package.json 作成完了後であれば、チームメンバーが Git からソースコード一式を手に入れて開発コンテナを開くとそれだけで最初から開発環境が完璧に揃って提供されることになります。

開発コンテナを開く

ついに開発コンテナが用意できましたね…。早速開きましょう。
開き方はいくつかあるのですが、本記事では Remote Explorer から開く方法をお勧めします。他の方法より手数が多い方法なのですが、Remote Explorer に慣れておけば後々便利です。
まだ一度も開いていないコンテナを開くには、Remote Explorer の右上の [+] ボタンを押し、Open Folder in Container... を選択します。
image.png
フォルダー選択ダイアログが開きますのでプロジェクトフォルダーを選択して開きます。
すると右下にメッセージが表示されコンテナイメージの作成が開始します。
image.png
作成が完了するとメッセージが消えます。
問題無ければこの時点でコンテナの中に入れているはずです。コンテナの中にいる時はステータスバーの左端に Dev Container: Node.js 12 と表示されます。
image.png

コンテナの中でプロジェクトフォルダーを開いたことにより、コンテナ内ではプロジェクトフォルダーがワークスペースそのものとなります。
本記事では、コンテナ内でのワークスペースのことをコンテナワークスペースと呼ぶことにします。

Docker ソケットのマウント確認

ターミナルから次のコマンドを実行し、正常にイメージ一覧を取得できることを確認してください。

docker images

エラーが発生してしまう場合は開発コンテナの準備に不備があったということですので設定を見直してください。
修正したら、開発コンテナをリビルドします。

node_modules のマウント確認

コンテナワークスペースに空の node_modules フォルダーが作成されていることを確認してください。
node_modules フォルダが作成されていない場合は開発コンテナの準備に不備があったということですので設定 (特にワークスペース名やプロジェクト名の誤字脱字、workspacesWorkspacesworkspace になっていないかなど) を見直してください。 (或いは前述の Git Init を行っていない場合にもマウントが正常に行えません。Git Init を行っていないと、コンテナワークスペースが /workspaces/{Workspace Name}/{Project Name} ではなく /workspaces/{Project Name} に配置されてしまいます。使い捨てで Git 管理しない場合でも Git Init だけは必ず行ってください。)
修正したら、開発コンテナをリビルドします。

なお、前述の通りこの node_modules フォルダーはホスト側にはファイルを一切作成しません。逆にホスト側で node_modules フォルダー内にファイルを作成しても、コンテナ側には共有されません。ただし、node_modules フォルダーそのものの削除を行ってしまうとお互い連動して削除されてしまいます。ホスト側では中身が無いからといって、くれぐれもフォルダーそのものを削除しないよう注意しましょう。

開発コンテナのリビルド

開発コンテナの準備に不備があった場合には、修正してリビルドを行ってください。
リビルドする方法もいくつかあるのですが、やはり Remote Explorer で行う方法をお勧めします。
Remote Explorer にコンテナとフォルダーが登録されていますので、コンテナを右クリックして Rebuild Container を実行するとリビルドが行われます。完了後は自動でコンテナが開きなおされます。
image.png
コンテナのリビルドはコンテナ内にいる時にしか実行できません。
コンテナ外にいるときは単純にコンテナを削除してしまえば、次回コンテナを開くときにコンテナが自動で作成されます。
image.png

開発コンテナを閉じる

VSCode を終了すればコンテナは停止されます。(ただし、devcontainer.json で "shutdownAction": "none" を設定している場合は停止されません。)
次回 VSCode 起動時には自動で開発コンテナが開かれます。

VSCode を終了させずにコンテナを閉じる時は、メニューバーの [File] - [Close Remote Connection] を実行します。(Connection といいつつ接続だけでなくコンテナも停止します。)
image.png
再び開発コンテナを開くには、1回目と同じ方法でも開くことができるのですが、もっと楽な手順で開くこともできます。Remote Explorer にコンテナとフォルダーが登録されていますので、フォルダーの右端の image.png (Open Folder in Container) ボタンを押せば一発で開けます。
image.png

無事開発コンテナを開けたら

ここからはもう、ずっと、最後まで、とことん、開発コンテナにひきこもります。

パッケージの作成

さて、まずはプロジェクトのパッケージ化を行いましょう。
パッケージ化すると、パッケージのビルドや実行などのスクリプトを登録できたり、パッケージが依存する Node モジュールを簡単に管理できたりします。

package.json の作成

コンテナワークスペースに package.json ファイルを作成し、下記の内容で {project name} 2箇所と {Your Name} 1箇所を適切に置き換えた上で保存してください。{project name} では大文字が禁止されているので全て小文字で記述します。
本記事の場合、{project name}moromoro.sample.frontend に、{Your Name}Kenji Yokoyama になります。
(npm init は今回使いません。下記を貼り付けて置換した方が手っ取り早いので。)

{
    "name": "{project name}",
    "version": "1.0.0",
    "description": "{project name}",
    "scripts": {
        "start": "     export NODE_ENV=production  && ts-node ./src/server/server.ts",
        "start:dev": " export NODE_ENV=development && ts-node-dev --nolazy --inspect=9229 ./src/server/server.ts",
        "build": "     export NODE_ENV=production  && webpack --config ./webpack.config.ts",
        "build:dev": " export NODE_ENV=development && webpack --config ./webpack.config.ts",
        "run": "       export NODE_ENV=production  && npm run build     && npm start",
        "run:dev": "   export NODE_ENV=development && npm run build:dev && npm run start:dev",
        "run:hmr": "   export NODE_ENV=development && export HMR=true   && concurrently \"npm run start:dev\" \"webpack-dev-server --config ./webpack.config.hmr.ts\"",
        "test": "      export NODE_ENV=test        && jest --coverage"
    },
    "author": "{Your Name}",
    "license": "UNLICENSED",
    "private": true
}

フロントエンド開発のための非公開パッケージですので、"license": "UNLICENSED""private": true を設定しておきます。
scripts に登録した各スクリプトについては後ほど適宜説明していきます。

Node モジュールのインストール

続いて Node モジュールのインストールです。VSCode のターミナルにて、モジュール名を指定して npm install コマンドを実行します。依存モジュールがある場合は、基本的に全て自動で追加インストールされます。

まずは本番環境用モジュールをインストールします。

本番環境用モジュールのインストールには -S オプションを使用します。

npm install -S typescript ts-node express @types/express log4js node-fetch @types/node-fetch

次は開発環境用モジュールのインストールです。

開発環境用モジュールのインストールには -D オプションを使用します。
実行環境用にインストールしたモジュールを改めてインストールする必要はありません。

npm install -D ts-node-dev stylus @types/stylus eslint eslint-plugin-react @typescript-eslint/eslint-plugin @typescript-eslint/parser stylint react @types/react react-dom @types/react-dom react-router-dom @types/react-router-dom react-hot-loader @hot-loader/react-dom webpack @types/webpack webpack-cli webpack-merge @types/webpack-merge webpack-dev-server @types/webpack-dev-server ts-loader style-loader css-loader stylus-loader url-loader file-loader @types/file-loader clean-webpack-plugin html-webpack-plugin @types/html-webpack-plugin concurrently @types/concurrently jest @types/jest ts-jest fetch-mock @types/fetch-mock @testing-library/react @testing-library/react-hooks @testing-library/jest-dom jest-css-modules

手動インストールが必要なモジュール (代替モジュールがあるもの) がいくつか報告されましたので追加でインストールします。

npm install -D react-test-renderer @types/react-test-renderer canvas bufferutil utf-8-validate

インストールは node_modules フォルダーに対して行われます。
コンテナワークスペースの node_modules フォルダーに大量のモジュールフォルダーが追加されていることを確認しておきましょう。
この時、ホスト側の node_modules には一切ファイルが追加されていないことが重要です。せっかくひきこもったのですが、念のため 偵察ドローンを飛ばして外の様子を Windows Explorer でホスト側の node_modules フォルダーが空であることを確認しておいてください。

なお、インストールに成功するとインストールされたモジュールのバージョン情報が package.json の dependenciesdevDependencies に記録されます。
更に、追加でインストールされた間接的な依存モジュールも含む全ての依存モジュールの情報が package-lock.json に記録されます。
package.json と package-lock.json によって依存モジュールのバージョンが固定されるので、モジュールの再インストールをしても全く同じ環境を復元することができます。

コンテナワークスペース用 VSCode 設定

コンテナワークスペースに .vscode フォルダを作成し、下記の内容の settings.json ファイルを作成してください。

{
    "editor.formatOnSave": true,
    "editor.formatOnPaste": true,
    "editor.formatOnType": true,
    "[stylus]": {
        "editor.formatOnType": false
    },
    "files.associations": {
        "*.stylintrc": "jsonc"
    },
    "eslint.format.enable": true,
    "jestrunner.debugOptions": {
        "skipFiles": [
            "<node_internals>/**/*.js",
            "node_modules/"
        ]
    }
}

上から3つの設定はフォーマッタの基本設定です。セーブ時、ペースト時、タイピング時 (主に改行時) に自動フォーマットが行われるように設定しています。
残りの設定は ESLint、Stylint、Jest の 設定となりますが、これらについては後述します。

TypeScript の設定

TypeScript の設定を行っておきます。
コンテナワークスペースに tsconfig.json を作成し、下記の内容で保存してください。

{
    "compilerOptions": {
        "baseUrl": "src",
        "jsx": "react",
        "moduleResolution": "node",
        "noEmitOnError": true,
        "noFallthroughCasesInSwitch": true,
        "noImplicitReturns": true,
        "noUnusedLocals": true,
        "noUnusedParameters": false, // Resolving with an underscore when a parameter cannot be removed, may leave the one when the parameter is used again.
        "removeComments": true,
        "resolveJsonModule": true,
        "strict": true,
        /* Applied only to client scripts. */
        "module": "CommonJS",
        "target": "es6",
        "sourceMap": true
    },
    "exclude": [
        "node_modules"
    ]
}

本記事では各項目の説明は省略させていただきますが、ソースコードチェックができるだけしっかり行われるよう設定してあります。例えば使われていないローカル変数があったり型の指定がされていなかったりしたらコンパイルエラーになります。
ただし、引数については使われていないものがあってもエラーにならないようにしてあります。引数は削除できない場合が多々あるからです。(その場合、引数名にアンダースコアを付けることでエラー回避できるのですが、後から引数を使うようになった時にアンダースコアを削除するようメンバーに徹底しきれなかったりするので。)

Lint の設定

Lint は設定した独自のコーディングルールに基づいてソースコードの詳細なチェックを行ってくれるツールです。命名規則や空白の使い方、1ファイルあたりの最大行数など、様々なルールを設定できます。
本記事では私が考えるコーディングルールを設定していますが、これを叩き台に適宜ルールを変更していただければと思います。

ESLint

コンテナワークスペースに .eslintrc ファイルを作成し、下記の内容で保存してください。

内容
{
    "env": {
        "browser": true,
        "es6": true,
        "node": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:@typescript-eslint/eslint-recommended"
    ],
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
        "ecmaFeatures": {
            "jsx": true
        },
        "sourceType": "module"
    },
    "plugins": [
        "react",
        "@typescript-eslint"
    ],
    "rules": {
        "max-len": [
            "off",
            80
        ],
        "max-lines": [
            "warn",
            500
        ],
        "max-statements": [
            "warn",
            30
        ],
        "max-params": [
            "warn",
            5
        ],
        "max-depth": [
            "warn",
            4
        ],
        "max-nested-callbacks": [
            "warn",
            {
                "max": 5
            }
        ],
        "require-jsdoc": [
            "warn",
            {
                "require": {
                    "FunctionDeclaration": true,
                    "MethodDefinition": true,
                    "ClassDeclaration": true,
                    "ArrowFunctionExpression": false,
                    "FunctionExpression": false
                }
            }
        ],
        "indent": [
            "error",
            4,
            {
                "SwitchCase": 1
            }
        ],
        "quotes": [
            "error",
            "double",
            {
                "avoidEscape": true
            }
        ],
        "semi": [
            "error",
            "always"
        ],
        "no-multiple-empty-lines": [
            "error",
            {
                "max": 2,
                "maxBOF": 0,
                "maxEOF": 0 // It allows one empty line.
            }
        ],
        "brace-style": [
            "error",
            "1tbs",
            {
                "allowSingleLine": false
            }
        ],
        "max-statements-per-line": [
            "error",
            {
                "max": 1
            }
        ],
        "one-var": [
            "error",
            "never"
        ],
        "one-var-declaration-per-line": [
            "error",
            "always"
        ],
        "comma-style": [
            "error",
            "last"
        ],
        "dot-location": [
            "error",
            "property"
        ],
        "no-useless-computed-key": [
            "error",
            {
                "enforceForClassMembers": true
            }
        ],
        "object-property-newline": [
            "error",
            {
                "allowAllPropertiesOnSameLine": true
            }
        ],
        "padded-blocks": [
            "error",
            "never"
        ],
        "wrap-iife": [
            "error",
            "inside"
        ],
        "camelcase": "error",
        "no-unused-vars": "off",
        "yoda": "error",
        "curly": "error",
        "arrow-spacing": "error",
        "arrow-parens": [
            "error",
            "as-needed",
            {
                "requireForBlockBody": true
            }
        ],
        "prefer-arrow-callback": "error",
        "object-curly-spacing": [
            "error",
            "always"
        ],
        "rest-spread-spacing": [
            "error",
            "never"
        ],
        "template-curly-spacing": "error",
        "block-spacing": "error",
        "array-bracket-spacing": "error",
        "semi-spacing": "error",
        "space-before-blocks": "error",
        "space-in-parens": "error",
        "key-spacing": "error",
        "keyword-spacing": "error",
        "space-infix-ops": "error",
        "comma-spacing": "error",
        "func-call-spacing": "error",
        "space-unary-ops": "error",
        "spaced-comment": "error",
        "use-isnan": "error",
        "new-parens": "error",
        "constructor-super": "off", // It is not needed, because VSCode already has the checker.
        "no-constant-condition": "off",
        "no-empty": [
            "error",
            {
                "allowEmptyCatch": true
            }
        ],
        "no-fallthrough": "error",
        "no-iterator": "error",
        "no-new-wrappers": "error",
        "no-path-concat": "error",
        "no-self-compare": "error",
        "no-throw-literal": "error",
        "no-undef-init": "error",
        "no-unreachable": "error",
        "no-unsafe-finally": "error",
        "no-unsafe-negation": "error",
        "no-useless-call": "error",
        "no-whitespace-before-property": "error",
        "eqeqeq": "error"
    }
}

設定できるルールについては下記のドキュメントから確認できます。

ESLint はソースコードのチェックだけでなく、一部のルールに対する自動修正機能を含んでいます。
コンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によって、ファイル保存時や改行時などに ESLint による自動修正が行われるようになります。

    "eslint.format.enable": true,

Stylint

コンテナワークスペースに .stylintrc ファイルを作成し、下記の内容で保存してください。

内容
{
    "blocks": false,
    "brackets": "never",
    "colons": "always",
    "colors": "always",
    "commaSpace": "always",
    "commentSpace": "always",
    "cssLiteral": "never",
    "customProperties": [],
    "depthLimit": false,
    "duplicates": true,
    "efficient": false,
    "exclude": [],
    "extendPref": "@extends",
    "globalDupe": false,
    "groupOutputByFile": true,
    "indentPref": 4,
    "leadingZero": "always",
    "maxErrors": false,
    "maxWarnings": false,
    "mixed": true,
    "mixins": [],
    "namingConvention": "camelCase",
    "namingConventionStrict": true,
    "none": "never",
    "noImportant": true,
    "parenSpace": "never",
    "placeholders": false,
    "prefixVarsWithDollar": "always",
    "quotePref": "double",
    "reporterOptions": {
        "columns": [
            "lineData",
            "severity",
            "description",
            "rule"
        ],
        "columnSplitter": "  ",
        "showHeaders": false,
        "truncate": true
    },
    "semicolons": "never",
    "sortOrder": false,
    "stackedProperties": "never",
    "trailingWhitespace": "never",
    "universal": false,
    "valid": true,
    "zeroUnits": false,
    "zIndexNormalize": false
}

設定できるルールについては下記のドキュメントから確認できます。

Stylint のルールチェックは Stylint 拡張機能、自動フォーマットは Manta's Stylus Supremacy 拡張機能が行ってくれます。ただ、自動フォーマットはやや強力すぎる (ルール違反を一瞬たりとも許さず、単に次の項目を入力するために改行しただけでも消し去られます) ので、コンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によってタイピング時の自動フォーマットを無効化しています。

    "[stylus]": {
        "editor.formatOnType": false
    },

また、.stylintrc ファイルは、そのままでは VSCode が JSON ファイルとして認識してくれないため、コンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によって言語モードに JSON with Comments を設定しています。

    "files.associations": {
        "*.stylintrc": "jsonc"
    },

<注意>
stylint とよく似た名前の stylelint という Lint 系ツールがありますが、これは Stylus ではなく CSS や Sass の Lint ツールです。本記事の構成では Stylus しか使用しないため不要です。

WebPack の設定

WebPack はモジュールバンドラです。
開発したソースコードや画像などのファイル群をリリース用にバンドルしてくれます。
特にソースコードについては、TypeScript からコンパクトな JavaScript へのトランスパイルや、トランスパイルされた JavaScript を呼び出すコードを HTML に埋め込んでくれたりします。
更に、画像ファイルなども全て JavaScript コード化してひとまとめにすることができます。(ひとまとめにせず独立したファイルのまま含めることもできます。)

設定に使用できるファイルフォーマットは複数ありますが、本記事では強力なコード補完機能の恩恵を受けられる TypeScript にて記述します。

基本設定

コンテナワークスペースに webpack.config.ts ファイルを作成し、下記の内容で保存してください。

import * as webpack from "webpack";
import { CleanWebpackPlugin } from "clean-webpack-plugin";
import * as HtmlWebpackPlugin from "html-webpack-plugin";
import * as path from "path";

const IS_DEV = (process.env.NODE_ENV === "development");

const config: webpack.Configuration = {
    mode: !IS_DEV ? "production" : "development",
    devtool: !IS_DEV ? false : "source-map",
    entry: [
        "./src/client/index.tsx"
    ],
    output: {
        filename: "bundle.js",
        path: path.resolve(__dirname, "dist"),
        publicPath: "/"
    },
    resolve: {
        extensions: [".js", ".ts", ".tsx", ".styl"],
        modules: [
            path.resolve(__dirname, "src"),
            path.resolve(__dirname, "node_modules"),
            "node_modules"
        ],
        alias: {
            "react-dom": "@hot-loader/react-dom",
        },
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: [
                    "react-hot-loader/webpack",
                    "ts-loader"
                ]
            },
            {
                test: /\.styl$/,
                use: [
                    "style-loader",
                    {
                        loader: "css-loader",
                        options: {
                            importLoaders: 1,
                            sourceMap: IS_DEV,
                            modules: {
                                localIdentName: !IS_DEV ? "[hash:base32]" : "[path][name]__[local]",
                            }
                        }
                    },
                    "stylus-loader"
                ]
            },
            {
                test: {
                    not: [
                        /\.html?$/,
                        /\.jsx?$/,
                        /\.tsx?$/,
                        /\.styl$/
                    ]
                },
                use: {
                    loader: "url-loader",
                    options: {
                        /* Every file exceeding the size limit is deployed as a file with a name of the indicated rule. */
                        limit: 51200,
                        name: !IS_DEV ? "[hash:base32].[ext]" : "[path][name].[ext]"
                    }
                }
            },
            {
                test: /favicon\.ico$/,
                use: "file-loader?name=[name].[ext]"
            },
        ],
    },
    plugins: [
        new CleanWebpackPlugin({
        }),
        new HtmlWebpackPlugin({
            template: "./src/client/index.html",
            filename: "./index.html"
        })
    ]
};

export default config;

細かく説明すると非常に長くなってしまうので、要点をまとめておきます。

  • ビルド時にセットされる NODE_ENV 環境変数 (productiondevelopment のいずれか) に従い、設定を切り替える
  • development モード時はデバッグ補助用にソースマップ (トランスパイル前後のソースコードの紐付け情報) を生成する
  • バンドル前のエントリーポイントは ./src/client/index.tsx
  • バンドル後のエントリーポイントは bundle.js、出力フォルダは dist
  • bundle.js の公開 URL をルート直下 ("/") に固定 (どの階層の URL からアクセスされてもルート直下にアクセスするようになる)
  • バンドルするソースコードが配置されているフォルダーは srcnode_modules
  • react-dom モジュールをインポートしようとすると代わりに @hot-loader/react-dom モジュールがインポートされる (後述の HMR で必要となる)
  • TypeScript ファイル (.ts or .tsx) は次のローダーを使ってバンドルを行う
    • ts-loader : TS から JS へのトランスパイル
    • react-hot-loader/webpack : 後述の HMR で必要となる
  • Stylus ファイル (.styl) は次のローダーを使ってバンドルを行う
    • stylus-loader : Stylus から CSS へのトランスパイル
    • css-loader : CSS のクラス名を衝突回避のためユニークな名前に変換する (後述の CSS Modules)
    • style-loader : CSS を JS で動的に出力する
  • HTML、JavaScript、TypeScript、Stylus 以外のファイルは、50KB 以下なら JS に直接埋め込み、50KB 以上ならファイル名を一意に変更した上で独立ファイルとしてバンドルする
  • favicon.ico はブラウザが名指しで直接取得しにくるので、ファイル名を維持して独立ファイルとしてバンドルする
  • CleanWebpackPlugin を使用し、バンドル処理開始時に前回の出力結果を全て削除する
  • HtmlWebpackPlugin を使用し、バンドル後の JS ファイル (bundle.js) を呼び出すコードを index.html に埋め込む

HMR 用補助サーバーの設定

HMR (Hot Module Replacement) というのは、開発時、ブラウザで Web アプリの動作を確認している時にソースコードを変更しても、サーバーの再起動もブラウザのリロードも行うことなく変更内容がブラウザに自動反映されるという機能です。(複雑な変更は追従しきれない場合があり、その場合は手動でリロードするようブラウザに表示されます。)

HMR の実現を補助する開発用サーバーが WebPack に用意されていますので、ここではそのサーバー設定を行います。HMR 利用時以外は不要なサーバーなので、基本設定とは別のファイルにします。(webpack-merge を使用して基本設定を HMR 用設定にマージします。)

コンテナワークスペースに webpack.config.hmr.ts ファイルを作成し、下記の内容で保存してください。

import * as webpack from "webpack";
import { merge } from "webpack-merge";
import config from "./webpack.config";
import "webpack-dev-server";

const hmrConfig: webpack.Configuration = merge(config, {
    devServer: {
        host: "localhost",
        port: 8080,
        disableHostCheck: true,
        contentBase: "src/client",
        historyApiFallback: true,
        inline: true,
        hot: true,
        open: false
    }
});

export default hmrConfig;

CSS Modules や画像ファイルを TypeScript で利用可能にする

TypeScript では型定義のないモジュールをインポートして使用するとエラーになってしまいます。
解決方法はいくつかありますが、本記事では手っ取り早く下記の定義を追加します。

  • 全ての Stylus ファイルに対して、string 配列がエクスポートされたモジュールとして型定義を追加
  • 全てのファイル (型定義が見つからなかった場合に限る) に対して、Any 型の値がデフォルトエクスポートされたモジュールとして型定義を追加

コンテナワークスペースに modules.d.ts ファイルを作成し、下記の内容で保存してください。

declare module "*.styl" {
    const classNames: {
        [className: string]: string
    };
    export = classNames;
}

declare module "*" {
    const value: any;
    export default value;
}

Jest の設定

Jest はテスティングフレームワークです。
本記事では Jest と React Testing Framework を組み合わせることで React コンポーネントのユニットテストを行います。

コンテナワークスペースに jest.config.json ファイルを作成し、下記の内容で保存してください。

{
    "preset": "ts-jest",
    "modulePaths": [
        "src"
    ],
    "moduleNameMapper": {
        "\\.(css|styl)$": "<rootDir>/node_modules/jest-css-modules"
    },
    "coveragePathIgnorePatterns": [
        "/node_modules/"
    ]
}

Jest で TypeScript をテストできるようにするため presetts-jest を設定します。
また、本来 WebPack (CSS Loader) を通さなければ処理できない CSS Modules (後述) という特殊なインポート方法を、WebPack を介さない Jest でも最低限エラー発生を回避して処理できるよう、moduleNameMapperjest-css-modules を設定しています。

デバッグ設定

次の5種類のデバッグを行えるよう設定を行います。

  • Chrome 上で動作しているクライアントサイドコードのデバッグ
  • Edge 上で動作しているクライアントサイドコードのデバッグ
  • サーバーサイドコードのデバッグ (既に起動しているサーバープロセスにアタッチしてデバッグ)
  • サーバーサイドコードのデバッグ (サーバープロセスを起動してデバッグ)
  • Jest でテスト実行しながらデバッグ

コンテナワークスペースに .vscode フォルダを作成し、下記の内容の launch.json ファイルを作成してください。

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Client (Chrome)",
            "type": "chrome",
            "request": "launch",
            "trace": true,
            "sourceMaps": true,
            "url": "http://localhost:3000",
            "webRoot": "${workspaceFolder}",
            "sourceMapPathOverrides": {
                "webpack:///*": "${workspaceFolder}/*"
            },
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        },
        {
            "name": "Debug Client (Edge)",
            "type": "edge",
            "request": "launch",
            "trace": true,
            "sourceMaps": true,
            "url": "http://localhost:3000",
            "webRoot": "${workspaceFolder}",
            "sourceMapPathOverrides": {
                "webpack:///*": "${workspaceFolder}/*"
            },
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        },
        {
            "name": "Debug Server (Attach)",
            "type": "node",
            "request": "attach",
            "cwd": "${workspaceFolder}",
            "port": 9229,
            "protocol": "inspector",
            "internalConsoleOptions": "openOnSessionStart",
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        },
        {
            "name": "Debug Server (Launch)",
            "type": "node",
            "request": "launch",
            // "preLaunchTask": "npm: build:dev",
            "runtimeArgs": [
                "--nolazy",
                "-r",
                "ts-node/register"
            ],
            "args": [
                "${workspaceFolder}/src/server/server.ts"
            ],
            "cwd": "${workspaceFolder}",
            "protocol": "inspector",
            "internalConsoleOptions": "openOnSessionStart",
            "env": {
                "TS_NODE_IGNORE": "false"
            },
            "skipFiles": [
                "<node_internals>/**/*.js",
                "node_modules"
            ]
        }
    ]
}

Jest のデバッグ実行は Jest Runner 拡張機能を使用して行うため launch.json では設定できません。代わりにコンテナワークスペース用 VSCode 拡張機能の設定で行った下記の設定によって Jest のデバッグ設定を行っています。

    "jestrunner.debugOptions": {
        "skipFiles": [
            "<node_internals>/**/*.js",
            "node_modules/"
        ]
    },

実装

ようやくここまで辿り着きました。(本記事を書き始めて1週間)
ここからは実装を行っていきます。

本記事では、ハードコーディングされた "Hello World." というメッセージを表示する Home ページと、サーバーに実装した hello API から取得した "Hello World!" というメッセージを動的に表示する Home Work ページを用意し、これらをメインページ内でタブ切り替えのように行き来できるようにします。二つのページには異なる URL を割り当てますので、URL 直打ちで最初から Home Work ページを表示させることもできます。
ezgif-1-7ea4b3d7020e.gif
Home Work ページではメッセージを動的に取得していることがわかりやすいよう、待機中は "Loading..." と表示しています。
あとデザインがクソダサいですが気にしないように。

ソースコードフォルダーの作成

コンテナワークスペースに下記のフォルダー構造を作成しておきます。

  • コンテナワークスペース
    • src
      • client
      • server

本記事ではこれ以上深いフォルダーはあえて作成しませんが、実開発ではフォルダー構成は重要です。
基本的な考え方として下記のページが参考になるかと思います。(後半を読み飛ばさないように)

サーバーサイド:loggers.ts

log4js を使用してシステムログ用のロガーとアクセスログ用のロガーを実装します。 (クライアントサイドはブラウザ上で実行されるためログは取れません。)
log4js の公式ドキュメントは下記にあります。

src/server フォルダーに loggers.ts ファイルを作成し、下記の内容で保存してください。

import * as log4js from "log4js";

const IS_DEV = process.env.NODE_ENV === "development";

log4js.configure({
    appenders: {
        "system_console": {
            type: "console",
            layout: {
                type: "pattern",
                pattern: "%[[%d] [%p]%] %c - %m [%f:%l:%o]"
            },
        },
        "system_file": {
            type: "file",
            filename: "logs/system/system.log",
            maxLogSize: 5 * 1024 * 1024,
            backups: 5,
            compress: true,
            layout: {
                type: "pattern",
                pattern: "[%d] [%p] %c - %m [%f:%l:%o]"
            },
        },
        "access_console": {
            type: "console",
        },
        "access_file": {
            type: "dateFile",
            filename: "logs/access/access.log",
            pattern: "yyyy-MM-dd",
            alwaysIncludePattern: true,
            keepFileExt: true,
            compress: true,
            daysToKeep: 5,
        }
    },
    categories: {
        "default": {
            appenders: ["system_console"],
            level: !IS_DEV ? "info" : "all",
            enableCallStack: true,
        },
        "system": {
            appenders: ["system_console", "system_file"],
            level: !IS_DEV ? "info" : "all",
            enableCallStack: true,
        },
        "access": {
            appenders: !IS_DEV ? ["access_file"] : ["access_console", "access_file"],
            level: !IS_DEV ? "info" : "all",
        }
    }
});

export const defaultLogger = log4js.getLogger();
export const systemLogger = log4js.getLogger("system");
export const accessLogger = log4js.getLogger("access");
export const accessLogConnector = log4js.connectLogger(accessLogger, { level: "auto" });

システムログは logs/system フォルダーに保存されます。
ログサイズが 5MB を超えたらログを圧縮してローテーションを行うように設定しています。

アクセスログは logs/access フォルダーに保存されます。
毎日ログを圧縮してローテーションを行うように設定しています。

また、NODE_ENV 環境変数が development の時 (開発時) は全てのレベルのログを出力し、production の時 (本番) は fatalerrorwarninfo のログを出力します。

サーバーサイド:server.ts

Express を使用してサーバーを実装します。
Express の公式ドキュメントは下記にあります。

src/server フォルダーに server.ts ファイルを作成し、下記の内容で保存してください。

import * as express from "express";
import * as process from "process";
import * as path from "path";
import fetch from "node-fetch";
import { systemLogger as logger, accessLogConnector } from "./loggers";

const clientRootPath = "dist";
const clientRootAbsolutePath = path.join(process.cwd(), clientRootPath);

const server = express();

server.use(accessLogConnector);
server.use(express.static(clientRootPath));

server.get("/api/hello", (req, res) => {
    res.send({ message: "Hello World!" });
});

server.get("*", (req, res) => {
    if (process.env.HMR === "true") {
        fetch(
            `http://localhost:8080${req.originalUrl}`,
            {
                method: req.method,
                headers: req.headers as { [key: string]: string }
            }
        ).then(innerRes => new Promise((resolve, reject) => {
            innerRes.body.pipe(res);
            res.on("close", resolve);
            res.on("error", reject);
        }));
        return;
    }
    res.sendFile("index.html", { root: clientRootAbsolutePath });
});

server.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
    logger.error(err);
    next(err);
});

server.listen(3000, () => {
    logger.info("server running");
});

dist フォルダー内の静的ファイルへのアクセスについてはそのまま該当のファイルを返します。

サンプル実装として、/api/hello という URL にアクセスされたら { message: "Hello World!" } という JSON データを返すようにしています。実際の開発では、リバースプロキシ化してバックエンドサービスに処理を委譲することが多いかと思います。

上記のいずれにも当てはまらない場合、通常は index.html を返します。ここまで特に触れませんでしたが、本記事で構築するのは SPA (Single Page Application) と呼ばれる形式のアプリケーションで、クライアント内で完結するルーティングを行えるため、サーバーはとにかく index.html を返してあげる必要があります。
ただし、HMR 環境変数が true の場合には HMR 用補助サーバーへの簡易リバースプロキシとして動作します。HMR 用補助サーバーが静的コンテンツをホスティングするためです。

また、エラーハンドラを追加してエラーをシステムログに記録するようにしてあります。

そして最後にポート番号 3000 を使用してサーバーを起動しています。

クライアントサイド:index.html

コンテンツは React で実装していきますので index.html は非常にコンパクトです。

src/client フォルダーに index.html ファイルを作成し、下記の内容で保存してください。

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sample</title>
</head>

<body>
    <div id="root"></div>
</body>

</html>

body には id="root" を設定した div 要素を配置するのみです。
React がこの div 要素に対して動的にコンポーネントをレンダリングします。

クライアントサイド:index.tsx

index.tsx はエントリーポイントです。ここからクライアントサイドの処理が開始されます。

src/client フォルダーに index.tsx ファイルを作成し、下記の内容で保存してください。

import { hot } from "react-hot-loader/root"; // Must be imported before "react" and "react-dom".
import * as React from "react";
import * as ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import App from "./App";

import "./favicon.ico";

const Root = () => {
    return (
        <BrowserRouter>
            <App />
        </BrowserRouter>
    );
};

ReactDOM.render(<Root />, document.getElementById("root"));

export default hot(Root);

<補足>
TypeScript の中に HTML のタグのような記述が混ざっていることに戸惑う人もいるかと思います。これは React の JSX という機能で、HTML のようなタグ構文を使用してオブジェクト (仮想 DOM コンポーネント) の生成を行うことができます。(トランスパイルすると React.createElement() を呼び出す普通の JavaScript コードに変換されます。その関係で、import * as React from "react"; を必ず記述しておく必要があります。)
上述の Root 関数の場合、App コンポーネントを生成し、さらにそれを子要素として渡して BrowserRouter コンポーネントを生成しています。この Root 関数も仮想 DOM を生成して返す関数ですので、ReactDOM.render() の引数部分のように JSX 構文で Root コンポーネントを生成することができます。

エントリーポイントでは、HMR に対応するための細工と、React Router (後述) に対応するための細工を行います。具体的なコンテンツの実装は、次に実装する App コンポーネントから行っていきます。
具体的には、React Router DOM の BrowserRouter という特殊なコンポーネントで App コンポーネントをラップし Root コンポーネントとして定義し、更にその Root コンポーネントを React Hot Loader の hot 関数でラップしたものを、先ほど index.html に配置した div 要素に対してレンダリングしています。

なお、NODE_ENV 環境変数が production の時には hot 関数は何も行わず引数で受け取ったコンポーネントをそのまま返しますので、hot 関数は除去せずそのままリリースして大丈夫です。

favicon.ico がインポートできるのは WebPack のおかげです。
favicon.ico にはブラウザが決め打ちでアクセスしてくるのでインポートするだけで良いですが、例えば img タグで読み込ませたい場合は import favicon from "/favicon.ico"; として <img src={favicon} /> というように指定します。

クライアントサイド:App.tsx

ここからが UI を作りこんでいくメインプログラミングとなります。

App コンポーネント (メインページ) は、 自コンポーネント内に Home コンポーネント (Home ページ) を表示する "Home" リンクと、同じく自コンポーネント内に HomeWork コンポーネント (Home Work ページ) を表示する "Home Work" リンクを持ちます。リンクをクリックしても App コンポーネント自体は消えたり再読み込みされたりせず、Home コンポーネントと HomeWork コンポーネントの切り替えだけが行われます。どちらのコンポーネントも JavaScript コード自体は最初からブラウザに読み込まれていますので、切り替え時にサーバー通信は発生しません。(HomeWork コンポーネントの実装がサーバーの API を叩くのでそれに関しての通信は発生しますが。)

また、Home は "/" に、HomeWork は "/homework" にルーティングしています。これによりユーザーが URL をブックマークに登録してショートカット表示するということが可能になります。

src/client フォルダーに App.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { Switch, Route, Link } from "react-router-dom";

import * as styles from "./App.styl";
import Home from "./Home";
import HomeWork from "./HomeWork";

const App = () => {
    return (
        <>
            <Link className={styles.menuButton} to="/">Home</Link>
            <Link className={styles.menuButton} to="/homework">Home Work</Link>
            <Switch>
                <Route exact path="/" component={Home} />
                <Route exact path="/homework" component={HomeWork} />
            </Switch>
        </>
    );
};

export default App;

コンポーネントのルーティングや切り替えには React Router DOM を使用しています。

Stylus ファイル (App.stylus) はインポートするだけでスタイルが適用されるのですが、クラスセレクタはそのままでは適用されませんので、className={styles.menuButton} というようにして CSS クラス名をコンポーネントにセットする必要があります。CSS クラス名はバンドル時に、衝突回避のため一意な名前に変換されます。このように、CSS ファイルや Stylus ファイルを JavaScript/TypeScript モジュールのように扱う機能のことを CSS Modules と言います。

<補足>
JSX 構文で仮想 DOM を作成する際には、必ず単一の親要素を用意する必要があります。
今回のように特に親要素に該当するコンポーネントや HTML 要素が無い場合には、Fragment コンポーネント (<> または <Fragment>) を使用します。

クライアントサイド:favicon.ico

src/client フォルダーに favicon.ico ファイルを作成してください。
用意しないとビルドエラーが発生しますので中身は空でも良いので作成しておいてください。

クライアントサイド:App.styl

src/client フォルダーに App.styl ファイルを作成し、下記の内容で保存してください。

$basicForegroundColor = #0000A0
$basicBackgroundColor = #A0A0FF

body
    color: $basicForegroundColor
    background-color: $basicBackgroundColor

.menuButton
    margin-right: 16px

<注意>
スタイルシートのファイル間の依存方向が、コンポーネントの依存方向と逆行しないよう気を付けてください。
本記事では App.styl しか用意しませんが、例えば Home.styl や HomeWork.styl を用意する場合、Home.styl や HomeWork.styl から App.styl を参照 (インポート) してはいけません。base.styl を用意してそちらを参照させるなど、適切に設計しましょう。

クライアントサイド:Home.tsx

単純に "Hello World." と表示するだけのページです。

src/client フォルダーに Home.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";

const Home = () => {
    return (
        <h1>Hello World.</h1>
    );
};

export default Home;

クライアントサイド:HomeWork.tsx

サーバーに実装した hello API から取得した "Hello World!" というメッセージを動的に表示するページです。

src/client フォルダーに HomeWork.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { useState, useEffect } from "react";

const HomeWork = () => {
    const [message, setMessage] = useState("Loading...");
    useEffect(() => {
        fetch("/api/hello")
            .then(response => response.json())
            .then(json => setMessage(json.message));
    }, []);
    return (
        <h1>{message}</h1>
    );
};

export default HomeWork;

hello API との通信にはfetch 関数 (Web 通信を行う非同期関数) を使っていますが、取得したデータを動的にコンテンツに埋め込むには React Hooks (useStateuseEffect) を利用する必要があります。

HomeWork 関数は h1 タグに message 変数の中身を埋め込んで返すようになっていて、この message 変数は通常の変数ではなく useState によって用意されたステート変数という特殊な変数です。
"Loading..." を初期値として与えていますので、HomeWork ページ表示直後はこの "Loading..." が表示され、その後 hello API からデータが取得されると、setMessage 関数を通じて message ステート変数が書き換えられ、"Hello World!" と表示されます。
このように、動的な書き換えを行うには必ずステート変数を利用する必要があります。

そしてここからが少しややこしいのですが、実は HomeWork 関数は初期化時に1回だけ呼ばれるわけではなく、レンダー時 (ステート変数書き換え時) に毎回呼びなおされます。
ですので hello API との通信を HomeWork 関数にべた書きしてしまうと、ステート変数書き換え後に再び hello API 呼び出しが行われ、またステート変数が書き換えられ…と無限ループしてしまいます。
このように、DOM のレンダーとは独立して動作するべき処理は副作用と呼ばれ、useEffect 関数を通じてレンダー後に処理する必要があります。
といっても、単に処理のタイミングをレンダー後に移動しただけでは、ループすることに変わりありません。今回の場合は特に useEffect 関数の第二引数が重要です。ここには、値が更新されるたびに副作用を再実行する必要のあるステート変数のリストを渡すようになっています。そしてここに空のリストを渡すと、副作用の実行を初回レンダー後の1回のみに制限することができます。ちなみに、第二引数を省略した場合はレンダー毎に副作用が実行されてしまうので、空のリストをしっかりと渡す必要があります。

React Hooks のより詳しい解説については下記のドキュメントを参照してください。

実行

一通りの実装が完了しましたので実行させてみましょう。
Explorer サイドバーの NPM Scripts に、package.jsonscripts で定義したスクリプトの一覧が並んでいますので、run:hmr を実行します。
image.png
実行するとサーバー起動とビルドが行われます。
ezgif-1-98b27c5cd798.gif
Terminal に [1] ℹ 「wdm」: Compiled successfully. と出力されたら成功です。
ブラウザを立ち上げて http://localhost:3000 にアクセスしてみてください。
ezgif-1-7ea4b3d7020e.gif
Home と HomeWork がうまく切り替わったでしょうか。

クライアントサイドコードの変更

HMR 用補助サーバーの設定で説明しましたとおり、クライアントサイドコードの変更は HMR 機能によってブラウザをリロードすることなく即座に反映されます。
試しに Home.tsx の "Hello World." を "Hello HMR." に変えてみたり、App.styl をいじってみてください。
ezgif-1-832e51b56c30.gif
ちなみにこの動画では、撮影の都合上 Browser Preview という拡張機能を使用して VSCode 内にブラウザを表示させていますが、もちろん普通のブラウザでも HMR はちゃんと動作します。

サーバーサイドコードの変更

サーバーサイドコードの変更は ts-node-dev によって検出され、サーバーが自動で再起動されます。

サーバーの停止

基本的にサーバーは起動しっぱなしで良いのですが、ターミナル上で Ctrl + C を押せばサーバーが停止します。

デバッグ

特にバグがあるわけでもないのですが、次はデバッグをしてみます。

クライアントサイドコードのデバッグ

HomeWork 関数をデバッグしてみます。

  • 準備
    1. サーバーを起動しておきます。
    2. Run サイドバーの上部にあるドロップダウンリストから launch.json で定義したデバッグ設定を選べますので、Debug Client (Chrome) もしくは Debug Client (Edge) を選択しておきます。
    3. HomeWrok.tsx の5行目にカーソルを移動し、F9 キーにてブレークポイントを設置します。
  • デバッグ
    1. F5 キーにてデバッグを開始します。
    2. 自動起動されたブラウザにて HomeWork ページを表示すると、ブレークポイント (HomeWork.tsx の5行目) でブレーク (一時停止) します。

ブレーク後は下記の操作が行えます。

操作 キー
ステップオーバー3 F10
ステップイン4 F11
再開 F5
停止5 6 Shift + F5

他にも、変数にカーソルをあてて変数の中身を確認したり、DEBUG CONSOLE から変数の中身を書き換えたりすることも可能です。

サーバーサイドコードのデバッグ

hello API をデバッグしてみます。

  • 準備
    1. サーバーを起動しておきます。
    2. Run サイドバーの上部にあるドロップダウンリストから、Debug Server (Attach) を選択しておきます。
    3. server.ts の16行目にカーソルを移動し、F9 キーにてブレークポイントを設置します。
  • デバッグ
    1. F5 キーにてデバッグを開始します。
    2. ブラウザ (自動起動はしません) にて HomeWork ページを表示すると、ブレークポイント (Server.ts の16行目) でブレーク (一時停止) します。

ブレーク後は、クライアントサイドコードのデバッグ時と同じ操作が可能です。

なお、サーバーの起動時の処理をデバッグしたい場合には、サーバーを停止し Debug Server (Launch) を使用してデバッグを開始してください。

ユニットテスト

次はユニットテストを用意して実行してみます。本当はテスト駆動開発で実装より先にテストを用意したかったのですが、記事の構成の都合から諦めて後回しにしました。

各テストケースの基本的な流れは次のようになります。

  1. testing-library の render 関数でテスト対象コンポーネントを疑似的にレンダーする
  2. テストしたいシナリオを fireEvent でエミュレートする
  3. expect でコンポーネントの状態を検証する

ユニットテストフレームワークの詳細は下記を参照してください。

App コンポーネントのテスト

App コンポーネントに対して次の3つのテストケースを用意します。

  • 最初に Home が表示されること
  • メニューから Home を表示できること
  • メニューから Home Work を表示できること

src/client フォルダーに App.spec.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { MemoryRouter } from "react-router-dom";
import { render, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";

const homeMock = jest.fn(() => <></>);
jest.mock("./Home", () => ({ __esModule: true, default: homeMock } as any));

const homeworkMock = jest.fn(() => <></>);
jest.mock("./HomeWork", () => ({ __esModule: true, default: homeworkMock } as any));

import App from "./App";

afterEach(cleanup);
afterEach(jest.clearAllMocks);
afterAll(() => {
    jest.unmock("./Home");
    jest.unmock("./HomeWork");
});

describe("App", () => {
    it("最初に Home が表示されること", () => {
        render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeMock).toBeCalled();
    });

    it("メニューから Home を表示できること", () => {
        const root = render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeMock).toBeCalled();
        homeMock.mockClear();

        const menuHomeButton = root.getByText("Home", { exact: true });
        fireEvent.click(menuHomeButton);

        expect(homeMock).toBeCalled();
    });

    it("メニューから Home Work を表示できること", () => {
        const root = render(<MemoryRouter><App /></MemoryRouter>);
        expect(homeworkMock).not.toBeCalled();

        const menuHomeWorkButton = root.getByText("Home Work", { exact: true });
        fireEvent.click(menuHomeWorkButton);

        expect(homeworkMock).toBeCalled();
    });
});

<補足>
現行の Jest の型定義 (@types/jest@26.0.5) にバグがあるため、jest.mock の第二引数を any にキャストしてバグを回避しています。バグが修正されればキャストは不要になります。

MemoryRouter コンポーネントについて
App コンポーネントは React Router DOM を使用しているため、Router コンポーネントでラップする必要があります。
実行時は index.tsx にて BrowserRouter コンポーネントでラップしていますが、テスト時はブラウザ上で動作するわけではないので MemoryRouter コンポーネントでラップします。

コンポーネントのモック化について
App コンポーネントのテストに専念するため、App.tsx をインポートする前に Home コンポーネントと HomeWork コンポーネントを下記のようにしてモック化しています。

const homeMock = jest.fn(() => <></>);
jest.mock("./Home", () => ({ __esModule: true, default: homeMock } as any));

const homeworkMock = jest.fn(() => <></>);
jest.mock("./HomeWork", () => ({ __esModule: true, default: homeworkMock } as any));

これは Home.tsx や HomeWork.tsx を下記のようなコードで一時的に上書きしているかのような効果を持ちます。

export default () => {
    return (
        <></>
    );
};

モックは更に、自分が呼び出しされたかどうか等を調べられるようになっています。

expect(homeMock).toBeCalled();

これにより、App コンポーネントが Home コンポーネントや HomeWork コンポーネントを適切なタイミングで呼び出しているかどうかをテストできるわけです。

モック化が Home.spec.tsx 以外のテストケースに影響しないよう、クリーンアップ処理の登録も忘れずに行います。

afterAll(() => {
    jest.unmock("./Home");
    jest.unmock("./HomeWork");
});

Home コンポーネントのテスト

Home コンポーネントに対して次の1つのテストケースを用意します。

  • メッセージが表示されること

src/client フォルダーに Home.spec.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { render, cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";

import Home from "./Home";

afterEach(cleanup);
afterEach(jest.clearAllMocks);

describe("Home", () => {
    it("メッセージが表示されること", () => {
        const root = render(<Home />);
        const heading = root.getByRole("heading");
        expect(heading).toHaveTextContent("Hello World.");
    });
});

HomeWork コンポーネントのテスト

HomeWork コンポーネントに対して次の1つのテストケースを用意します。

  • サーバーから取得したメッセージが表示されること

src/client フォルダーに HomeWork.spec.tsx ファイルを作成し、下記の内容で保存してください。

import * as React from "react";
import { render, cleanup, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import * as fetchMock from "fetch-mock";

import HomeWork from "./HomeWork";

afterEach(cleanup);
afterEach(jest.clearAllMocks);
afterEach(() => fetchMock.restore());

describe("HomeWork", () => {
    it("サーバーから取得したメッセージが表示されること", async () => {
        fetchMock.get("/api/hello", (url, opts) => ({
            status: 200,
            body: {
                message: "Hello Mock."
            }
        }));
        const root = render(<HomeWork />);
        const heading = root.getByRole("heading");
        expect(heading).toHaveTextContent("Loading...");
        await waitFor(() => {
            expect(heading).toHaveTextContent("Hello Mock.");
        });
    });
});

fetch 関数のモック化について
HomeWork コンポーネントのテストに専念するため、fetch 関数 (hello API への通信) を下記のようにしてモック化しています。

        fetchMock.get("/api/hello", (url, opts) => ({
            status: 200,
            body: {
                message: "Hello Mock."
            }
        }));

今回のモック化は各テストケースがテストシナリオに合わせて用意している形ですので、他のテストケースに影響しないようテストケース実行のたびにクリーンアップを行います。

afterEach(fetchMock.restore);

waitFor 関数について
HomeWork コンポーネントはレンダー後に副作用として hellow API 呼び出しとメッセージ更新を行います。副作用はレンダースレッドがフリーになってから、つまりテストケースの実行が完了してからでないと本来実行されません。そこで、テストケースの中で waitFor 関数を使用し、スレッドを一旦フリーにしてあげる必要があります。
waitFor 関数はレンダーされた仮想 DOM の変更を一定 (50ms) 間隔で監視しながら待機する関数です。仮想 DOM が更新されると、引数に指定されたコールバック関数を実行して waitFor 関数が完了します。

テストの個別実行とデバッグ

テストを個別に実行するには、テストコードを右クリックして Run Jest を実行します。
ezgif-1-b04e1c98b6b7.gif
Run Jest ではなく Debug Jest を実行することでデバッグも可能です。
ezgif-1-bb2c8e5571b7.gif

テストの全体実行とカバレッジ

テスト全体を実行するには、Explorer サイドバーの NPM Scripts から、test を実行します。
image.png
結果と共にカバレッジも出力するようにしてあります。
image.png
カバレッジの詳細レポートはコンテナワークスペースの coverage フォルダーに出力されています。coverage/lcov-report/index.html をブラウザで開けば、どの行が何回実行されたかといった情報も確認できます。
image.png

本番用コンテナ

本記事もいよいよ大詰めです。
本番用コンテナは Dockerfile + docker-compose.yml で作成します。

Dockerfile

コンテナワークスペースに Dockerfile を作成し、下記の内容で保存してください。

FROM node:12.16-buster-slim AS base
WORKDIR /app
COPY ["package.json", "package-lock.json", "tsconfig.json", "./"]
RUN npm install --production --silent

FROM base AS dev
WORKDIR /app
RUN npm install --silent
COPY . .

FROM dev AS build
WORKDIR /app
RUN npm run build

FROM base AS product
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/src/server ./src/server
EXPOSE 3000

このコードでは、マルチステージビルドで次の4つのイメージを作成しています。

イメージ名 概要
base 開発環境イメージと本番イメージのベースイメージ。
dev 開発環境イメージ。開発環境用の Node モジュールのインストール、プロジェクトフォルダ内の全てのファイルのコピーを行う。
build ビルド実行イメージ。開発環境イメージをベースとして、package.json に定義した build スクリプトを使用してビルドを行う。
product 本番イメージ。build イメージで生成したビルド成果物が配置される。開発環境用の Node モジュールはインストールされない。

大元のベースイメージには node の 12.16-buster-slim を使用しています。これは、現時点での Node.js の安定バージョンと Debian の最新バージョンのスリム版の組み合わせです。
node の Docker イメージは下記ページから確認できます。

.dockerignore

コンテナワークスペースに .dockerignore を作成し、下記の内容で保存してください。

**/coverage
**/dist
**/logs
**/node_modules

ビルド時や実行時に生成されるディレクトリがイメージ内にコピーされないようにしています。
他にもビルドに不要なファイルはありますが、特別大きなファイルであったりしない限り、そこまで厳密にリストアップする必要はありません。

docker-compose.yml

docker-compose.yml は下記の内容で、{project name} 1箇所を適切に置き換えた上で保存してください。{project name}package.json に合わせて小文字で記述します。

version: '2.1'

services:
    app:
        image: {project name}
        build: .
        ports:
            - 3001:3000
        command: npm start

コンテナ起動設定を app というサービス名で登録しています。
開発コンテナで既にポート3000をそのまま使用しているため、こちらはポート3001にフォワーディングしておきます。
また、コンテナ起動時にサーバーが起動するよう npm start コマンドを設定しておきます。

イメージビルドとコンテナ起動

ターミナルで下記のコマンドを実行すると、イメージがビルドされ、コンテナが起動します。(ビルド済みのイメージをそのまま起動する場合は --build オプションを付けずに実行します。)

docker-compose up --build app

ブラウザを立ち上げて http://localhost:3001/ にアクセスできることを確認してください。

コンテナにシェルで接続

コンテナ起動後、別ターミナルで下記のコマンドを実行すると、コンテナにシェルで接続することができます。

docker-compose exec app /bin/bash

本番用コンテナ内の調査などで役立ちますが、本番用コンテナにはツール類がほとんどインストールされていませんので、状況に応じて apt-get でツールのインストールを行う必要があります。

コンテナ停止

コンテナ起動後、別ターミナルで下記のコマンドを実行すると、コンテナが停止します。

docker-compose down

終わりに

コンテナ生活、如何でしたでしょうか。
といっても、コンテナに引きこもっていることを忘れてしまうくらいに普通に開発が行えてしまうので、あまり実感は無いかもしれません。
開発コンテナの利点は環境周りのトラブルの低減です。
今後は是非、コンテナにひきこもって快適なフロントエンド開発を満喫していただければと思います。


番外編:Storybook で UI コンポーネントをカタログ化する

Storybook というフレームワークを導入すると、作成した UI コンポーネントをカタログのように管理することができます。
image.png
UI コンポーネントをカタログ化することで再利用が促進されますし、例えばアプリを操作して特定の手順を踏まないと表示されないような UI コンポーネントも簡単に確認できるようになります。
また、カタログ化された各コンポーネントのスクリーンショットを記録することができるため、視覚情報としての差分検出も可能です。

続きは下記にまとめてありますのでご参照ください。

番外編:CSS フレームワークについて

本編では扱いませんでしたが、実開発ではデザインについても考えなければいけません。
世の中には Bootstrap をはじめ様々な CSS フレームワークがありますので、それらから選択するのが無難です。ちなみに React 開発においては、Material UI という CSS フレームワークが一番人気だそうです。
参考までに、本記事の構成に Material UI の AppBar コンポーネントを取り入れた例をご紹介しておきます。

続きは下記にまとめてありますのでご参照ください。

おまけ

本編に挟み込めなかった小ネタをいくつか。

ワークスペース外のファイルを開く

開発コンテナ内のファイルは、コンテナワークスペースには含まれていなくても VSCode で開くことができます。
下記のコマンドを {File Path} を置き換えて実行するとファイルが開かれます。

code {File Path}

Source Control サイドバーが git を検出しなくなった場合の対処法

ワークスペースをコピーして開発コンテナを開くなどしていると、Source Control サイドバーが git を検出できなくなる場合があります。
.git フォルダーの権限周りがおかしくなっている可能性がありますので、下記のコマンドで .git フォルダーの権限を再設定することで解決するか確認してみてください。

chmod -R 644 ../.git

ダメな場合は開発コンテナをリビルドしましょう。数分で解決です。

apt-get でタイムスタンプ絡みのエラーが発生する場合の対処法

Docker デーモンの時刻設定がずれている可能性が高いです。
Docker Desktop の再起動を行ってみてください。

image.png


変更履歴

  • 2020/03/24: .dockerignore セクションを追加
  • 2020/03/29: 番外編:Storybook で UI コンポーネントをカタログ化する セクションを追加
  • 2020/04/20: webpack.config.tsresolve - modules に相対パス形式でも node_modules を含めるよう追加。参考リンク
  • 2020/06/11: webpack.config.tsoutput - publicPath を追加。
  • 2020/07/23: import 文のファイルパスを絶対パス形式でも指定できるよう、webpack.config.ts 及び jest.config.json の設定を追加・変更。その他細かな修正。
  • 2020/08/20: package.json 内の test スクリプト定義で NODE_ENVdevelopment ではなくより適切な test を設定するよう変更。

  1. ホスト環境とコンテナ環境の両方に入れる必要があります。 

  2. もしくは絶対パスでホストのワークスペースを指定する。でも絶対パスは論外。 

  3. 一行ずつ進めていき、関数を呼び出している場合には関数内に入らず飛び越していく形式 

  4. 一行ずつ進めていき、関数を呼び出している場合には関数内に進んでいく形式 

  5. デバッグは停止しますが、サーバーは停止しません。 

  6. ブレークしていない時でも停止は可能です。 

13
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
16