2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Docker 環境と React (JavaScript) で最小フロントエンドを実装する:FastAPI + MongoDB

Last updated at Posted at 2023-08-29

Docker 環境と React (JavaScript) で最小フロントエンドを実装する:FastAPI + MongoDB

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

目的

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

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

実現すること

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

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

実行環境

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

Web アプリ コンテナ

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

REST API サービス コンテナ

要素 概要
api-todo-fastapi REST API サービス カスタムコンテナ
python Python 実行環境
uvicorn Web サーバー
main.py Python スクリプト

データベース コンテナ

要素 概要
mongodb-todo データベースコンテナ
mongodb 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 サービスと、NoSQL データベースを作成し、Docker コンテナとして起動する手順をご確認いただけます。

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

$ docker ps
CONTAINER ID   IMAGE              COMMAND                   CREATED       STATUS       PORTS                                           NAMES
d1c93bcada17   api-todo-fastapi   "uvicorn app.main:ap…"   5 hours ago   Up 5 hours   0.0.0.0:5000->5000/tcp, :::5000->5000/tcp       api-local
f374bb7a57ac   mongo-base         "docker-entrypoint.s…"   8 days ago    Up 7 hours   0.0.0.0:27017->27017/tcp, :::27017->27017/tcp   mongodb-todo

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

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

プロジェクトの作成

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

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

JS ファイルの作成

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

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

ファイルの内容

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

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

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

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

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

    // 新規追加ボタンのクリックイベントハンドラ
    const handleButtonAddClick = async () => {
        try {
            const content = _inputAdd.current.value.trim();
            if (content === "") { return; }
            if (confirm("アイテムを新規追加しますか?")) {
                const 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 todo: ", error); }
    };

    // 完了ボタンのクリックイベントハンドラ
    const handleButtonCompleteClick = (todo) => async () => {
        try {
            if (confirm("このアイテムを完了にしますか?")) {
                const 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 todo: ", error); }
    };

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

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

    // 更新ボタンのクリックイベントハンドラ
    const handleButtonUpdateClick = (todo) => async () => {
        try {
            const updatedContent = todo.editContent.trim();
            if (updatedContent === "") { return; }
            if (confirm("このアイテムを更新しますか?")) {
                const 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 todo: ", error); }
    };

    // 削除ボタンのクリックイベントハンドラ
    const handleButtonDeleteClick = (todo) => async () => {
        try {
            if (confirm("このアイテムを削除しますか?")) {
                const 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 todo: ", error); }
    };

    // JSX レンダリング部分
    return (
        <div className="div-container">
            <h1>ToDo リスト</h1>
            <div className="div-add">
                <input
                    type="text" placeholder="新規アイテムを追加"
                    ref={_inputAdd}
                    onClick={handleContentClick({ id: '_dummy' })}
                />
                <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.js ※抜粋
// データの取得と表示
const fetchAndRender = async () => {
    try {
        const response = await fetch(`${_apiUrl}/todos`);
        if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); return; }
        const data = await response.json();
        const filtered = data.filter(elem => elem.completed_date === null);
        const updated = filtered.map(elem => ({ // ToDo データに編集関連の情報を追加して新しい配列を作成
            ...elem, // 全ての要素を引継ぎ
            editing: false, // 編集フラグは OFF
            editContent: elem.content, // 元のテキストをコピー
        }));
        setTodos(updated); // 新しい配列をセットして ToDo データを更新
    } catch (error) { alert("Error fetching todos: ", error); }
};

// 新規追加ボタンのクリックイベントハンドラ
const handleButtonAddClick = async () => {
    try {
        const content = _inputAdd.current.value.trim();
        if (content === "") { return; }
        if (confirm("アイテムを新規追加しますか?")) {
            const 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 todo: ", error); }
};

// 完了ボタンのクリックイベントハンドラ
const handleButtonCompleteClick = (todo) => async () => {
    try {
        if (confirm("このアイテムを完了にしますか?")) {
            const 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 todo: ", error); }
};

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

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

// 更新ボタンのクリックイベントハンドラ
const handleButtonUpdateClick = (todo) => async () => {
    try {
        const updatedContent = todo.editContent.trim();
        if (updatedContent === "") { return; }
        if (confirm("このアイテムを更新しますか?")) {
            const 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 todo: ", error); }
};

// 削除ボタンのクリックイベントハンドラ
const handleButtonDeleteClick = (todo) => async () => {
    try {
        if (confirm("このアイテムを削除しますか?")) {
            const 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 todo: ", 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>

CSS ファイルの作成

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

$ vim src/styles.css

ファイルの内容

コードの全体を表示する
src/styles.css
body {
    font-family: Arial, sans-serif;
    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: royalblue;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}
@media (max-width: 768px) {
    .button-complete {
        margin-left: 2px;
        padding: 5px 2px;
    }
}

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

/* 更新ボタン:無効 */
.button-update:disabled {
    background-color: gray;
    cursor: not-allowed;
}

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

/* 削除ボタン */
.button-delete {
    margin-left: 10px;
    padding: 8px 12px;
    background-color: indianred;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}
@media (max-width: 768px) {
    .button-delete {
        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;
}
.div-add input:focus { /* フォーカス */
    background-color: lightyellow; 
}

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

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

src/styles.css ※抜粋
/* テーブル */
.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.js

ファイルの内容

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

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    }),
    new webpack.DefinePlugin({
      'process.env.API_URL': JSON.stringify(process.env.API_URL)
    })
  ]
};
説明を開きます。
要素 内容
mode ビルドモードを指定します。
entry アプリケーションのエントリーポイントとなる JavaScript ファイル (ここでは index.js) を指定します。このファイルを起点にして依存関係を解決してバンドルします。
output バンドルされたファイル (ここでは bundle.js) の出力先を指定します。filename で出力ファイル名、path で出力先ディレクトリの絶対パスを指定します。
module.rules ファイルのローダー(変換ツール)を定義します。test でファイルの正規表現を指定し、ローダーを適用するファイルを絞り込みます。use で使用するローダーを指定します。ここでは、JavaScript ファイルには babel-loader を適用し、CSS ファイルには style-loader と css-loader を順番に適用します。
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-react",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.js",
  "scripts": {
    "build": "webpack --mode production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
説明を開きます。
要素 内容
name プロジェクトの名前。一意の識別子として使用されます。
version プロジェクトのバージョン。通常はメジャーバージョン.マイナーバージョン.パッチバージョンの形式です。
description プロジェクトの簡単な説明。ここでは空です。
main プロジェクトのエントリーポイントとなるファイル。ここでは webpack.config.js と指定されています。
scripts タスクやコマンドを定義するスクリプトセクションです。ここでは build スクリプトが定義されています。npm run build コマンドを実行すると、Webpack がプロジェクトをビルドします。
keywords プロジェクトに関連するキーワード。ここでは空です。
author プロジェクトの作者。ここでは空です。
license プロジェクトのライセンスを示す。ここでは ISC ライセンスと指定されています。

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

$ npm install \
    react react-dom \
    webpack webpack-cli html-webpack-plugin \
    css-loader style-loader babel-loader \
    @babel/core @babel/preset-env @babel/preset-react \
    --save-dev
説明を開きます。
要素 内容
react react-dom React と React DOM の2つのライブラリを指定しています。
webpack Webpack のコアパッケージです。
webpack-cli Webpack のコマンドラインインターフェースです。
html-webpack-plugin バンドルされたスクリプトを提供するための HTML ファイルを生成します。
css-loader CSS のインポートを処理します。
style-loader CSS スタイルを HTML に注入します。
babel-loader Babel を使用して JavaScript をトランスパイルします。
@babel/core Babel のコア機能です。
@babel/preset-env Babel を設定してモダンな JavaScript 構文を変換するためのプリセットです。
@babel/preset-react React アプリケーションをコンパイルするための Babel プリセットです

環境変数を作成します。

$ 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 /app/
COPY webpack.config.js /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 /app/ アプリケーションの依存関係をインストールするために package.json と package-lock.json をワーキングディレクトリにコピーします。
COPY webpack.config.js /app/ Webpack の設定ファイル webpack.config.js をワーキングディレクトリにコピーします。
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-react:latest .

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

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

$ docker images | grep app-todo-react
app-todo-react   latest      355665b38d17    9 seconds ago    187MB

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

コンテナを起動

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

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

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

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

コンテナの動作確認

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

$ wslview http://localhost:8000

image.png

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

$ docker ps
CONTAINER ID   IMAGE              COMMAND                   CREATED          STATUS          PORTS                                           NAMES
67a6efb70e9c   app-todo-react     "/docker-entrypoint.…"   32 seconds ago   Up 31 seconds   0.0.0.0:8000->80/tcp, :::8000->80/tcp           app-local
d1c93bcada17   api-todo-fastapi   "uvicorn app.main:ap…"   5 hours ago      Up 5 hours      0.0.0.0:5000->5000/tcp, :::5000->5000/tcp       api-local
f374bb7a57ac   mongo-base         "docker-entrypoint.s…"   8 days ago       Up 7 hours      0.0.0.0:27017->27017/tcp, :::27017->27017/tcp   mongodb-todo

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

コンテナに接続

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

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

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

# pwd
/
# cd /usr/share/nginx/html
# ls -lah
total 184K
drwxr-xr-x 1 root root 4.0K Aug 29 05:12 .
drwxr-xr-x 1 root root 4.0K Aug 16 09:50 ..
-rw-r--r-- 1 root root  497 Aug 15 17:03 50x.html
-rw-r--r-- 1 root root 157K Aug 29 05:12 bundle.js
-rw-r--r-- 1 root root  871 Aug 29 05:12 bundle.js.LICENSE.txt
-rw-r--r-- 1 root root  239 Aug 29 05:12 index.html

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

# apt update
# apt install procps
# top
top - 05:15:00 up  6:49,  0 user,  load average: 0.34, 0.15, 0.11
Tasks:   6 total,   1 running,   5 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.8 us,  0.6 sy,  0.0 ni, 98.5 id,  0.0 wa,  0.0 hi,  0.1 si,  0.0 st
MiB Mem :   7897.1 total,   3025.2 free,   2798.5 used,   2353.3 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   5098.6 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    1 root      20   0   11380   7060   5948 S   0.0   0.1   0:00.03 nginx
   30 nginx     20   0   11540   2584   1324 S   0.0   0.0   0:00.00 nginx
   31 nginx     20   0   11540   2584   1324 S   0.0   0.0   0:00.00 nginx
   32 nginx     20   0   11540   2584   1324 S   0.0   0.0   0:00.00 nginx
   33 root      20   0    4188   3540   3036 S   0.0   0.0   0:00.02 bash
  223 root      20   0    8632   5048   2924 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 環境で、JavaScript React の最小フロントエンドを実装することができました。

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

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

推奨コンテンツ

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?