Skip to content

TodoMVC 案例

参考 https://cn.vuejs.org/examples/#todomvc

创建工程和组件

  • 需求 1: 创建新工程
  • 需求 2: 分组件创建 – 准备标签和样式 (从.md 笔记复制)

分析:

  1. 初始化 todo 工程
  2. 创建3个组件和里面代码 (在预习资料.md 复制)
  3. 把 styles 的样式文件准备好 (从预习资料复制)
  4. App.vue 引入注册使用,最外层容器类名 todoapp

预先准备:把 styles 的样式文件准备好 (从预习资料复制), 在 App.vue 引入使用

styles 样式
css
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 {
  word-break: break-all;
  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 个组件准备复用

image-20210423162040577

TodoHeader

components/TodoHeader.vue - 复制标签和类名

vue
<script setup></script>

<template>
  <header class="header">
    <h1>todos</h1>
    <input id="toggle-all" class="toggle-all" type="checkbox" />
    <label for="toggle-all"></label>
    <input class="new-todo" placeholder="输入任务名称-回车确认" autofocus />
  </header>
</template>

<style scoped></style>

TodoMain

components/TodoMain.vue - 复制标签和类名

vue
<script setup></script>

<template>
  <ul class="todo-list">
    <!-- completed: 完成的类名 -->
    <li class="completed">
      <div class="view">
        <input class="toggle" type="checkbox" />
        <label>任务名</label>
        <button class="destroy"></button>
      </div>
    </li>
  </ul>
</template>

<style scoped></style>

TodoFooter

components/TodoFooter.vue - 复制标签和类名

vue
<script setup></script>

<template>
  <footer class="footer">
    <span class="todo-count">剩余<strong>数量值</strong></span>
    <ul class="filters">
      <li>
        <a class="selected" href="javascript:;">全部</a>
      </li>
      <li>
        <a href="javascript:;">未完成</a>
      </li>
      <li>
        <a href="javascript:;">已完成</a>
      </li>
    </ul>
    <button class="clear-completed">清除已完成</button>
  </footer>
</template>

<style scoped></style>

App

App.vue 中引入和使用

vue
<script setup>
import { reactive, ref } from "vue";

import TodoHeader from "./todo/Header.vue";
import TodoMain from "./todo/Main.vue";
import TodoFooter from "./todo/Footer.vue";
</script>

<template>
  <section class="todoapp">
    <!-- 除了驼峰,还可以使用 - 转换链接 -->
    <TodoHeader></TodoHeader>
    <TodoMain></TodoMain>
    <TodoFooter></TodoFooter>
  </section>
</template>

<style scoped></style>

铺设待办任务

  • 需求 1 : 把待办任务,展示到页面 TodoMain.vue 组件上
  • 需求 2 : 关联选中状态,设置相关样式

分析:

  1. App.vue – 准备数组传入 TodoMain.vue 内
  2. v-for 循环展示数据
  3. v-model 绑定复选框选中状态
  4. 根据选中状态,设置完成划线样式

App.vue

vue
<script setup>
import { reactive, ref } from "vue";

const todos = reactive([
  { id: 100, name: "吃饭", isDone: true },
  { id: 201, name: "睡觉", isDone: false },
  { id: 103, name: "打豆豆", isDone: true },
]);
</script>

<TodoMain :todos="todos"></TodoMain>

TodoMain.vue

vue
<script setup>
defineProps(["todos"]);
</script>

<template>
  <ul class="todo-list">
    <!-- 2.2 循环任务 - 关联选中状态 - 铺设数据 -->
    <!-- completed: 完成的类名 -->
    <li :class="{ completed: todo.isDone }" v-for="(todo, index) in todos">
      <div class="view">
        <input class="toggle" type="checkbox" v-model="todo.isDone" />
        <label>{{ todo.name }}</label>
        <!-- 4.0 注册点击事件 -->
        <button class="destroy" @click="del_todo(index)"></button>
      </div>
    </li>
  </ul>
</template>

<style scoped></style>

数据修改

添加任务

目标:在顶部输入框输入要完成的任务名,敲击回车,完成新增功能

  • 需求:输入任务敲击回车,新增待办任务

分析:

  1. TodoHeader.vue – 输入框 – 键盘事件 – 回车按键
  2. 子传父,把待办任务 – App.vue 中 – 加入数组 list 里
  3. 原数组改变,所有用到的地方都会更新
  4. 输入框为空,提示用户必须输入内容
vue
<script>
const add_todo = (task) => {
  const todo = {
    id: todos[todos.length - 1].id + 1,
    name: task,
    isDone: false,
  };
  todos.push(todo);
};
</script>

<TodoHeader @add_todo="add_todo"></TodoHeader>
vue
<script setup>
// 3. 目标 - 新增任务
import { ref } from "vue";

const emit = defineEmits(["add_todo"]);

// 表单任务数据
const task = ref("");

const add_todo = () => {
  if (task.value.trim().length === 0) {
    alert("任务名不能为空");
    return;
  }
  // 3.2(重要) - 当前任务名字要加到 list 数组里
  // 子传父技术
  emit("add_todo", task.value);
  task.value = "";
};
</script>

<template>
  <header class="header">
    <h1>todos</h1>
    <input id="toggle-all" class="toggle-all" type="checkbox" />
    <label for="toggle-all"></label>
    <!-- 3.0 键盘事件 - 回车按键
         3.1 输入框 - v-model 获取值
     -->
    <input
      class="new-todo"
      placeholder="输入任务名称-回车确认"
      autofocus
      @keydown.enter="add_todo"
      v-model="task"
    />
  </header>
</template>

<style scoped></style>

删除任务

目标:实现点 x , 删除任务功能

  • 需求:点击任务后的 x , 删除当前这条任务

分析:

  1. x 标签 – 点击事件 – 传入 id 区分
  2. 子传父,把 id 传回– App.vue 中 – 删除数组 list 里某个对应的对象
  3. 原数组改变,所有用到的地方都会更新
vue
<script setup>
const del_todo = (id) => {
  let index = todos.findIndex((obj) => obj.id === id);
  todos.splice(index, 1);
};
</script>

<TodoMain :todos="todos" @del_todo="del_todo"></TodoMain>
vue
<script setup>
defineProps(["todos"]);
const emit = defineEmits(["del_todo"]);
const del_todo = (id) => {
  emit("del_todo", id);
};
</script>

<!-- 4.0 注册点击事件 -->
<button class="destroy" @click="del_todo(todo.id)"></button>

App.vue - 传入自定义事件等待接收要被删除的序号

TodoMain.vue - 把 id 传回去实现删除 (想好数据在哪里,就在哪里删除)

底部统计

  • 需求:统计当前任务的条数

分析:

  1. App.vue 中 – 数组 list – 传给 TodoFooter.vue
  2. 直接在标签上显示 / 定义计算属性用于显示都可以
  3. 原数组只要改变,所有用到此数组的地方都会更新
vue
<TodoFooter :todos="todos"></TodoFooter>
vue
<script setup>
// 5.0 props 定义
defineProps(["todos"]);
</script>

<template>
  <footer class="footer">
    <span class="todo-count"
      >剩余<strong>{{ todos.length }}</strong></span
    >
    <ul class="filters">
      <li>
        <a class="selected" href="javascript:;">全部</a>
      </li>
      <li>
        <a href="javascript:;">未完成</a>
      </li>
      <li>
        <a href="javascript:;">已完成</a>
      </li>
    </ul>
    <button class="clear-completed">清除已完成</button>
  </footer>
</template>

<style scoped></style>

App.vue - 传入数据

TodoFooter.vue - 接收 todos 统计直接显示

数据切换

  • 需求 1: 点击底部切换 – 点谁谁有边框
  • 需求 2: 对应切换不同数据显示

分析:

  1. : TodoFooter.vue – 定义 isSel – 值为 all, yes, no 其中一种
  2. : 多个 class 分别判断谁应该有类名 selected
  3. : 点击修改 isSel 的值
  4. : 子传父,把类型 isSel 传到 App.vue
  5. : 定义计算属性 showArr, 决定从 list 里显示哪些数据给 TodoMain.vue 和 TodoFooter.vue
vue
<TodoFooter :todos="show_todos" @change_type="change_type"></TodoFooter>

<script setup>
// 6.5 定义 show_todos 数组 - 通过 todos 配合条件筛选而来
const show_todos = computed(() => {
  if (sel_type.value === "yes") {
    // 显示已完成
    return todos.filter((obj) => {
      return obj.isDone === true;
    });
  } else if (sel_type.value === "no") {
    // 显示未完成
    return todos.filter((obj) => {
      return obj.isDone === false;
    });
  } else {
    // 全部显示
    return todos;
  }
});
</script>
vue
<script setup>
// 5. 目标:数量统计
// 5.0 props 定义
import { ref } from "vue";

defineProps(["todos"]);

const sel_type = ref("all");
const emit = defineEmits(["change_type"]);
// 切换筛选条件
// 6.3 子 -> 父 把类型字符串传给 App.vue
const change_type = () => {
  emit("change_type", sel_type.value);
};
</script>

<template>
  <footer class="footer">
    <span class="todo-count"
      >剩余<strong>{{ todos.length }}</strong></span
    >
    <ul class="filters" @click="change_type">
      <li>
        <a
          :class="{ selected: sel_type === 'all' }"
          href="javascript:;"
          @click="sel_type = 'all'"
          >全部</a
        >
      </li>
      <li>
        <a
          :class="{ selected: sel_type === 'no' }"
          href="javascript:;"
          @click="sel_type = 'no'"
          >未完成</a
        >
      </li>
      <li>
        <a
          :class="{ selected: sel_type === 'yes' }"
          href="javascript:;"
          @click="sel_type = 'yes'"
          >已完成</a
        >
      </li>
    </ul>
    <button class="clear-completed">清除已完成</button>
  </footer>
</template>

<style scoped></style>

清空已完成

  • 需求:点击右下角链接标签,清除已完成任务

分析:

  1. 清空标签 – 点击事件
  2. 子传父 – App.vue – 一个清空方法
  3. 过滤未完成的覆盖 list 数组 (不考虑恢复)

App.vue - 先传入一个自定义事件 - 因为得接收 TodoFooter.vue 里的点击事件

vue
<script setup>
// 清除已完成
const clear_done = () => {
  let del_index = [];
  todos.forEach((todo, index) => {
    if (todo.isDone === true) {
      del_index.unshift(index);
    }
  });
  del_index.forEach((index) => {
    todos.splice(index, 1);
  });
};
</script>

<TodoFooter
  :todos="show_todos"
  @change_type="change_type"
  @clear_done="clear_done"
></TodoFooter>
vue
<script setup>
const emit = defineEmits(["change_type", "clear_done"]);
</script>

<!-- 7.0 点击事件 -->
<!-- 7. 目标:清除已完成 -->
<button class="clear-completed" @click="emit('clear_done')">清除已完成</button>

全选功能

点击左上角 v 号,可以设置一键完成,再点一次取消全选

  • 需求 1: 点击全选 – 小选框受到影响
  • 需求 2: 小选框都选中 (手选) – 全选自动选中状态

分析:

  1. TodoHeader.vue – 计算属性 - isAll
  2. App.vue – 传入数组 list – 在 isAll 的 set 里影响小选框
  3. isAll 的 get 里统计小选框最后状态,影响 isAll – 影响全选状态
  4. 考虑无数据情况空数组 – 全选不应该勾选

提示:就是遍历所有的对象,修改他们的完成状态属性的值

vue
<TodoHeader @add_todo="add_todo" :todos="todos"></TodoHeader>
vue
<script setup>
const props = defineProps(["todos"]);

// 全选
const is_all = computed({
  set: (checked) => {
    // 9.3 影响数组里每个小选框绑定的 isDone 属性
    props.todos.forEach((obj) => (obj.isDone = checked));
  },
  get: () => {
    // 9.4 小选框统计状态 -> 全选框
    // 9.5 如果没有数据,直接返回 false-不要让全选勾选状态
    return (
      props.todos.length !== 0 &&
      props.todos.every((obj) => obj.isDone === true)
    );
  },
});
</script>
<!-- 9. 目标:全选状态
9.0 v-model 关联全选状态
页面变化 (勾选 true, 未勾选 false) -> v-model -> isAll 变量
-->
<input id="toggle-all" class="toggle-all" type="checkbox" v-model="is_all" />

数据缓存

  • 需求:新增/修改状态/删除 后,马上把数据同步到浏览器本地存储

分析:

  1. App.vue – 侦听 list 数组改变 – 深度
  2. 覆盖式存入到本地 – 注意本地只能存入 JSON 字符串
  3. 刷新页面 – list 应该默认从本地取值 – 要考虑无数据情况空数组
vue
<script setup>
// 8.1 默认从本地取值
const _todos = JSON.parse(localStorage.getItem("todos")) || [];
const todos = reactive(_todos);

const add_todo = (task) => {
  const todo = {
    id: todos.length >= 1 ? todos[todos.length - 1].id + 1 : 1,
    name: task,
    isDone: false,
  };
  todos.push(todo);
};

// 数据缓存
// 8. 目标:数据缓存
watch(
  todos,
  () => {
    // 8.0 只要 list 变化 - 覆盖式保存到 localStorage 里
    localStorage.setItem("todos", JSON.stringify(todos));
  },
  { deep: true },
);
</script>

概念问题

  1. 请说下封装 vue 组件的过程

    首先,组件可以提升整个项目的开发效率。能够把页面抽象成多个相对独立的模块,解决了我们传统项目开发:效率低、难维护、复用性等问题。

    分析需求:确定业务需求,把页面中可以复用的结构,样式以及功能,单独抽离成一个组件,实现复用

    具体步骤:Vue.component 或者在 new Vue 配置项 components 中,定义组件名, 可以在 props 中接受给组件传的参数和值,子组件修改好数据后,想把数据传递给父组件。可以采用 $emit 方法。

  2. Vue 组件如何进行传值的

    父向子 -> props 定义变量 -> 父在使用组件用属性给 props 变量传值

    子向父 -> $emit 触发父的事件 -> 父在使用组件用@自定义事件名=父的方法 (子把值带出来)

  3. 讲一下组件的命名规范

    给组件命名有两种方式 (在 Vue.Component/components 时),一种是使用链式命名 my-component,一种是使用大驼峰命名 MyComponent

    因为要遵循 W3C 规范中的自定义组件名 (字母全小写且必须包含一个连字符),避免和当前以及未来的 HTML 元素相冲突