Vue3 - 做個簡單的 TodoList

tags: Vue
category: Front-End
description: Vue3 - 做個簡單的 TodoList
created_at: 2022/07/16 22:00:00

cover image


回到 手把手開始寫 Vue3


前言

前面把一些基本功能都先帶過了,差不多該把前面提到的東西組合成一個小 APP 了。

不會做得太複雜,複雜的留到之後另一個章節(因為目前提到的功能也不多)。

成品的長相,真的很陽春DEMO

cover image


初始化專案

首先跟第一篇提到的一樣,弄好環境

建立專案

$ npm init vue@latest

而這次我的配置是讓他幫我設定好 ESLintPrettier

  • √ Add ESLint for code quality? ... No / Yes
  • √ Add Prettier for code formatting? ... No / Yes

之後就把你的專案打開,安裝相依套件 (也可以使用 yarn)

$ npm install

在來安裝 tailwindcss

$ npx lai-cmd init vue-tailwindcss

然後老樣子的把專案清理一下。

假設前置作業都做好了。


開始


先簡單建立一個 header 來用

src/components/TheHeader.vue

<script setup></script>

<template>
  <header class="bg-indigo-600 text-white px-4 py-2 mb-4">
    <h1 class="text-4xl">TodoList</h1>
  </header>
</template>

再來做一個簡單的表單放在畫面中間

這時候的 src/App.vue

<script setup>
import { ref } from "vue";
import TheHeader from "./components/TheHeader.vue";

const name = ref("");

const addTodo = () => {
  const val = name.value;
  if (!val) return;

  name.value = "";
  console.log(val);
};
</script>

<template>
  <TheHeader />

  <main class="flex flex-col items-center">
    <form @submit.prevent="addTodo">
      <div class="flex items-center justify-center">
        <input
          id="todo-input"
          class="border-b outline-none w-96 px-4 py-2"
          type="text"
          v-model="name"
          placeholder="Please input todo name..."
        />
        <button class="bg-blue-500 text-white px-4 py-2">Add</button>
      </div>
    </form>
  </main>
</template>

會看到每次輸入完後送出表單都會在 console.log 印出輸入的值。

再來需要建立一個存代辦事項的陣列。

假設要額外建立一個 model,存單一 todo 的資料結構

src/models/Todo.js

export default class Todo {
  constructor(name, done = false) {
    this.name = name;
    this.done = done;
  }
}

那麼現在的 App.vuejs 部分會變成這樣

import { reactive, ref } from "vue";
import TheHeader from "./components/TheHeader.vue";
import Todo from "./models/Todo";

const name = ref("");
const todos = reactive([]);

const addTodo = () => {
  const val = name.value;
  if (!val) return;

  name.value = "";
  todos.push(new Todo(val));
};

每當輸入完後送出一次表單,就會往 todos 裡面塞一個新的代辦事項

再來需要一個 TodoList 元件去顯示這些代辦事項

src/components/TodoList.vue

<script setup>
defineProps({
  todos: {
    type: Array,
    default() {
      return [];
    },
  },
});
</script>

<template>
  <div class="w-96 py-2">
    <div class="text-center my-4">
      <button class="bg-red-500 text-white px-3 py-1">Batch delete.</button>
    </div>
    <ul>
      <li
        class="flex justify-between border-b mb-2"
        v-for="todo in todos"
        :key="todo.name"
      >
        <label class="flex-grow py-1">
          <input type="checkbox" />
          {{ todo.name }}
        </label>

        <div class="flex">
          <button class="bg-green-500 text-white px-3 py-1" v-if="!todo.done">
            Done
          </button>
          <button class="bg-yellow-500 text-white px-3 py-1" v-else>
            Undone
          </button>
          <button class="bg-red-500 text-white px-3 py-1">x</button>
        </div>
      </li>
    </ul>
  </div>
</template>

再來先嘗試對 checkbox 綁上 v-model,去抓出選取的代辦事項的索引值。

<script setup>
import { ref } from "vue";
// ...
const selectedIndexes = ref([]);
</script>

<!-- 略... -->
<li
  class="flex justify-between border-b mb-2"
  v-for="(todo, index) in todos"
  :key="todo.name"
>
<label class="flex-grow py-1">
  <input type="checkbox" v-model="selectedIndexes" :value="index" />
  {{ todo.name }}
</label>
<!-- ...略 -->

記得要把 v-for 的索引值拿出來,還有設定一下 inputvalue

抓出索引之後,就可以開始做切換代辦事項的狀態(done/undone)或是刪除了。

先在 src/App.vue 寫入函數定義

<script setup>
// 略...
const switchTodoStatusByIndex = (index) => {
  todos[index].done = !todos[index].done;
};

const deleteTodoByIndexes = (...indexes) => {
  const newTodos = todos.filter((_, i) => !indexes.includes(i));
  todos.splice(0, todos.length, ...newTodos);
};
</script>

<template>
  <!-- 略... -->
  <TodoList
      :todos="todos"
      @deleteByIndexes="deleteTodoByIndexes"
      @switchStatusByIndex="switchTodoStatusByIndex"
  />
<!-- ...略 -->
</template>

從上層傳入兩個事件,讓子元件有能力可以對代辦事項做操作。

而子元件的部分主要多了這幾段

<script setup>
// 略...
const emit = defineEmits(["deleteByIndexes", "switchStatusByIndex"]);

const batchDelete = () => {
  emit("deleteByIndexes", ...selectedIndexes.value);
  selectedIndexes.value.length = 0;
};

const deleteByIndex = (index) => {
  const idx = selectedIndexes.value.indexOf(index);
  if (idx !== -1) {
    selectedIndexes.value.splice(idx, 1);
  }
  selectedIndexes.value = selectedIndexes.value.map((idx) =>
    idx > index ? idx - 1 : idx
  );

  emit("deleteByIndexes", index);
};
</script>

<template>
  <!-- 略... -->
      <button class="bg-red-500 text-white px-3 py-1" @click="batchDelete">
        Batch delete.
      </button>
  <!-- 略... -->
        <div class="flex">
          <button
            class="bg-green-500 text-white px-3 py-1"
            v-if="!todo.done"
            @click="$emit('switchStatusByIndex', index)"
          >
            Done
          </button>
          <button
            class="bg-yellow-500 text-white px-3 py-1"
            v-else
            @click="$emit('switchStatusByIndex', index)"
          >
            Undone
          </button>
          <button
            class="bg-red-500 text-white px-3 py-1"
            @click="deleteByIndex(index)"
          >
            x
          </button>
        </div>
  <!-- 略... -->
</template>

會看到中間那一段 v-ifv-else,大多地方是重複的,所以可以再換個寫法,就看個人喜歡

<button
  class="text-white px-3 py-1"
  :class="todo.done ? 'bg-yellow-500' : 'bg-green-500'"
  @click="$emit('switchStatusByIndex', index)"
>
  {{ todo.done ? "Undone" : "Done" }}
</button>

如果需要,還可以再自己包成計算屬性(computed)去寫。

基本上到這邊簡單的 TodoList 就完成了。


再來就是一些函數的說明

switchTodoStatusByIndex()

const switchTodoStatusByIndex = (index) => {
  todos[index].done = !todos[index].done;
};

這個應該是裡面最簡單的,就很單純抓出特定索引的物件,把 done 這個欄位做 反向(not)運算。

也就是 true 變成 false,反之亦然。


deleteTodoByIndexes()

const deleteTodoByIndexes = (...indexes) => {
  const newTodos = todos.filter((_, i) => !indexes.includes(i));
  todos.splice(0, todos.length, ...newTodos);
};

這邊傳入的是陣列的資料(indexes),然後因為 todos 是一個 reactive 的常數資料,所以不能直接蓋過他,只能使用陣列的函數來影響內容,所以選擇這樣的方式覆蓋原來的資料。

整個流程大概是:

newTodos 會先使用 filter 找出哪些是留下來的代辦事項。

再來使用 splice 把陣列中所有資料刪除,並放入 newTodos 的資料。


batchDelete()

const batchDelete = () => {
  emit("deleteByIndexes", ...selectedIndexes.value);
  selectedIndexes.value.length = 0;
};

再這邊批量刪除反而比較簡單,所以先拿出來講,因為有 selectedIndexes 紀錄哪些 index 的資料有被選取,也就是要刪除的資料,既然剛好對上了,那就只要在呼叫完父元件的 deleteByIndexes 後,把 selectedIndexes 直接清空就好。


deleteByIndex()

const deleteByIndex = (index) => {
  const idx = selectedIndexes.value.indexOf(index);
  if (idx !== -1) {
    selectedIndexes.value.splice(idx, 1);
  }
  selectedIndexes.value = selectedIndexes.value.map((idx) =>
    idx > index ? idx - 1 : idx
  );

  emit("deleteByIndexes", index);
};

這個應該是這次相對複雜的函數了,因為在刪除單一個的時候可能有些目前是被選取的,要做額外的處理。

主要是兩種情況要處理,分別是:

  • 選起來的剛好被刪掉,那要刪除這個 index 的紀錄,避免下一筆自動被選取。
  • 之後索引比較大的都要往前遞移一項,避免選錯索引。

總結

以上就是這次的小練習了,等未來補上像是 Router、漸變動畫、狀態管理...之類的再來做比較複雜的。

這篇文章的成品在 Github




最後更新時間: 2022年07月16日.