2
0
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

フロントエンド学習記No.4 クラスとモジュールを使ってポケモンTodoアプリ作ってみる

Last updated at Posted at 2024-01-14

はじめに

今回は、TypeScriptでクラスとモジュールの使ってミニアプリを作ることで理解を深めて行きたいと思います。
クラス、モジュールって何?という方は以下を参照してください。

目標物

クラスやモジュールを使うということで複数の機能を組み合わせたアプリにしていきます。
大まかな機能は以下の通り

  • ユーザー作成機能(PokeAPIでポケモンのデータでユーザ登録する)
  • Todo機能
  • レベルアップ機能(タスクを消化するたびに経験値がたまる、上限値までたまるとレベルアップする)

このような形で作っていきます。
今まで作ってきたものと+αを掛け合わせた感じですね笑

作成した結果はこちら

ソースコード

index.html

<!DOCTYPE html>
<html lang='ja'>
  <head>
    <meta charset="utf-8">
    <title>Hello typescript</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <h1>PokeTodoApp</h1>
    <div class="userCreateContainer">
      <h3>ユーザー作成</h3>
      <input type="text" id="pokeId">
      <button id="pokeSearch">検索</button>
    </div>
    <div class="userStatusContainer">
      <h3>ユーザー情報</h3>
      <img src="" alt="" id="userImg">
      <p id="userName"></p>
      <p id="userLevel"></p>
      <svg width="210"height="50">
        <rect width="200" height="10" x="5" y="5" fill="none" stroke="black" stroke-width="2"></rect>
        <rect id="meter" width="0" height="10" x="5" y="5" fill="blue"></rect>
      </svg>
    <h3>タスクを作成</h3>
    <input type="text" id="task">
      <button id="addBtn">+</button>
    <h3>タスク一覧</h3>
    <div id="tasklist">
    </div>
    <button id="taskComplete">タスク完了</button>
  </div>
    <dialog id="createUserDialog">
        <h3>ユーザー作成</h3>
        <img src="" alt="" id="checkPokeImg">
        <p id="checkPokeName"></p>
        <p>このポケモンで良いですか?</p>
        <button id="createUserBtn">作成</button>
        <button id="returnBtn">戻る</button>
    </dialog>


    <script src="bundle.js"></script>
  </body>
</html>

クラス

ユーザ情報となるポケモンのデータを取り扱うクラスです。
PokeAPIで取得した情報を保持します。
経験値やレベルを操作するメソッド等あります。

pokemon.ts

// ポケモンクラス
// ポケモンの名前、画像、レベル、経験値を持つ
export class Pokemon {
    pokeName: string;
    pokeImg: string;
    pokeLv: number = 1;
    pokeExp: number = 0;
    maxEXP :number = 10;
    defaultValue :number = 0;
    userPokeLv = document.querySelector('#userLevel')! as HTMLParagraphElement;

    // ポケモンの名前と画像を引数に取り、初期値を設定する
    constructor(pokeName: string, pokeImg: string) {
        this.pokeName = pokeName;
        this.pokeImg = pokeImg;
        this.pokeLv = 1;
        this.pokeExp = 0;
    }

    // ポケモンのデータを画面上にセットする
    userSetData() :Pokemon {
        const userPokeName = document.querySelector('#userName')! as HTMLParagraphElement;
        const userPokeImg = document.querySelector('#userImg')! as HTMLImageElement;
        userPokeName.textContent = this.pokeName;
        userPokeImg.src = this.pokeImg;
        this.userPokeLv.textContent = `Lv.${this.pokeLv}`;
        return this;
    }

    // 経験値を加算する、加算後の状態によりメーター、レベルの更新を行う
    updateMeter(expValue: number) {
        const meter = document.querySelector('#meter')! as SVGElement;
        const maxWidth = 200;
        const newWidth = (maxWidth / this.maxEXP) * expValue;
        meter.setAttribute('width', `${newWidth}`);
        if (expValue === this.maxEXP){
            this.levelUp();
            this.pokeExp = this.defaultValue;
            meter.setAttribute('width', `${this.defaultValue}`);
        }
    }

    // レベルアップ時にレベルを加算する
    levelUp() {
        window.alert('おめでとうございます!レベルアップしました!')
        this.pokeLv = this.pokeLv +1;
        this.userPokeLv.textContent = `Lv.${this.pokeLv}`;
    }
}

task.ts

タスクのデータを保持するクラスです
タスク追加時に、ユーザが入力した情報から、エレメント要素を作成します。

typescript task.ts
export class Task {
    taskFrame : HTMLDivElement;
    taskLabel: HTMLLabelElement;
    taskCheck: HTMLInputElement;
    uuid: string;

    // タスク作成時、ユーザが入力したタスク名を引数に取り要素を作成する
    constructor(taskName: string) {
        this.uuid = crypto.randomUUID();
        this.taskLabel = document.createElement('label');
        this.taskLabel.textContent = taskName;
        this.taskLabel.htmlFor = this.uuid;

        this.taskCheck = document.createElement('input');
        this.taskCheck.type = 'checkbox';
        this.taskCheck.id = this.uuid;

        this.taskCheck.addEventListener('change', () => {
            this.handleTaskCheckChange();
        });

        this.taskFrame = document.createElement('div');
    }
    
    // タスクのチェックボックスの変更を検知、状態に応じてスタイルを変更する
    handleTaskCheckChange() {
       if (this.taskCheck.checked){
        this.taskLabel.classList.add('done')
       } else {
        this.taskLabel.classList.remove('done')
       }
    }

    // タスクのチェックボックスの状態を返す
    isChecked(): boolean {
        return this.taskCheck.checked;
    }
}

taskmanager.ts

タスクの追加、削除など管理を行うクラス

// タスク管理クラス
import { Task } from './task';

export class TaskManager {
    taskList: Task[] = [];

    // 追加するタスクの要素を取得し、タスクのリストに追加する
    taskContainer: HTMLDivElement =  document.getElementById('tasklist')! as HTMLDivElement;
    appendTask(task: Task) {
        this.taskList.push(task);
        task.taskFrame.appendChild(task.taskCheck);
        task.taskFrame.appendChild(task.taskLabel);
        this.taskContainer.appendChild(task.taskFrame);
    }

    // 完了したタスクを削除する、削除したタスクの数を返す
    removeCompletedTasks(pokeExp: number): number {
        const completedTasks = this.taskList.filter(task => task.isChecked());
        let expCount :number = pokeExp;
        completedTasks.forEach((task) => {
            task.taskFrame.remove();
            expCount = ++expCount;
        });
        this.taskList = this.taskList.filter(task => !task.isChecked());
        return expCount;
    }
}

モジュール類

pokeAPI.ts

pokeAPIを叩いてデータを取得する関数をまとめたモジュールです。
前回のコードをほぼそのまま拝借してます

詳しく知りたい方はこちら

// PokeAPIからデータを取得する関数を定義する
const baseUri: string = "https://pokeapi.co/api/v2/pokemon/"
const nameUri: string = "https://pokeapi.co/api/v2/pokemon-species/"
interface PokemonLanguageEntry {
    language: {
      name: string;
      url: string;
    };
    name: string;
  }

async function showPokeData(pokeId :string) :Promise<string> {
    const res = await fetch(`${baseUri}${pokeId}`);
    const json = await res.json();
    const pokeData = json.sprites.front_default;
    return pokeData
}

async function showPokeName(pokeId :string): Promise<string> {
    const res = await fetch(`${nameUri}${pokeId}`);
    const json = await res.json();
    const jaPokeName = json.names.find((nameEntry: PokemonLanguageEntry) => nameEntry.language.name === 'ja');
    return `${jaPokeName.name}`
}

export { showPokeData, showPokeName }

dialog.ts

ユーザ作成時にポケモンデータのプレビューを表示するダイアログ操作のモジュール

// ダイアログの表示・非表示を制御する関数を定義
const createUserDialog = document.querySelector('#createUserDialog')! as HTMLDialogElement;
const pokeNameElement = document.querySelector('#checkPokeName')! as HTMLParagraphElement;
const pokeImageElement = document.querySelector('#checkPokeImg')! as HTMLImageElement;

function openCreateUserDialog(pokeName: string, pokeImg: string) {
    pokeNameElement.textContent = pokeName;
    pokeImageElement.src = pokeImg;
    createUserDialog.showModal();
}

function closeCreateUserDialog() {
    createUserDialog.close();
}

export { openCreateUserDialog, closeCreateUserDialog, pokeNameElement, pokeImageElement }

app.ts

メインとなるクラス

// メインとなるTypeScriptファイル
import { Task } from './task';
import { TaskManager } from './taskmanager';
import { showPokeData,showPokeName} from './pokeApi';
import { Pokemon } from './pokemon';
import { openCreateUserDialog, closeCreateUserDialog, pokeNameElement, pokeImageElement } from './dialog';

// タスク管理クラスのインスタンスを作成
const taskManager : TaskManager = new TaskManager();
// ボタンの要素を取得
const addBtn = document.querySelector('#addBtn')! as HTMLButtonElement;
const pokeSearch = document.querySelector('#pokeSearch')! as HTMLButtonElement;
const createUserBtn = document.querySelector('#createUserBtn')! as HTMLButtonElement;
const returnBtn = document.querySelector('#returnBtn')! as HTMLButtonElement;
const taskCompleteBtn = document.querySelector('#taskComplete')! as HTMLButtonElement;

// ユーザー作成画面とタスク画面の要素を取得(表示・非表示の切り替えに使用)
const userStatusContainer = document.querySelector('.userStatusContainer')! as HTMLDivElement;
const userCreateContainer = document.querySelector('.userCreateContainer')! as HTMLDivElement;
// ユーザーのポケモンデータを格納する変数
let userPokemon :Pokemon  | null = null;

// ポケモン検索ボタンを押した時の処理、ポケモンの名前と画像を取得し、プレビュー画面を表示する
pokeSearch.addEventListener('click', async () => {
    const pokeId = document.querySelector('#pokeId')! as HTMLInputElement;
    const PokeImg = await showPokeData(pokeId.value)
    const PokeName = await showPokeName(pokeId.value)
    openCreateUserDialog(PokeName, PokeImg)
});

// タスク追加ボタンを押した時の処理、タスクを追加する
addBtn.addEventListener('click', () => {
    const taskName = document.querySelector('#task')! as HTMLInputElement;
    const task = new Task(taskName.value);
    taskManager.appendTask(task);
    taskName.value = '';
});

// ダイアログの戻るボタンを押した時の処理、ダイアログを閉じる
returnBtn.addEventListener('click', () => {
    closeCreateUserDialog();
});

// ダイアログのユーザー作成ボタンを押した時の処理、ユーザーのポケモンデータを作成し、ユーザー作成画面を非表示にする
createUserBtn.addEventListener('click', () => {
    const pokeName :string = pokeNameElement.textContent || '';
    const pokeImg :string = pokeImageElement.src || '';
    if (!pokeName || !pokeImg) {
        console.error('ポケモンが選択されていません');
      return;
    }
    else{
    userPokemon = new Pokemon(pokeName, pokeImg).userSetData();
    userCreateContainer.style.display = 'none';
    userStatusContainer.style.display = 'block';
    closeCreateUserDialog();
}});

// タスク完了ボタンを押した時の処理、完了したタスクを削除し、経験値を加算する
taskCompleteBtn.addEventListener('click', () => {
    if (!userPokemon) {
        console.error('ポケモンのデータがありません');
      return;
    }
    const expCount = taskManager.removeCompletedTasks(userPokemon.pokeExp);
    userPokemon.pokeExp = expCount;
    userPokemon.updateMeter(expCount);
});

解説

量が多いので、主要な場所に数を絞って解説していきます。
SVGのメータ操作やPokeAPIに関するところは前回したので触れません!

タスククラス

    taskFrame : HTMLDivElement;
    taskLabel: HTMLLabelElement;
    taskCheck: HTMLInputElement;
    uuid: string; 使ってない

4つのプロパティを持ちます。
div要素、ラベル要素、インプット要素です。
あと識別子としてuuid定義したのですが、全く活用してませんでした・・・
無視してください。笑

    // タスク作成時、ユーザが入力したタスク名を引数に取り要素を作成する
    constructor(taskName: string) {
        this.uuid = crypto.randomUUID();
        this.taskLabel = document.createElement('label');
        this.taskLabel.textContent = taskName;
        this.taskLabel.htmlFor = this.uuid;

        this.taskCheck = document.createElement('input');
        this.taskCheck.type = 'checkbox';
        this.taskCheck.id = this.uuid;

        this.taskCheck.addEventListener('change', () => {
            this.handleTaskCheckChange();
        });

        this.taskFrame = document.createElement('div');
    }
    

newした時に入力値を受け取ってそれを元に各エレメント要素を設定しています。
エレメント要素のプロパティにはイベントを持たせることもできます。

this.taskCheck.addEventListener('change', () => {
            this.handleTaskCheckChange();
        });

ここですね。自身のチェックボックスの要素にchangeイベントを感知させ、要素の状態を変更するメソッドを呼び出せるようにしています。

    handleTaskCheckChange() {
       if (this.taskCheck.checked){
        this.taskLabel.classList.add('done')
       } else {
        this.taskLabel.classList.remove('done')
       }
    }

チェックボックスの状態によりクラスを操作し、表示を変更してます。
チェックボックスの状態は.chackedで確認できます。
ONされていれば true そうでなければ falseになります。
要素のクラスを追加はclassList.add
削除はremoveで行えます。

タスク管理クラス

appendtask

    appendTask(task: Task) {
        this.taskList.push(task);
        task.taskFrame.appendChild(task.taskCheck);
        task.taskFrame.appendChild(task.taskLabel);
        this.taskContainer.appendChild(task.taskFrame);
    }

タスクを追加するメソッド
引数で受け取ったタスクのインスタンスを自身の配列に追加した後、タスクが持つエレメント要素を、追加しています。
書いていて思いましたが、check要素とラベル要素をtaskFrameに追加する処理に関してはtaskクラスで行った方がよかったですね・・・

removeCompletedTasks

    removeCompletedTasks(pokeExp: number): number {
        const completedTasks = this.taskList.filter(task => task.isChecked());
        let expCount :number = pokeExp;
        completedTasks.forEach((task) => {
            task.taskFrame.remove();
            expCount = ++expCount;
        });
        this.taskList = this.taskList.filter(task => !task.isChecked());
        return expCount;
    }

チェックされたタスクを削除し、削除したタスクの数を返します。
この値は後にユーザデータの経験値を加算する際に使用します。
削除自体は、filterメソッドでチェックしたタスクの要素だけの新たな配列を作成しています。その配列に対してforEachで削除処理を行っています。
ループした数だけ経験値を加算するようにしています。
その後filterメソッドで今度はチェックされていない要素だけの新たな配列を作成しています。

filterメソッドについてはこちら

ポケモンクラス

updateMeter

    // 経験値を加算する、加算後の状態によりメーター、レベルの更新を行う
    updateMeter(expValue: number) {
        const meter = document.querySelector('#meter')! as SVGElement;
        const maxWidth = 200;
        const newWidth = (maxWidth / this.maxEXP) * expValue;
        meter.setAttribute('width', `${newWidth}`);
        if (expValue === this.maxEXP){
            this.levelUp();
            this.pokeExp = this.defaultValue;
            meter.setAttribute('width', `${this.defaultValue}`);
        }
    }

先ほどのremoveCompletedTasksで返された削除されたタスク数をうけとり、経験値、メータを操作します。
経験値が10になるとlevelUpメソッドを呼び出し、レベルを加算します。

ダイアログ操作のモジュール

// ダイアログの表示・非表示を制御する関数を定義
const createUserDialog = document.querySelector('#createUserDialog')! as HTMLDialogElement;
const pokeNameElement = document.querySelector('#checkPokeName')! as HTMLParagraphElement;
const pokeImageElement = document.querySelector('#checkPokeImg')! as HTMLImageElement;

function openCreateUserDialog(pokeName: string, pokeImg: string) {
    pokeNameElement.textContent = pokeName;
    pokeImageElement.src = pokeImg;
    createUserDialog.showModal();
}

function closeCreateUserDialog() {
    createUserDialog.close();
}

export { openCreateUserDialog, closeCreateUserDialog, pokeNameElement, pokeImageElement }

pokeAPIで検索したポケモン情報のプレビューに使用しました。

ダイアログ表示はconfirm()を使用してもできるのですが、画像を表示したりはできないのでdialogタグを使いました。
自身で自由に変更できるのでとても便利でした。
使い方も簡単で

    <dialog id="createUserDialog">
        <h3>ユーザー作成</h3>
        <img src="" alt="" id="checkPokeImg">
        <p id="checkPokeName"></p>
        <p>このポケモンで良いですか?</p>
        <button id="createUserBtn">作成</button>
        <button id="returnBtn">戻る</button>
    </dialog>

このようにdialogタグで囲んだ中に自身でhtmlを書くだけです。
表示させたい時はdialogの要素を取得して
showModal() または Modal()を使用
違いはshowModalだと文字通りモーダル形式で表示できます。
閉じる際はclose()を使います。

<dialog open id="createUserDialog">

ちなみにこのようにopenと付けると常時表示されるようになるので、作成時見た目を確認しながら作りたい時に便利です。

詳しく知りたい方はこちら

メイン処理

これらのクラス、モジュールを使って処理を行っていきます。

ユーザ作成処理

// ポケモン検索ボタンを押した時の処理、ポケモンの名前と画像を取得し、プレビュー画面を表示する
pokeSearch.addEventListener('click', async () => {
    const pokeId = document.querySelector('#pokeId')! as HTMLInputElement;
    const  = await showPokeData(pokeId.value)
    const PokeName = await showPokeName(pokeId.value)
    openCreateUserDialog(PokeName, PokeImg)
});

検索ボタンを押した際に、pokeAPIからポケモンのデータを取得します。
取得したデータをダイアログ表示のメソッドに渡し、プレビューを表示させています。
取得するたびにインスタンス生成はしてたら使われないオブジェクトが量産されてしまいそうなのでダイアログとAPI操作のコードはモジュールで行いました。
この考えが良いのかどうかはわかりませんが・・・笑

ユーザ登録処理

// ダイアログのユーザー作成ボタンを押した時の処理、ユーザーのポケモンデータを作成し、ユーザー作成画面を非表示にする
createUserBtn.addEventListener('click', () => {
    const pokeName :string = pokeNameElement.textContent || '';
    const pokeImg :string = pokeImageElement.src || '';
    if (!pokeName || !pokeImg) {
        console.error('ポケモンが選択されていません');
      return;
    }
    else{
    userPokemon = new Pokemon(pokeName, pokeImg).userSetData();
    userCreateContainer.style.display = 'none';
    userStatusContainer.style.display = 'block';
    closeCreateUserDialog();
}});

ダイアログで作成ボタンを押した際に、プレビューに表示されているポケモン情報からpokemonクラスのインスタンスを作成しています。
それをuserSetDateで画面上の要素にセットします。
その後、ユーザ作成画面を非表示にし、ユーザ情報画面、タスク作成画面を表示させています。
表示、非表示は単縦にcssのdisplayを変更しているだけです。

タスク完了ボタン

// タスク完了ボタンを押した時の処理、完了したタスクを削除し、経験値を加算する
taskCompleteBtn.addEventListener('click', () => {
    if (!userPokemon) {
        console.error('ポケモンのデータがありません');
      return;
    }
    const expCount = taskManager.removeCompletedTasks(userPokemon.pokeExp);
    userPokemon.pokeExp = expCount;
    userPokemon.updateMeter(expCount);
});

クリックした際に、タスクを削除、削除した件数を経験値に反映させています。上限に行くとレベルアップします。

まとめ

以上、クラス、モジュールを用いてのアプリ製作でした。
やってみて感じましたが、機能をどの単位で切り分けるかと言う判断がとても難しかったです。
クラスを作ることで、オブジェクトを決まった形式で管理できたり、イベントやエレメント要素も持たせることができるので、あちこちで要素取得したりといったことも無くなりメリットも多く感じましたが、トータルで見るととても分かりづらい仕様、コードになってしまったように思います。

便利だけどきちんと扱えないよ、逆に複雑になってしまうリスクがあるということを身を持って感じました。
だからこそ設計が大事なんだなと。

ただ、手を動かしながら作ることで
どのように自身でクラスやモジュールを作成するのか、部品単位で作った機能をどのように組み合わせて処理を行うのかなど、イメージはとても深まったように思います。
今後は、今回の反省点から、クラス設計、デザインパターンなども学ぶこと、見た目が残念すぎるのでCSSフレームワークを使用したアプリ、そしてフレームワークなども試していきたいなと思います!

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