はじめに
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. メソッド名の重複
Model と Controller で同じような名前のメソッドになる時がありました。Controller はhandleをつけて区別することにしました。
3. インライン編集
編集中と通常表示の切り替えが大変でした。
selectタグで既存のジャンルを選択状態にするのも苦労しました。
4. イベント委譲
動的に生成される要素のイベント処理で最初は戸惑いました。
data属性とswitch文の組み合わせで解決できました。
学んだこと
- 責任分離の大切さ: 各クラスの役割を明確にすることで、コードが整理される
- モジュール化の利点: ファイルを分けることで管理しやすくなった
- 設計の重要性: 機能を実装する前にしっかり設計を考える
- 段階的開発: 一気に作らずに少しずつ確認しながら進める
まとめ
なんとか動くものが作れました。特にモジュール化することでコードの構造化、可読性の大切さがわかった気がします。
同じくらいの学習期間の人の参考になれば嬉しいです。