สวัสดีครับ หลังจากเราได้ทราบ concept การใช้ nuxt.js ร่วมกับ type script กันมาบ้างแล้ว ถึงเวลาที่เราจะเริ่มลองนำมาใช้งานจริงกันครับ
วันนี้เราจะมาทำ Todo List ง่าย ๆ กันครับ
Todo list ของเราจะทำอะไรได้บ้าง?
เก็บ todo list ของเราไว้ใน local storage
มีบอร์ดแสดงค่า total todo, total done todo และ total undone todo
มี List แสดง todo และสามารถเลือกที่จะแสดงแค่ done todo หรือ undone todo ได้
Design todo จาก requirement
หลังจากเราได้ requirement มาเราก็ใช้ figma design ออกมาได้ประมาณนี้ครับ
แยก Component
เราจะพิจารณาจาก design ของเราครับว่า จะสามารถแยก component อะไรบ้าง ซึ้งจาก design ที่ผมทำไว้ผมแยก component ออกมาได้แบบนี้ครับ
Todo input box (TodoInput)
จะเห็นได้ว่าเราแยกออกมาได้เป็น 4 component ครับ ให้เราทำการสร้างไฟล์ Navbar.vue, Board.vue, TodoInput.vue และสุดท้าย TodoItem.vue ที่ folder components ครับ
ให้เรา style ตัว component ออกมาให้เหมือน design ของเราที่สุดได้เลยครับ ผมไม่ขอลงรายละเอียดของ template และ style นะครับ เดียวมันจะเยอะเกิน แต่ว่าหากใครที่ขี้เกียจเขียน template และ style เอง ผมมี project starter ที่ผมทำเสร็จแล้วมาแจกครับเป็นตัวที่มีแค่ส่วนของ template และ style พร้อมที่จะเข้าสู่การเขียน script เลยครับ
เริ่มจาก Store กันก่อน
ให้เราติดตั้ง module nuxt-typed-vuex ให้เรียบร้อยก่อนนะครับ หลังจากนั้นสร้างไฟล์ index.ts ใน folder store กันเลยครับ
State
State ของ todo list ก็ไม่มีอะไรมากเลยครับก็มีแค่ array ของ todo เท่านั้นเองครับ ส่วน todo ของเราก็เป็น object ที่มี string และ boolean อย่างหละตัวเพื่อเก็บ todo text และ สถานะว่า done หรือ undone เราก็จะเริ่มจากการสร้าง interface ให้ todo และ state ได้ออกมาแบบนี้ครับ
Copy //store/index.ts
interface Todo {
done: boolean
todo: string
}
interface State {
todoList: Todo[]
}
export const state = (): State => ({
todoList: [],
})
Getters
ส่วนของ getters เราจะทำ getters ไว้สองตัวครับคือ doneTodo และ undoneTodo เพื่อเวลาเรา filter done หรือ undone todo เราสามารถมาเอา todo list ได้จาก getters สองตัวนี้ครับ (* อย่าลืม import getterTree ด้วยนะครับ)
Copy //store/index.ts
import { getterTree } from 'nuxt-typed-vuex'
...
export const getters = getterTree(state, {
doneTodo: (state: State): Todo[] =>
state.todoList.filter((todo) => todo.done),
undoneTodo: (state: State): Todo[] =>
state.todoList.filter((todo) => !todo.done),
})
Mutations
เราจะทำ store ของเราให้ครบในส่วนของ CRUD เลยครับ เพียงแต่ว่า read เราใช้ส่วนของ state และ getters ไปแล้ว เพราะฉนั้น mutations ของเราต้องมี create, update และ delete ครับ เหมือนตัวอย่างด้านล่างเลย (*อย่าลืม import mutationTree ด้วยนะครับ)
Copy //store/index.ts
import { getterTree, mutationTree } from 'nuxt-typed-vuex'
...
interface UpdateTodoPayload {
todo: Todo
todoIndex: number
}
...
export const mutations = mutationTree(state, {
UNSHIFT_TODO_LIST(state, todo: Todo): void {
state.todoList.unshift(todo)
},
UPDATE_TODO(state, { todo, todoIndex }: UpdateTodoPayload): void {
state.todoList[todoIndex].todo = todo.todo
state.todoList[todoIndex].done = todo.done
},
SPLICE_TODO_LIST(state, index: number): void {
state.todoList.splice(index, 1)
}
})
จะเห็นได้ว่าในส่วนของ UPDATE_TODO ของผม ผมต้องการ payload ที่เป็น object ที่มี todo และ index ของ todo ที่ต้องการอัปเดท ซึ้งเราก็ควรเขียน type ให้กับ payload ตัวนี้ด้วย เพื่อป้องกันการส่งผ่านค่า payload ที่ผิดพลาดเข้ามาได้ครับ ผมจึงแอบไปสร้าง interface สำหรับ UpdateTodoPayload เข้ามาเหมือนในตัวอย่างครับ
Actions
สำหรับ actions ช่วงแรก็ไม่มีอะไรมากครับ ทำ actions ให้ครอบคลุมทั้ง mutations ของเราไปก่อนเหมือนตัวอย่างด่านล้างครับ (* อย่าลืม import actionTree นะครับ)
Copy //store/index.ts
import { getterTree, mutationTree, actionTree } from 'nuxt-typed-vuex'
...
export const actions = actionTree(
{ state, getters, mutations },
{
addTodo({ commit, dispatch }, todo: string): void {
commit('UNSHIFT_TODO_LIST', { todo, done: false })
},
editTodo({ commit, dispatch }, { todo, todoIndex }: UpdateTodoPayload): void {
commit('UPDATE_TODO', { todo, todoIndex })
},
deleteTodo({ commit, dispatch }, index: number): void {
commit('SPLICE_TODO_LIST', index)
}
}
)
อย่าลืมประกาศ $accessor
สุดท้าย export accessorType และ อย่าลืมสร้างไฟล์ index.d.ts และประกาศ $accessor ด้วยครับ ใครที่ไม่รู้ว่าคืออะไรสามารถย้อนกลับไปดูที่ pt.3 ได้ครับ
ทำงานกับ Localstorage
เพื่อเก็บ todo list ของเราไว้ใน local storage เราจะมาอัปเดท store ของเราให้ทำงานร่วมกับ localstorage กันครับ โดยผมได้สร้าง action ชื่อว่า updateStorage เพื่อเก็บ todo list ของเราไว้ใน localstorage แล้วหลังจากนั้นผมจะเรียกใช้ action นี้ทุกครั้งที่มีการ create, update หรือ delete todo เหมือนตัวอย่างด้านล่างเลยครับ
Copy //store/index.ts
...
export const actions = actionTree(
{ state, getters, mutations },
{
addTodo({ commit, dispatch }, todo: string): void {
commit('UNSHIFT_TODO_LIST', { todo, done: false })
dispatch('updateStorage')
},
editTodo(
{ commit, dispatch },
{ todo, todoIndex }: UpdateTodoPayload
): void {
commit('UPDATE_TODO', { todo, todoIndex })
dispatch('updateStorage')
},
deleteTodo({ commit, dispatch }, index: number): void {
commit('SPLICE_TODO_LIST', index)
dispatch('updateStorage')
},
updateStorage({ state }): void {
localStorage.setItem('todoList', JSON.stringify(state.todoList))
},
}
)
...
ไม่เพียงเท่านั้นครับ เราก็ต้องมีส่วนของการ get localstorage มาใช้ด้วย เราก็จะเพิ่ม action และ mutation เหมือนตัวอย่างนี้เลยครับ
Copy //store/index.ts
...
export const mutations = mutationTree(state, {
SET_TODO_LIST(state, todoList: Todo[]): void {
state.todoList = todoList
},
...
})
export const actions = actionTree(
{ state, getters, mutations },
{
getTodoList({ commit }): void {
const todolist: Todo[] = JSON.parse(
localStorage.getItem('todoList') || '[]'
)
commit('SET_TODO_LIST', todolist)
},
...
}
)
Set current tab?
จาก design จะเห็นได้ว่าเรามี navbar ที่จะเลือกโชว์ todo list ในแบบต่าง ๆ ด้วย ในขณะที่เรากระทำกับ navbar แต่ส่งผลมาถึง todo item list ข้าม componet แบบนี้ ผมจึงเอามาจัดการใน store ด้วยครับ โดยเพิ่ม current tab เข้าไปใน state แล้วเพิ่ม mutation และ action จัดการเอาไว้ด้วย เหมือนตัวอย่างนี้ครับ
Copy //store/index.ts
...
interface State {
currentTab: string
todoList: Todo[]
}
export const state = (): State => ({
currentTab: 'all',
todoList: [],
})
export const mutations = mutationTree(state, {
...
SET_CURRENT_TAB(state, tab: string): void {
state.currentTab = tab
},
})
export const actions = actionTree(
{ state, getters, mutations },
{
...
setTab({ commit }, tab: string): void {
commit('SET_CURRENT_TAB', tab)
},
}
)
เพียงเท่านี้ก็เสร็จสิ้นการจัดเตรียม store ของเราในรูปแบบ typescript แล้วครับ
จัดการกับ Component
ต่อไปเราจะมาใช้งาน store ของเราในแต่หละ component ครับ โดยจะเรียงตามลำดับกันเลยครับ
Navbar.vue
Navbar ของเราไม่มีอะไรมากครับ เพียงแค่เรากดตรง tab ไหนเราก็เรียกใช้ action setTab นั้นและเราก็เพิ่ม class active ให้กับ tab นั้นเมื่อ currenTab ตรงกับ ตัวเอง เหมือนตัวอย่างนี้ครับ
Copy //components/Navbar.vue
<template>
...
<ul class="nav-list">
<li
:class="{ active: $accessor.currentTab === 'all' }"
@click="$accessor.setTab('all')"
>
All
</li>
<li
:class="{ active: $accessor.currentTab === 'undone' }"
@click="$accessor.setTab('undone')"
>
Todo
</li>
<li
:class="{ active: $accessor.currentTab === 'done' }"
@click="$accessor.setTab('done')"
>
Done
</li>
</ul>
...
</template>
Board.vue
ส่วนของ Board ยิ่งง่ายไปกันใหญ่ครับ เพียงแค่เราเข้าถึง state หรือ getters นั้น ๆ แล้ว .length ออกมาเพื่อเป็นค่า totol แค่นั้นเองครับ ไปดูตัวอย่างกันเลยครับ
Copy //component/Board.vue
<template>
<div class="board">
<div class="counter">
<span class="counter__total">{{ $accessor.todoList.length }}</span>
<span class="counter__label">TOTAL</span>
</div>
<div class="counter">
<span class="counter__total">{{ $accessor.undoneTodo.length }}</span>
<span class="counter__label">TODO</span>
</div>
<div class="counter">
<span class="counter__total">{{ $accessor.doneTodo.length }}</span>
<span class="counter__label">DONE</span>
</div>
</div>
</template>
ในส่วนของ Input ก็ไม่มีอะไรซับซ้อนมากครับ เพียงเรามี input หนึ่งตัว และหลังจากกดปุ่ม add หรือ enter ก็ให้เรียกใช้ method addTodo เพื่อส่งค่าที่ป้อนไปกับ actions addTodo อีกที แค่นี้เองครับ ลองไปดูตัวอย่างกันเลย
Copy //component/TodoInput.vue
<template>
<div class="todo-input">
<input
v-model="newTodo"
type="text"
placeholder="Type todo here..."
@keyup.enter="addTodo"
/>
<button class="add-button" @click="addTodo">
<i class="fas fa-plus"></i>
</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
interface State {
newTodo: string
}
export default Vue.extend({
name: 'TodoInput',
data(): State {
return {
newTodo: '',
}
},
methods: {
addTodo(): void {
if (this.newTodo) {
this.$accessor.addTodo(this.newTodo)
this.newTodo = ''
}
},
},
})
</script>
Index.vue
ก่อนที่เราจะเริ่มในส่วนของ TodoItem.vue เราต้องมาจัดการข้อมูล todo list ของเราในหน้า index ก่อนครับ ทุกครั้งที่เริ่มโหลดหน้า Index ขึ้นมาเราต้อง get todo มาจาก localstorage เพื่อเซ็ตให้กับ store ของเราก่อน อีกทั้งเรายังต้องแสดง todo list ให้สอดคล้องกับ tab ใน navbar ที่เราได้เลือกไว้อีกด้วยครับ อีกทั้งหน้า index นี้แหละที่เราจะประกอบ Navbar, Board, TodoInput และ TodoItem เข้าด้วยกัน เรามาดูตัวอย่างด้านล่างกันเลยครับ
Copy //pages/index.vue
<template>
<div class="container">
<navbar />
<div class="main">
<board />
<div class="todo">
<todo-input />
<div class="todo-wrapper">
<todo-item
v-for="(todo, $todoIndex) in todoList"
:key="todo + $todoIndex"
:todo="todo"
:index="$todoIndex"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import Navbar from '~/components/Navbar.vue'
import Board from '~/components/Board.vue'
import TodoItem from '~/components/TodoItem.vue'
import TodoInput from '~/components/TodoInput.vue'
interface Todo {
done: boolean
todo: string
}
interface State {}
export default Vue.extend({
components: {
Navbar,
Board,
TodoItem,
TodoInput,
},
data(): State {
return {}
},
computed: {
todoList(): Todo[] {
return this.$accessor.currentTab === 'all'
? this.$accessor.todoList
: this.$accessor.currentTab === 'done'
? this.$accessor.doneTodo
: this.$accessor.currentTab === 'undone'
? this.$accessor.undoneTodo
: []
},
},
mounted(): void {
this.$accessor.getTodoList()
},
})
</script>
จะเห็นได้ว่า todoList ที่ผมนำมาวนลูป todo-item นั้นผมได้ประกาศเป็น computed ที่ return ค่าให้สอดคล้องกับ current tab นั้นเองครับ (*สำหรับใครที่งงโค้ดตรงนี้นะครับ มันเป็น if-else ธรรมดาๆ เลยครับเพียงแค่ผมเขียนในรูปแบบ shorthand แค่นั้นเอง)
TodoItem.vue
มาถึง component สุดท้ายของเรานะครับ ตัวนี้พิเศษหน่อยเพราะว่าการ set done, edit todo และ delete todo จะอยู่ที่ component นี้หมดเลย อาจมีลูกเล่นในการทำ edit mode และมีการเปลี่ยนแปลง style จากสถานะ done หรือ undone อยู่ด้วย แต่ action ส่วนใหญ่เราได้สร้างและเตรียมไว้ใน store เรียบร้อยแล้ว ก็แค่จับมาใช้งานให้ครบเท่านั้นเองครับ มาดูโค้ดตัวอย่างกันดีกว่า
Copy //components/TodoItem.vue
<template>
<div
class="todo-item"
:class="{
done: todo.done,
editing,
}"
>
<button class="delete-button" @click="deleteTodo">
<i class="fas fa-trash-alt"></i>
</button>
<button class="edit-button" @click="setEditing(true)">
<i class="fas fa-pencil-alt"></i>
</button>
<div class="todo-card">
<span class="todo-status">
{{ todo.done ? 'Done' : 'Undone' }}
</span>
<div class="todo-status-icon" @click="toggleDone">
<img v-if="todo.done" src="~/assets/check.svg" />
</div>
<div v-if="!editing" class="todo-text" @click="setEditing(true)">
{{ todo.todo }}
</div>
<input
v-else
:id="id"
v-model.lazy="todoForm"
class="todo-edit-input"
type="text"
@blur="setEditing(false)"
@keyup.enter="setEditing(false)"
/>
</div>
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue'
interface State {
editing: boolean
id: string
}
interface Todo {
done: boolean
todo: string
}
export default Vue.extend({
props: {
index: {
type: Number,
required: true,
},
todo: {
type: Object as PropType<Todo>,
required: true,
},
},
data(): State {
return {
id: 'input' + this.index,
editing: false,
}
},
computed: {
todoForm: {
get(): string {
return this.todo.todo
},
set(val: string): void {
const todo: Todo = { done: this.todo.done, todo: val }
this.$accessor.editTodo({ todo, todoIndex: this.index })
},
},
},
methods: {
setEditing(bool: boolean): void {
this.editing = bool
if (bool) {
this.$nextTick(() => {
document.getElementById(this.id)?.focus()
})
}
},
deleteTodo(): void {
this.$accessor.deleteTodo(this.index)
},
toggleDone(): void {
const todo: Todo = { done: !this.todo.done, todo: this.todo.todo }
this.$accessor.editTodo({ todo, todoIndex: this.index })
},
},
})
</script>