Vue Practice Project - Todolist
Referenced from this.
Links:
Requirements
Screenshot:
Header
- Press Enter to add a task. Enter the task name and press Enter to add it to the task list.
- If the input is empty when pressing Enter, show the message "输入不能为空".
Task List
- Tasks can be checked/unchecked.
- Delete button. When hovering over a task item, show the delete button; otherwise, hide it. Clicking the delete button prompts "Are you sure you want to delete this task?". If confirmed, delete the corresponding task item regardless of its checked status.
Footer
- Check all tasks.
- Show completed count / total count.
- Hide footer when task count is 0.
- Clear completed tasks. When clicked, prompt "确定清除所有已完成任务吗?".
Component Breakdown
The component breakdown is shown in the image below, where MyItem
is a child component of MyList
.
In this article, the components are named as:
MyHeader.vue
: HeaderMyList.vue
: Task ListMyItem.vue
: Task ItemMyFooter.vue
: Footer
Most of the logic is self-explanatory from the source code. Here are some noteworthy points.
App.vue
<template>
<div id="root">
<div class="todo-container">
<div class="todo-wrap">
<MyHeader :addTodo="addTodo"/>
<MyList
:todos="todos"
:checkTodo="checkTodo"
:deleteTodo="deleteTodo"
/>
<MyFooter
:todos="todos"
:checkAllTodo="checkAllTodo"
:clearAllTodo="clearAllTodo"
/>
</div>
</div>
</div>
<script>
import MyHeader from './components/MyHeader.vue'
import MyList from './components/MyList.vue'
import MyFooter from './components/MyFooter.vue'
export default {
name:'App',
components:{MyHeader,MyList,MyFooter},
data() {
return {
todos: JSON.parse(localStorage.getItem('todos')) ||
[
{id:'001', title:'Task 1', done:true},
{id:'002', title:'Task 2', done:true},
{id:'003', title:'Task 3', done:false}
]
}
},
methods: {
// Add a task
addTodo(todoObj){
this.todos.unshift(todoObj)
},
// Check/uncheck a task
checkTodo(id){
this.todos.forEach((todo)=>{
if(todo.id === id) todo.done = !todo.done
})
},
// Delete a task
deleteTodo(id){
this.todos = this.todos.filter( todo => todo.id !== id )
},
// Check/uncheck all tasks
checkAllTodo(doneStatus){
this.todos.forEach((todo)=>{
todo.done = doneStatus
})
},
// Clear all completed tasks
clearAllTodo(){
this.todos = this.todos.filter((todo)=>{
return !todo.done
})
}
},
watch: {
todos: {
handler(value) {
localStorage.setItem('todos', JSON.stringify(value))
},
deep: true
}
},
}
Task List Definition
Task List Code
data() {
return {
todos: JSON.parse(localStorage.getItem('todos')) ||
[
{id:'001', title:'Task 1', done:true},
{id:'002', title:'Task 2', done:true},
{id:'003', title:'Task 3', done:false}
]
}
},
The task list is stored as an array of objects in App.vue
.
Each task object has three properties: id
, title
, and done
.
id
serves as a unique identifier, generated using nanoid. uuid could also be used, but it's unnecessarily long.
title
is the task name. title
cannot be used as a unique identifier because tasks may have the same name.
done
is the task status (checked/unchecked). This property is used for counting completed tasks and clearing completed tasks in the footer.
The task list is stored in App.vue
rather than MyList.vue
because when MyHeader
adds a task, it needs to be rendered in the MyList
area. Since they are sibling components, it's not ideal to pass data using props
. Instead, we use state lifting to store the data in their common parent component App.vue
.
Task Methods Definition
Task Methods Code
methods: {
// Add a task
addTodo(todoObj){
this.todos.unshift(todoObj)
},
// Check/uncheck a task
checkTodo(id){
this.todos.forEach((todo)=>{
if(todo.id === id) todo.done = !todo.done
})
},
// Delete a task
deleteTodo(id){
this.todos = this.todos.filter( todo => todo.id !== id )
},
// Check/uncheck all tasks
checkAllTodo(doneStatus){
this.todos.forEach((todo)=>{
todo.done = doneStatus
})
},
// Clear all completed tasks
clearAllTodo(){
this.todos = this.todos.filter((todo)=>{
return !todo.done
})
}
},
Since the task data is stored in App.vue
, methods for adding, checking/unchecking, deleting, and clearing completed tasks are also unified in App.vue
to manage the data state centrally.
The implementation of these methods is self-explanatory.
Browser Cache
Browser Cache Related Code
data() {
return {
todos: JSON.parse(localStorage.getItem('todos')) ||
[
{id:'001', title:'Task 1', done:true},
{id:'002', title:'Task 2', done:true},
{id:'003', title:'Task 3', done:false}
]
}
},
watch: {
todos: {
deep: true,
handler(value) {
localStorage.setItem('todos', JSON.stringify(value))
}
}
},
To implement browser caching so that data persists after page refresh, we need to consider two timing points:
- Reading cache during initialization. Note that we need to add
||
and define an initial task list (which can be empty) separately, otherwise the browser cache will be null on first use and the console will report an error. - When the task object array changes (add/delete). This is implemented using the
watch
property.
Note that we need to use JSON.stringify() and JSON.parse() to convert between objects and JSON strings.
Header MyHeader.vue
<template>
<div class="todo-header">
<input
type="text"
placeholder="Enter task name, press Enter to confirm"
v-model="title"
@keyup.enter="add"
/>
</div>
<script>
import {nanoid} from 'nanoid'
export default {
name:'MyHeader',
props:['addTodo'],
data() {
return {
title: ''
}
},
methods: {
add(){
if(!this.title.trim()) return alert('Input cannot be empty')
// Capture user input and wrap it as an object
const headerTodoObj = { id:nanoid(), title:this.title, done:false }
this.addTodo(headerTodoObj)
// Clear input box
this.title = ''
}
},
}
This is pretty straightforward.
Worth mentioning is that if there was a server, the id
should be generated by the server. But since this is a standalone version, we'll use nanoid()
instead.
We could also use Date.now()
as the id, as long as it's unique.
Task List MyList.vue
<template>
<ul class="todo-main">
<MyItem
v-for="todoObj in todos"
:key="todoObj.id"
:todo="todoObj"
:checkTodo="checkTodo"
:deleteTodo="deleteTodo"
/>
</ul>
<script>
import MyItem from './MyItem.vue'
export default {
name:'MyList',
components:{MyItem},
props:['todos','checkTodo','deleteTodo']
}
It's important to note that when using v-for
to render <MyItem>
, we should use id
as the key
instead of index
. This is because new tasks are added to the front of the task list, involving reverse order operations. Using index
would prevent DOM rendering reuse and reduce efficiency.
See:
Task Item MyItem.vue
<template>
<li>
<label>
<input
type="checkbox"
:checked="todo.done"
@change="handleCheck(todo.id)"
/>
<span>{{todo.title}}</span>
</label>
<button class="btn btn-danger" @click="handleDelete(todo.id)">Delete</button>
</li>
<script>
export default {
name:'MyItem',
props:['todo','checkTodo','deleteTodo'],
methods: {
handleCheck(id){
this.checkTodo(id)
},
handleDelete(id){
if(confirm('Are you sure you want to delete this task?')){
this.deleteTodo(id)
}
}
},
}
change Event Can Be Replaced with click Event
<input
type="checkbox"
:checked="todo.done"
@change="handleCheck(todo.id)"
/>
Here, the change event can be replaced with the click event, because for input elements, these two events have the same effect.
Component Communication Issue
As mentioned earlier, we use state lifting to pass todo
, checkTodo()
, and deleteTodo()
from App.vue
to MyItem.vue
.
Since MyItem
is a child component of MyList
, which is a child component of App
, using props
requires passing level by level, i.e., App.vue
→ MyList.vue
→ MyItem.vue
.
There are two other methods to handle component communication:
- Global Event Bus
- Message Subscription and Publishing
But I haven't learned these yet, will supplement later.
Inappropriate Data Communication Method: v-model
<input
type="checkbox"
v-model="todo.done"
@change="handleCheck(todo.id)"
/>
This can also achieve task item state synchronization, however, referring to One-Way Data Flow, in this way, the child component MyItem
modifies the parent component App
's data, violating Vue's one-way binding principle for props
, which is inappropriate.
Footer MyFooter.vue
<template>
<template>
<div class="todo-footer" v-show="total">
<label>
<input type="checkbox" v-model="isAll"/>
</label>
<span>
<span>Completed {{doneTotal}}</span> / Total {{total}}
</span>
<button class="btn btn-danger" @click="clearAll">Clear Completed Tasks</button>
</div>
</template>
<script>
<script>
export default {
name:'MyFooter',
props:['todos','checkAllTodo','clearAllTodo'],
computed: {
// Total count
total(){
return this.todos.length
},
// Completed count
doneTotal(){
return this.todos.reduce((pre,todo)=> pre + (todo.done ? 1 : 0), 0)
},
// Check all
isAll:{
// Auto-check when all tasks are completed
get(){
return this.doneTotal === this.total && this.total > 0
},
// Manual check/uncheck all
set(value){
this.checkAllTodo(value)
}
}
},
methods: {
// Clear all completed
clearAll(){
if(confirm('Are you sure you want to clear all completed tasks?')){
this.clearAllTodo()
}
}
},
}
</script>
Hide Footer
Hide the footer when task count is 0, implemented through v-show="total"
.
Check/Uncheck All
Check/uncheck all is implemented through computed properties. Since isAll
is not just for reading but can also be modified, we can't use the shorthand form and need to write the complete getter get()
and setter set()
.
computed: {
isAll:{
// Auto-check when all tasks are completed
get(){
return this.doneTotal === this.total && this.total > 0
},
// Manual check/uncheck all
set(value){
this.checkAllTodo(value)
}
}
},
checkAllTodo(doneStatus){
this.todos.forEach((todo)=>{
todo.done = doneStatus
})
},
Check/Uncheck All, Another Implementation Method
Actually, v-model="isAll"
is a combination of :checked="isAll"
and @change="checkAll"
.
<input type="checkbox" :checked="isAll" @change="checkAll"/>
computed: {
isAll:{
return this.doneTotal === this.total && this.total > 0
}
},
methods: {
checkAll(e){
this.checkAllTodo(e.target.checked)
}
}
Deploy to GitLab Pages
We could actually use StackBlitz to deploy this Vue project, which would be more convenient.
But I chose to use GitLab as my personal practice project collection center, so I deployed it to GitLab Pages.
.gitlab-ci.yml
image: node:latest
stages:
- build
- deploy
build:
stage: build
script:
- npm install
- npm run build
pages:
stage: deploy
script:
- rm -rf public
- mkdir public && cp -rf dist/* public
artifacts:
paths:
- public
expire_in: 30 days
cache:
paths:
- node_modules
- dist
A Small Bug
In the GitLab repository's Deploy
- Pages
settings, Use unique domain
is checked by default, and when opening the project address, everything works normally.
However, if Use unique domain
is unchecked and https://tangjan.gitlab.io/vue-todolist/ is used as the project address, an error occurs:
I've looked up some related information but haven't studied it carefully yet. Will deal with it later, using unique domain for now.