6
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?

【JS初心者】Vanilla JSで簡易的な映画管理アプリを作成

Last updated at Posted at 2025-06-29

はじめに

JavaScript勉強し始めて1ヶ月ちょっと。課題でTodoアプリ作り終わったので、今度は映画管理アプリをMVCパターンで挑戦してみました。

今回はES6モジュールも使ってファイル分割したので、その辺りも含めて書いていきます。

作ったもの

映画の追加・削除・編集ができるアプリです。

主な機能

  • 映画の追加・削除
  • インライン編集機能
  • 視聴済み/未視聴の切り替え
  • ジャンル選択
  • 統計表示(全映画数・視聴済み・未視聴)

技術仕様

  • Vanilla JavaScript(ES6モジュール)
  • MVCアーキテクチャ
  • Bootstrap 5
  • data属性 + イベント委譲

ファイル構成

今回はファイルを分割してモジュール化しました。

movie-app/
├── index.html
├── js/
│   ├── app.js
│   ├── MovieModel.js
│   ├── MovieView.js
│   └── MovieController.js

MVCパターンについて

簡単に言うと役割分担です。
今回は以下のように分けました。

  • Model: データの管理・操作
  • View: 画面表示・DOM操作
  • Controller: ユーザー操作と Model / View の橋渡し

書き始めは全部ごちゃ混ぜだったので、しっかり分けて作ってみました。

実装コード

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>映画管理アプリ</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body class="container mx-auto p-5" style="max-width: 768px;">
    <div id="favoriteMovies" class="d-flex justify-content-center gap-3">
        <input type="text" id="title" placeholder="映画のタイトルを入力...">
        <select id="genre">
            <option disabled selected>ジャンルを選択</option>
            <option value="アクション">アクション</option>
            <option value="ロマンス">ロマンス</option>
            <option value="コメディ">コメディ</option>
            <option value="ドラマ">ドラマ</option>
            <option value="ホラー">ホラー</option>
            <option value="SF">SF</option>
            <option value="アニメ">アニメ</option>
            <option value="アドベンチャー">アドベンチャー</option>
            <option value="ファンタジー">ファンタジー</option>
            <option value="サスペンス">サスペンス</option>
            <option value="ミステリー">ミステリー</option>
            <option value="その他">その他</option>
        </select>
        <button id="addBtn" class="btn btn-primary" data-action="add">追加</button>
    </div>
    <div id="movieStatus" class="text-center m-4">全ての映画: 0 視聴済み: 0 未視聴: 0</div>
    <ul id="movieList">
    </ul>
    <script type="module" src="./js/app.js"></script>
</body>
</html>

MovieModel.js

データの管理を担当するクラスです。

export class MovieModel {
    constructor() {
        this.nextId = 1;
        this.movies = [];   // 映画を配列で格納する変数
    }

    // 新しい映画を追加
    addMovie(title, genre) {
        const newMovie = {
            id: this.nextId++,
            title: title.trim(),
            genre: genre.trim(),
            watched: false
        }
        this.movies.push(newMovie);
        return newMovie;
    }

    // 全映画取得
    getAllMovies() {
        return this.movies;
    }

    // 特定映画検索
    getMovieById(id) {
        return this.movies.find(movie => movie.id === id);
    }

    // 映画削除
    deleteMovie(id) {
        this.movies = this.movies.filter(movie => movie.id !== id);
        return this.movies;
    }

    // 視聴状態切り替え
    toggleWatched(id) {
        this.movies = this.movies.map(movie => {
            if (movie.id === id) {
                return { ...movie, watched: !movie.watched };
            }
            return movie;
        });
        return this.movies;
    }

    // タイトル変更
    updateTitle(id, updateTitle) {
        this.movies = this.movies.map(movie => {
            if (movie.id === id) {
                return { ...movie, title: updateTitle };
            }
            return movie;
        });
        return this.movies
    }

    // ジャンル変更
    updateGerne(id, updateGerne) {
        this.movies = this.movies.map(movie => {
            if (movie.id === id) {
                return { ...movie, genre: updateGerne };
            }
            return movie;
        });
        return this.movies
    }

    // 視聴済み/未視聴の状態
    getStatus() {
        const allMovies = this.movies.length;
        const watched = this.movies.filter(movie => movie.watched === true).length;
        const notWatched = allMovies - watched;
        return { allMovies, watched, notWatched };
    }
}

配列のmap、filter、findメソッドを使ってデータ操作してます。スプレッド構文でimmutableな更新も意識しました。

MovieView.js

画面表示とDOM操作を担当するクラスです。

export class MovieView {
    constructor() {
        this.title = document.getElementById('title');
        this.genre = document.getElementById('genre');
        this.addBtn = document.getElementById('addBtn');
        this.movieStatus = document.getElementById('movieStatus');
        this.movieList = document.getElementById('movieList');

        this.availableGenres = ['アクション', 'ロマンス', 'コメディ', 'ドラマ', 'ホラー', 'SF', 'アニメ', 'アドベンチャー', 'ファンタジー', 'サスペンス', 'ミステリー', 'その他'];
        this.editingId = null;
    }

    // タイトル取得
    getTitle() {
        return this.title.value.trim();
    }

    // ジャンル取得
    getGenre() {
        const genre = this.genre.value.trim();
        if (genre === 'ジャンルを選択') {
            alert('ジャンルを選択してください');
            return;
        }
        return genre;
    }

    // フォームクリア
    clearForm() {
        this.title.value = '';
        this.genre.value = '';
    }

    // 編集開始
    startEdit(id) {
        this.editingId = id;
    }

    // 編集終了
    endEdit() {
        this.editingId = null;
    }

    // 編集した値を取得
    getEditInput(id) {
        const editTitle = document.getElementById(`edit-title-${id}`)
        const editGenre = document.getElementById(`edit-genre-${id}`)
        return {
            title: editTitle ? editTitle.value.trim() : '',
            genre: editGenre ? editGenre.value.trim() : ''
        }
    }

    // 統計表示更新
    renderStatus(status) {
        this.movieStatus.innerHTML = `全ての映画: ${status.allMovies} 視聴済み: ${status.watched} 未視聴: ${status.notWatched}`;
    }

    // ジャンルループ
    #genreHTML(movie) {
        const optionHTML = this.availableGenres.map(genre => {
            return `<option value="${genre}" ${movie.genre === genre ? 'selected' : ''}>${genre}</option>`
        });
        return optionHTML.join('');
    }
    
    // 映画リスト表示
    renderMovies(movies) {
        const html = movies.map(movie => {
            const options = this.#genreHTML(movie);
            if(this.editingId === movie.id) {
                return `
                <li class="list-unstyled align-items-center row column-gap-1">
                    <input type="text" value="${movie.title}" id="edit-title-${movie.id}" class="col-3">
                    <select id="edit-genre-${movie.id}" class="col-3">
                        ${options}
                    </select>
                    <button class="btn btn-success col-2" data-action="update" data-movie-id="${movie.id}">保存</button>
                    <button class="btn btn-dark col-2" data-action="cancel" data-movie-id="${movie.id}">戻る</button>
                </li>
                `
            } else {
                return `
                <li class="list-unstyled align-items-center row column-gap-1 p-2">
                    <input type="checkbox" class="col-1" data-action="toggle" data-movie-id="${movie.id}" ${movie.watched ? 'checked' : ''}>
                    <span class="fw-bold col-3 text-center ${movie.watched ? 'text-decoration-line-through' : ''}">${movie.title}</span>
                    <span class="fst-italic col-3 text-center ${movie.watched ? 'text-decoration-line-through' : ''}">${movie.genre}</span>
                    <button class="btn btn-secondary col-2" data-action="edit" data-movie-id="${movie.id}">編集</button>
                    <button class="btn btn-danger col-2" data-action="delete" data-movie-id="${movie.id}">削除</button>
                </li>
                `
            }
        }).join('');

        this.movieList.innerHTML = html;
    }
}

インライン編集が一番大変でした。editingIdで編集中の映画を管理して、条件分岐で表示を切り替えています。

MovieController.js

ユーザーの操作を受け取って、ModelとViewを制御するクラスです。

import { MovieModel } from "./movieModel.js";
import { MovieView } from "./movieView.js";

export class MovieController {
    constructor() {
        this.model = new MovieModel();
        this.view = new MovieView();

        this.setupEventListeners();
    }

    setupEventListeners() {
        // 追加ボタン
        try {
            this.view.addBtn.addEventListener('click', () => this.handleAddMovie());
    
            // 映画リスト
            this.view.movieList.addEventListener('click', (e) => {
                const movieId = parseInt(e.target.dataset.movieId);
                const action = e.target.dataset.action;
    
                switch (action) {
                    case 'toggle':
                        this.handleToggleWatched(movieId);
                        break;
                    case 'edit':
                        this.handleEdit(movieId);
                        break;
                    case 'update':
                        this.handleUpdate(movieId);
                        break;
                    case 'cancel':
                        this.handleCancel(movieId);
                        break;
                    case 'delete':
                        this.handleDeleteMovie(movieId);
                        break;
                }
            });
        } catch (error) {
            console.log('エラーが発生しました', error);
        }
    }

    // 画面全体を更新
    render() {
        const movies = this.model.getAllMovies();
        const status = this.model.getStatus();

        this.view.renderMovies(movies);
        this.view.renderStatus(status);
    }

    // 映画追加
    handleAddMovie() {
        try {
            // フォームからタイトル, ジャンルを取得
            const title = this.view.getTitle();
            const genre = this.view.getGenre();

            // Model側で映画追加
            this.model.addMovie(title, genre);
            this.view.clearForm()
            this.render();
        } catch (error) {
            console.log('エラーが発生しました。', error);
        }
    }

    // 視聴完了切り替え
    handleToggleWatched(id) {
        this.model.toggleWatched(id);
        this.render();
    }

    // 映画削除
    handleDeleteMovie(id) {
        try {
            const confirm = window.confirm('本当に削除しますか?');
            
            if (!confirm) {
                return
            }
            
            this.model.deleteMovie(id);
            this.render();
        } catch (error) {
            console.log('エラーが発生しました', error);
        }
    }

    // タイトル / ジャンル編集開始
    handleEdit(id) {
        // 編集モード
        this.view.startEdit(id);
        this.render();
    }

    // タイトル / ジャンル編集保存
    handleUpdate(id) {
        try {
            // viewから編集した値を取得
            const { title, genre } = this.view.getEditInput(id);
    
            if (!title) {
                alert('タイトルを入力してください');
                return;
            }
    
            // 編集予定の映画情報を取得
            const movie = this.model.getMovieById(id);
    
            // 既存のデータから変更があれば Modelでデータ更新
            if (movie.title !== title) {
                this.model.updateTitle(id, title);
            }
    
            if (movie.genre !== genre) {
                this.model.updateGerne(id, genre);
            }
    
            this.view.endEdit();
            this.render();
        } catch (error) {
            console.log('エラーが発生しました', error);
        }
    }

    // タイトル / ジャンル編集キャンセル
    handleCancel() {
        this.view.endEdit();
        this.render();
    }
}

イベントリスナーの設定で悩みました。追加ボタンは直接設定、映画リストはイベント委譲を使っています。data属性でどのアクションかを判別してswitch文で分岐させました。

app.js

エントリーポイントです。

import { MovieController } from "./MovieController.js";

const movie = new MovieController();

シンプルに Controller のインスタンスを作るだけです。

開発で躓いたところ

1. モジュール化

今回初めてES6モジュールを使いました。
書き始めは全ての機能を1つのファイルにまとめようとしていたので
モジュール化を使って可読性が上がりました。

2. メソッド名の重複

ModelController で同じような名前のメソッドになる時がありました。Controllerhandleをつけて区別することにしました。

3. インライン編集

編集中と通常表示の切り替えが大変でした。
selectタグで既存のジャンルを選択状態にするのも苦労しました。

4. イベント委譲

動的に生成される要素のイベント処理で最初は戸惑いました。
data属性とswitch文の組み合わせで解決できました。

学んだこと

  • 責任分離の大切さ: 各クラスの役割を明確にすることで、コードが整理される
  • モジュール化の利点: ファイルを分けることで管理しやすくなった
  • 設計の重要性: 機能を実装する前にしっかり設計を考える
  • 段階的開発: 一気に作らずに少しずつ確認しながら進める

まとめ

なんとか動くものが作れました。特にモジュール化することでコードの構造化、可読性の大切さがわかった気がします。

同じくらいの学習期間の人の参考になれば嬉しいです。

6
2
2

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
6
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?