はじめに
Vue.js の**公式ガイド**ってとても丁寧ですよね。
一通り読み終えたので Vue での TypeScript の書き方を調べつつ、TODO リストを実装してみました。
TODOリストの実装は 基礎から学ぶ Vue.js - ToDoリストを作りながら学習しよう! を参考にさせていただきました。
完成形
- TODO リストはブラウザのローカルストレージに保存する
- 追加ボタンで TODO を追加できる
-
全て
作業中
完了
で TODO の絞り込みができる - 各 TODO の
作業中
・完了
を切り替えられる -
shift
キーを押しながら削除
ボタンをクリックすると TODO を削除できる
実装
TODO リストの保存
ブラウザを閉じてもリストが失われないようにローカルストレージにを保存します。
ローカルストレージに直接依存してしまうと、
- Web API の先の DB など別のストレージに保存先を変える
- テストで保存先にモックを使う
等が難しくなるのでStorable
インターフェースに依存するようにし、localStorage
は DI するようにしました。
import { TodoItem } from '@/todoItem'
interface Storable {
getItem(key: string): string | null
setItem(key: string, value: string): void
}
const STORAGE_KEY = 'vue-ts-todoapp'
export default class TodoStorage {
// 各TODO ユニーク ID の採番用
get nextId(): number {
return this.fetchAll().length + 1
}
constructor(
// デフォルト引数でローカルストレージを DI
private storage: Storable = localStorage
) { }
// TODO リストを全件取得する
public fetchAll(): TodoItem[] {
const todos = JSON.parse(
this.storage.getItem(STORAGE_KEY) || '[]'
) as TodoItem[]
todos.forEach((todo, index) => todo.id = index)
return todos
}
// TODO リストを保存する
public save(todos: TodoItem[]) {
this.storage.setItem(STORAGE_KEY, JSON.stringify(todos))
}
}
TODO の型は以下の通りです。
export enum State { All, Working, Done }
export interface TodoItem {
id: number
name: string
state: State.Working | State.Done
}
単一ファイルコンポーネント
クラス構文で実装しました。
基本的なことは参考にさせていただいた 基礎から学ぶ Vue.js - ToDoリストを作りながら学習しよう! を見ていただくとわかります。
TypeScript + クラス構文 で Vue を書くために調べたことは以下の通りです。
- 「
data
」は クラスのフィールド - 「
computed
」は プロパティ(get
set
) - 「
methods
」は インスタンスメソッド - 「
watch
」は@Watch
デコレータ + インスタンスメソッド
<template>
<div>
<h1>Vue-TypeScript-TODOリスト</h1>
<!--Map型をv-forで回すにはArray.fromで入れ子の配列する必要がある。
イメージ `[[key1, val1], [key2, val2], [key3, val3]]`-->
<label v-for="[state, text] in Array.from(labels)" :key="state">
<input type="radio" v-model="current" :value="state">
{{ text }}
</label>
{{ filteredTodos.length }} 件を表示中
<table>
<thead>
<tr>
<th class="id">ID</th>
<th class="comment">コメント</th>
<th class="state">状態</th>
<th class="button">-</th>
</tr>
</thead>
<tbody>
<tr v-for="todo in filteredTodos" :key="todo.id">
<th>{{ todo.id }}</th>
<td>{{ todo.name }}</td>
<td class="state">
<button @click="toggleState(todo)">
{{ labels.get(todo.state) }}
</button>
</td>
<td class="button">
<button @click.shift="removeTodo(todo)">
削除
</button>
</td>
</tr>
</tbody>
</table>
<p>
※削除ボタンはコントロールキーを押しながらクリックして下さい
</p>
<h2>新しい作業の追加</h2>
<form class="add-item" @submit.prevent="addTodo">
コメント <input type="text" ref="name">
<button type="submit">追加</button>
</form>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator'
import TodoStorage from '@/todoStorage'
import { State, TodoItem } from '@/todoItem.ts'
const todoStorage = new TodoStorage()
@Component
export default class App extends Vue {
// TODO 一覧
private todos: TodoItem[] = []
// TODO 絞り込み作業区分
private labels = new Map<State, string>([
[State.All, '全て'],
[State.Working, '作業中'],
[State.Done, '完了']
])
// 現在表示中の作業区分
private current: State = State.All
// 現在の表示する作業区分でTODO一覧を絞り込む
private get filteredTodos() {
return this.todos.filter(t =>
this.current === State.All ? true : this.current === t.state)
}
// コンポーネントのインスタンスを作成がされた時にストレージからTODO全件を取得
private created() {
this.todos = todoStorage.fetchAll()
}
// TODO 追加する
private addTodo() {
const name = this.$refs.name as HTMLInputElement
if (!name.value.length) {
return
}
this.todos.push({
id: todoStorage.nextId,
name: name.value,
state: State.Working
})
name.value = ''
}
// TODOを削除する
private removeTodo(todo: TodoItem) {
const index = this.todos.indexOf(todo)
this.todos.splice(index, 1)
}
// TODOの作業中・完了を切り替える
private toggleState(todo: TodoItem) {
todo.state = todo.state === State.Working ? State.Done : State.Working
}
// TODO一覧が変更された度、ストレージに保存する
@Watch('todos', { deep: true })
private onTodoChanged(todos: TodoItem[]) {
todoStorage.save(todos)
}
}
</script>
<style>
* {
box-sizing: border-box;
}
#app {
max-width: 640px;
margin: 0 auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
border-bottom: 2px solid #0099e4; /*#d31c4a */
color: #0099e4;
}
th,
th {
padding: 0 8px;
line-height: 40px;
}
thead th.id {
width: 50px;
}
thead th.state {
width: 100px;
}
thead th.button {
width: 60px;
}
tbody td.button, tbody td.state {
text-align: center;
}
tbody tr td,
tbody tr th {
border-bottom: 1px solid #ccc;
transition: All 0.4s;
}
tbody tr.Done td,
tbody tr.Done th {
background: #f8f8f8;
color: #bbb;
}
tbody tr:hover td,
tbody tr:hover th {
background: #f4fbff;
}
button {
border: none;
border-radius: 20px;
line-height: 24px;
padding: 0 8px;
background: #0099e4;
color: #fff;
cursor: pointer;
}
</style>
最後に
Vue を TypeScript + vscode で書くと、入力補完が効いて快適でした。
クラス構文で書くと TypeScript でも記述量が増えにくいです。
vue-cli で気軽にvue + TypeScript のプロジェクトを作れるので、まだ TypeScript で Vue で書いていない方はぜひ試してみてはいかがでしょうか。
参考
- 基礎から学ぶ Vue.js - ToDoリストを作りながら学習しよう!
- TypeScriptでVue.jsを書く – Vue CLIを使った開発のポイントを紹介
- vue.js + typescript = vue.ts ことはじめ
作成した TODO リストアプリのソース
GitHub に作成したプロジェクトを保存しました。
https://github.com/sano-suguru/vue-typescript-todoapp
補足
TSLint
は vue-cli で作成したプロジェクトのデフォルト設定に加えて以下の設定をしています。
-
semicolon : false
セミコロン不要 -
trailing-comma
オブジェクトのプロパティや配列の末尾のカンマ不要 -
"arrow-parens": false
アロー関数の引数の括弧不要
{
"defaultSeverity": "warning",
"extends": [
"tslint:recommended"
],
"linterOptions": {
"exclude": [
"node_modules/**"
]
},
"rules": {
"quotemark": [
true,
"single"
],
"indent": [
true,
"spaces",
2
],
"interface-name": false,
"ordered-imports": false,
"object-literal-sort-keys": false,
"no-consecutive-blank-lines": false,
"semicolon": false,
"trailing-comma": false,
"arrow-parens": false
}
}