Skip to content

待办清单 v2.0

相较于第一版待办清单而言,待办清单 2.0 版本升级为由多个组件构成整个应用,使用 pinia 进行状态存储。

项目仓库地址:xihuanxiaorang/todos (github.com),有需要的小伙伴可以自行下载。

创建项目

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

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

准备工作

Tip

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

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

    1. 删除 components 目录下的所有组件和 assets 目录下的 base.css 样式文件;
    2. 删除 views 目录下的 HomeViewAboutView 组件;
    3. 删除 stores 目录下的 counter.ts 文件;
    4. 清空 App.vue 中的所有内容,只在 template 模板中保留一个路由出口 <RouterView />
    5. 清空 assets 目录下 main.css 样式文件中定义的所有样式;
  2. 从 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;
    }
  3. 从 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>
  4. 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>
  5. 修改路由文件 router/index.ts 中的路由数组 routes,如下所示:

    ts
    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-20240321155717040

功能制作

渲染待办事项列表

涉及到的主要功能点:

  • 在新建的 types 目录中创建一个类型声明文件,用于定义待办事项的类型;
  • 使用 pinia 定义一个 todos 存储对象用于管理整个应用的状态;
  • 将待办事项拆分成一个单独的组件 TodoItem

💡注意:在使用 v-for 指令遍历待办事项列表时,每一条待办事项组件 TodoItem 使用 v-model 指令进行双向绑定,在进行双向绑定时不能直接使用循环变量本身!那么该怎么办呢?可以使用 array[index] 的方式进行替代。

ts
/**
 * 待办事项
 */
export interface Todo {
  /* 编号 */
  id: string
  /* 标题 */
  title: string
  /* 是否完成 */
  completed: boolean
}
ts
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 }
})
vue
<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>
vue
<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 方法用于将新建的待办事项添加到列表中。

vue
<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>
ts
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,该方法的参数为要删除的待办事项。

ts
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
  }
})
vue
<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 键则表示取消修改;
  • 把编辑文本框清空并确认修改则表示删除当前待办事项;
vue
<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>

切换待办事项状态

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

实现步骤如下所示:

  1. 安装 VueUse:pnpm i @vueuse/core

  2. 使用:

    ts
    import { 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[])
      
      // ...
    })