はじめに
タイトル通り、vanillaのTypeScriptでシンプルなToDoアプリを作成しました。
昨今、フロントエンドで開発するには基本VueやReact、AngularといったUIフレームワークを用いて開発するのが主流かと思います。
ですので敢えて、そういったUIフレームワークを使わず、TypeScriptとHTMLだけでToDoアプリを作成することで、改めてUIフレームワークのありがたみを知ろうというのが今回実装したきっかけになります。
そしてせっかく作成したのだから見てほしい! と、本記事を執筆しました。
成果物
環境構築
parcelを用います。
parcelはtsconfigとか飛ばして動かせるのでtypescript要らないかもです。
npm init -y
npm install parcel typescript
npx tsc --init
ディレクトリ構成
.
├── package.json
├── src
│ ├── category.ts
│ ├── index.html
│ ├── main.ts
│ ├── parent-category.ts
│ ├── style.css
│ ├── task-form.ts
│ └── todo-item.ts
└── tsconfig.json
実行
npx parcel src/index.html --open
HTML・CSS
説明すべきところがあまりなかったので解説かなり薄いです。
HTML
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>ToDoList</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="./todo-item.ts"></script>
<script type="module" src="./category.ts"></script>
<script type="module" src="./task-form.ts"></script>
<script type="module" src="./parent-category.ts"></script>
<script type="module" src="./main.ts" defer></script>
</head>
<body>
<header>
<h1>ToDoリスト</h1>
</header>
<main>
<div class="task-form">
<div>
<label for="task-input">タスク</label>
<input type="text" id="task-input"/>
</div>
<button id="add-button">追加</button>
</div>
<div class="task-categories">
<div class="category-box">
<h2>未着手</h2>
<div
id="not-started-yet"
class="item-box"
>
</div>
</div>
<div class="category-box">
<h2>進行中</h2>
<div
id="wip"
class="item-box"
>
</div>
</div>
<div class="category-box">
<h2>完了</h2>
<div
id="finish"
class="item-box"
>
</div>
</div>
</div>
<dialog id="item-operate-dialog">
<form class="dialog-form">
どうする?
<div class="dialog-operate">
<div>
<label for="select-new-state">状態:</label>
<select id="select-new-state">
<option value="not-started-yet">未着手</option>
<option value="wip">進行中</option>
<option value="finish">完了</option>
</select>
</div>
<div class="dialog-buttons">
<button
value="cancel"
formmethod="dialog"
>キャンセル</button>
<button id="change-button">変更</button>
<button id="remove-button">削除</button>
</div>
</div>
</form>
</dialog>
</main>
</body>
</html>
type="module"
<script type="module" src="./**.ts"></script>
import/exportを使ったらエラーになったのでこういう形でmoduleで読み込んでいます。
CSS
style.css
main {
margin: 24px;
padding: 0;
}
.task-form {
display: flex;
gap: 8px;
}
.task-categories {
display: flex;
justify-content: space-around;
}
.category-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.item-box {
width: 25vw;
height: 560px;
border: 1px solid black;
}
.dialog-form {
min-width: 300px;
display: flex;
flex-direction: column;
gap: 16px;
}
.dialog-operate {
display: flex;
flex-direction: column;
gap: 16px;
}
.dialog-buttons {
display: flex;
justify-content: space-around;
}
最低限見た目を整えましたが、基本はブラウザのデフォルトスタイルまんまです。
見た目を整えるのにはflexboxを多用しています。
TypeScirpt
依存関係順に紹介します。
DOMの取得周りは存在するもの前提で書いています。
task-form.ts
タスク入力フォーム回りをまとめたクラスです。
/**
* タスク追加ボタン押下時に発火する関数の型
* @param inputValue 入力値
*/
type RegisterAddTaskButtonClickEventCallbackType = (inputValue: string) => void;
/** タスク登録フォーム */
export class TaskForm {
/** タスク名入力フォーム */
private taskInput: HTMLInputElement;
/** タスク追加ボタン */
private addTaskButton: HTMLButtonElement;
constructor() {
this.taskInput = document.querySelector('#task-input')!
this.addTaskButton = document.querySelector('#add-button')!
}
/** タスク追加ボタン押下イベントで発火する関数を登録 */
registerAddTaskButtonClickEventFunc(f: RegisterAddTaskButtonClickEventCallbackType): void {
this.addTaskButton.addEventListener('click', () => {
f(this.taskInput.value);
})
}
}
JSdoc
/**
* 説明
* @param param パラメータ
* @return 返却値
*/
import、export
export class ClassName
class
class MyClass {
constructor() {}
}
private prop: Type;
document.querySelector
element = document.querySelector('%query_selector%')!;
Element型を返します。
Element型を HTMLInputElement や HTMLButtonElement に何故代入できるのか? それは、それらの型がElement型の拡張だからです。
仕様書
からも、HTMLElementを継承していることがわかり、HTMLElementがElementの継承であることも、
親である Element からプロパティを継承しています。
https://developer.mozilla.org/ja/docs/Web/API/HTMLElement
から分かります。
addEventListener
特定のイベントが起きた時に第二引数の関数を実行します。
特定のイベントは第一引数に指定します。
関数を引数に受け取る
registerAddTaskButtonClickEventFunc(f: RegisterAddTaskButtonClickEventCallbackType): void {
this.addTaskButton.addEventListener('click', () => {
f(this.taskInput.value);
})
}
関数を引数に受け取り実行できます。
せっかくTypeScriptを使っているのですから、関数の受け取る引数及び返り値の型も縛りたいものです。
ここではボタン押下時に発火する関数を引数として受け取り、そこに入力された値を流し込みたいため、
type RegisterAddTaskButtonClickEventCallbackType = (inputValue: string) => void;
と定義されます。
todo-item.ts
それぞれのタスクについて処理をまとめたクラスです。
/**
* アイテムクリック時イベントで発火する関数の型
* @param event クリック時イベント
* @param todoItem this
*/
type ClickEvent = (event: MouseEvent, todoItem: TodoItem) => void;
/** カテゴリの種別、およびそれぞれのID */
export type BoxIdLiteral = 'not-started-yet' | 'wip' | 'finish';
/** タスク */
export class TodoItem {
/** タスクを一意に示すID */
uuid: string;
/** 現在所属するグループ */
currentGroup: BoxIdLiteral = 'not-started-yet';
/** Node */
elm: HTMLDivElement | null = null;
constructor(
taskName: string,
clickEvent: ClickEvent
) {
this.uuid = crypto.randomUUID();
const div = document.createElement('div');
div.textContent = taskName;
div.addEventListener(
'click',
(event) => clickEvent(event, this)
);
this.elm = div;
}
/** 自身を削除 */
remove(): void {
if (this.elm) {
this.elm.remove();
this.elm = null;
} else {
throw Error('[TodoItem.remove] this.elm = null');
}
}
}
リテラル型
type BoxIdLiteral = 'not-started-yet' | 'wip' | 'finish';
これと似た使い方をするのにenumsを用いることもあります。
uuid
どうせ被んないだろという楽観的考えのもと、重複判定を行なっておりません。
Crypto.randomUUIDで生成できます。
CryptoはWeb APIなのでJavaScriptおよびTypeScriptの機能ではありません。
重複判定を行うとしたら、現在持つUUIDをSetあたりを使って管理するのが良さそうですね。
document.createElement
HTML要素を生成します。
第一引数で与えた文字列に応じた型で生成してくれます。
const div = document.createElement('div');
例えば上記の例なら、HTMLDivElementで生成してくれます。
なぜこのように柔軟になっているのでしょうか?
型定義を覗いてみます。
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"area": HTMLAreaElement;
"article": HTMLElement;
"aside": HTMLElement;
"audio": HTMLAudioElement;
"b": HTMLElement;
"base": HTMLBaseElement;
"bdi": HTMLElement;
"bdo": HTMLElement;
"blockquote": HTMLQuoteElement;
"body": HTMLBodyElement;
"br": HTMLBRElement;
"button": HTMLButtonElement;
"canvas": HTMLCanvasElement;
"caption": HTMLTableCaptionElement;
"cite": HTMLElement;
"code": HTMLElement;
"col": HTMLTableColElement;
"colgroup": HTMLTableColElement;
"data": HTMLDataElement;
"datalist": HTMLDataListElement;
"dd": HTMLElement;
"del": HTMLModElement;
"details": HTMLDetailsElement;
"dfn": HTMLElement;
"dialog": HTMLDialogElement;
"div": HTMLDivElement;
"dl": HTMLDListElement;
"dt": HTMLElement;
"em": HTMLElement;
"embed": HTMLEmbedElement;
"fieldset": HTMLFieldSetElement;
"figcaption": HTMLElement;
"figure": HTMLElement;
"footer": HTMLElement;
"form": HTMLFormElement;
"h1": HTMLHeadingElement;
"h2": HTMLHeadingElement;
"h3": HTMLHeadingElement;
"h4": HTMLHeadingElement;
"h5": HTMLHeadingElement;
"h6": HTMLHeadingElement;
"head": HTMLHeadElement;
"header": HTMLElement;
"hgroup": HTMLElement;
"hr": HTMLHRElement;
"html": HTMLHtmlElement;
"i": HTMLElement;
"iframe": HTMLIFrameElement;
"img": HTMLImageElement;
"input": HTMLInputElement;
"ins": HTMLModElement;
"kbd": HTMLElement;
"label": HTMLLabelElement;
"legend": HTMLLegendElement;
"li": HTMLLIElement;
"link": HTMLLinkElement;
"main": HTMLElement;
"map": HTMLMapElement;
"mark": HTMLElement;
"menu": HTMLMenuElement;
"meta": HTMLMetaElement;
"meter": HTMLMeterElement;
"nav": HTMLElement;
"noscript": HTMLElement;
"object": HTMLObjectElement;
"ol": HTMLOListElement;
"optgroup": HTMLOptGroupElement;
"option": HTMLOptionElement;
"output": HTMLOutputElement;
"p": HTMLParagraphElement;
"picture": HTMLPictureElement;
"pre": HTMLPreElement;
"progress": HTMLProgressElement;
"q": HTMLQuoteElement;
"rp": HTMLElement;
"rt": HTMLElement;
"ruby": HTMLElement;
"s": HTMLElement;
"samp": HTMLElement;
"script": HTMLScriptElement;
"search": HTMLElement;
"section": HTMLElement;
"select": HTMLSelectElement;
"slot": HTMLSlotElement;
"small": HTMLElement;
"source": HTMLSourceElement;
"span": HTMLSpanElement;
"strong": HTMLElement;
"style": HTMLStyleElement;
"sub": HTMLElement;
"summary": HTMLElement;
"sup": HTMLElement;
"table": HTMLTableElement;
"tbody": HTMLTableSectionElement;
"td": HTMLTableCellElement;
"template": HTMLTemplateElement;
"textarea": HTMLTextAreaElement;
"tfoot": HTMLTableSectionElement;
"th": HTMLTableCellElement;
"thead": HTMLTableSectionElement;
"time": HTMLTimeElement;
"title": HTMLTitleElement;
"tr": HTMLTableRowElement;
"track": HTMLTrackElement;
"u": HTMLElement;
"ul": HTMLUListElement;
"var": HTMLElement;
"video": HTMLVideoElement;
"wbr": HTMLElement;
}
なるほど。対応する型をマッピングしていました。
textContentとinnerHTML
「textContent innerHTML」みたいに検索をかければ山ほど違いに関する記述が出てくるため検索してください。
textContentを用いている理由はXSSを防ぐためです。
自身を削除
remove(): void {
if (this.elm) {
this.elm.remove();
this.elm = null;
} else {
throw Error('[TodoItem.remove] this.elm = null');
}
}
削除時にElement.removeでエレメントを消してあげます。
そして明示的に this.elm
をnullにします。
今回は処理の流れ的に this.elm = null
でremoveが叩かれることはありえないためエラーを発生させています。
category.ts
タスクの状態(未着手か、進行中か、完了か)のそれぞれのカテゴリについてのクラスです。
import { TodoItem, BoxIdLiteral } from './todo-item';
/** カテゴリ */
export class Category {
/** 所属するタスク一覧 */
children: TodoItem[] = [];
/** Node */
private categoryBox: HTMLDivElement;
/**
* @param boxId カテゴリ種別
*/
constructor(private readonly boxId: BoxIdLiteral) {
this.categoryBox = document.querySelector(`#${boxId}`)!;
}
/**
* タスクの追加
* @param item タスク
*/
appendTodo(item: TodoItem): void {
if (item.elm) {
item.currentGroup = this.boxId;
this.children.push(item);
this.refreshCategoryBox();
} else {
throw Error('[Category.appendTodo] item.elm = null');
}
}
/**
* タスクをカテゴリから削除
* @param itemUuid タスクのUUID
*/
deleteTodo(itemUuid: string): void {
this.children = this.children.filter((child) => child.uuid !== itemUuid);
this.refreshCategoryBox();
}
/** 画面表示のリフレッシュ */
private refreshCategoryBox(): void {
while (this.categoryBox.firstChild) {
this.categoryBox.removeChild(this.categoryBox.firstChild);
}
this.children.forEach((child) => {
if (child.elm) {
this.categoryBox.appendChild(child.elm);
}
})
}
}
モジュール
import { TodoItem, BoxIdLiteral } from './todo-item';
インスタンス
children: TodoItem[] = [];
class TodoItem
のインスタンスの配列型です。
new TodoItem(/** param */)
でインスタンスを作成して、それを格納します。
classやインスタンスが何かは下記を読んだりご自身で調べるなどしてください。
また、余談ですがどのクラスのインスタンスかを確認するにはObject.prototype.constructorで確認できます。
>> class A {
constructor(a) {
this.a = a;
}
}
>> const ac = new A('a');
>> ac.constructor
class A {
constructor(a) {
this.a = a;
}
}
そして、そのインスタンスがあるクラスのインスタンスかを判別するにはinstanceofを用います。
>> ac instanceof A
true
さらに余談ですが、JavaScriptのArrayはObjectです。
>> typeof ['a', 'b', 'b']
'object'
なので配列かを確認したい場合はinstanceofやconstructorをみるか、isArrayを用います。
>> ['a', 'b', 'c'].constructor
ƒ Array() { [native code] }
>> ['a', 'b', 'c'] instanceof Array
true
>> Array.isArray(['a', 'b', 'c'])
true
>> Array.isArray({a: 'aaa', b: 'bbb'})
false
配列はオブジェクトというのは文字だけで見ると違和感を覚えます(Objectがルートクラスな言語も多いのでやっぱ違和感覚えないかもです)が、挙動を見るとキーが連番の数字のオブジェクトだと納得できます。
振る舞いを見てみましょう。
>> {...['a', 'b', 'c']}
{0: 'a', 1: 'b', 2: 'c'}
>> const a = ['a', 'b', 'c'];
>> delete a[1]
true
>> a
['a', empty, 'c']
>> a[1]
undefined
>> {...a}
{0: 'a', 2: 'c'}
constructor shorthand
constructor(private readonly boxId: BoxIdLiteral) {}
画面表示のリフレッシュ
カテゴリ内の要素を一旦全部消して、最新の状態で作り直しています。
private refreshCategoryBox(): void {
while (this.categoryBox.firstChild) {
this.categoryBox.removeChild(this.categoryBox.firstChild);
}
this.children.forEach((child) => {
if (child.elm) {
this.categoryBox.appendChild(child.elm);
}
});
}
removeChildで対象子要素の削除をwhileでfirstChildが取れる限りぶん回して要素内の子要素を全て削除しています。
ちなみに、このfirstChildはCSSにも擬似クラスとして存在します。
そして、 this.children.forEach
で現在の子要素を取得し、appendChildで詰め直しています。
child.elm
はnullの可能性があるため、型ガードを挟んでいます。
要素の洗い替えは重たい処理のため、該当箇所の追加・削除のみにすべきですが、面倒だったので全て洗い替えしています。
parent-category.ts
カテゴリ全体を統率する親カテゴリのクラスです。
import { Category } from './category';
import { BoxIdLiteral, TodoItem } from './todo-item';
/** カテゴリ全体を統括 */
export class ParentCategory {
/** ダイアログ */
private dialog: HTMLDialogElement;
/** [ダイアログ]セレクトボックス */
private selectInput: HTMLSelectElement;
/** [ダイアログ]変更ボタン */
private changeButton: HTMLButtonElement;
/** [ダイアログ]削除ボタン */
private removeButton: HTMLButtonElement;
/** 未着手カテゴリ */
private notStartedYetCategory: Category;
/** 進行中カテゴリ */
private wipCategory: Category;
/** 完了カテゴリ */
private finishCategory: Category;
/** 現在選択中タスク */
private currentSelectItem: TodoItem | null = null;
constructor() {
this.dialog = document.querySelector('#item-operate-dialog')!;
this.selectInput = document.querySelector('#select-new-state')!;
this.changeButton = document.querySelector('#change-button')!;
this.removeButton = document.querySelector('#remove-button')!;
this.notStartedYetCategory = new Category('not-started-yet');
this.wipCategory = new Category('wip');
this.finishCategory = new Category('finish');
this.dialog.addEventListener('close', () => {
if (this.currentSelectItem === null) {
throw Error('[ParentCategory.constructor] this.currentSelectItem = null');
}
switch (this.dialog.returnValue) {
case 'not-started-yet':
case 'wip':
case 'finish':
this.moveItem(this.dialog.returnValue);
break;
case 'remove':
this.deleteItem();
break;
default:
return;
}
this.currentSelectItem = null;
});
this.changeButton.addEventListener('click', (event) => {
event.preventDefault();
this.dialog.close(this.selectInput.value);
});
this.removeButton.addEventListener('click', (event) => {
event.preventDefault();
this.dialog.close('remove');
});
}
/**
* 新アイテム追加
* @param taskName タスク名
* @note 新アイテムの追加は仕様上未着手にしか入らない
*/
appendNewTask(taskName: string): void {
const todoItem = new TodoItem(
taskName,
(_event, todoItem) => {
this.currentSelectItem = todoItem;
this.selectInput.value = todoItem.currentGroup;
this.dialog.showModal();
}
);
this.notStartedYetCategory.appendTodo(todoItem);
}
/**
* アイテムの移動
* @param category 移動先カテゴリ
*/
private moveItem(category: BoxIdLiteral): void {
if (this.currentSelectItem === null) {
throw Error('[ParentCategory.moveItem] this.currentSelectItem = null');
}
this.removeItemInCategory();
switch (category) {
case 'not-started-yet':
this.notStartedYetCategory.appendTodo(this.currentSelectItem);
break;
case 'wip':
this.wipCategory.appendTodo(this.currentSelectItem);
break;
case 'finish':
this.finishCategory.appendTodo(this.currentSelectItem);
break;
}
this.currentSelectItem = null;
}
/** タスクの削除 */
private deleteItem(): void {
if (this.currentSelectItem === null) {
throw Error('[ParentCategory.deleteItem] this.currentSelectItem = null');
}
this.currentSelectItem.remove();
this.removeItemInCategory();
this.currentSelectItem = null;
}
/** カテゴリからタスクを取り除く */
private removeItemInCategory(): void {
if (this.currentSelectItem === null) {
throw Error('[ParentCategory.removeItemInCategory] this.currentSelectItem = null');
}
switch (this.currentSelectItem.currentGroup) {
case 'not-started-yet':
this.notStartedYetCategory.deleteTodo(this.currentSelectItem.uuid);
break;
case 'wip':
this.wipCategory.deleteTodo(this.currentSelectItem.uuid);
break;
case 'finish':
this.finishCategory.deleteTodo(this.currentSelectItem.uuid);
break;
}
}
}
ダイアログ
this.dialog.addEventListener('close', () => {...});
this.changeButton.addEventListener('click', (event) => {
event.preventDefault();
this.dialog.close(this.selectInput.value);
});
this.removeButton.addEventListener('click', (event) => {
event.preventDefault();
this.dialog.close('remove');
});
ダイアログからほぼまるパクしてます。
削除もキャンセル同様
<button value="remove" formmethod="dialog">削除</button>
としても良かったかもしれません。
switch-case
case 'not-started-yet':
case 'wip':
case 'finish':
this.moveItem(this.dialog.returnValue);
break;
moveItem
は引数に 'not-started-yet' | 'wip' | 'finish'
を取ります。
caseはbreakしなければ続けて下の処理も行います。
これを利用して、
case 'not-started-yet':
case 'wip':
case 'finish':
とすることで、this.dialog.returnValueは 'not-started-yet' | 'wip' | 'finish'
のいずれかとなり、下記のように似たコードを書かずに済みます。
case 'not-started-yet':
this.moveItem('not-started-yet');
break;
case 'wip':
this.moveItem('wip');
break;
case 'finish':
this.moveItem('finish');
break;
アイテムの移動
カテゴリから削除し、選択された移住先カテゴリに追加しています。
// カテゴリから削除
this.removeItemInCategory();
// 選択された移住先カテゴリに追加
switch (category) {
case 'not-started-yet':
this.notStartedYetCategory.appendTodo(this.currentSelectItem);
break;
case 'wip':
this.wipCategory.appendTodo(this.currentSelectItem);
break;
case 'finish':
this.finishCategory.appendTodo(this.currentSelectItem);
break;
}
タスクの削除
カテゴリからの削除だけでなく、タスクの削除もしています。
this.currentSelectItem.remove();
this.removeItemInCategory();
main.ts
メイン処理。
import { TaskForm } from './task-form';
import { ParentCategory } from './parent-category';
function main(): void {
const taskForm = new TaskForm();
const parentCategory = new ParentCategory();
taskForm.registerAddTaskButtonClickEventFunc((inputValue) => {
parentCategory.appendNewTask(inputValue);
});
}
window.addEventListener('load', main);
フォームと親カテゴリの実態を作って、結合してあげて終了です。
お疲れ様でした!
終わりに
実際にやってみて、
HTMLやCSSを書いているときはコンポーネントとして分割し、スコープを小さくしたい。
TypeScriptを書いているときはpropsやemitが欲しくなることが多々ありました。
また、イベント登録もテンプレート側に書いて、関数を紐づけるだけなのもUIフレームワークのいいところですね。(buttonエレメントにも onClick
が生えていますが)
それとUIフレームワークだけでなく、大体UIフレームワークに紐づくUIライブラリも使いながら開発することのほうが多いので、自分で頑張ってCSSを書くのも久々の経験でした。
ほんと感謝ですね。
ただ、実際Vanilla TSで書いてみて、データの受け渡しやクラスの切り分け方は改めて勉強になりました。昨今のUIフレームワークへの解像度も上がったと思います。
ここまで読んで頂きありがとうございました。
終わります。