點燈坊

戦わなければ、勝てない

使用 Component 實作 Todo List

Sam Xiao's Avatar 2024-02-22

將 Todo List 以 Component 實現,將更接近實務上的使用。

Version

Vue 3.4

Component

todolist001

  • 整個 Todo List 包成 <TodoList> component
  • <TodoList> 內再拆分成 <TodoHeader><TodoMain><TodoFooter> 三個 component
  • <ToDoHeader><TodoMain><TodoFooter> 負責發 event 給 <TodoList>,由 <TodoList> 負責呼叫 API function
  • <TodoFooter> 提供 slot 傳入 items left

App.vue

<template>
  <TodoList />
</template>

<script setup>
import TodoList from '@/components/TodoList.vue'
</script>
  • 只引用 <TodoList> component,不包含任何代碼

TodoList

TodoList.vue

<template>
  <TodoHeader v-model="newTodo" @addTodo="onAddTodo" />
  <TodoMain
    :dataSrc="filteredTodos"
    @completedTodo="onCompletedTodo"
    @saveTodo="onSaveTodo"
    @delTodo="onDelTodo"
  />
  <TodoFooter
    :dataSrc="todos"
    @filterAll="onFilterAll"
    @filterActive="onFilterActive"
    @filterCompleted="onFilterCompleted"
    @clearCompleted="onClearCompleted"
    >items left
  </TodoFooter>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import TodoHeader from '@/components/TodoHeader.vue'
import TodoMain from '@/components/TodoMain.vue'
import TodoFooter from '@/components/TodoFooter.vue'
import { getTodos, addTodo, saveTodo, delTodo } from '@/api/todos.js'

let todos = ref([])
let newTodo = ref('')
let completed = computed(() => todos.value.filter((todo) => todo.isCompleted))

let filter = ref(() => true)
let filteredTodos = computed(() => todos.value.filter(filter.value))

onMounted(async () => (todos.value = await getTodos()))

let onAddTodo = async (val) => {
  if (val === '') return

  await addTodo(val)
  todos.value = await getTodos()
  newTodo.value = ''
}

let onCompletedTodo = async (item) => {
  await saveTodo(item)
}

let onSaveTodo = async (item) => {
  await saveTodo(item)
}

let onDelTodo = async (item) => {
  await delTodo(item)
  todos.value = await getTodos()
}

let onClearCompleted = async () => {
  for (let item of completed.value) {
    await delTodo(item)
  }

  todos.value = await getTodos()
}

let onFilterAll = () => (filter.value = () => true)
let onFilterActive = () => (filter.value = (todo) => !todo.isCompleted)
let onFilterCompleted = () => (filter.value = (todo) => todo.isCompleted)
</script>
  • 使用 <TodoHeader><TodoMain><TodoFooter> component
  • 負責呼叫 API function

Line 2

<TodoHeader />
<TodoMain />
<TodoFooter />
  • 使用 <TodoHeader><TodoMain><TodoFooter> component

Line 2

<TodoHeader v-model="newTodo" @addTodo="onAddTodo" />
  • 使用 <TodoHeader> component
  • v-model:將 newTodo state 以 v-model 傳入
  • @addTodo:從 addTodo event 取得新輸入的 todo

Line 3

<TodoMain
  :dataSrc="filteredTodos"
  @completedTodo="onCompletedTodo"
  @saveTodo="onSaveTodo"
  @delTodo="onDelTodo"
/>
  • 使用 <TodoMain> component
  • :dataSrc:將 filter 過的 todos 從 dataSrc prop 傳入
  • @completedTodo:從 completeTodo event 取得 todo 的完成狀態
  • @saveTodo:從 saveTodo event 取得編輯過的 todo
  • @delTodo:從 delTodo event 取得要刪除的 todo

Line 9

<TodoFooter
  :dataSrc="todos"
  @filterAll="onFilterAll"
  @filterActive="onFilterActive"
  @filterCompleted="onFilterCompleted"
  @clearCompleted="onClearCompleted"
>items left
</TodoFooter>
  • 使用 <TodoFooter> component
  • :dataSrc:將完整 todos 從 dataSrc prop 傳入
  • @filterAll:從 filterAll event 得知 All button 被按下
  • @filterActive:從 filterActive event 得知 Active button 被按下
  • @filterCompleted:從 filterCompleted event 得知 Completed button 被按下
  • @clearCompleted:從 clearCompleted event 得知 Clear Completed button 被按下

Line 26

let newTodo = ref('')
let todos = ref([])
  • newTodo state:儲存新輸入的 todo
  • todos state:儲存所有 todo

Line 28

let completed = computed(() => todos.value.filter((todo) => todo.isCompleted))
  • active computed:當 todo state 改變時,active computed 會 reactive 跟著改變,只顯示所有 未完成 todo

Line 30

let filter = ref(() => true)
let filteredTodos = computed(() => todos.value.filter(filter.value))
  • filter state:儲存 AllActiveCompleted 所需要的 filter function
  • filteredTodos computed:當 todo 改變時,filteredTodos computed 會 reactive 跟著改變,只顯示所有目前 filter state 的 filter function 所指定的 todo

Line 33

onMounted(async () => (todos.value = await getTodos()))
  • onMounted():呼叫 getTodos() API function 取得所有 todos

Line 35

let onAddTodo = async (val) => {
  if (val === '') return

  await addTodo(val)
  todos.value = await getTodos()
  newTodo.value = ''
}
  • 按下 Add button 時被觸發
  • 呼叫 addTodo() API function 新增一筆 todo
  • 新增完呼叫 getTodos() API function 重新取得所有 todo

Line 43

let onCompletedTodo = async (item) => {
  await saveTodo(item)
}
  • 當 todo 狀態被改變時被觸發

  • 呼叫 saveTodo() API function 儲存目前 todo 完成狀態

Line 47

let onSaveTodo = async (item) => {
  await saveTodo(item)
}
  • 按下 save button 時被觸發
  • 呼叫 saveTodo() API function 儲存 todo

Line 51

let onDelTodo = async (item) => {
  await delTodo(item)
  todos.value = await getTodos()
}
  • 按下 delete button 時被觸發
  • 呼叫 delTodo() API function 刪除 todo
  • 刪除完呼叫 getTodos() API function 重新取得所有 todo

Line 56

let onClearCompleted = async () => {
  for (let item of completed.value) {
    await delTodo(item)
  }

  todos.value = await getTodos()
}
  • 按下 Clear Completed button 時被觸發
  • 使用 for loop 一筆一筆呼叫 delTodo() API function 刪除 todo
  • 刪除完呼叫 getTodos() API function 重新取得所有 todo

Line 64

let onFilterAll = () => (filter.value = () => true)
  • 按下 All button 時被觸發
  • 顯示所有 todo,直接提供 filter function

Line 65

let onFilterActive = () => (filter.value = (todo) => !todo.isCompleted)
  • 按下 Active button 時被觸發
  • 顯示尚未完成 todo,直接提供 filter function

Line 66

let onFilterCompleted = () => (filter.value = (todo) => todo.isCompleted)
  • 按下 Completed button 時被觸發
  • 顯示已完成 todo,直接提供 filter function

TodoHeader

TodoHeader.vue

<template>
  <input type="text" v-model="newTodo" />
  <button @click="onAdd">Add</button>
</template>

<script setup>
let newTodo = defineModel()
let emit = defineEmits(['addTodo'])

let onAdd = () => {
  if (newTodo.value === '') return

  emit('addTodo', newTodo.value)
}
</script>

Line 2

<input type="text" v-model="newTodo" />
<button @click="onAdd">Add</button>
  • v-model:將輸入的新 todo 綁定到 newTodo state
  • 新增一筆 todo,並將 click event 指定到 onAdd()

Line 7

let newTodo = defineModel()
  • defineModel():定義 newTodo state 給 v-model 使用

Line 8

let emit = defineEmits(['addTodo'])
  • defineEmits():定義 addTodo event

Line 10

let onAdd = () => {
  if (newTodo.value === '') return

  emit('addTodo', newTodo.value)
}
  • 新增一筆 todo
  • 發出 addTodo event 將新輸入 todo 傳到外層

TodoMain

<template>
  <ul>
    <li v-for="item in filteredTodos" :key="item.id">
      <input type="checkbox" v-model="item.isCompleted" @change="onCompleted(item)" />
      <span v-if="!item.isEdit">
        <span>{{ item.todo }}</span>
        <button @click="onEdit(item)">edit</button>
        <button @click="onDel(item)">delete</button>
      </span>
      <span v-else>
        <input type="text" v-model="item.todo_" />
        <button @click="onSave(item)">save</button>
        <button @click="onCancel(item)">cancel</button>
      </span>
    </li>
  </ul>
</template>

<script setup>
import { computed } from 'vue'

let props = defineProps({ dataSrc: Object })
let emit = defineEmits(['completedTodo', 'delTodo', 'saveTodo'])

let filteredTodos = computed(() => props.dataSrc)

let onCompleted = (item) => {
  emit('completedTodo', item)
}

let onEdit = (item) => {
  item.isEdit = true
  item.todo_ = item.todo
}

let onDel = (item) => {
  emit('delTodo', item)
}

let onSave = (item) => {
  item.isEdit = false
  item.todo = item.todo_

  emit('saveTodo', item)
}

let onCancel = (item) => {
  item.isEdit = false
}
</script>

Line 2

<ul>
  <li v-for="item in filteredTodos" :key="item.id">
    <span>{{ item.todo }}</span>
  </li>
</ul>
  • v-for :列舉 filteredTodos computed

Line 4

<input type="checkbox" v-model="item.isCompleted" @change="onCompleted(item)" />
  • v-model:將 checkbox 綁定到 item.isCompleted
  • @change:當改變 checkbox 改變時,呼叫 onCompleted()

Line 5

<span v-if="!item.isEdit">
  <span>{{ item.todo }}</span>
  <button @click="onEdit(item)">edit</button>
  <button @click="onDel(item)">delete</button>
</span>
  • v-if:若是 非編輯模式,則直接顯示 todo
  • Button 此時為 editdelete

Line 10

<span v-else>
  <input type="text" v-model="item.todo_" />
  <button @click="onSave(item)">save</button>
  <button @click="onCancel(item)">cancel</button>
</span>
  • v-else:若是 編輯模式,則以 <input> 顯示 todo,注意此時 v-model 綁定到 item.todo_ 而非 item.todo,此為儲存 暫時 的 todo,主要為了 cancel 所用
  • Button 此時為 savecancel

Line 22

let props = defineProps({ dataSrc: Object })
  • defineProps():定義 dataSrc prop,將傳入 filter 過的 todos 供列舉

Line 23

let emit = defineEmits(['completedTodo', 'delTodo', 'saveTodo'])
  • defineEmits():定義 completedTododelTodosaveTodo event

Line 25

let filteredTodos = computed(() => props.dataSrc)
  • filterTodos completed:能與 dataSrc prop 連動

Line 27

let onCompleted = (item) => {
  emit('completedTodo', item)
}
  • 改變 todo 狀態為 完成未完成 時觸發
  • 將修改過的 todo 透過 completedTodo event 傳出

Line 31

let onEdit = (item) => {
  item.isEdit = true
  item.todo_ = item.todo
}
  • 編輯一筆 todo 時觸發
  • 將 item 的 isEdit 設定為 true 表示為 編輯模式,此時 button 會改為顯示 savecancel
  • 在 item 下新增 todo_ property 作為臨時 state,並將目前 toto 值寫入 todo_,作為綁定 <input>

Line 36

let onDel = (item) => {
  emit('delTodo', item)
}
  • 刪除一筆 todo 時觸發
  • 將要刪除 todo 透過 delTodo event 傳出

Line 40

let onSave = (item) => {
  item.isEdit = false
  item.todo = item.todo_

  emit('saveTodo', item)
}
  • 儲存一筆 todo 時觸發
  • 將 item 的 isEdit 設定為 false 表示為 非編輯模式,此時 button 會改為顯示 editdelete
  • 因為目前編輯的 <input> 綁定到 item.todo_,將資料從 item.todo_ 複製到 item.todo 完成儲存
  • 將要儲存 todo 透過 saveTodo event 傳出

Line 47

let onCancel = (item) => {
  item.isEdit = false
}
  • 取消編輯一筆 todo 時觸發
  • 將 item 的 isEdit 設定為 false 表示為 非編輯模式,此時 button 會改為顯示 editdelete

TodoFooter

TodoFooter.vue

<template>
  <span>{{ active.length }} <slot></slot></span>
  <button @click="onFilterAll">All</button>
  <button @click="onFilterActive">Active</button>
  <button @click="onFilterCompleted">Completed</button>
  <button v-if="completed.length" @click="onClearCompleted">Clear Completed</button>
</template>

<script setup>
import { computed } from 'vue'

let props = defineProps({ dataSrc: Array })
let emit = defineEmits(['filterAll', 'filterActive', 'filterCompleted', 'clearCompleted'])

let todos = computed(() => props.dataSrc)
let active = computed(() => todos.value.filter((todo) => !todo.isCompleted))
let completed = computed(() => todos.value.filter((todo) => todo.isCompleted))

let onFilterAll = () => emit('filterAll')
let onFilterActive = () => emit('filterActive')
let onFilterCompleted = () => emit('filterCompleted')
let onClearCompleted = () => emit('clearCompleted')
</script>

Line 2

<span>{{ active.length }} <slot></slot></span>
  • 顯示目前尚未完成的 todo 筆數

Line 3

<button @click="onFilterAll">All</button>
  • 顯示所有 todo,並將 click event 指定到 onFilterAll()

Line 4

<button @click="onFilterActive">Active</button>
  • 顯示尚未完成 todo,並將 click event 指定到 onFilterActive()

Line 5

<button @click="onFilterCompleted">Completed</button>
  • 顯示已完成 todo,並將 click event 指定到 onFilterCompleted()

Line 6

<button v-if="completed.length" @click="onClearCompleted">Clear Completed</button>
  • v-if:若有已完成 todo,則顯示 Clear Completed button
  • click event 指定到 onClearCompleted()

Line 12

let props = defineProps({ dataSrc: Array })
let emit = defineEmits(['filterAll', 'filterActive', 'filterCompleted', 'clearCompleted'])
  • defineProps():定義 dataSrc prop,將傳入所有 todos 供列舉
  • defineEmits():定義 filterAllfilterActivefilterCompletedclearCompleted event

Line 15

let todos = computed(() => props.dataSrc)
  • todos computed:能與 dataSrc prop 連動

Line 16

let active = computed(() => todos.value.filter((todo) => !todo.isCompleted))
  • active computed:當 todo state 改變時,active computed 會 reactive 跟著改變,只顯示所有 未完成 todo

Line 17

let completed = computed(() => todos.value.filter((todo) => todo.isCompleted))
  • completed computed:當 todo state 改變時,completed computed 會 reactive 跟著改變,只顯示所有 已完成 todo

Line 19

let onFilterAll = () => emit('filterAll')
  • 顯示所有 todo,發出 filterAll event 通知外部

Line 20

let onFilterActive = () => emit('filterActive')
  • 顯示尚未完成 todo,發出 filterActive event 通知外部

Line 21

let onFilterCompleted = () => emit('filterCompleted')
  • 顯示已完成 todo,發出 filterCompleted event 通知外部

Line 22

let onClearCompleted = () => emit('clearCompleted')
  • 刪除所有已完成 todo,發出 clearCompleted event 通知外部

API Function

api/todos.js

import axios from 'axios'

export let getTodos = async () => {
  let url = 'http://localhost:3000/todos'

  try {
    let { data } = await axios.get(url)
    return data
  } catch (e) {
    console.error(e)
  }
}

export let addTodo = async (todo) => {
  let url = 'http://localhost:3000/todos'

  let body = {
    isCompleted: false,
    todo
  }

  try {
    let { data } = await axios.post(url, body)
    return data
  } catch (e) {
    console.error(e)
  }
}

export let saveTodo = async (item) => {
  let url = `http://localhost:3000/todos/${item.id}`

  let body = {
    isCompleted: item.isCompleted,
    todo: item.todo
  }

  try {
    let { data } = await axios.put(url, body)
    return data
  } catch (e) {
    console.error(e)
  }
}

export let delTodo = async (item) => {
  let url = `http://localhost:3000/todos/${item.id}`

  try {
    let { data } = await axios.delete(url)
    return data
  } catch (e) {
    console.error(e)
  }
}

Line 1

import axios from 'axios'
  • 引用 Axios

Line 3

export let getTodos = async () => {
  let url = 'http://localhost:3000/todos'

  try {
    let { data } = await axios.get(url)
    return data
  } catch (e) {
    console.error(e)
  }
}
  • 取得目前所有 todo
  • 使用 axios.get() 呼叫 GET API
  • axios.get() 所回傳的資料都放在 data property 下,可使用 { data } 直接 Object Destructure

Line 14

export let addTodo = async (todo) => {
  let url = 'http://localhost:3000/todos'

  let body = {
    isCompleted: false,
    todo
  }

  try {
    let { data } = await axios.post(url, body)
    return data
  } catch (e) {
    console.error(e)
  }
}
  • 新增一筆 todo
  • 使用 axios.post() 呼叫 POST API

Line 30

export let saveTodo = async (item) => {
  let url = `http://localhost:3000/todos/${item.id}`

  let body = {
    isCompleted: item.isCompleted,
    todo: item.todo
  }

  try {
    let { data } = await axios.put(url, body)
    return data
  } catch (e) {
    console.error(e)
  }
}
  • 儲存一筆 todo
  • 在 url 提供 id
  • 使用 axios.put() 呼叫 PUT API

Line 46

export let delTodo = async (item) => {
  let url = `http://localhost:3000/todos/${item.id}`

  try {
    let { data } = await axios.delete(url)
    return data
  } catch (e) {
    console.error(e)
  }
}
  • 刪除一筆 todo
  • 在 url 提供 id
  • 使用 axios.delete() 呼叫 DELETE API

Conclusion

  • 實務上可能不需要將 component 切的這麼細,但亦可藉此練習很多 component 技巧,如 prop、event、v-model、slot … 等使用方法