2
2

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 環境と Vue.js (JavaScript) で最小フロントエンドを実装する:FastAPI + MongoDB

Last updated at Posted at 2023-08-30

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

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

目的

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

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

実現すること

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

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

実行環境

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

Web アプリ コンテナ

要素 概要
app-todo-vuejs 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 データベース

技術トピック

Vue.js とは?

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

Vue.js (ビュージェイエス)

Vue.js (または Vue) は、JavaScript フレームワークの1つで、ユーザーインターフェイスを構築するためのプログレッシブフレームワークです。

キーワード 内容
リアクティブデータバインディング Vue はデータと UI を結びつけるリアクティブデータバインディングを提供し、データの変更が自動的に UI に反映される仕組みを提供します。
コンポーネントベース Vue は UI を再利用可能なコンポーネントに分割するアーキテクチャを採用しており、コンポーネントを組み合わせることでアプリケーションを構築できます。
テンプレート構文 Vue のテンプレート構文は HTML に類似しており、データの表示や操作が直感的に行えます。
単一ファイルコンポーネント Vue ではコンポーネントごとに HTML、JavaScript、CSS を1つのファイルにまとめることができる単一ファイルコンポーネントが利用できます。
プログレッシブフレームワーク Vue はプログレッシブフレームワークとして位置づけられ、必要に応じて徐々に導入して利用できます。
シンプルな学習曲線 Vue のシンプルな構文とコンセプトは初心者にも理解しやすく、学習曲線が緩やかです。
拡張性とカスタマイズ性 Vue はカスタムディレクティブやプラグインなどの機能を追加することができ、アプリケーションの要件に合わせて拡張性を持たせることができます。
パフォーマンス リアクティブデータバインディングと仮想 DOM により、高速な UI パフォーマンスを提供します。
コミュニティとエコシステム Vue はアクティブなコミュニティと多くのサードパーティライブラリ、ツールを持ち、効率的な開発が可能です。
ドキュメントとサポート Vue は豊富な公式ドキュメントとサポートリソースを提供し、開発者が問題を解決しやすい環境を提供します。

開発環境

  • 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-vuejs をプロジェクトフォルダとします。

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

JS ファイルの作成

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

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

ファイルの内容

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

import { createApp, ref, onMounted } from 'vue';

// Vue アプリケーションのセットアップ
const app = createApp({
    // コンポーネントの初期化およびロジックの定義
    setup() {
        const _apiUrl = process.env.API_URL; // API の URL
        const _todos = ref([]); // ToDo アイテムのリストの参照
        const _inputAdd = ref(null); // 新規追加の input 要素の参照

        // Vue コンポーネントが DOM にマウントされた直後に呼び出されるコールバック関数
        onMounted(() => {
            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);
                _todos.value = filtered.map(elem => ({ // ToDo データに編集関連の情報を追加
                    ...elem, // 全ての要素を引継ぎ
                    editing: false, // 編集フラグは OFF
                    editContent: elem.content // 元のテキストをコピー
                }));
            } catch (error) { alert("Error fetching todos: ", error); }
        };

        // 新規追加ボタンのクリックイベントハンドラ
        const handleButtonAddClick = async () => {
            try {
                const content = _inputAdd.value.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.value.value = "";
                }
            } catch (error) { alert("Error adding todo: ", error); }
        };

        // 完了ボタンのクリックイベントハンドラ
        const handleButtonCompleteClick = async (todo) => {
            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) => {
            _todos.value = _todos.value.map(elem => ({
                ...elem, editing: false // すべて編集フラグは OFF
            }));
            _todos.value = _todos.value.map(elem =>
                elem.id === todo.id
                    ? { ...elem, editing: true } // id が一致したら編集フラグは ON
                    : elem // 一致しなければそのまま
            );
        };

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

        // 更新ボタンのクリックイベントハンドラ
        const handleButtonUpdateClick = async (todo) => {
            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 = async (todo) => {
            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); }
        };

        // これらの関数や変数は Vue コンポーネント内でシンプルな記法で使用可能
        return {
            todos: _todos,
            inputAdd: _inputAdd,
            fetchAndRender,
            handleButtonAddClick,
            handleButtonCompleteClick,
            handleContentClick,
            handleRowContentChange,
            handleButtonUpdateClick,
            handleButtonDeleteClick
        };
    }
});

// アプリケーションを #app にマウント
app.mount('#app');

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

src/main.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);
        _todos.value = filtered.map(elem => ({ // ToDo データに編集関連の情報を追加
            ...elem, // 全ての要素を引継ぎ
            editing: false, // 編集フラグは OFF
            editContent: elem.content // 元のテキストをコピー
        }));
    } catch (error) { alert("Error fetching todos: ", error); }
};

// 新規追加ボタンのクリックイベントハンドラ
const handleButtonAddClick = async () => {
    try {
        const content = _inputAdd.value.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.value.value = "";
        }
    } catch (error) { alert("Error adding todo: ", error); }
};

// 完了ボタンのクリックイベントハンドラ
const handleButtonCompleteClick = async (todo) => {
    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) => {
    _todos.value = _todos.value.map(elem => ({
        ...elem, editing: false // すべて編集フラグは OFF
    }));
    _todos.value = _todos.value.map(elem =>
        elem.id === todo.id
            ? { ...elem, editing: true } // id が一致したら編集フラグは ON
            : elem // 一致しなければそのまま
    );
};

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

// 更新ボタンのクリックイベントハンドラ
const handleButtonUpdateClick = async (todo) => {
    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 = async (todo) => {
    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 class="div-container">
                <h1>ToDo リスト</h1>
                <div class="div-add">
                    <input 
                        type="text" placeholder="新規アイテムを追加" 
                        ref="inputAdd" 
                        @click="handleContentClick({ id: '_dummy' })" 
                    />
                    <button id="button-add" @click="handleButtonAddClick">
                        新規追加
                    </button>
                </div>
                <div class="div-table" id="div-todo-list">
                    <div v-for="todo in todos" :key="todo.id" class="div-row">
                        <div class="div-content">
                            <!-- 編集中の場合は input を表示、そうでなければ div を表示 -->
                            <input v-if="todo.editing" 
                                type="text" 
                                class="input-edit" 
                                :value="todo.editContent"
                                @input="handleRowContentChange(todo, $event.target.value)" 
                            />
                            <div v-else @click="handleContentClick(todo)">
                                {{ todo.content }}
                            </div>
                        </div>
                        <div class="div-buttons">
                            <button 
                                class="button-complete" 
                                @click="handleButtonCompleteClick(todo)"
                            >
                                完了
                            </button>
                            <button 
                                class="button-update" 
                                @click="handleButtonUpdateClick(todo)" 
                                :disabled="!todo.editing"
                            >
                                更新
                            </button>
                            <button 
                                class="button-delete" 
                                @click="handleButtonDeleteClick(todo)"
                            >
                                削除
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </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/main.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ],
  },
  resolve: {
    alias: {
      'vue': 'vue/dist/vue.esm-bundler.js'
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    }),
    new webpack.DefinePlugin({
      'process.env.API_URL': JSON.stringify(process.env.API_URL)
    })
  ]
};
説明を開きます。
要素 内容
mode ビルドモードを指定します。
entry アプリケーションのエントリーポイントとなる JavaScript ファイル (ここでは main.js) を指定します。このファイルを起点にして依存関係を解決してバンドルします。
output バンドルされたファイル (ここでは bundle.js) の出力先を指定します。filename で出力ファイル名、path で出力先ディレクトリの絶対パスを指定します。
module.rules ファイルのローダー(変換ツール)を定義します。test でファイルの正規表現を指定し、ローダーを適用するファイルを絞り込みます。use で使用するローダーを指定します。ここでは、JavaScript ファイルには babel-loader を適用し、CSS ファイルには style-loader と css-loader を順番に適用します。
resolve.alias 特定のライブラリやモジュールを別のパスにリダイレクトすることができます。ここでは vue モジュールを vue/dist/vue.esm-bundler.js というファイルパスに対応付けています。これにより、Vue の ESM (ECMAScript Modules) バンドラーバージョンを使用することが指定されています。Vue の ESM バンドラーバージョンは、Vue のコンパイラを含まず、ランタイム専用の軽量なバージョンであり、通常の Webpack バンドルサイズを減らすために使用されます。この設定により、Vue アプリケーションが最適なバンドルを生成できるようになります。
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-vuejs",
  "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 \
    vue \
    webpack webpack-cli html-webpack-plugin \
    css-loader style-loader babel-loader \
    @babel/core @babel/preset-env \
    --save-dev
説明を開きます。
要素 内容
vue Vue.js ライブラリを指定しています。
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 構文を変換するためのプリセットです。

環境変数を作成します。

$ 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-vuejs:latest .

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

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

$ docker images | grep app-todo-vuejs
app-todo-vuejs   latest      3ff61c64ae16    10 seconds ago   187MB

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

コンテナを起動

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

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

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

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

コンテナの動作確認

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

$ wslview http://localhost:8000

image.png

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

$ docker ps
CONTAINER ID   IMAGE              COMMAND                   CREATED          STATUS          PORTS                                           NAMES
acac08afa5d1   app-todo-vuejs     "/docker-entrypoint.…"   36 seconds ago   Up 35 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 180K
drwxr-xr-x 1 root root 4.0K Aug 29 05:19 .
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 154K Aug 29 05:19 bundle.js
-rw-r--r-- 1 root root  149 Aug 29 05:19 bundle.js.LICENSE.txt
-rw-r--r-- 1 root root 1.2K Aug 29 05:19 index.html

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

# apt update
# apt install procps
# top
top - 05:21:45 up  6:56,  0 user,  load average: 0.16, 0.11, 0.09
Tasks:   6 total,   1 running,   5 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.3 us,  0.3 sy,  0.0 ni, 99.2 id,  0.0 wa,  0.0 hi,  0.2 si,  0.0 st
MiB Mem :   7897.1 total,   2914.0 free,   2802.3 used,   2470.2 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   5094.8 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    1 root      20   0   11380   7168   6052 S   0.0   0.1   0:00.02 nginx
   29 nginx     20   0   11540   2492   1220 S   0.0   0.0   0:00.00 nginx
   30 nginx     20   0   11540   2492   1220 S   0.0   0.0   0:00.00 nginx
   31 nginx     20   0   11540   2492   1220 S   0.0   0.0   0:00.00 nginx
   32 root      20   0    4188   3492   2988 S   0.0   0.0   0:00.02 bash
  222 root      20   0    8632   4688   2804 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 Vue.js の最小フロントエンドを実装することができました。

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

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

推奨コンテンツ

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?