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

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

こんにちは、@studio_meowtoon です。今回は、WSL の Ubuntu 22.04 で TypeScript Vanilla TS Webアプリケーションを作成し、最小限のフロントエンドを実装する方法を紹介します。
![vanilla-ts_and_spring-boot_and_mysql_on_docker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3206449/3cd03e17-ef36-fda7-dfb5-dfbda0563711.png)

## 目的

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

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

https://qiita.com/studio_meowtoon/items/6119ab05d8b13527efd8

## 実現すること

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

**HTML ファイル形式のアプリをコンテナとして起動**
```mermaid
flowchart LR
    web-browser --> app[index.html]
    app[index.html] <--> api[app.jar]
    subgraph Ubuntu
        subgraph Docker
            subgraph app-container[app-todo-vanillats]
                subgraph nginx
                    app[index.html]
                end
            end
            subgraph api-container[api-todo-spring-boot]
                subgraph JVM
                    subgraph api[app.jar]
                        tomcat
                    end
                end
            end
            subgraph db-container[mysql-todo]
                subgraph mysql
                    db_todo
                end
            end
            api[app.jar] <--> db_todo
        end
    end
style Ubuntu fill:#FFBCA4, stroke:#E0460F
style Docker fill:#EEFDFF, stroke:#74B9DC
style JVM fill:#DCFFDC, stroke:#2F872F
style app-container fill:#FFD7E4, stroke:#E13C73
style api-container fill:#FFD7E4, stroke:#E13C73
style db-container fill:#FFD7E4, stroke:#E13C73
style app fill:#ECECFF, stroke:#9674DC
style api fill:#ECECFF, stroke:#9674DC
style tomcat fill:#FFFFDE, stroke:#B4AE43
style mysql fill:#FFFFDE, stroke:#B4AE43
```

**実行環境**

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

**Web アプリ コンテナ**

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

## 技術トピック

### Vanilla TS とは？

<details>
<summary>こちらを展開してご覧いただけます。</summary>

### [Vanilla TS (バニラティーエス)](http://vanilla-ts.com/)

:::note info
Vanilla TS は、純粋な形式の TypeScript のことを指します。つまり、ライブラリやフレームワークを使用せずに、TypeScript をシンプルで基本的な方法で使用することを指すます。

|キーワード|内容|
|:--|:--|
|TypeScript (TS)|TypeScript は、Microsoft によって開発されたプログラミング言語で、JavaScript のスーパーセットです。TypeScript は、静的型付け言語であり、変数や関数の型を宣言することができます。これにより、コードの品質向上やバグの早期発見が可能になります。JavaScript のコードを TypeScript に変換することができ、既存の JavaScript プロジェクトにも容易に導入できます。ブラウザや Node.js などの環境で実行でき、幅広いアプリケーションに適しています。
|静的型付け|TypeScript は静的型付けを採用しており、変数や関数の型情報をコード内で宣言します。これにより、型に関連するエラーや問題をコンパイル時に検出できます。|
|エコシステム|TypeScript は広く採用されており、豊富なエコシステムが存在します。型定義ファイル (.d.ts) を利用して、多くの JavaScript ライブラリやフレームワークと統合できます。|
|IDE サポート|主要な統合開発環境 (IDE) やコードエディターは、TypeScript をサポートしており、型情報に基づいたコード補完やリファクタリングなどの強力な機能を提供します。|
|コンパイル|TypeScript コードは、TypeScript コンパイラを使用して JavaScript に変換されます。この JavaScript コードがブラウザや Node.js 環境で実行されます。|
|型安全性|TypeScript は型安全性を提供し、型に関連するエラーや不整合を事前に検出できるため、コードの信頼性を向上させます。|
:::

</details>

## 開発環境

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

:::note info
[WSL](https://learn.microsoft.com/ja-jp/windows/wsl/about) の Ubuntu を操作していきますので macOS の方も参考にして頂けます。  
:::

<details>

WSL (Microsoft Store アプリ版) [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/2aaa6579cb8e4fa1d874)

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

Ubuntu [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/2aaa6579cb8e4fa1d874)

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

npm [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/111f556d8fb11a76430a)

```sh
$ node -v
v19.8.1
```

```sh
$ npm -v
9.5.1
```

Docker [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/3bceee25ac67cc73ebbe)

```sh
$ docker --version
Docker version 23.0.1, build a5ee5b1
```

</details>

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

https://qiita.com/studio_meowtoon/items/7771ad48676926e41463

## 作成するアプリの外観
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3206449/b3d513fd-c9e1-3257-0807-61810041a0d4.png)

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

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

https://qiita.com/studio_meowtoon/items/335f65bd59e79897aa9a

https://qiita.com/studio_meowtoon/items/ce1c853893388c106d22

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

```sh
$ 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
```

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

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

### プロジェクトの作成

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

```sh
$ mkdir -p ~/tmp/restclt-vanillats
$ cd ~/tmp/restclt-vanillats
```

### TS ファイルの作成

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

```sh
$ mkdir -p src
$ vim src/main.ts
```

ファイルの内容

<details>
<summary>コードの全体を表示する</summary>

```ts:src/main.ts
import './styles.scss';

const _apiUrl: string | undefined = process.env.API_URL; // API URL
const _divTodos: HTMLElement | null = document.getElementById("div-todo-list"); // ToDo テーブル
const _buttonAdd: HTMLElement | null = document.getElementById("button-add"); // 新規追加ボタン
const _inputAdd: HTMLInputElement | null = document.getElementById("input-add") as HTMLInputElement; // 新規追加テキスト
const _inputEdit: HTMLInputElement = document.createElement("input"); // 更新テキスト
const _todos: Array<Todo> = []; // データ保持配列

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

// データの取得と表示
const fetchAndRender = async (): Promise<void> => {
    try {
        const response: Response = await fetch(`${_apiUrl}/todos`);
        if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
        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 を追加
        }));
        setTodos(updated); // 新しい配列をセットして ToDo データを更新
    } catch (error) { alert(`Error fetching todos: ${error}`); }
};

// データを展開する関数
const setTodos = (todos: Array<Todo>): void => {
    if (_divTodos === null) { return; }
    _divTodos.innerHTML = "";
    _todos.length = 0;
    _todos.push(...todos);
    _todos.forEach((value: Todo) => {
        initializeComponent(value);
    });
};

// 新規追加ボタンのクリックイベントハンドラ
const handleButtonAddClick = async (): Promise<void> => {
    try {
        if (_inputAdd === null) { return; }
        const content: string = _inputAdd.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.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 handleRowContentClick = (todo: Todo) => (): void => {
    try {
        // 要素がアクティブなら return
        if (document.activeElement === _inputEdit) { return; }
        // 編集中フラグを更新
        const updated: Array<Todo> = _todos.map((elem: Todo) => ({
            ...elem,
            editing: elem.id === todo.id
        }));
        _todos.length = 0;
        _todos.push(...updated);
        _todos.forEach((value: Todo) => {
            // テーブル行の初期化
            const div_content: HTMLElement | null = document.getElementById(`div-content-${value.id}`);
            if (div_content === null) { return; }
            div_content.innerHTML = value.content;
            const button_update: HTMLElement | null = document.getElementById(`button-update-${value.id}`);
            button_update?.setAttribute("disabled", "true");
            // 編集中フラグが ON なら input 要素に置き換え
            if (value.editing) {
                button_update?.removeAttribute("disabled");
                _inputEdit.value = value.content;
                _inputEdit.className = "input-edit";
                div_content.innerHTML = "";
                div_content.appendChild(_inputEdit);
            }
        });
    } catch (error) { alert(`Error clicking todos: ${error}`); }
};

// 更新ボタンのクリックイベントハンドラ
const handleButtonUpdateClick = (todo: Todo) => async (): Promise<void> => {
    try {
        if (_inputEdit) {
            const content: string = _inputEdit.value.trim();
            if (content === "") { return; }
            if (confirm("このアイテムを更新しますか?")) {
                const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                    method: "PUT",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ content: content })
                });
                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}`); }
};

// テーブルの構築
const initializeComponent = (todo: Todo): void => {
    // 列作成
    const divRow: HTMLDivElement = document.createElement("div");
    divRow.className = "div-row";
    // ボタングループ
    const divButtons: HTMLDivElement = document.createElement("div");
    divButtons.className = "div-buttons";
    // 完了ボタン
    const buttonComplete: HTMLButtonElement = document.createElement("button");
    buttonComplete.textContent = "完了";
    buttonComplete.className = "button-complete";
    buttonComplete.addEventListener("click", handleButtonCompleteClick(todo));
    // 更新ボタン
    const buttonUpdate: HTMLButtonElement = document.createElement("button");
    buttonUpdate.textContent = "更新";
    buttonUpdate.className = "button-update";
    buttonUpdate.id = `button-update-${todo.id}`;
    buttonUpdate.setAttribute("disabled", "true");
    buttonUpdate.addEventListener("click", handleButtonUpdateClick(todo));
    // 削除ボタン
    const buttonDelete: HTMLButtonElement = document.createElement("button");
    buttonDelete.textContent = "削除";
    buttonDelete.className = "button-delete";
    buttonDelete.addEventListener("click", handleButtonDeleteClick(todo));
    // コンテンツ
    const divContent: HTMLDivElement = document.createElement("div");
    divContent.className = "div-content";
    divContent.id = `div-content-${todo.id}`;
    divContent.textContent = `${todo.content}`;
    divContent.addEventListener("click", handleRowContentClick(todo));
    // テーブル要素構築
    divButtons.appendChild(buttonComplete);
    divButtons.appendChild(buttonUpdate);
    divButtons.appendChild(buttonDelete);
    divRow.appendChild(divContent);
    divRow.appendChild(divButtons);
    _divTodos?.appendChild(divRow);
};

// アイテム新規追加イベント
_inputAdd?.addEventListener("click", fetchAndRender);
_buttonAdd?.addEventListener("click", handleButtonAddClick);

// 初期表示
fetchAndRender();
```

</details>

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

```ts:src/main.ts ※抜粋
// データの取得と表示
const fetchAndRender = async (): Promise<void> => {
    try {
        const response: Response = await fetch(`${_apiUrl}/todos`);
        if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
        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 を追加
        }));
        setTodos(updated); // 新しい配列をセットして ToDo データを更新
    } catch (error) { alert(`Error fetching todos: ${error}`); }
};

// 新規追加ボタンのクリックイベントハンドラ
const handleButtonAddClick = async (): Promise<void> => {
    try {
        if (_inputAdd === null) { return; }
        const content: string = _inputAdd.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.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 handleRowContentClick = (todo: Todo) => (): void => {
    try {
        // 要素がアクティブなら return
        if (document.activeElement === _inputEdit) { return; }
        // 編集中フラグを更新
        const updated: Array<Todo> = _todos.map((elem: Todo) => ({
            ...elem,
            editing: elem.id === todo.id
        }));
        _todos.length = 0;
        _todos.push(...updated);
        _todos.forEach((value: Todo) => {
            // テーブル行の初期化
            const div_content: HTMLElement | null = document.getElementById(`div-content-${value.id}`);
            if (div_content === null) { return; }
            div_content.innerHTML = value.content;
            const button_update: HTMLElement | null = document.getElementById(`button-update-${value.id}`);
            button_update?.setAttribute("disabled", "true");
            // 編集中フラグが ON なら input 要素に置き換え
            if (value.editing) {
                button_update?.removeAttribute("disabled");
                _inputEdit.value = value.content;
                _inputEdit.className = "input-edit";
                div_content.innerHTML = "";
                div_content.appendChild(_inputEdit);
            }
        });
    } catch (error) { alert(`Error clicking todos: ${error}`); }
};

// 更新ボタンのクリックイベントハンドラ
const handleButtonUpdateClick = (todo: Todo) => async (): Promise<void> => {
    try {
        if (_inputEdit) {
            const content: string = _inputEdit.value.trim();
            if (content === "") { return; }
            if (confirm("このアイテムを更新しますか?")) {
                const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
                    method: "PUT",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ content: content })
                });
                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}`); }
};
```

<details>
<summary>説明を開きます。</summary>

|要素|説明|
|:--|:--|
|アイテム追加イベント|_inputAdd 要素がクリックされたときに、新しいアイテムを追加するためのイベントハンドラを登録しています。_buttonAdd 要素がクリックされたときに、handleButtonAddClick() 関数が呼び出されます。|
|データ表示|fetchAndRender() 関数は、サーバーからデータを取得して未完了の ToDo アイテムを表示するための非同期関数です。サーバーからのデータをフィルタリングし、それぞれのアイテムに対して initializeComponent() 関数を呼び出して初期化します。|
|アイテム追加ボタンのクリック|_buttonAdd 要素がクリックされたときに実行される関数です。入力内容を取得し、サーバーに新しいアイテムを追加するための非同期処理を行います。|
|完了ボタンのクリック|各アイテムの完了ボタンがクリックされたときに実行される関数です。サーバーにアイテムの完了情報を送信してアイテムのステータスを更新します。|
|アイテムテキスト編集|ToDo アイテムのテキストをクリックすると、その内容が編集可能な input 要素に置き換えられます。|
|アイテム更新と削除|更新ボタンをクリックすると、アイテムの内容がサーバーに送信されて更新されます。削除ボタンをクリックすると、アイテムがサーバーから削除されます。|

</details>

### HTML ファイルの作成

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

```sh
$ vim src/index.html
```

ファイルの内容

```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 class="div-container">
            <h1>ToDo リスト</h1>
            <div class="div-add">
                <input type="text" id="input-add" placeholder="新規アイテムを追加">
                <button id="button-add">新規追加</button>
            </div>
            <div class="div-table" id="div-todo-list"></div>
        </div>
    </body>
</html>
```

### SCSS ファイルの作成

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

```sh
$ vim src/styles.scss
```

ファイルの内容

<details>
<summary>コードの全体を表示する</summary>

```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;
}
```

</details>

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

```css: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 の設定ファイルを作成します。

```sh
$ vim webpack.config.ts
```

ファイルの内容

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

module.exports = {
  mode: 'production',
  entry: './src/main.ts',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'build')
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        use: 'ts-loader'
      },
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader']
      }
    ]
  },
  resolve: {
    extensions: ['.ts', '.js']
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    }),
    new webpack.DefinePlugin({
      'process.env.API_URL': JSON.stringify(process.env.API_URL)
    })
  ]
};
```

<details>
<summary>説明を開きます。</summary>

|要素|内容|
|:--|:--|
|mode|ビルドモードを指定します。|
|entry|アプリケーションのエントリーポイントとなる TypeScript ファイル (ここでは main.ts) を指定します。このファイルを起点にして依存関係を解決してバンドルします。|
|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 を定義しています。|

</details>

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

```sh
$ npm init -y
```

**package.json** を修正します。

```sh
$ vim package.json
```

ファイルの内容

```json:package.json
{
  "name": "restclt-vanillats",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.ts",
  "scripts": {
    "build": "webpack --mode production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
```

<details>
<summary>説明を開きます。</summary>

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

</details>

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

```sh
$ npm install \
    webpack webpack-cli html-webpack-plugin \
    css-loader style-loader node-sass sass-loader\
    typescript ts-node ts-loader \
    --save-dev
```

<details>
<summary>説明を開きます。</summary>

|要素|内容|
|:--|:--|
|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 バンドル内でコンパイルするのに使用されます。|

</details>

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

```sh
$ vim tsconfig.json
```

ファイルの内容

```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
  },
  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}
```

<details>
<summary>説明を開きます。</summary>

|要素|内容|
|:--|:--|
|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 に進む) をエラーとして検出する設定です。|
|include: src/**/*.ts|TypeScript ファイルのソースコードが存在するディレクトリとファイルを指定します。この設定に基づいて、コンパイル対象の TypeScript ファイルが選択されます。
|exclude: node_modules|コンパイルから除外するディレクトリを指定します。通常、プロジェクト内の Node.js モジュールがここに含まれます。|

</details>

環境変数を作成します。

```sh
$ export API_URL=http://localhost:5000
```

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

ビルドします。

```sh
$ npm run build
```

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

```sh
$ python3 \
    -m http.server 8000 \
    --directory ./build
```

<details>
<summary>説明を開きます。</summary>

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

</details>

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

```sh
$ wslview http://localhost:8000
```

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

### コンテナイメージの作成

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

```sh
$ vim nginx.conf
```

ファイルの内容

```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 を作成します。

```sh
$ vim Dockerfile
```

ファイルの内容

```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;"]
```

<details>
<summary>説明を開きます。</summary>

|要素|説明|
|:--|:--|
|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 をバックグラウンドで実行するコマンドを設定しています。|

</details>

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

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

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

https://qiita.com/studio_meowtoon/items/3bceee25ac67cc73ebbe

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

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

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

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

```sh
$ docker images | grep app-todo-vanillats
app-todo-vanillats    latest    545622fec978    8 seconds ago    187MB
```

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

### コンテナを起動

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

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

```sh
$ docker run --rm \
    --publish 8000:80 \
    --name app-local \
    --net net-todo \
    app-todo-vanillats
```

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

### コンテナの動作確認

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

```sh
$ wslview http://localhost:8000
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3206449/18f5e0da-a46b-f31f-9b38-c3e5a7f12dd2.png)

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

```sh
$ docker ps
CONTAINER ID   IMAGE                  COMMAND                   CREATED             STATUS             PORTS                                                  NAMES
9d8baf332086   app-todo-vanillats     "/docker-entrypoint.…"   36 seconds ago      Up 35 seconds      0.0.0.0:8000->80/tcp, :::8000->80/tcp                  app-local
e85cb3c156e5   api-todo-spring-boot   "java -jar app.jar"       About an hour ago   Up About an hour   0.0.0.0:5000->8080/tcp, :::5000->8080/tcp              api-local
63a3acec271c   mysql-base             "docker-entrypoint.s…"   9 days ago          Up About an hour   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp   mysql-todo
```

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

### コンテナに接続

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

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

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

```
# pwd
/
# cd /usr/share/nginx/html
# ls -lah
total 32K
drwxr-xr-x 1 root root 4.0K Sep  2 01:17 .
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 9.9K Sep  2 01:17 bundle.js
-rw-r--r-- 1 root root  460 Sep  2 01:17 index.html
```

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

```
# apt update
# apt install procps
# top
top - 01:20:34 up  1:26,  0 user,  load average: 0.15, 0.09, 0.06
Tasks:   6 total,   1 running,   5 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.2 sy,  0.0 ni, 99.8 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   7897.1 total,   4055.5 free,   2569.6 used,   1522.9 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   5327.6 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    1 root      20   0   11380   6988   5872 S   0.0   0.1   0:00.02 nginx
   29 nginx     20   0   11540   2556   1292 S   0.0   0.0   0:00.00 nginx
   30 nginx     20   0   11540   2560   1292 S   0.0   0.0   0:00.00 nginx
   31 nginx     20   0   11540   2560   1292 S   0.0   0.0   0:00.00 nginx
   32 root      20   0    4188   3412   2908 S   0.0   0.0   0:00.02 bash
  222 root      20   0    8632   4932   2804 R   0.0   0.1   0:00.00 top
```

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

```cs
# 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/"
```

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

## まとめ

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

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

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

## 推奨コンテンツ

https://qiita.com/studio_meowtoon/items/335f65bd59e79897aa9a

https://qiita.com/studio_meowtoon/items/ce1c853893388c106d22

https://qiita.com/studio_meowtoon/items/9d91e53d6ff4e5753b05