【Vue3 / Composition API】Vue.jsのTodoMVCをシンプルにしてみた

## 概要

こちらの記事を参考に、Vueの学習をしています。

https://zenn.dev/comm_vue_nuxt/articles/start-to-learn-vue-2024

上記の記事にもある通り、Vue.js公式サイトの実装例『TodoMVC』はUIが凝っていて初心者にはレベルが高いです。

https://ja.vuejs.org/examples/#todomvc

そこで、**Vueの機能の学習**に注力するため、公式の実装例の複雑なUIをそぎ落として、シンプルなTodoリストを作成してみました。

## 実装した機能の一覧

以下の通りです。公式の実装例の機能は網羅できていると思います。

✅ Todoの追加・削除・編集
✅ 完了状態の変更
✅ ダブルクリックで編集開始
✅ 未完了の数を表示
✅ 完了済の非表示切り替え
✅ 完了済の一括削除
✅ `localStorage`への保存と復元
✅ 固有IDの付与（ただし実装例の`Date.now()`ではなく、より安全なUUIDを使用）

## 完成品

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/911578/2718a0de-044a-4677-81e0-e71d12c854bc.png)

Vue SFC Playgroundでコードと動作を確認できます。

https://play.vuejs.org/#eNqNV91u3EQUfpWphbS7UtablnLj/DSlRCK9aBFpxQXLhWOPd6ed9Vj2eDchWondLaV/qAIRilRQhUCoamiEBEKlAvVhpkm45BE4Mx7b480mjVpF9pwz5+eb73ye3bYuRpHdT7HlWIuJF5OIowTzNFpuh6QXsZijbRTjYA55rBelHPtzaOByr7saBNjjaIiCmPVQDSLU2mE7bLWQmDwXk7/FeFdMdsX4LzG5Iya/qpUXYnRfjO+K0d7+w2/F+N7+Fy/F6FE79FiYQFq3j/1rzGcJWkKUeS5d5yx2O9juYL7Gca9e49Jaa+hEh/f+PPj8fr5d2WAnFFs3Ql1Al9evXrEjN06wsd5ADvr4E6RCZftDPJAWHaEms2QG7BNOws6ar03zjYXcRJL1LhtcAmQoBmi0A49TnNcoJjti/JNC4tXBizvQukwiJs8yZJSPqlyMb5GkiCRGD/Z/vivGD8X4vhg9EaPfxWgXVg4e/wGIqZVb8u94JD4b5Scjxl8ffvNUWX7Zv/3lv0/vHO6B744YPYYwec0+SSLqbuVA55vr9QZagkNH003ZfZemGHBUZeo3x3yzA0I5jut1LkOgM9z2WSgRyEE4+P7Z/t6D1y9vQ/sHO7/llaRhkeRK2jtSC5LFvDmNTXHY4d0y24n8g/97r1/9sP/8u3Zo8Fhn3JYpK9SDWTCpN5exKeExMIIEW3WjvgYUMCyL+FGM/xGTiZjczft1fcU9aNTIpslLOMVg0CTUDUOWXh0iwpkEqK58GtmuKjBRmnTreh18fQcNSOizge3FWxFnduzCW+/69bX36o253E2Fc6oZC6NE1kGBS5N8TXYGf+WfyhYoum3BvxBMxSRzN+arMDayVeKXzRajVOwlPgyTsVUmznfKFucQtFzFSqbXcAFEbgzqMBOv3K8CmW0iLR9VVwhDo7lbjHusj2WHqgIbGihaP9rAfKX8cu906+b0LJ1Aaw750JkliYzk03RoU2sMFp02ejmbRmTOOh2Kp5XMiD5bEZbQmZkGFXuxlX1MQFHgBSYINIdjpS+LPukDRbYoXmpbPXezOSA+7zro/Px8tLmAenCiJHSQm3K2gCIYGgDcQWdxb6FtqQAQontuWQnYYgue9BqE1Y/wQkJQkvwNoX6zx3xMIaEmr2Rsbly5ibfSyMYhAAUeekxNDyjew11GfeXwUdeVLMR+AtChDaxIe6H0L6vYSDlnIVrxKPFumqELF4Qu+n7h38o25B21spbK/gzYJEhNziIHzdvvmNjMSDvjgCslbG8f+ZJdgKl+n/i4bYHWty1pbFvDoZLoLMCpapbXCO0HmsnCzjLkmtL94RDIkhllJdNfBRiFs6ocAizS5cjHBJ6hIIoDfc6LLZlNP6e0zEwJnH/A5NHJyUAkrHwEZUxggLbC+FWwyZiE+FYkcfe62Lu5wTZhT04ptUkyoEietRu5xqtkIAkgQHmZkDNeJDQ9HY+6SQK+26gm49ayj63KASpU8V3xN2h+yoXqlrJl+FaP25DCoRK2DEBZ9AkzJFWyfHfUtGf9q1DHTlQu6lrR38J9sFSqW9mg6ek8c3AkkM2swqODN2MGZqm6tfzfk0dfTTFY4kBJQeeCSMdELMgKHx5JigQmRVMp0VcTtDxFakh8iWI3LqepUkM+Q4stQzfhVQ0/SjwWYR9WMkJk8o83edPHHtxZOGEgnpSEuMm7MUs73ewDa5eAZXu0hMSk0+WFiMB6objnok10HhRZLgYs5M2EfAqXAiXEMuIbY4GPxsx0kgNb8ZHTD40tW3MWh+bCgHTsGwkL4ReJ2gdTBzARiuOrkewOBtbJv9UgqZSywWW1Ju/d+raiJ3XG+o0EhldqyAcxTnDcB4UrbNllIjOvrl8BUA0jTHsKHD/J+CFOGE1ljZnbu2noQ9mGn6p2Tf2uAoyvJaubHIdJ3pQsVF01lH/bgh9VkjbHtV6W+7Z9Xl9Rhtbwf2eU138=

## コード

```vue
<script setup>
import { ref, computed, watchEffect } from 'vue'

// ローカルストレージからの取り出し
const savedTodos = localStorage.getItem('todos')

// 状態
const todos = ref(savedTodos ? JSON.parse(savedTodos) : [] )

const newTodo = ref('')
const editingId = ref(0);
const isShowCompleted = ref(true)

// フィルタ済のTodoリスト
// todosやisCompletedが変わるたびに変更したいため、computedを用いて再計算させる
const displayTodos = computed(() =>
  isShowCompleted.value ? todos.value : todos.value.filter((t) => !t.done)
)

// 未完了の数
const unCompletedNum = computed(() => 
  todos.value.filter((t) => !t.done).length
)

// ローカルストレージへの保存
watchEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos.value))
})

// メソッド
const addTodo = () => {
  const title = newTodo.value.trim()
  if (title) {
    todos.value.push({
      id: window.crypto.randomUUID(),
      title: newTodo.value,
      done: false,
    })
  }
  newTodo.value = ""
}

const startEdit = (id) => {
  editingId.value = id;
}

const doneEdit = (todo, e) => {
  const newTitle = e.target.value.trim()
  if (newTitle) {
    todo.title = newTitle
  } else {
    removeTodo(todo.id)
  }
  editingId.value = 0
}

const removeTodo = (id) => {
  todos.value = todos.value.filter((t) => t.id !== id)
}

const removeCompleted = () => {
  todos.value = todos.value.filter((t) => !t.done)
}

const toggleShowCompleted = () => {
  isShowCompleted.value = !isShowCompleted.value
}
</script>

<template>
  <div style="max-width: 400px; margin: auto; padding: 1em;">
    <h2>Todos</h2>
    <div>
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="What needs to be done?"
      >
      <button @click="addTodo">
        Add
      </button>
    </div>

    <div style="margin-top: 0.5em;">
      <button @click="toggleShowCompleted">
        {{ isShowCompleted ? "Hide" : "Show"}} completed
      </button>
    </div>

    <p>
      <strong>{{ unCompletedNum }}</strong> {{ unCompletedNum === 1 ? "item" : "items" }} left
    </p>

    <ul>
      <li v-for="todo in displayTodos" :key="todo.id">
        <input type="checkbox" v-model=todo.done>

        <span
          v-if="editingId !== todo.id"
          :class="{ 'done': todo.done }"
          @dblclick="startEdit(todo.id)"
        >
        {{ todo.title }}
      </span>
      <input
        v-else
        :value=todo.title
        @keyup.enter="doneEdit(todo, $event)"
        @blur="doneEdit(todo, $event)"
        class="edit-input"
      >
        <button @click="removeTodo(todo.id)">🗑</button>
      </li>
    </ul>
    <button @click="removeCompleted()" v-show="todos.length > unCompletedNum">Clear completed</button>
  </div>
</template>

<style scoped>
.done {
  text-decoration: line-through;
}
.edit-input {
  margin-right: 0.5em;
  padding: 2px 4px;
  font-size: 1em;
}
input {
  margin-right: 0.5em;
}
button {
  margin-left: 0.5em;
}
</style>
```


## 参考

### HTML クラスのバインディング

https://ja.vuejs.org/guide/essentials/class-and-style#binding-html-classes

### v-showとv-ifの違い

https://ja.vuejs.org/guide/essentials/conditional#v-show

https://qiita.com/aqua_ix/items/61eac355f3c24d7676e1

### ブラウザでUUID生成

https://developer.mozilla.org/ja/docs/Web/API/Crypto/randomUUID

https://blog.kimizuka.org/entry/2023/11/20/000000#google_vignette

### ローカルストレージ

https://qiita.com/masuda-sankosc/items/cff6131efd6e1b5138e6

### watchとwatchEffectの使い分け

https://zenn.dev/n4sh/articles/d51f505835d171

https://qiita.com/doz13189/items/d09cfc6e1ff38621c2cc