LoginSignup
3
5

Docker 環境と React (TypeScript) で最小フロントエンドを実装する:Spring Boot + MySQL

Last updated at Posted at 2023-09-21

Docker 環境と React (TypeScript) で最小フロントエンドを実装する:Spring Boot + MySQL

こんにちは、@studio_meowtoon です。今回は、WSL の Ubuntu 22.04 で TypeScript React Webアプリケーションを作成し、最小限のフロントエンドを実装する方法を紹介します。
react_ts_and_spring-boot_and_mysql_on_docker.png

目的

Windows 11 の Linux でクラウド開発します。

こちらから記事の一覧がご覧いただけます。

実現すること

ローカル環境の Ubuntu の Docker 環境で、Dockerfile からビルドした TypeScript React Web アプリのカスタムコンテナと、REST API サービスコンテナ、MySQL データベースコンテナを起動します。

HTML ファイル形式のアプリをコンテナとして起動

実行環境

要素 概要
web-browser Web ブラウザ
Ubuntu OS
Docker コンテナ実行環境

Web アプリ コンテナ

要素 概要
app-todo-reactts Web アプリ カスタムコンテナ
nginx Web サーバー
index.html HTML アプリケーション

REST API サービス コンテナ

要素 概要
api-todo-spring-boot REST API サービス カスタムコンテナ
JVM Java 実行環境
app.jar Java アプリケーション
tomcat Web サーバー

データベース コンテナ

要素 概要
mysql-todo データベースコンテナ
mysql DB サーバー
db_todo データベース

技術トピック

React とは?

こちらを展開してご覧いただけます。

React (リアクト)

React は、Meta (旧称 Facebook 社) によって開発された JavaScript ライブラリで、ユーザーインターフェースを構築するために使用されます。

キーワード 内容
コンポーネントベース React は UI を小さな再利用可能な部品であるコンポーネントに分割します。これによりコードの保守性や再利用性が向上します。
JSX JSX は JavaScript の拡張構文で、UI コンポーネントを記述する際に HTML のような記法を使用できます。これにより UI の記述が直感的になります。
仮想DOM React は仮想 DOM (Virtual DOM) を使用して、実際の DOM への変更を最小限に抑えて効率的な UI 更新を行います。これにより高速なアプリケーションが実現されます。
リアクティブな UI 仮想 DOM と単一方向データフローにより、リアルタイムな UI 更新がスムーズに行えます。
パフォーマンス 仮想 DOM と効率的な更新手法により、高速な UI パフォーマンスが実現されます。
モジュール性 コンポーネントごとに独立したスタイルやロジックを持ち、モジュール性の高いコードを書くことができます。
単一方向データフロー データは親コンポーネントから子コンポーネントへ一方向で流れます。このアーキテクチャはデータの管理や予測可能な振る舞いを容易にします。
コミュニティとエコシステム React は広大なコミュニティと豊富なエコシステムを持ち、多くのライブラリやツールが利用可能です。

開発環境

  • Windows 11 Home 22H2 を使用しています。

WSL の Ubuntu を操作していきますので macOS の方も参考にして頂けます。

WSL (Microsoft Store アプリ版) ※ こちらの関連記事からインストール方法をご確認いただけます

> wsl --version
WSL バージョン: 1.0.3.0
カーネル バージョン: 5.15.79.1
WSLg バージョン: 1.0.47

Ubuntu ※ こちらの関連記事からインストール方法をご確認いただけます

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04.1 LTS
Release:        22.04

npm ※ こちらの関連記事からインストール方法をご確認いただけます

$ node -v
v19.8.1
$ npm -v
9.5.1

Docker ※ こちらの関連記事からインストール方法をご確認いただけます

$ docker --version
Docker version 23.0.1, build a5ee5b1

この記事では基本的に Ubuntu のターミナルで操作を行います。Vim を使用してコピペする方法を初めて学ぶ人のために、以下の記事で手順を紹介しています。ぜひ挑戦してみてください。

作成するアプリの外観

image.png

REST API サービス コンテナと、データベース コンテナの起動

こちらの記事で、ToDo アプリ用の REST API サービスと、RDBMS データベースを作成し、Docker コンテナとして起動する手順をご確認いただけます。

REST API サービス、データベース コンテナが起動していることを確認します。

$ docker ps
CONTAINER ID   IMAGE                  COMMAND                   CREATED         STATUS          PORTS                                                  NAMES
e85cb3c156e5   api-todo-spring-boot   "java -jar app.jar"       9 minutes ago   Up 9 minutes    0.0.0.0:5000->8080/tcp, :::5000->8080/tcp              api-local
63a3acec271c   mysql-base             "docker-entrypoint.s…"   9 days ago      Up 10 minutes   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp   mysql-todo

コンテナ間通信するために net-todo という Docker ネットワークを予め作成しています。ご注意ください。

REST クライアント を実装する手順

プロジェクトの作成

プロジェクトフォルダを作成します。
※ ~/tmp/restclt-reactts をプロジェクトフォルダとします。

$ mkdir -p ~/tmp/restclt-reactts
$ cd ~/tmp/restclt-reactts

TSX ファイルの作成

index.tsx ファイルを作成します。

$ mkdir -p src
$ vim src/index.tsx

ファイルの内容

コードの全体を表示する
src/index.tsx
import './styles.scss';

import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

// ToDo アイテムのインターフェイス
interface Todo {
    id: string;
    content: string;
    created_date: string | null;
    completed_date: string | null;
    editing: boolean;
    editContent: string;
}

// メインコンポーネント
function App() {
    const _apiUrl: string | undefined  = process.env.API_URL; // API の URL
    const [_todos, setTodos]: [Todo[], React.Dispatch<React.SetStateAction<Todo[]>>] = useState<Todo[]>([]); // ToDo の一覧を管理するステート変数
    const _inputAdd: React.MutableRefObject<HTMLInputElement | null> = useRef<HTMLInputElement | null>(null); // 新規追加の input 要素の参照

    // コンポーネントがマウントされたときに実行される副作用フック
    useEffect(() => {
        fetchAndRender(); // データの取得と表示
    }, []);

    // データの取得と表示
    const fetchAndRender = async (): Promise<void> => {
        try {
            const response: Response = await fetch(`${_apiUrl}/todos`);
            if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); return; }
            const data: Array<Todo> = await response.json();
            const filtered: Array<Todo> = data.filter((elem: Todo) => elem.completed_date === null);
            const updated: Array<Todo> = filtered.map((elem: Todo) => ({ // ToDo データに編集関連の情報を追加して新しい配列を作成
                ...elem, // 全ての要素を引継ぎ
                editing: false, // 編集フラグは OFF
                editContent: elem.content, // 元のテキストをコピー
            }));
            setTodos(updated); // 新しい配列をセットして ToDo データを更新
        } catch (error) { alert(`Error fetching todos: ${error}`); }
    };

    // 新規追加ボタンのクリックイベントハンドラ
    const handleButtonAddClick = async (): Promise<void> => {
        try {
            if (_inputAdd.current === null) { return; }
            const content: string = _inputAdd.current.value.trim();
            if (content === "") { return; }
            if (confirm("アイテムを新規追加しますか?")) {
                const response: Response = await fetch(`${_apiUrl}/todos`, {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ content: content })
                });
                if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
                fetchAndRender();
                _inputAdd.current.value = "";
            }
        } catch (error) { alert(`Error adding todos: ${error}`); }
    };

    // 完了ボタンのクリックイベントハンドラ
    const handleButtonCompleteClick = (todo: Todo) => async (): Promise<void> => {
        try {
            if (confirm("このアイテムを完了にしますか?")) {
                const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                    method: "PUT",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                        content: todo.content,
                        completed_date: new Date().toISOString()
                    })
                });
                if (!response.ok) { alert(`Update failed with status: ${response.status}`); }
                fetchAndRender();
            }
        } catch (error) { alert(`Error updating todos: ${error}`); }
    };

    // テキスト要素のクリックイベントハンドラ
    const handleContentClick = (todo: Todo) => (): void => {
        setTodos((state: Todo[]) => state.map((elem: Todo) =>
            ({ ...elem, editing: false }) // すべて編集フラグは OFF
        ));
        setTodos((state: Todo[]) => state.map((elem: Todo) =>
            elem.id === todo.id
                ? { ...elem, editing: true } // id が一致したら編集フラグは ON
                : elem // 一致しなければそのまま
        ));
    };

    // テーブル行テキストのチェンジイベントハンドラ
    const handleRowContentChange = (todo: Todo, value: string) => {
        setTodos((state: Todo[]) => state.map((elem: Todo) =>
            elem.id === todo.id
                ? { ...elem, editContent: value } // id が一致したら編集テキストに適用
                : elem // 一致しなければそのまま
        ));
    };

    // 更新ボタンのクリックイベントハンドラ
    const handleButtonUpdateClick = (todo: Todo) => async (): Promise<void>  => {
        try {
            const updatedContent: string = todo.editContent.trim();
            if (updatedContent === "") { return; }
            if (confirm("このアイテムを更新しますか?")) {
                const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                    method: "PUT",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ content: updatedContent })
                });
                if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
                fetchAndRender();
            }
        } catch (error) { alert(`Error updating todos: ${error}`); }
    };

    // 削除ボタンのクリックイベントハンドラ
    const handleButtonDeleteClick = (todo: Todo) => async (): Promise<void> => {
        try {
            if (confirm("このアイテムを削除しますか?")) {
                const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                    method: "DELETE",
                });
                if (!response.ok) { alert(`Delete failed with status: ${response.status}`); }
                fetchAndRender();
            }
        } catch (error) { alert(`Error deleting todos: ${error}`); }
    };

    // JSX レンダリング部分
    return (
        <div className="div-container">
            <h1>ToDo リスト</h1>
            <div className="div-add">
                <input
                    type="text" placeholder="新規アイテムを追加"
                    ref={_inputAdd}
                    onClick={handleContentClick({ id: '_dummy', content: '', created_date: null, completed_date: null, editing: false, editContent: '' })}
                />
                <button id="button-add" onClick={handleButtonAddClick}>
                    新規追加
                </button>
            </div>
            <div className="div-table" id="div-todo-list">
                {_todos.map((todo) => (
                    <div key={todo.id} className="div-row">
                        <div className="div-content">
                            {/* 編集中の場合は input を表示、そうでなければ div を表示 */}
                            {todo.editing ? (
                                <input
                                    type="text"
                                    className="input-edit"
                                    value={todo.editContent}
                                    onChange={(e) => handleRowContentChange(todo, e.target.value)}
                                />
                            ) : (
                                <div onClick={handleContentClick(todo)}>
                                    {todo.content}
                                </div>
                            )}
                        </div>
                        <div className="div-buttons">
                            <button
                                className="button-complete"
                                onClick={handleButtonCompleteClick(todo)}
                            >
                                完了
                            </button>
                            <button
                                className="button-update"
                                onClick={handleButtonUpdateClick(todo)}
                                disabled={!todo.editing}
                            >
                                更新
                            </button>
                            <button
                                className="button-delete"
                                onClick={handleButtonDeleteClick(todo)}
                            >
                                削除
                            </button>
                        </div>
                    </div>
                ))}
            </div>
        </div>
    );
}

// アプリケーションをレンダリング
ReactDOM.render(<App />, document.getElementById("app"));

以下、ポイントを説明します。

src/index.tsx ※抜粋
// データの取得と表示
const fetchAndRender = async (): Promise<void> => {
    try {
        const response: Response = await fetch(`${_apiUrl}/todos`);
        if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); return; }
        const data: Array<Todo> = await response.json();
        const filtered: Array<Todo> = data.filter((elem: Todo) => elem.completed_date === null);
        const updated: Array<Todo> = filtered.map((elem: Todo) => ({ // ToDo データに編集関連の情報を追加して新しい配列を作成
            ...elem, // 全ての要素を引継ぎ
            editing: false, // 編集フラグは OFF
            editContent: elem.content, // 元のテキストをコピー
        }));
        setTodos(updated); // 新しい配列をセットして ToDo データを更新
    } catch (error) { alert(`Error fetching todos: ${error}`); }
};

// 新規追加ボタンのクリックイベントハンドラ
const handleButtonAddClick = async (): Promise<void> => {
    try {
        if (_inputAdd.current === null) { return; }
        const content: string = _inputAdd.current.value.trim();
        if (content === "") { return; }
        if (confirm("アイテムを新規追加しますか?")) {
            const response: Response = await fetch(`${_apiUrl}/todos`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ content: content })
            });
            if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
            fetchAndRender();
            _inputAdd.current.value = "";
        }
    } catch (error) { alert(`Error adding todos: ${error}`); }
};

// 完了ボタンのクリックイベントハンドラ
const handleButtonCompleteClick = (todo: Todo) => async (): Promise<void> => {
    try {
        if (confirm("このアイテムを完了にしますか?")) {
            const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                method: "PUT",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({
                    content: todo.content,
                    completed_date: new Date().toISOString()
                })
            });
            if (!response.ok) { alert(`Update failed with status: ${response.status}`); }
            fetchAndRender();
        }
    } catch (error) { alert(`Error updating todos: ${error}`); }
};

// テキスト要素のクリックイベントハンドラ
const handleContentClick = (todo: Todo) => (): void => {
    setTodos((state: Todo[]) => state.map((elem: Todo) =>
        ({ ...elem, editing: false }) // すべて編集フラグは OFF
    ));
    setTodos((state: Todo[]) => state.map((elem: Todo) =>
        elem.id === todo.id
            ? { ...elem, editing: true } // id が一致したら編集フラグは ON
            : elem // 一致しなければそのまま
    ));
};

// テーブル行テキストのチェンジイベントハンドラ
const handleRowContentChange = (todo: Todo, value: string) => {
    setTodos((state: Todo[]) => state.map((elem: Todo) =>
        elem.id === todo.id
            ? { ...elem, editContent: value } // id が一致したら編集テキストに適用
            : elem // 一致しなければそのまま
    ));
};

// 更新ボタンのクリックイベントハンドラ
const handleButtonUpdateClick = (todo: Todo) => async (): Promise<void>  => {
    try {
        const updatedContent: string = todo.editContent.trim();
        if (updatedContent === "") { return; }
        if (confirm("このアイテムを更新しますか?")) {
            const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                method: "PUT",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ content: updatedContent })
            });
            if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
            fetchAndRender();
        }
    } catch (error) { alert(`Error updating todos: ${error}`); }
};

// 削除ボタンのクリックイベントハンドラ
const handleButtonDeleteClick = (todo: Todo) => async (): Promise<void> => {
    try {
        if (confirm("このアイテムを削除しますか?")) {
            const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                method: "DELETE",
            });
            if (!response.ok) { alert(`Delete failed with status: ${response.status}`); }
            fetchAndRender();
        }
    } catch (error) { alert(`Error deleting todos: ${error}`); }
};
説明を開きます。
要素 説明
アイテム追加イベント _input_add 要素がクリックされたときに、新しいアイテムを追加するためのイベントハンドラを登録しています。_buttonAdd 要素がクリックされたときに、handleButtonAddClick() 関数が呼び出されます。
データ表示 fetchAndRender() 関数は、サーバーからデータを取得して未完了の ToDo アイテムを表示するための非同期関数です。サーバーからのデータをフィルタリングし、それぞれのアイテムに対して初期化します。
アイテム追加ボタンのクリック _buttonAdd 要素がクリックされたときに実行される関数です。入力内容を取得し、サーバーに新しいアイテムを追加するための非同期処理を行います。
完了ボタンのクリック 各アイテムの完了ボタンがクリックされたときに実行される関数です。サーバーにアイテムの完了情報を送信してアイテムのステータスを更新します。
アイテムテキスト編集 ToDo アイテムのテキストをクリックすると、その内容が編集可能な input 要素に置き換えられます。
アイテム更新と削除 更新ボタンをクリックすると、アイテムの内容がサーバーに送信されて更新されます。削除ボタンをクリックすると、アイテムがサーバーから削除されます。

HTML ファイルの作成

index.html ファイルを作成します。

$ vim src/index.html

ファイルの内容

src/index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>ToDo アプリ</title>
    </head>
    <body>
        <div id="app"></div>
    </body>
</html>

SCSS ファイルの作成

styles.scss ファイルを作成します。

$ vim src/styles.scss

ファイルの内容

コードの全体を表示する
src/styles.scss
$primary-font: Arial, sans-serif;

/* カラーパレット */
$blue: royalblue;
$green: mediumseagreen;
$yellow: lightyellow;
$red: indianred;
$gray: gray;

body {
    font-family: $primary-font;
    margin: 0;
    padding: 0;
}

h1 {
    text-align: center;
    margin: 20px 0;
}

/* コンテナ */
.div-container {
    max-width: 800px;
    margin: 0 auto;
}

/* テーブル */
.div-table {
    display: table;
    width: 100%;
    max-width: 800px;
    margin: 0 auto;
    border-collapse: collapse;
    border: 1px solid #ddd;
}

/* テーブルの行 */
.div-row {
    display: table-row;
}

/* 行のテキストセル */
.div-content {
    display: table-cell;
    padding: 10px;
    border-top: 1px solid #ddd;
    flex: 1;
    text-align: left;
    width: 68%;
}

/* ボタングループセル */
.div-buttons {
    display: table-cell;
    padding: 10px;
    border-top: 1px solid #ddd;
    text-align: right;
}

/* 完了ボタン */
.button-complete {
    margin-left: 10px;
    padding: 8px 12px;
    background-color: $blue;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    @media (max-width: 768px) {
        margin-left: 2px;
        padding: 5px 2px;
    }
}

/* 更新ボタン */
.button-update {
    margin-left: 10px;
    padding: 8px 12px;
    background-color: $green;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    &:disabled {
        background-color: $gray;
        cursor: not-allowed;
    }
    @media (max-width: 768px) {
        margin-left: 2px;
        padding: 5px 2px;
    }
}

/* 更新テキスト */
.input-edit {
    padding: 8px;
    background-color: $yellow;
    font-size: 16px;
    border: 1px solid #ccc;
    border-radius: 4px;
    width: 100%;
}

/* 削除ボタン */
.button-delete {
    margin-left: 10px;
    padding: 8px 12px;
    background-color: $red;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    @media (max-width: 768px) {
        margin-left: 2px;
        padding: 5px 2px;
    }
}

/* 新規追加エリア */
.div-add {
    margin-bottom: 20px;
    display: flex;
    align-items: center;
    width: 100%;
}

/* 新規追加テキスト */
.div-add input {
    flex: 1;
    padding: 8px;
    font-size: 16px;
    border: 1px solid #ccc;
    border-radius: 4px;
    &:focus {
        background-color: $yellow;
    }
}

/* 新規追加ボタン */
.div-add button {
    margin-left: 10px;
    padding: 8px 12px;
    background-color: $blue;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}

以下、ポイントを説明します。

src/styles.scss ※抜粋
/* テーブル */
.div-table {
    display: table;
    /* 省略 */
}

/* テーブルの行 */
.div-row {
    display: table-row;
}

/* 行のテキストセル */
.div-content {
    display: table-cell;
    /* 省略 */
}

/* ボタングループセル */
.div-buttons {
    display: table-cell;
   /* 省略 */
}
要素 内容
display: table 要素をテーブルとして表示するために使用されます。
display: table-row テーブル内の行を表すために使用されます。
display: table-cell テーブル内のセル (セル内のデータやコンテンツ) を表すために使用されます。

ビルドと実行

Webpack の設定ファイルを作成します。

$ vim webpack.config.ts

ファイルの内容

webpack.config.ts
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  mode: 'production',
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: 'ts-loader'
      },
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader']
      }
    ]
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    }),
    new webpack.DefinePlugin({
      'process.env.API_URL': JSON.stringify(process.env.API_URL)
    })
  ]
};
説明を開きます。
要素 内容
mode ビルドモードを指定します。
entry アプリケーションのエントリーポイントとなる TypeScript ファイル (ここでは index.tsx) を指定します。このファイルを起点にして依存関係を解決してバンドルします。
output バンドルされたファイル (ここでは bundle.js) の出力先を指定します。filename で出力ファイル名、path で出力先ディレクトリの絶対パスを指定します。
module.rules ファイルのローダー (変換ツール) を定義します。test でファイルの正規表現を指定し、ローダーを適用するファイルを絞り込みます。use で使用するローダーを指定します。ここでは、TypeScript ファイルには ts-loader を適用し、SCSS ファイルには style-loader と css-loader、sass-loader を順番に適用します。
resolve.extensions: extensions Webpack がモジュールを解決する際に、指定した拡張子のファイル (ここでは .ts, .js) を自動的に解決対象として扱うことを定義しています。
plugins プラグインを設定します。ここでは HtmlWebpackPlugin と DefinePlugin が設定されています。HtmlWebpackPlugin は HTML ファイルを生成するプラグインで、template オプションで指定した HTML ファイルを元に、バンドルされた JavaScript ファイルを自動的に挿入します。DefinePlugin は環境変数をバンドル内で利用可能にします。ここでは process.env.API_URL を定義しています。

プロジェクトの初期化を行います。

$ npm init -y

package.json を修正します。

$ vim package.json

ファイルの内容

package.json
{
  "name": "restclt-reactts",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.ts",
  "scripts": {
    "build": "webpack --mode production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
説明を開きます。
要素 内容
name プロジェクトの名前。一意の識別子として使用されます。
version プロジェクトのバージョン。通常はメジャーバージョン.マイナーバージョン.パッチバージョンの形式です。
description プロジェクトの簡単な説明。ここでは空です。
main プロジェクトのエントリーポイントとなるファイル。ここでは webpack.config.ts と指定されています。
scripts タスクやコマンドを定義するスクリプトセクションです。ここでは build スクリプトが定義されています。npm run build コマンドを実行すると、Webpack がプロジェクトをビルドします。
keywords プロジェクトに関連するキーワード。ここでは空です。
author プロジェクトの作者。ここでは空です。
license プロジェクトのライセンスを示す。ここでは ISC ライセンスと指定されています。

ライブラリをインストールします。

$ npm install \
    react react-dom @types/react @types/react-dom \
    webpack webpack-cli html-webpack-plugin \
    css-loader style-loader node-sass sass-loader\
    typescript ts-node ts-loader \
    --save-dev
説明を開きます。
要素 内容
react react-dom React と React DOM の2つのライブラリを指定しています。
@types/react, @types/react-dom TypeScript で React, React DOM を使用する際に、React, React DOM の型定義を提供するパッケージです。
webpack Webpack のコアパッケージです。
webpack-cli Webpack のコマンドラインインターフェースです。
html-webpack-plugin バンドルされたスクリプトを提供するための HTML ファイルを生成します。
css-loader CSS のインポートを処理します。
style-loader CSS スタイルを HTML に注入します。
node-sass Node.js 環境で SASS ファイルをコンパイルするためのモジュールです。
sass-loader SCSS のインポートを処理します。
typescript TypeScript コンパイラを提供します。
ts-node TypeScript を実行するための Node.js ランタイムです。
ts-loader TypeScript ファイルを Webpack バンドル内でコンパイルするのに使用されます。

TypeScript のトランスパイル設定ファイルを作成します。

$ vim tsconfig.json

ファイルの内容

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "strict": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "allowSyntheticDefaultImports": true,
    "jsx": "react"
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}
説明を開きます。
要素 内容
target: ES2020 TypeScript コンパイラが生成する JavaScript コードの対象バージョンを指定します。ここではECMAScript 2020 をターゲットにしています。
useDefineForClassFields: true クラスフィールドのプロパティに defineProperty を使用して定義するように指定します。これにより、クラスフィールドの初期化が遅延され、プロトタイプには直接追加されなくなります。
module: ESNext コードモジュールの形式を指定します。ここでは ECMAScript のモジュールシステム (ES6モジュール) を採用しています。
lib: ["ES2020", "DOM", "DOM.Iterable"] コード内で利用可能な組み込みライブラリを指定します。この設定は、指定されたバージョンの ECMAScript および DOM ライブラリにアクセスできることを意味します。
strict: true TypeScript コンパイラの厳密な型チェックを有効にする設定です。これにより、型の不整合やエラーが検出され、コードの品質が向上します。
noImplicitAny: true 暗黙的な any 型の使用を許可しないかを指定します。
noUnusedLocals: true 未使用のローカル変数がある場合にエラーを発生させる設定です。
noUnusedParameters: true 未使用のパラメータがある場合にエラーを発生させる設定です。
noFallthroughCasesInSwitch: true switch 文内の case ステートメントにおいて、フォールスルー (case の終了後に次の case に進む) をエラーとして検出する設定です。
allowSyntheticDefaultImports: true import 文でのデフォルトインポートの合成を許可するかを指定します。
jsx JSX をどのように扱うかを指定します。ここでは react に設定されており、React の JSX 構文を使用することを示しています。
include: src//*.ts, src//*.tsx TypeScript ファイルのソースコードが存在するディレクトリとファイルを指定します。この設定に基づいて、コンパイル対象の TypeScript ファイルが選択されます。
exclude: node_modules コンパイルから除外するディレクトリを指定します。通常、プロジェクト内の Node.js モジュールがここに含まれます。

環境変数を作成します。

$ export API_URL=http://localhost:5000

API サービス コンテナの URL を設定してます。この環境変数が一時的なものであることに注意してください。

ビルドします。

$ npm run build

アプリを起動します。
※ アプリを停止するときは ctrl + C を押します。

$ python3 \
    -m http.server 8000 \
    --directory ./build
説明を開きます。
要素 内容
python3 Python のインタープリターを起動するコマンドです。
-m http.server Python の組み込みモジュールである http.server モジュールを指定して起動します。このモジュールを使用することで、簡単な HTTP サーバーをローカルに立ち上げることができます。
8000 サーバーが使用するポート番号です。ここではポート番号 8000 を指定していますが、必要に応じて変更することができます。
--directory ./build オプションとして --directory を使用し、提供したディレクトリ (./build ディレクトリ) をルートとするよう指定します。これにより、指定したディレクトリ内のファイルが提供されます。

Web ブラウザで確認します。

$ wslview http://localhost:8000

ここまでの手順で、Ubuntu でアプリを Web サーバー上で起動することができました。

コンテナイメージの作成

Nginx の設定ファイルを作成します。

$ vim nginx.conf

ファイルの内容

nginx.conf
user nginx;
worker_processes 3; # ワーカープロセス数を 3 に設定:調整してください。
events {
    worker_connections 256; # 同時接続数の最大値を設定:調整してください。
}
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    server {
        listen 80;
        server_name localhost;
        location / {
            root /usr/share/nginx/html;  # 静的ファイルのルートディレクトリ
            index index.html;  # デフォルトのインデックスファイル
        }
    }
}

Dockerfile を作成します。

$ vim Dockerfile

ファイルの内容

Dockerfile
# build the app.
FROM node:lts-bookworm-slim as build-env

# set the working dir.
WORKDIR /app

# copy json files to the working dir.
COPY package.json package-lock.json tsconfig.json /app/
COPY webpack.config.ts /app/

# copy the source files to the working dir.
COPY src /app/src

# set the environment.
ARG API_URL
ENV API_URL=$API_URL

# run the build command to build the app.
RUN npm install && \
    npm run build

# set up the production container.
FROM nginx:bookworm

# copy nginx.conf.
COPY nginx.conf /etc/nginx/nginx.conf

# copy the build output from the build-env.
COPY --from=build-env /app/build /usr/share/nginx/html

# expose port.
EXPOSE 80

# command to run nginx.
CMD ["nginx","-g","daemon off;"]
説明を開きます。
要素 説明
FROM node:lts-bookworm-slim as build-env ベースイメージを node:lts-bookworm-slim に設定します。このイメージは Node.js の環境を提供します。
WORKDIR /app ワーキングディレクトリを /app に設定します。
COPY package.json package-lock.json tsconfig.json /app/ アプリケーションの依存関係をインストールするために package.json と package-lock.json、また TypeScript のコンパイル設定ファイル tsconfig.json をワーキングディレクトリにコピーします。
COPY webpack.config.ts /app/ Webpack の設定ファイル webpack.config.ts をワーキングディレクトリにコピーします。
COPY src /app/src アプリケーションのソースコードが含まれる src ディレクトリ内のファイルを /app/src にコピーします。
ARG API_URL ビルド時に渡される API_URL という名前の引数を定義します。この引数はビルド時に変数を渡すために使用されます。
ENV API_URL=$API_URL ビルドステージ内で定義された API_URL の値をコンテナ内の環境変数 API_URL に設定します。
RUN npm install && npm run build 依存関係をインストールしてアプリケーションをビルドします。この段階で先に定義した API_URL がアプリケーション内に埋め込まれます
FROM nginx:bookworm 2つ目のビルドステージを始めます。Nginx コンテナのベースイメージ nginx:bookworm に設定します。
COPY nginx.conf /etc/nginx/nginx.conf Nginx の設定ファイル nginx.conf を /etc/nginx/ にをコピーします。
COPY --from=build-env /app/build /usr/share/nginx/html build-env ステージからビルドされた結果を Nginx コンテナ内の /usr/share/nginx/html にコピーします。これにより、Nginx が静的ファイルを提供できるようになります。
EXPOSE 80 コンテナ内のポート 80 をエクスポートします。
CMD ["nginx","-g","daemon off;"] コンテナが起動した際に実行されるコマンドを指定します。ここでは Nginx をバックグラウンドで実行するコマンドを設定しています。

Docker デーモンを起動します。

$ sudo service docker start
 * Starting Docker: docker    [ OK ]

Docker 環境をお持ちでない場合は、以下の関連記事から Docker Engine のインストール手順をご確認いただけます。

コンテナイメージをビルドします。

$ docker build \
    --no-cache \
    --build-arg API_URL=http://localhost:5000 \
    --tag app-todo-reactts:latest .

コンテナをビルドする際に、API サービスの URL確定されている必要があります。

コンテナイメージを確認します。

$ docker images | grep app-todo-reactts
app-todo-reactts    latest    67b59f4ec08d    8 seconds ago    187MB

ここまでの手順で、ローカル環境の Docker にアプリのカスタムコンテナイメージをビルドすることができました。

コンテナを起動

ローカルでコンテナを起動します。
※ コンテナを停止するときは ctrl + C を押します。

コンテナ間通信するために net-todo という Docker ネットワークを予め作成しています。ご注意ください。

$ docker run --rm \
    --publish 8000:80 \
    --name app-local \
    --net net-todo \
    app-todo-reactts

ここまでの手順で、ローカル環境の Docker でアプリのカスタムコンテナを起動することができました。

コンテナの動作確認

Web ブラウザで確認します。

$ wslview http://localhost:8000

image.png

コンテナの状態を確認してみます。

$ docker ps
CONTAINER ID   IMAGE                  COMMAND                   CREATED          STATUS          PORTS                                                  NAMES
25ac7237b907   app-todo-reactts       "/docker-entrypoint.…"   29 seconds ago   Up 28 seconds   0.0.0.0:8000->80/tcp, :::8000->80/tcp                  app-local
727b5353330e   api-todo-spring-boot   "java -jar app.jar"       42 minutes ago   Up 42 minutes   0.0.0.0:5000->8080/tcp, :::5000->8080/tcp              api-local
63a3acec271c   mysql-base             "docker-entrypoint.s…"   4 weeks ago      Up 43 minutes   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp   mysql-todo

ここまでの手順で、カスタムコンテナとして起動した Todo アプリを操作することができました。

コンテナに接続

別ターミナルからコンテナに接続します。

$ docker exec -it app-local /bin/bash

コンテナに接続後にディレクトリを確認します。
※ コンテナから出るときは ctrl + D を押します。

# pwd
/
# cd /usr/share/nginx/html
# ls -lah
total 172K
drwxr-xr-x 1 root root 4.0K Sep 21 06:03 .
drwxr-xr-x 1 root root 4.0K Sep 20 16:43 ..
-rw-r--r-- 1 root root  497 Aug 15 17:03 50x.html
-rw-r--r-- 1 root root 145K Sep 21 06:03 bundle.js
-rw-r--r-- 1 root root  721 Sep 21 06:03 bundle.js.LICENSE.txt
-rw-r--r-- 1 root root  239 Sep 21 06:03 index.html

top コマンドで状況を確認します。

# apt update
# apt install procps
# top
top - 06:05:57 up  6:57,  0 user,  load average: 0.21, 0.27, 0.15
Tasks:   6 total,   1 running,   5 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.5 us,  0.2 sy,  0.0 ni, 99.2 id,  0.0 wa,  0.0 hi,  0.1 si,  0.0 st
MiB Mem :   7897.1 total,   2921.8 free,   2649.3 used,   2610.2 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   5247.8 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    1 root      20   0   11380   7504   6388 S   0.0   0.1   0:00.02 nginx
   29 nginx     20   0   11540   2656   1392 S   0.0   0.0   0:00.00 nginx
   30 nginx     20   0   11540   2656   1392 S   0.0   0.0   0:00.00 nginx
   31 nginx     20   0   11540   2656   1392 S   0.0   0.0   0:00.00 nginx
   32 root      20   0    4188   3452   2936 S   0.0   0.0   0:00.02 bash
  222 root      20   0    8632   4744   2856 R   0.0   0.1   0:00.00 top

コンテナの情報を表示してみます。

# cat /etc/*-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

このコンテナは Debian GNU/Linux をベースに作成されています。つまり、Debian GNU/Linux と同じように扱うことができます。

まとめ

WSL Ubuntu の Docker 環境で、TypeScript React の最小フロントエンドを実装することができました。

この記事の実装例は一つのアプローチに過ぎず、必ずしも正しい方法とは限りません。他にも多様な方法がありますので、さまざまな情報を照らし合わせて検討してみてください。

どうでしたか? WSL Ubuntu で、TypeScript React アプリケーションを手軽に起動することができます。ぜひお試しください。今後も React の開発環境などを紹介していきますので、ぜひお楽しみにしてください。

推奨コンテンツ

3
5
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
3
5