JS の迷わないための入門書 としてJavaScriptが 学べるページ
URL: https://jsprimer.net/use-case/todoapp
以前にもここで勉強させてもらったことがあるのですがしばらく JSを触らなくなってしまってすっかり忘れてしまったので再度勉強しにやってきました。
第二部の応用編にある練習用Todoアプリなんですが、VueやReactを使わずに、またライブラリも自前で用意していて、しかもクラスでコードを書いてあるので、どういった仕組みで動いているのかがわかってすばらしい出来の練習アプリになっています。
この練習アプリをせっかくクラスで書いてあるのをあえてクラスを使わなかったらどういったコードになるのかを試してみたいと思います。
index.jsを書き換える感じなので、 HTML CSS ライブラリは、そのまま使わせていただきます。
簡易サーバーとして、vscode の拡張機能で Live Server でやっていきたいと思います。
JavaScript Primerさんのと見比べるとわかりやすいです。
.
├── index.html
└── index.js
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Todo App</title>
<!-- 1. CSSファイルを読み込み -->
<link
href="https://jsprimer.net/use-case/todoapp/final/final/index.css"
rel="stylesheet"
/>
</head>
<body>
<!-- 2. class属性をCSSのために指定 -->
<div class="todoapp">
<!-- 3. id属性をJavaScriptのために指定 -->
<form id="js-form">
<input
id="js-form-input"
class="new-todo"
type="text"
placeholder="What need to be done?"
autocomplete="off"
/>
</form>
<!-- 4. TodoアプリのメインとなるTodoリスト -->
<div id="js-todo-list" class="todo-list">
<!-- 動的に更新されるTodoリスト -->
</div>
<footer class="footer">
<!-- 5. Todoアイテム数の表示 -->
<span id="js-todo-count">Todoアイテム数: 0</span>
</footer>
</div>
<script src="./index.js" type="module"></script>
</body>
</html>
index.js
// console.log("index.js...")
const formElement = document.querySelector("#js-form");
const inputElement = document.querySelector("#js-form-input");
const containerElement = document.querySelector("#js-todo-list");
const todoItemCountElement = document.querySelector("#js-todo-count");
let todoListItems = [];
let idNum = 0;
formElement.addEventListener("submit" , (event) => {
event.preventDefault();
const item = {
"id": idNum ++,
"title": inputElement.value,
"completed" : false,
};
todoListItems.push(item);
inputElement.value = "";
console.log(todoListItems)
})
最初は、だいたい同じような感じですが、クラスで作る場合はメインは別のファイルを App.js
を作りそこにコードを書いて行く感じですが、こちらはindex.js
にコードを書いていきます。
DOM上formからのsubmit
メソッド で なにかタイトルになるメッセージを入力してエンターしたものが入っていきます。 なにか入力をリスニングしていて、エンターキーがトリガーになります。
クラスで書く場合の最小単位は、todoItemModel
のクラスインスタンスですがこちらはただの連想配列です。
そして、クラスでそれを纏めるのは、todoListModel
のクラスインスタンスでそこで、追加や削除更新 メソッド書いていきますが、今回は todoListItems
という配列を使って、追加更新削除 を行っていきます。
グローバル変数扱いになるので、どこからでも変更可能ですがコードが増えるとどこから代入されているのかわからなくなってきそうです。
保存のイメージとしては、以下の感じです。
todoListItems = [
{"id": 0, "title": title1, "completed": false },
{"id": 1, "title": title2, "completed": false }
]
描画部分の追加
データと描画を極力分けて、 todoListItems
の配列で追加削除更新を 処理してから DOM 要素をつけて描画する形にしていきたいと思います。
index.js
const formElement = document.querySelector("#js-form");
const inputElement = document.querySelector("#js-form-input");
const containerElement = document.querySelector("#js-todo-list");
const todoItemCountElement = document.querySelector("#js-todo-count");
let todoListItems = [];
let idNum = 0;
formElement.addEventListener("submit" , (event) => {
event.preventDefault();
const item = {
"id": idNum ++,
"title": inputElement.value,
"completed" : false,
};
todoListItems.push(item);
inputElement.value = "";
console.log(todoListItems)
// view関係
const ulElement = htmlToElement(`<ul />`)
todoListItems.forEach((ITEM) => {
const liElement = htmlToElement(`<li>${ITEM.title}</li>`)
ulElement.appendChild(liElement)
})
containerElement.innerHTML = "";
containerElement.appendChild(ulElement);
todoItemCountElement.textContent = `Todoアイテム数 : ${todoListItems.length}`
})
// html文字列を htmlエレメントに変換している
function htmlToElement(html) {
const template = document.createElement("template");
template.innerHTML = html;
// 下のメソッドで文字列から変換されている
return template.content.firstElementChild;
}
view 以下を追加しています。
ここでは、todoListItems
の配列に入っているデータをループして一個づつli要素をつけてul要素に付けて最終的に index.html id='js-todo-list'
がある場所に貼り付けています。
ここで大事な部分は、HTMLの文字列をHTMLエレメントに変換している箇所です。
DOMを操作するためには、文字列のままではできないので、エレメントにしておく必要があります。
文字列のままでは、削除や変更をする箇所の特定ができないためです。 document.querySelector
を使うにはエレメントにしておく必要があります。
Primerさんでは、別ファイルhtml-util.js
でエスケープ処理までまるっとしています element'<li>タイトル1</li>'
で処理している部分です。
ここでは、文字列 -> HTML 部分だけをシンプルにするために使わせてもらいます。
処理的には htmlToElement('<li>${ITEM.title}</li>')
になります。
あとここで大事な変数は連想配列が入ったITEM
です。 これは他の変数とうっかりかぶるとまずいので大文字にしておきます。
リスナーエミッター 処理
次にしたいのは、これからどんどん膨らんでくる submit以下を 描画部分と純粋なinput処理とを分けたいです。
index.js
// リスナーエミッター
class EventEmitter {
constructor() {
this._listeners = new Map();
}
addEventListener(type, listener) {
if (!this._listeners.has(type)) {
this._listeners.set(type, new Set());
}
const listenerSet = this._listeners.get(type);
listenerSet.add(listener);
}
emit(type) {
const listenerSet = this._listeners.get(type);
if (!listenerSet) {
return;
}
listenerSet.forEach(listener => {
listener.call(this);
});
}
}
const formElement = document.querySelector("#js-form");
const inputElement = document.querySelector("#js-form-input");
const containerElement = document.querySelector("#js-todo-list");
const todoItemCountElement = document.querySelector("#js-todo-count");
let todoListItems = [];
let idNum = 0;
// リスナ、エミッタ処理に変更
const eventEmitter = new EventEmitter() // ここだけクラスインスタンスを使用します
// 描画関係
eventEmitter.addEventListener("todoListItems Update", () => { // リスナー
const ulElement = htmlToElement(`<ul />`)
todoListItems.forEach((ITEM) => {
const liElement = htmlToElement(`<li>${ITEM.title}</li>`)
ulElement.appendChild(liElement)
})
containerElement.innerHTML = "";
containerElement.appendChild(ulElement);
todoItemCountElement.textContent = `Todoアイテム数 : ${todoListItems.length}`
})
formElement.addEventListener("submit" , (event) => {
event.preventDefault();
const item = {
"id": idNum ++,
"title": inputElement.value,
"completed" : false,
};
todoListItems.push(item);
inputElement.value = "";
eventEmitter.emit("todoListItems Update"); // 発火ポイント エミッター
})
function htmlToElement(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content.firstElementChild;
}
DOMのsubmit処理の仕組みと同じような処理を好きなタイミングで実行するのにリスナーとエミッターを使います。
例によってPrimerさんからコードを拝借してきます。
これの中身はわかる必要はありません、使い方がわかれば十分です。
eventEmitter.emit("todoListItems Update");
が発火ポイントで eventEmitter.addEventListener("todoListItems Update", () => {
処理以下が処理部分です。
コレ自体は、関数を実行させているだけなので処理したい関数をコードに挟むことで代用できますが、考え型としてはこちらのほうが応用が効きそうです。
ここでは、1ファイルですべて処理していますが、モジュール化していると関数だけもってきても動かないのでいろいろ関連部分ももってこないといけなくなると思います。
これを使うタイミングは、todoListItems
の配列が更新されたタイミングでエミッタを挟みます。
発火をうけてリスナー以下の関数が処理されます。
これで描画関係がsubmit更新タイミング から todoListItems
の更新タイミングに切り替わりました。
チェックボックス処理
次にTodo を完了したときにチェックするチェックボタンを付けたいです。
このときにDOM上のチェックボックスの位置を調べるのにquerySelector
を使うため HTML エレメントにしておく必要がありました。
class EventEmitter {
constructor() {
this._listeners = new Map();
}
addEventListener(type, listener) {
if (!this._listeners.has(type)) {
this._listeners.set(type, new Set());
}
const listenerSet = this._listeners.get(type);
listenerSet.add(listener);
}
emit(type) {
const listenerSet = this._listeners.get(type);
if (!listenerSet) {
return;
}
listenerSet.forEach(listener => {
listener.call(this);
});
}
}
const formElement = document.querySelector("#js-form");
const inputElement = document.querySelector("#js-form-input");
const containerElement = document.querySelector("#js-todo-list");
const todoItemCountElement = document.querySelector("#js-todo-count");
let todoListItems = [];
let idNum = 0;
const eventEmitter = new EventEmitter();
eventEmitter.addEventListener("todoListItems Update", () => {
const ulElement = htmlToElement(`<ul />`)
todoListItems.forEach((ITEM) => {
// チェックボックス追加
const liElement = ITEM.completed
? htmlToElement(`<li><input type="checkbox" class="checkbox" checked><s>${ITEM.title}</s></li>`)
: htmlToElement(`<li><input type="checkbox" class="checkbox">${ITEM.title}</li>`)
ulElement.appendChild(liElement)
const checkboxElement = liElement.querySelector(".checkbox"); // チェックボックス取得
checkboxElement.addEventListener("change", () => { // DOMのリスナー
const todoItem = todoListItems.find( todo => {
return todo.id === ITEM.id
})
const changeCompleted = {
id : todoItem.id,
title: todoItem.title,
completed: !todoItem.completed
}
// 配列のupdate
todoListItems[changeCompleted.id] = changeCompleted
eventEmitter.emit("todoListItems Update"); // 状態更新依頼
console.log(todoListItems)
})
})
containerElement.innerHTML = "";
containerElement.appendChild(ulElement);
todoItemCountElement.textContent = `Todoアイテム数 : ${todoListItems.length}`
})
formElement.addEventListener("submit" , (event) => {
event.preventDefault();
const item = {
"id": idNum ++,
"title": inputElement.value,
"completed" : false,
};
todoListItems.push(item);
inputElement.value = "";
eventEmitter.emit("todoListItems Update");
})
function htmlToElement(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content.firstElementChild;
}
ここで、チェックボックスの表示 と、チェックボックスがチェックされたか、チェックが外れたかを DOMのリスナーがリスニングしています。
処理は、 連想配列を再現して、completed
の true / false を反転させています。
そのあと、配列todoListItems
を更新して エミッタで更新通知をしています。
チェックボックスをチェックしたときに、文字に修正バーが付いているので、画面が更新されていることがわかります。
ちなみに、エミッターをコメントアウトにすると更新されていないこともわかります。
削除処理
次は削除ボタンを付けたいと思います。
class EventEmitter {
constructor() {
this._listeners = new Map();
}
addEventListener(type, listener) {
if (!this._listeners.has(type)) {
this._listeners.set(type, new Set());
}
const listenerSet = this._listeners.get(type);
listenerSet.add(listener);
}
emit(type) {
const listenerSet = this._listeners.get(type);
if (!listenerSet) {
return;
}
listenerSet.forEach(listener => {
listener.call(this);
});
}
}
const formElement = document.querySelector("#js-form");
const inputElement = document.querySelector("#js-form-input");
const containerElement = document.querySelector("#js-todo-list");
const todoItemCountElement = document.querySelector("#js-todo-count");
let todoListItems = [];
let idNum = 0;
const eventEmitter = new EventEmitter();
eventEmitter.addEventListener("todoListItems Update", () => {
const ulElement = htmlToElement(`<ul />`)
todoListItems.forEach((ITEM) => {
const liElement = ITEM.completed
? htmlToElement(
`<li>
<input type="checkbox" class="checkbox" checked>
<s>${ITEM.title}</s>
<button class="delete">x</button>
</li>`
)
: htmlToElement(
`<li>
<input type="checkbox" class="checkbox">
${ITEM.title}
<button class="delete">x</button>
</li>`
)
ulElement.appendChild(liElement)
const checkboxElement = liElement.querySelector(".checkbox");
checkboxElement.addEventListener("change", () => {
const todoItem = todoListItems.find( todo => {
return todo.id === ITEM.id
})
const changeCompleted = {
id : todoItem.id,
title: todoItem.title,
completed: !todoItem.completed
}
todoListItems[changeCompleted.id] = changeCompleted
eventEmitter.emit("todoListItems Update");
})
// delete 処理追加
const deleteButtonElement = liElement.querySelector(".delete");
deleteButtonElement.addEventListener("click", () => {
const todoItem = todoListItems.find( todo => {
return todo.id === ITEM.id
})
// deleteメソッドでするとうまく処理できないため 新しい 配列を代入する形に変更
const newItems = todoListItems.filter( todo => {
return todo.id !== todoItem.id
})
todoListItems = newItems // 配列を全部入れ替えている
eventEmitter.emit("todoListItems Update"); // 状態更新通知
console.log(todoListItems)
})
})
containerElement.innerHTML = "";
containerElement.appendChild(ulElement);
todoItemCountElement.textContent = `Todoアイテム数 : ${todoListItems.length}`
})
formElement.addEventListener("submit" , (event) => {
event.preventDefault();
const item = {
"id": idNum ++,
"title": inputElement.value,
"completed" : false,
};
todoListItems.push(item);
inputElement.value = "";
eventEmitter.emit("todoListItems Update");
})
function htmlToElement(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content.firstElementChild;
}
ここではデリートボタンの設置とデリートボタンを押したときに デリートボタンのクリックをリスナーしていて反応します。
処理的には、ループ処理でtodoListItems
のITEM
が回っていて、それとクリックした対象が一致するid
を探してのいます。
一致するidが見つかったら、フィルター処理で該当id以外の連想配列で再度、配列を組み直しています。
新しく作成した配列には、 クリックされたものは含まれていません。
元の配列に、新しい配列をまるごとコピーして上書きしています。
そのあと、更新通知をなげて、描画を書き換えています。
最初、delete
命令で該当 idを 直接削除する方法を試したのですが、emptyというObject が残って lengthがそのまま変わらないので、delete
命令を諦めて、すべての配列を書き換えることにしました。
この方法でうまく行っていますが、データが増えるとコストがかかる処理ということをあたまに入れておく必要がありそうです。
Idを決め打ちで、処理するほうが早いし、保存場所が離れている時は時間もかかるかと思います。
リファクタリング
実装はこれで終わったのですが、JSで DOM を作っている部分を関数として抜き出したいと思います。
htmlToElement
で作っている ul
以下の部分です。
class EventEmitter {
constructor() {
this._listeners = new Map();
}
addEventListener(type, listener) {
if (!this._listeners.has(type)) {
this._listeners.set(type, new Set());
}
const listenerSet = this._listeners.get(type);
listenerSet.add(listener);
}
emit(type) {
const listenerSet = this._listeners.get(type);
if (!listenerSet) {
return;
}
listenerSet.forEach(listener => {
listener.call(this);
});
}
}
const formElement = document.querySelector("#js-form");
const inputElement = document.querySelector("#js-form-input");
const containerElement = document.querySelector("#js-todo-list");
const todoItemCountElement = document.querySelector("#js-todo-count");
let todoListItems = [];
let idNum = 0;
const eventEmitter = new EventEmitter();
eventEmitter.addEventListener("todoListItems Update", () => {
const ulElement = view(todoListItems);
// const ulElement = htmlToElement(`<ul />`)
// todoListItems.forEach((ITEM) => {
// const liElement = ITEM.completed
// ? htmlToElement(
// `<li>
// <input type="checkbox" class="checkbox" checked>
// <s>${ITEM.title}</s>
// <button class="delete">x</button>
// </li>`
// )
// : htmlToElement(
// `<li>
// <input type="checkbox" class="checkbox">
// ${ITEM.title}
// <button class="delete">x</button>
// </li>`
// )
// ulElement.appendChild(liElement)
// const checkboxElement = liElement.querySelector(".checkbox");
// checkboxElement.addEventListener("change", () => {
// const todoItem = todoListItems.find( todo => {
// return todo.id === ITEM.id
// })
// const changeCompleted = {
// id : todoItem.id,
// title: todoItem.title,
// completed: !todoItem.completed
// }
// todoListItems[changeCompleted.id] = changeCompleted
// eventEmitter.emit("todoListItems Update");
// })
// // delete 処理追加
// const deleteButtonElement = liElement.querySelector(".delete");
// deleteButtonElement.addEventListener("click", () => {
// const todoItem = todoListItems.find( todo => {
// return todo.id === ITEM.id
// })
// // deleteメソッドでするとうまく処理できないため 新しい 配列を代入する形に変更
// const newItems = todoListItems.filter( todo => {
// return todo.id !== todoItem.id
// })
// todoListItems = newItems // 配列を全部入れ替えている
// eventEmitter.emit("todoListItems Update"); // 状態更新通知
// console.log(todoListItems)
// })
// })
containerElement.innerHTML = "";
containerElement.appendChild(ulElement);
todoItemCountElement.textContent = `Todoアイテム数 : ${todoListItems.length}`
})
formElement.addEventListener("submit" , (event) => {
event.preventDefault();
handleAdd({ todoListItems, title: inputElement.value })
// const item = {
// "id": idNum ++,
// "title": inputElement.value,
// "completed" : false,
// };
// todoListItems.push(item);
inputElement.value = "";
eventEmitter.emit("todoListItems Update");
})
function htmlToElement(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content.firstElementChild;
}
function view () {
const ulElement = htmlToElement(`<ul />`)
todoListItems.forEach((ITEM) => {
const liElement = ITEM.completed
? htmlToElement(
`<li>
<input type="checkbox" class="checkbox" checked>
<s>${ITEM.title}</s>
<button class="delete">x</button>
</li>`
)
: htmlToElement(
`<li>
<input type="checkbox" class="checkbox">
${ITEM.title}
<button class="delete">x</button>
</li>`
)
ulElement.appendChild(liElement)
const checkboxElement = liElement.querySelector(".checkbox");
checkboxElement.addEventListener("change", () => {
handleUpdate({ todoListItems, ITEM })
eventEmitter.emit("todoListItems Update");
console.log(todoListItems)
})
const deleteButtonElement = liElement.querySelector(".delete");
deleteButtonElement.addEventListener("click", () => {
const newItems = handleDelete({ todoListItems, ITEM })
todoListItems = newItems
eventEmitter.emit("todoListItems Update");
console.log(todoListItems)
})
})
return ulElement;
}
function handleDelete({ todoListItems, ITEM }) {
const todoItem = todoListItems.find( todo => {
return todo.id === ITEM.id
})
const newItems = todoListItems.filter( todo => {
return todo.id !== todoItem.id
})
return newItems;
}
function handleUpdate({ todoListItems, ITEM }) {
const todoItem = todoListItems.find( todo => {
return todo.id === ITEM.id
})
const changeCompleted = {
id : todoItem.id,
title: todoItem.title,
completed: !todoItem.completed
}
return todoListItems[changeCompleted.id] = changeCompleted
}
function handleAdd({ todoListItems, title }) {
const item = {
"id": idNum ++,
"title": title,
"completed" : false,
};
return todoListItems.push(item);
}
ul以下のJSで作った描画部分を view関数にして外に出しました。
後追加、削除、更新部分の実装部分も関数にして、外に出しました。
できるだけ描画部分と コントロール 部分を分けでコードすることができたかと思います。
また少しづつUPしていきたいと思いますので、わかりずらい説明や誤解しているところを教えてもらえると助かります。