LoginSignup
43
36

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-10-21

はじめに

Vue.js の公式ガイドってとても丁寧ですよね。
一通り読み終えたので Vue での TypeScript の書き方を調べつつ、TODO リストを実装してみました。

TODOリストの実装は 基礎から学ぶ Vue.js - ToDoリストを作りながら学習しよう! を参考にさせていただきました。

完成形

image.png

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

実装

TODO リストの保存

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

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

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

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 の型は以下の通りです。

todoItem.ts
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デコレータ + インスタンスメソッド
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 で書いていない方はぜひ試してみてはいかがでしょうか。

参考

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

GitHub に作成したプロジェクトを保存しました。
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
  }
}
43
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
36