Skip to content

待办清单

该项目来源于 TodoMVC,本人只是借助该项目练习与巩固所学的 vue3 知识点!

创建项目

使用 pnpm create vue@latest 命令创建项目,项目名称为 todomvc
image-20240320221347093

在项目被创建后,通过以下步骤安装依赖并启动开发服务器,如下所示:
image-20240320221549669

准备工作

  1. 首先,删除或者清空所有不需要的内容;

    1. 删除 components 目录下的所有组件和 assets 目录下的 base.css 样式文件;
    2. 删除 views 目录下的 HomeViewAboutView 组件;
    3. 清空 App.vue 中的所有内容,只在 template 模板中保留一个路由出口 <RouterView />
    4. 清空 assets 目录下 main.css 样式文件中定义的所有样式;
  2. views 目录下创建一个名为 TodoView 的组件;

    Tip

    对于本项目,咱们的精力应该集中在使用 Vue3 编写其业务逻辑,至于 HTML 页面和 CSS 样式可以到 TodoMVC 官方的 Github 仓库中进行下载。

    1. 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>
    2. 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;
      }
  3. 修改路由文件 router/index.js 中的路由数组 routes,如下所示:

    js
    import { 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 命令运行项目,可以看到项目的整体效果,如下所示:
image-20240319220449435

功能制作

渲染待办事项列表

TodoView 组件的 <script setup> 中定义待办事项列表 todos 的响应式对象(为了显示效果添加了模拟数据)。

vue
<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>
html
<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 方法用于将新建的待办事项添加到列表中。

vue
<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>
html
<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 暴露出来作为删除方法的参数。

vue
<script setup>
// ...

/**
 * 删除待办事项
 * @param {number} index 索引
 */
const removeTodo = (index) => {
  todos.value.splice(index, 1);
};
</script>
html
<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 键则表示取消修改;
  • 把编辑文本框清空并确认修改则表示删除当前待办事项;
vue
<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>
html
<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>

切换待办事项状态

  • 点击全选按钮(头部向下的箭头)时改变所有待办事项的勾选状态;
  • 点击下方的 AllActiveCompleted 按钮时切换显示的待办事项列表;
  • 计算所有未完成的待办事项个数显示在左下方;
  • 点击右下方 Clear completed 按钮时移除所有已完成的项目;
  • 当没有任何待办事项时,隐藏主体和底部区域;
vue
<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>
html
<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 中。
vue
<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>