待办清单
该项目来源于 TodoMVC,本人只是借助该项目练习与巩固所学的 vue3 知识点!
创建项目
使用 pnpm create vue@latest
命令创建项目,项目名称为 todomvc
。
在项目被创建后,通过以下步骤安装依赖并启动开发服务器,如下所示:
准备工作
首先,删除或者清空所有不需要的内容;
- 删除
components
目录下的所有组件和assets
目录下的base.css
样式文件; - 删除
views
目录下的HomeView
和AboutView
组件; - 清空
App.vue
中的所有内容,只在template
模板中保留一个路由出口<RouterView />
; - 清空
assets
目录下main.css
样式文件中定义的所有样式;
- 删除
在
views
目录下创建一个名为TodoView
的组件;Tip
对于本项目,咱们的精力应该集中在使用 Vue3 编写其业务逻辑,至于 HTML 页面和 CSS 样式可以到 TodoMVC 官方的 Github 仓库中进行下载。
tastejs/todomvc-app-template: Template used for creating TodoMVC apps (github.com) 仓库:
index.html
页面 ->body
中的内容拷贝到TodoView.vue
组件 -><template>
模板;html<template> <section class="todoapp"> <header class="header"> <h1>todos</h1> <input class="new-todo" placeholder="What needs to be done?" autofocus /> </header> <!-- 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> <!-- 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> </section> <footer class="info"> <p>Double-click to edit a todo</p> <!-- Remove the below line ↓ --> <p>Template by <a href="http://sindresorhus.com">Sindre Sorhus</a></p> <!-- Change this out with your name and url ↓ --> <p>Created by <a href="http://todomvc.com">you</a></p> <p>Part of <a href="http://todomvc.com">TodoMVC</a></p> </footer> </template>
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; }
修改路由文件
router/index.js
中的路由数组routes
,如下所示:jsimport { 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
命令运行项目,可以看到项目的整体效果,如下所示:
功能制作
渲染待办事项列表
在 TodoView
组件的 <script setup>
中定义待办事项列表 todos
的响应式对象(为了显示效果添加了模拟数据)。
<script setup>
import { ref } from 'vue';
/**
* 待办事项列表
*/
const todos = ref([
{
id: 1,
title: 'Taste JavaScript',
completed: true,
},
{
id: 2,
title: 'Buy a unicorn',
completed: false,
},
]);
</script>
<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
v-for="todo in todos"
:key="todo.id"
: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>
</ul>
添加待办事项
在 TodoView
组件的 <script setup>
中定义一个响应式对象 newTodo
用于双向绑定 input
输入框中的新建待办事项,然后编写一个 addTodo
方法用于将新建的待办事项添加到列表中。
<script setup>
import { ref } from 'vue';
/**
* 待办事项列表
*/
const todos = ref([]);
/**
* 用于绑定新建的待办事项
*/
const newTodo = ref('');
/**
* 添加待办事项
*/
const addTodo = () => {
if (!newTodo.value) return;
todos.value.unshift({
id: uuid(),
title: newTodo.value,
completed: false,
});
newTodo.value = '';
};
/**
* 生成uuid
* @returns {string} 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;
};
</script>
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus
v-model.trim="newTodo"
@keyup.enter="addTodo"
/>
</header>
删除待办事项
点击每个待办事项后面的❌号实现删除功能,因此需要在该❌号按钮上添加一个点击事件的监听器方法 removeTodo
,该方法的参数为要删除的待办事项在待办事项列表中的索引 index
值。
该功能主要对应模板中 section > ul
部分,在使用 v-for
指令遍历待办事项列表时需要将索引 index
暴露出来作为删除方法的参数。
<script setup>
// ...
/**
* 删除待办事项
* @param {number} index 索引
*/
const removeTodo = (index) => {
todos.value.splice(index, 1);
};
</script>
<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
v-for="(todo, index) in todos"
:key="todo.id"
: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(index)"></button>
</div>
<input class="edit" value="Create a TodoMVC template" />
</li>
</ul>
编辑待办事项
- 双击已添加的待办事项时会进入编辑状态,显示编辑文本框并自动获取焦点;
- 按回车键或者使编辑文本框失去焦点则表示确认修改;
- 按 ESC 键则表示取消修改;
- 把编辑文本框清空并确认修改则表示删除当前待办事项;
<script setup>
// ...
/**
* 正在编辑的待办事项,初始值为null
*/
const editingTodo = ref(null);
/**
* 编辑前的待办事项标题,用于取消编辑时还原
*/
let beforeEditTitle = '';
/**
* 开始编辑
* @param {object} todo 待办事项
*/
const startEdit = (todo) => {
editingTodo.value = todo;
beforeEditTitle = todo.title;
};
/**
* 完成编辑
* @param {object} todo 待办事项
* @param {number} index 索引
*/
const doneEdit = (todo, index) => {
// 防止回车与失去焦点时触发两次
if (editingTodo.value) {
editingTodo.value = null;
if (!todo.title) removeTodo(index);
}
};
/**
* 取消编辑
* @param {object} todo 待办事项
*/
const cancelEdit = (todo) => {
editingTodo.value = null;
todo.title = beforeEditTitle;
};
</script>
<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
v-for="(todo, index) in todos"
:key="todo.id"
:class="{
completed: todo.completed,
editing: todo === editingTodo,
}"
>
<div class="view">
<input class="toggle" type="checkbox" v-model="todo.completed" />
<label @dblclick="startEdit(todo, index)">{{ todo.title }}</label>
<button class="destroy" @click="removeTodo(index)"></button>
</div>
<input
v-if="todo === editingTodo"
class="edit"
v-model.trim="todo.title"
@vue:mounted="({ el }) => el.focus()"
@keyup.enter="doneEdit(todo, index)"
@blur="doneEdit(todo, index)"
@keyup.esc="cancelEdit(todo)"
/>
</li>
</ul>
切换待办事项状态
- 点击全选按钮(头部向下的箭头)时改变所有待办事项的勾选状态;
- 点击下方的
All
、Active
和Completed
按钮时切换显示的待办事项列表; - 计算所有未完成的待办事项个数显示在左下方;
- 点击右下方
Clear completed
按钮时移除所有已完成的项目; - 当没有任何待办事项时,隐藏主体和底部区域;
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
// ...
/**
* 全选/反选
* @param {object} e 事件对象
*/
const toggleAll = (e) => {
todos.value.forEach((todo) => (todo.completed = e.target.checked));
};
/**
* 获取路由实例
*/
const route = useRoute();
/**
* 过滤方法
*/
const filters = {
active: (todos) => todos.value.filter((todo) => !todo.completed),
completed: (todos) => 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;
case 'completed':
return completedTodos;
default:
return todos;
}
});
/**
* 清除已完成的待办事项
*/
const clearCompleted = () => {
todos.value = activeTodos.value;
};
</script>
<section class="todoapp">
<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>
<!-- 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="toggleAll"
/>
<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
v-for="(todo, index) in filteredTodos.value"
:key="todo.id"
:class="{
completed: todo.completed,
editing: todo === editingTodo,
}"
>
<div class="view">
<input class="toggle" type="checkbox" v-model="todo.completed" />
<label @dblclick="startEdit(todo, index)">{{ todo.title }}</label>
<button class="destroy" @click="removeTodo(index)"></button>
</div>
<input
v-if="todo === editingTodo"
class="edit"
v-model.trim="todo.title"
@vue:mounted="({ el }) => el.focus()"
@keyup.enter="doneEdit(todo, index)"
@blur="doneEdit(todo, index)"
@keyup.esc="cancelEdit(todo)"
/>
</li>
</ul>
</section>
<!-- 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>
</section>
持久化存储
- 在启动项目时,先从本地存储
LocalStorage
中读取先前已存储的待办事项; - 使用
watchEffect
侦听器侦听待办事项列表的变化,每当待办事项列表发生变化时,就将其最新数据保存到本地存储LocalStorage
中。
<script>
import { ref, watchEffect } from 'vue';
const STORAGE_KEY = 'todos';
/**
* 待办事项列表,先从本地存储中获取,如果没有的话则初始化为空数组
*/
const todos = ref(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'));
/**
* 使用watchEffect侦听器将当前待办事项列表保存到本地存储
*/
watchEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.value));
});
</script>