待办清单 v2.0
相较于第一版待办清单而言,待办清单 2.0 版本升级为由多个组件构成整个应用,使用
pinia
进行状态存储。项目仓库地址:xihuanxiaorang/todos (github.com),有需要的小伙伴可以自行下载。
创建项目
使用 pnpm create vue@latest
命令创建项目,项目名称为 todomvc
。
在项目被创建后,通过以下步骤安装依赖并启动开发服务器,如下所示:
准备工作
Tip
对于本项目,咱们的精力应该集中在使用 Vue3 编写其业务逻辑,至于 HTML 页面和 CSS 样式可以到 TodoMVC 官方的 Github 仓库中进行下载。
首先,删除或者清空所有不需要的内容;
- 删除
components
目录下的所有组件和assets
目录下的base.css
样式文件; - 删除
views
目录下的HomeView
和AboutView
组件; - 删除
stores
目录下的counter.ts
文件; - 清空
App.vue
中的所有内容,只在template
模板中保留一个路由出口<RouterView />
; - 清空
assets
目录下main.css
样式文件中定义的所有样式;
- 删除
从 TodoMVC 官方 Github 仓库 tastejs/todomvc-app-css: CSS for TodoMVC apps (github.com) 将
index.css
中的所有样式拷贝到main.css
;css@charset 'utf-8'; html, body { margin: 0; padding: 0; } button { margin: 0; padding: 0; border: 0; background: none; font-size: 100%; vertical-align: baseline; font-family: inherit; font-weight: inherit; color: inherit; -webkit-appearance: none; appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; line-height: 1.4em; background: #f5f5f5; color: #111111; min-width: 230px; max-width: 550px; margin: 0 auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-weight: 300; } .hidden { display: none; } .todoapp { background: #fff; margin: 130px 0 40px 0; position: relative; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); } .todoapp input::-webkit-input-placeholder { font-style: italic; font-weight: 400; color: rgba(0, 0, 0, 0.4); } .todoapp input::-moz-placeholder { font-style: italic; font-weight: 400; color: rgba(0, 0, 0, 0.4); } .todoapp input::input-placeholder { font-style: italic; font-weight: 400; color: rgba(0, 0, 0, 0.4); } .todoapp h1 { position: absolute; top: -140px; width: 100%; font-size: 80px; font-weight: 200; text-align: center; color: #b83f45; -webkit-text-rendering: optimizeLegibility; -moz-text-rendering: optimizeLegibility; text-rendering: optimizeLegibility; } .new-todo, .edit { position: relative; margin: 0; width: 100%; font-size: 24px; font-family: inherit; font-weight: inherit; line-height: 1.4em; color: inherit; padding: 6px; border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .new-todo { padding: 16px 16px 16px 60px; height: 65px; border: none; background: rgba(0, 0, 0, 0.003); box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); } .main { position: relative; z-index: 2; border-top: 1px solid #e6e6e6; } .toggle-all { width: 1px; height: 1px; border: none; /* Mobile Safari */ opacity: 0; position: absolute; right: 100%; bottom: 100%; } .toggle-all + label { display: flex; align-items: center; justify-content: center; width: 45px; height: 65px; font-size: 0; position: absolute; top: -65px; left: -0; } .toggle-all + label:before { content: '❯'; display: inline-block; font-size: 22px; color: #949494; padding: 10px 27px 10px 27px; -webkit-transform: rotate(90deg); transform: rotate(90deg); } .toggle-all:checked + label:before { color: #484848; } .todo-list { margin: 0; padding: 0; list-style: none; } .todo-list li { position: relative; font-size: 24px; border-bottom: 1px solid #ededed; } .todo-list li:last-child { border-bottom: none; } .todo-list li.editing { border-bottom: none; padding: 0; } .todo-list li.editing .edit { display: block; width: calc(100% - 43px); padding: 12px 16px; margin: 0 0 0 43px; } .todo-list li.editing .view { display: none; } .todo-list li .toggle { text-align: center; width: 40px; /* auto, since non-WebKit browsers doesn't support input styling */ height: auto; position: absolute; top: 0; bottom: 0; margin: auto 0; border: none; /* Mobile Safari */ -webkit-appearance: none; appearance: none; } .todo-list li .toggle { opacity: 0; } .todo-list li .toggle + label { /* Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ */ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: center left; } .todo-list li .toggle:checked + label { background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E'); } .todo-list li label { overflow-wrap: break-word; padding: 15px 15px 15px 60px; display: block; line-height: 1.2; transition: color 0.4s; font-weight: 400; color: #484848; } .todo-list li.completed label { color: #949494; text-decoration: line-through; } .todo-list li .destroy { display: none; position: absolute; top: 0; right: 10px; bottom: 0; width: 40px; height: 40px; margin: auto 0; font-size: 30px; color: #949494; transition: color 0.2s ease-out; } .todo-list li .destroy:hover, .todo-list li .destroy:focus { color: #C18585; } .todo-list li .destroy:after { content: '×'; display: block; height: 100%; line-height: 1.1; } .todo-list li:hover .destroy { display: block; } .todo-list li .edit { display: none; } .todo-list li.editing:last-child { margin-bottom: -1px; } .footer { padding: 10px 15px; height: 20px; text-align: center; font-size: 15px; border-top: 1px solid #e6e6e6; } .footer:before { content: ''; position: absolute; right: 0; bottom: 0; left: 0; height: 50px; overflow: hidden; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2); } .todo-count { float: left; text-align: left; } .todo-count strong { font-weight: 300; } .filters { margin: 0; padding: 0; list-style: none; position: absolute; right: 0; left: 0; } .filters li { display: inline; } .filters li a { color: inherit; margin: 3px; padding: 3px 7px; text-decoration: none; border: 1px solid transparent; border-radius: 3px; } .filters li a:hover { border-color: #DB7676; } .filters li a.selected { border-color: #CE4646; } .clear-completed, html .clear-completed:active { float: right; position: relative; line-height: 19px; text-decoration: none; cursor: pointer; } .clear-completed:hover { text-decoration: underline; } .info { margin: 65px auto 0; color: #4d4d4d; font-size: 11px; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); text-align: center; } .info p { line-height: 1; } .info a { color: inherit; text-decoration: none; font-weight: 400; } .info a:hover { text-decoration: underline; } /* Hack to remove background from Mobile Safari. Can't use it globally since it destroys checkboxes in Firefox */ @media screen and (-webkit-min-device-pixel-ratio:0) { .toggle-all, .todo-list li .toggle { background: none; } .todo-list li .toggle { height: 40px; } } @media (max-width: 430px) { .footer { height: 50px; } .filters { bottom: 10px; } } :focus, .toggle:focus + label, .toggle-all:focus + label { box-shadow: 0 0 2px 2px #CF7D7D; outline: 0; }
从 TodoMVC 官方 Github 仓库 tastejs/todomvc-app-template: Template used for creating TodoMVC apps (github.com) 将
index.html
页面中的body
中的内容拆分成如下三个组件:vue<script setup lang="ts"></script> <template> <header class="header"> <h1>todos</h1> <input class="new-todo" placeholder="What needs to be done?" autofocus /> </header> </template> <style scoped></style>
vue<script setup lang="ts"></script> <template> <!-- This section should be hidden by default and shown when there are todos --> <section class="main"> <input id="toggle-all" class="toggle-all" type="checkbox" /> <label for="toggle-all">Mark all as complete</label> <ul class="todo-list"> <!-- These are here just to show the structure of the list items --> <!-- List items should get the class `editing` when editing and `completed` when marked as completed --> <li class="completed"> <div class="view"> <input class="toggle" type="checkbox" checked /> <label>Taste JavaScript</label> <button class="destroy"></button> </div> <input class="edit" value="Create a TodoMVC template" /> </li> <li> <div class="view"> <input class="toggle" type="checkbox" /> <label>Buy a unicorn</label> <button class="destroy"></button> </div> <input class="edit" value="Rule the web" /> </li> </ul> </section> </template> <style scoped></style>
vue<script setup lang="ts"></script> <template> <!-- This footer should be hidden by default and shown when there are todos --> <footer class="footer"> <!-- This should be `0 items left` by default --> <span class="todo-count"><strong>0</strong> item left</span> <!-- Remove this if you don't implement routing --> <ul class="filters"> <li> <a class="selected" href="#/">All</a> </li> <li> <a href="#/active">Active</a> </li> <li> <a href="#/completed">Completed</a> </li> </ul> <!-- Hidden if no completed items are left ↓ --> <button class="clear-completed">Clear completed</button> </footer> </template> <style scoped></style>
在
views
目录下创建一个名为TodoView
的组件,然后将上面拆分出来的组件引入到该组件中,如下所示:vue<script setup lang="ts"> import TodoHeader from '@/components/TodoHeader.vue' import TodoMain from '@/components/TodoMain.vue' import TodoFooter from '@/components/TodoFooter.vue' </script> <template> <section class="todoapp"> <TodoHeader></TodoHeader> <TodoMain></TodoMain> <TodoFooter></TodoFooter> </section> </template> <style scoped></style>
修改路由文件
router/index.ts
中的路由数组routes
,如下所示:tsimport { createRouter, createWebHistory } from 'vue-router' import TodoView from '@/views/TodoView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'all', component: TodoView }, { path: '/active', name: 'active', component: TodoView }, { path: '/completed', name: 'completed', component: TodoView } ] }) export default router
使用 pnpm dev
命令运行项目,可以看到项目的整体效果,如下所示:
功能制作
渲染待办事项列表
涉及到的主要功能点:
- 在新建的
types
目录中创建一个类型声明文件,用于定义待办事项的类型; - 使用
pinia
定义一个todos
存储对象用于管理整个应用的状态; - 将待办事项拆分成一个单独的组件
TodoItem
;
💡注意:在使用 v-for
指令遍历待办事项列表时,每一条待办事项组件 TodoItem
使用 v-model
指令进行双向绑定,在进行双向绑定时不能直接使用循环变量本身!那么该怎么办呢?可以使用 array[index]
的方式进行替代。
/**
* 待办事项
*/
export interface Todo {
/* 编号 */
id: string
/* 标题 */
title: string
/* 是否完成 */
completed: boolean
}
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Todo } from '@/types/todos'
export const useTodosStore = defineStore('todos', () => {
/**
* 待办事项列表
*/
const todos = ref<Todo[]>([
{
id: '1',
title: 'Taste JavaScript',
completed: true
},
{
id: '2',
title: 'Buy a unicorn',
completed: false
}
])
return { todos }
})
<script setup lang="ts">
import TodoItem from '@/components/TodoItem.vue'
import { storeToRefs } from 'pinia'
import { useTodosStore } from '@/stores/todos'
const { todos } = storeToRefs(useTodosStore())
</script>
<template>
<!-- This section should be hidden by default and shown when there are todos -->
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<!-- These are here just to show the structure of the list items -->
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<TodoItem v-for="(todo, index) in todos" :key="todo.id" v-model="todos[index]" />
</ul>
</section>
</template>
<style scoped></style>
<script setup lang="ts">
import type { Todo } from '@/types/todos'
const todo = defineModel<Todo>({ required: true })
</script>
<template>
<li :class="{ completed: todo.completed }">
<div class="view">
<input class="toggle" type="checkbox" v-model="todo.completed" />
<label>{{ todo.title }}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Create a TodoMVC template" />
</li>
</template>
<style scoped></style>
添加待办事项
在 TodoHeader
组件中定义一个响应式对象 newTodo
用于双向绑定 input
输入框中的新建待办事项,然后编写一个 addTodo
方法用于将新建的待办事项添加到列表中。
<script setup lang="ts">
import { ref } from 'vue'
import { useTodosStore } from '@/stores/todos'
const newTodo = ref('')
/**
* 生成uuid
*/
const uuid = () => {
let uuid = ''
for (let i = 0; i < 32; i++) {
let random = (Math.random() * 16) | 0
if (i === 8 || i === 12 || i === 16 || i === 20) uuid += '-'
uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16)
}
return uuid
}
/**
* 添加待办事项
*/
const addTodo = () => {
if (!newTodo.value) return
const { addTodo } = useTodosStore()
addTodo({
id: uuid(),
title: newTodo.value,
completed: false
})
newTodo.value = ''
}
</script>
<template>
<header class="header">
<RouterLink to="/"><h1>todos</h1></RouterLink>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus
v-model.trim="newTodo"
@keyup.enter="addTodo"
/>
</header>
</template>
<style scoped></style>
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Todo } from '@/types/todos'
export const useTodosStore = defineStore('todos', () => {
/**
* 待办事项列表
*/
const todos = ref<Todo[]>([])
/**
* 添加待办事项
* @param todo 代办事项
*/
const addTodo = (todo: Todo) => {
if (!todo) return
todos.value.unshift(todo)
}
return {
todos,
addTodo
}
})
删除待办事项
点击每个待办事项后面的❌号实现删除功能,因此需要在该❌号按钮上添加一个点击事件的监听器方法 removeTodo
,该方法的参数为要删除的待办事项。
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Todo } from '@/types/todos'
export const useTodosStore = defineStore('todos', () => {
/**
* 待办事项列表
*/
const todos = ref<Todo[]>([])
/**
* 添加待办事项
* @param todo 代办事项
*/
const addTodo = (todo: Todo) => {
if (!todo) return
todos.value.unshift(todo)
}
/**
* 删除待办事项
* @param todo 待办事项
*/
const removeTodo = (todo: Todo) => {
todos.value.splice(todos.value.indexOf(todo), 1)
}
return {
todos,
addTodo,
removeTodo
}
})
<script setup lang="ts">
import type { Todo } from '@/types/todos'
import { useTodosStore } from '@/stores/todos'
const todo = defineModel<Todo>({ required: true })
const { removeTodo } = useTodosStore()
</script>
<template>
<li :class="{ completed: todo.completed }">
<div class="view">
<input class="toggle" type="checkbox" v-model="todo.completed" />
<label>{{ todo.title }}</label>
<button class="destroy" @click="removeTodo(todo)"></button>
</div>
<input class="edit" value="Create a TodoMVC template" />
</li>
</template>
<style scoped></style>
编辑待办事项
- 双击已添加的待办事项时会进入编辑状态,显示编辑文本框并自动获取焦点;
- 按回车键或者使编辑文本框失去焦点则表示确认修改;
- 按 ESC 键则表示取消修改;
- 把编辑文本框清空并确认修改则表示删除当前待办事项;
<script setup lang="ts">
import type { Todo } from '@/types/todos'
import { useTodosStore } from '@/stores/todos'
import { nextTick, ref } from 'vue'
const todo = defineModel<Todo>({ required: true })
const { removeTodo } = useTodosStore()
// 是否正在编辑标识
const editing = ref(false)
// 获取编辑输入框的模板引用,使其在编辑时能自动获取焦点
const editInput = ref<HTMLInputElement>()
// 用于双向绑定编辑输入框
const editingTitle = ref('')
/**
* 开始编辑
*/
const startEdit = () => {
editing.value = true
editingTitle.value = todo.value.title
nextTick(() => {
editInput.value?.focus()
})
}
/**
* 确认编辑
*/
const doneEdit = () => {
if (editing.value) {
editing.value = false
if (!editingTitle.value) removeTodo(todo.value)
todo.value.title = editingTitle.value
}
}
</script>
<template>
<li :class="{ completed: todo.completed, editing: editing }">
<div class="view">
<input class="toggle" type="checkbox" v-model="todo.completed" />
<label @dblclick="startEdit">{{ todo.title }}</label>
<button class="destroy" @click="removeTodo(todo)"></button>
</div>
<input
class="edit"
v-if="editing"
ref="editInput"
v-model="editingTitle"
@keyup.enter="doneEdit"
@blur="doneEdit"
@keyup.esc="editing = false"
/>
</li>
</template>
<style scoped></style>
切换待办事项状态
- 点击全选按钮(头部向下的箭头)时改变所有待办事项的勾选状态;
- 点击下方的
All
、Active
和Completed
按钮时切换显示的待办事项列表; - 计算所有未完成的待办事项个数显示在左下方;
- 点击右下方
Clear completed
按钮时移除所有已完成的待办事项; - 当没有任何待办事项时,隐藏主体和底部区域;
import { defineStore } from 'pinia'
import { computed, type Ref, ref } from 'vue'
import type { Todo } from '@/types/todos'
import { useRoute } from 'vue-router'
export const useTodosStore = defineStore('todos', () => {
// 路由实例
const route = useRoute()
// 待办事项列表
const todos = ref<Todo[]>([])
// 过滤器
const filters = {
active: (todos: Ref<Todo[]>) => todos.value.filter((todo) => !todo.completed),
completed: (todos: Ref<Todo[]>) => todos.value.filter((todo) => todo.completed)
}
// 未完成的待办事项列表
const activeTodos = computed(() => filters.active(todos))
// 已完成的待办事项列表
const completedTodos = computed(() => filters.completed(todos))
// 根据路由名称过滤后的待办事项列表,用于显示不同状态下的待办事项列表
const filteredTodos = computed(() => {
switch (route.name) {
case 'active':
return activeTodos.value
case 'completed':
return completedTodos.value
default:
return todos.value
}
})
/**
* 添加待办事项
* @param todo 代办事项
*/
const addTodo = (todo: Todo) => {
if (!todo) return
todos.value.unshift(todo)
}
/**
* 删除待办事项
* @param todo 待办事项
*/
const removeTodo = (todo: Todo) => {
todos.value.splice(todos.value.indexOf(todo), 1)
}
/**
* 全选/反选
* @param completed 是否完成
*/
const toggleAll = (completed: boolean) => {
todos.value.forEach((todo) => (todo.completed = completed))
}
/**
* 清除已完成的待办事项
*/
const clearCompleted = () => {
todos.value = todos.value.filter((todo) => !todo.completed)
}
return {
todos,
activeTodos,
completedTodos,
filteredTodos,
addTodo,
removeTodo,
toggleAll,
clearCompleted
}
})
<script setup lang="ts">
import TodoItem from '@/components/TodoItem.vue'
import { storeToRefs } from 'pinia'
import { useTodosStore } from '@/stores/todos'
const { todos, filteredTodos } = storeToRefs(useTodosStore())
const { toggleAll } = useTodosStore()
</script>
<template>
<!-- This section should be hidden by default and shown when there are todos -->
<section class="main" v-show="todos.length">
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
@change="(e) => toggleAll((e.target as HTMLInputElement).checked)"
/>
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<!-- These are here just to show the structure of the list items -->
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<TodoItem
v-for="(todo, index) in filteredTodos"
:key="todo.id"
v-model="filteredTodos[index]"
/>
</ul>
</section>
</template>
<style scoped></style>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useTodosStore } from '@/stores/todos'
const route = useRoute()
const { todos, activeTodos, completedTodos } = storeToRefs(useTodosStore())
const { clearCompleted } = useTodosStore()
</script>
<template>
<!-- This footer should be hidden by default and shown when there are todos -->
<footer class="footer" v-show="todos.length">
<!-- This should be `0 items left` by default -->
<span class="todo-count"
><strong>{{ activeTodos.length }}</strong> item left</span
>
<!-- Remove this if you don't implement routing -->
<ul class="filters">
<li>
<RouterLink to="/" :class="{ selected: route.name === 'all' }"> All </RouterLink>
</li>
<li>
<RouterLink to="/active" :class="{ selected: route.name === 'active' }">
Active
</RouterLink>
</li>
<li>
<RouterLink to="/completed" :class="{ selected: route.name === 'completed' }">
Completed
</RouterLink>
</li>
</ul>
<!-- Hidden if no completed items are left ↓ -->
<button class="clear-completed" v-show="completedTodos.length" @click="clearCompleted">
Clear completed
</button>
</footer>
</template>
<style scoped></style>
持久化存储
使用 VueUse 中的 useStorage
函数来声明 todos 响应式对象,这样做的话,有如下两点好处:
- 当项目启动时,先从本地存储
localStorage
中读取先前已存储的待办事项,如果读取不到则初始化为空数组; - 实时监听 todos 的变化,只要待办事项列表状态发生任何变化,就会将其最新数据自动保存到本地存储
localStorage
中。
实现步骤如下所示:
安装 VueUse:
pnpm i @vueuse/core
使用:
tsimport { defineStore } from 'pinia' import { computed, type Ref, ref } from 'vue' import type { Todo } from '@/types/todos' import { useRoute } from 'vue-router' import { useStorage } from '@vueuse/core' export const useTodosStore = defineStore('todos', () => { // ... // 待办事项列表 const todos = useStorage('todos', [] as Todo[]) // ... })