Vue.js と TypeScript で Todo リストアプリを実装した

## はじめに

Vue.js の**[公式ガイド](https://jp.vuejs.org/v2/guide/)**ってとても丁寧ですよね。
一通り読み終えたので Vue での TypeScript の書き方を調べつつ、TODO リストを実装してみました。

TODOリストの実装は **[基礎から学ぶ Vue.js - ToDoリストを作りながら学習しよう！](https://cr-vue.mio3io.com/tutorials/todo.html#todo%E3%83%AA%E3%82%B9%E3%83%88%E3%82%92%E4%BD%9C%E3%82%8A%E3%81%AA%E3%81%8C%E3%82%89%E5%AD%A6%E7%BF%92%E3%81%97%E3%82%88%E3%81%86%EF%BC%81)** を参考にさせていただきました。

## 完成形

![image.png](https://qiita-image-store.s3.amazonaws.com/0/195024/e97dd949-3ed7-4f28-4131-4b71361723f8.png)

- TODO リストはブラウザのローカルストレージに保存する
- 追加ボタンで TODO を追加できる
- `全て` `作業中` `完了`で TODO の絞り込みができる
- 各 TODO の `作業中`・`完了`を切り替えられる
- `shift`キーを押しながら`削除`ボタンをクリックすると TODO を削除できる

## 実装

### TODO リストの保存

ブラウザを閉じてもリストが失われないようにローカルストレージにを保存します。
ローカルストレージに直接依存してしまうと、

- Web API の先の DB など別のストレージに保存先を変える
- テストで保存先にモックを使う

等が難しくなるので`Storable`インターフェースに依存するようにし、`localStorage`は DI するようにしました。

```ts:todoStorage.ts
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 の型は以下の通りです。

```ts:todoItem.ts
export enum State { All, Working, Done }

export interface TodoItem {
  id: number
  name: string
  state: State.Working | State.Done
}
```

### 単一ファイルコンポーネント

クラス構文で実装しました。

基本的なことは参考にさせていただいた **[基礎から学ぶ Vue.js - ToDoリストを作りながら学習しよう！](https://cr-vue.mio3io.com/tutorials/todo.html#todo%E3%83%AA%E3%82%B9%E3%83%88%E3%82%92%E4%BD%9C%E3%82%8A%E3%81%AA%E3%81%8C%E3%82%89%E5%AD%A6%E7%BF%92%E3%81%97%E3%82%88%E3%81%86%EF%BC%81)** を見ていただくとわかります。
TypeScript + クラス構文 で Vue を書くために調べたことは以下の通りです。

- 「`data`」は クラスのフィールド
- 「`computed`」は プロパティ(`get` `set`)
- 「`methods`」は インスタンスメソッド
- 「`watch`」は`@Watch`デコレータ + インスタンスメソッド

```App.vue
<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リストを作りながら学習しよう！](https://cr-vue.mio3io.com/tutorials/todo.html#todo%E3%83%AA%E3%82%B9%E3%83%88%E3%82%92%E4%BD%9C%E3%82%8A%E3%81%AA%E3%81%8C%E3%82%89%E5%AD%A6%E7%BF%92%E3%81%97%E3%82%88%E3%81%86%EF%BC%81)
- [TypeScriptでVue.jsを書く – Vue CLIを使った開発のポイントを紹介](https://mae.chab.in/archives/60167)
- [vue.js + typescript = vue.ts ことはじめ](https://qiita.com/nrslib/items/be90cc19fa3122266fd7)

### 作成した TODO リストアプリのソース

GitHub に作成したプロジェクトを保存しました。
[`https://github.com/sano-suguru/vue-typescript-todoapp`](https://github.com/sano-suguru/vue-typescript-todoapp)

## 補足

`TSLint` は vue-cli で作成したプロジェクトのデフォルト設定に加えて以下の設定をしています。

- `semicolon : false` セミコロン不要
- `trailing-comma` オブジェクトのプロパティや配列の末尾のカンマ不要
- `"arrow-parens": false` アロー関数の引数の括弧不要

```tslint.json
{
  "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
  }
}
```