TodoMVC 案例
创建工程和组件
- 需求 1: 创建新工程
- 需求 2: 分组件创建 – 准备标签和样式 (从.md 笔记复制)
分析:
- 初始化 todo 工程
- 创建3个组件和里面代码 (在预习资料.md 复制)
- 把 styles 的样式文件准备好 (从预习资料复制)
- App.vue 引入注册使用,最外层容器类名 todoapp
预先准备:把 styles 的样式文件准备好 (从预习资料复制), 在 App.vue 引入使用
styles 样式
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 个组件准备复用
TodoHeader
components/TodoHeader.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 - 复制标签和类名
<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 - 复制标签和类名
<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 中引入和使用
<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 : 关联选中状态,设置相关样式
分析:
- App.vue – 准备数组传入 TodoMain.vue 内
- v-for 循环展示数据
- v-model 绑定复选框选中状态
- 根据选中状态,设置完成划线样式
App.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
<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>
数据修改
添加任务
目标:在顶部输入框输入要完成的任务名,敲击回车,完成新增功能
- 需求:输入任务敲击回车,新增待办任务
分析:
- TodoHeader.vue – 输入框 – 键盘事件 – 回车按键
- 子传父,把待办任务 – App.vue 中 – 加入数组 list 里
- 原数组改变,所有用到的地方都会更新
- 输入框为空,提示用户必须输入内容
<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>
<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 , 删除当前这条任务
分析:
- x 标签 – 点击事件 – 传入 id 区分
- 子传父,把 id 传回– App.vue 中 – 删除数组 list 里某个对应的对象
- 原数组改变,所有用到的地方都会更新
<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>
<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 传回去实现删除 (想好数据在哪里,就在哪里删除)
底部统计
- 需求:统计当前任务的条数
分析:
- App.vue 中 – 数组 list – 传给 TodoFooter.vue
- 直接在标签上显示 / 定义计算属性用于显示都可以
- 原数组只要改变,所有用到此数组的地方都会更新
<TodoFooter :todos="todos"></TodoFooter>
<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: 对应切换不同数据显示
分析:
- : TodoFooter.vue – 定义 isSel – 值为 all, yes, no 其中一种
- : 多个 class 分别判断谁应该有类名 selected
- : 点击修改 isSel 的值
- : 子传父,把类型 isSel 传到 App.vue
- : 定义计算属性 showArr, 决定从 list 里显示哪些数据给 TodoMain.vue 和 TodoFooter.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>
<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>
清空已完成
- 需求:点击右下角链接标签,清除已完成任务
分析:
- 清空标签 – 点击事件
- 子传父 – App.vue – 一个清空方法
- 过滤未完成的覆盖 list 数组 (不考虑恢复)
App.vue - 先传入一个自定义事件 - 因为得接收 TodoFooter.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>
<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: 小选框都选中 (手选) – 全选自动选中状态
分析:
- TodoHeader.vue – 计算属性 - isAll
- App.vue – 传入数组 list – 在 isAll 的 set 里影响小选框
- isAll 的 get 里统计小选框最后状态,影响 isAll – 影响全选状态
- 考虑无数据情况空数组 – 全选不应该勾选
提示:就是遍历所有的对象,修改他们的完成状态属性的值
<TodoHeader @add_todo="add_todo" :todos="todos"></TodoHeader>
<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" />
数据缓存
- 需求:新增/修改状态/删除 后,马上把数据同步到浏览器本地存储
分析:
- App.vue – 侦听 list 数组改变 – 深度
- 覆盖式存入到本地 – 注意本地只能存入 JSON 字符串
- 刷新页面 – list 应该默认从本地取值 – 要考虑无数据情况空数组
<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>
概念问题
请说下封装 vue 组件的过程
首先,组件可以提升整个项目的开发效率。能够把页面抽象成多个相对独立的模块,解决了我们传统项目开发:效率低、难维护、复用性等问题。
分析需求:确定业务需求,把页面中可以复用的结构,样式以及功能,单独抽离成一个组件,实现复用
具体步骤:Vue.component 或者在 new Vue 配置项 components 中,定义组件名, 可以在 props 中接受给组件传的参数和值,子组件修改好数据后,想把数据传递给父组件。可以采用 $emit 方法。
Vue 组件如何进行传值的
父向子 ->
props
定义变量 -> 父在使用组件用属性给props
变量传值子向父 ->
$emit
触发父的事件 -> 父在使用组件用@自定义事件名=父的方法
(子把值带出来)讲一下组件的命名规范
给组件命名有两种方式 (在 Vue.Component/components 时),一种是使用链式命名
my-component
,一种是使用大驼峰命名MyComponent
,因为要遵循 W3C 规范中的自定义组件名 (字母全小写且必须包含一个连字符),避免和当前以及未来的 HTML 元素相冲突