一、项目概况
1. 功能清单
- ✅ 任务添加:输入任务名称,按回车确认添加
- ✅ 任务列表:渲染所有任务,显示任务状态(已完成 / 未完成)
- ✅ 任务操作:单个任务删除、单个任务状态切换(已完成 / 未完成)
- ✅ 全选 / 取消全选:一键切换所有任务状态
- ✅ 统计功能:显示 “已完成任务数 / 总任务数”
- ✅ 本地存储:任务数据持久化(刷新页面不丢失)
- ✅ 边界处理:空任务拦截、删除确认、无任务时隐藏底部统计
2. 组件结构
采用 “父子组件” 分层设计,App 作为根组件统一管理数据,子组件负责具体功能,结构如下:
App(父组件)→ 管理核心数据items,提供操作方法
├─ MyHeader(子组件)→ 任务添加
├─ MyList(子组件)→ 任务列表容器
│ └─ MyItem(子组件)→ 单个任务项(渲染+删除+状态切换)
└─ MyFooter(子组件)→ 全选+统计+清除已完成任务
二、完整代码实现
1. 入口文件:main.js
//引入Vue
import Vue from 'vue'
//引入App
import App from './App.vue'
//关闭Vue的生产提示
Vue.config.productionTip = false
//创建vm
new Vue({
el:'#app',
render: h => h(App)
})
2. 根组件:App.vue(核心数据管理)
App 是父组件,负责存储任务数据items
,并提供操作数据的方法(添加、删除、状态切换等),通过props
传递给子组件。
<template>
<div id="root">
<div class="todo-container">
<div class="todo-wrap">
<!--
<MyHeader/>、<MyList/>、<MyFooter/>三个兄弟组件都需用到items。因此item数据可定义在<App/>并由父组件实现数据:
1 父传子:传递子组件data数据(props实现)
2 子传父:传递子组件回调函数(props实现)
-->
<MyHeader :addItem="addItem"/>
<MyList :items="items" :changeCheck="changeCheck" :del="del"/>
<MyFooter :items="items" :changeAllCheck="changeAllCheck" :del="del"/>
</div>
</div>
</div>
</template>
<script>
import MyHeader from './components/MyHeader.vue';
import MyFooter from './components/MyFooter.vue';
import MyList from './components/MyList.vue';
export default{
name:'App',
components:{
MyFooter,
MyHeader,
MyList
},
data() {
return {
items: JSON.parse(localStorage.getItem('items')) || [] //第一次调用localStorage.getItem('items')为null
//复杂写法items:JSON.parse(localStorage.getItem('items'))?JSON.parse(localStorage.getItem('items')):[]
}
},
//addItem()执行->items改变->模板重新解析->MyList组件里的items变(又因为props有响应式)->MyList组件重新解析
methods:{
addItem(item){
this.items.unshift(item)
},
//函数写在哪里遵循原则:要修改的数据在哪,函数在哪
changeCheck(id){
this.items.forEach(item => {
if(item.id === id) item.isChecked = !item.isChecked
});
},
del(id){
this.items = this.items.filter(item=>item.id!==id)
},
changeAllCheck(isChecked){
this.items.forEach(item => {
item.isChecked = isChecked
});
}
},
watch:{
items:{
//监视items变化并存入localStorage
deep:true,
handler(val){
localStorage.setItem('items',JSON.stringify(val))
}
}
}
}
</script>
<style>
/*base*/
body {
background: #fff;
}
.btn {
display: inline-block;
padding: 4px 12px;
margin-bottom: 0;
font-size: 14px;
line-height: 20px;
text-align: center;
vertical-align: middle;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.btn-danger {
color: #fff;
background-color: #da4f49;
border: 1px solid #bd362f;
}
.btn-danger:hover {
color: #fff;
background-color: #bd362f;
}
.btn:focus {
outline: none;
}
.todo-container {
width: 600px;
margin: 0 auto;
}
.todo-container .todo-wrap {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
}
</style>
3. 任务添加组件:MyHeader.vue
负责输入任务名称,按回车触发添加逻辑,通过props
接收父组件的addItem
方法,将新任务传递给父组件。
<template>
<div class="todo-header">
<input type="text" placeholder="请输入你的任务名称,按回车键确认" v-model="name" @keyup.enter="newItem"/>
</div>
</template>
<script>
export default {
/* 注意点:data,props,methods,computed中的名字不能重复。因为这些属性方法最终出现在vc或vm身上 */
name:'MyHeader',
data() {
return {
name:''
}
},
props:['addItem'],
methods:{
newItem(){
if(!this.name.trim()) return alert('输入不能为空')
const item = {id:crypto.randomUUID(),name:this.name,isChecked:false}
//注意这里子组件虽然调用了父组件的addItem()但是执行addItem()时里面的this依然是父组件
this.addItem(item)
this.name = ''
}
}
}
</script>
<style scoped>
/*header*/
.todo-header input {
width: 560px;
height: 28px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 7px;
}
.todo-header input:focus {
outline: none;
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
</style>
4. 任务列表容器:MyList.vue
作为任务列表的 “容器”,接收父组件的items
数据,通过v-for
循环渲染MyItem
子组件,同时传递任务数据和操作方法。
<template>
<ul class="todo-main">
<!-- 使用props给MyItem子组件传item对象 -->
<MyItem v-for="
item in items"
:key="item.id"
:item="item"
:changeCheck="changeCheck"
:del="del"
/>
</ul>
</template>
<script>
import MyItem from './MyItem.vue';
export default {
name:'MyList',
components:{
MyItem
},
props:['items','changeCheck','del']
}
</script>
<style scoped>
/*main*/
.todo-main {
margin-left: 0px;
border: 1px solid #ddd;
border-radius: 2px;
padding: 0px;
}
.todo-empty {
height: 40px;
line-height: 40px;
border: 1px solid #ddd;
border-radius: 2px;
padding-left: 5px;
margin-top: 10px;
}
</style>
5. 单个任务项组件:MyItem.vue
渲染单个任务的内容、状态(复选框)和删除按钮,不直接修改props
,通过调用父组件方法修改源数据(遵循单向数据流)。
<template>
<div>
<li >
<label >
<!--注意:v-model中绑定的不要是props的值。因为props建议只读-->
<input type="checkbox" :checked="item.isChecked" @change="changeCheck(item.id)"/>
<span>{{item.name}}</span>
</label>
<button class="btn btn-danger" @click="handleDel(item.id)">删除</button>
</li>
</div>
</template>
<script>
export default {
name:'MyItem',
//props是响应式的。但是应遵循单项数据流原则,即不改其值。因此item值应该由父组件统一来改便于后期调试
props:['item','changeCheck','del'],
methods:{
handleDel(id){
if(confirm('确定删除?'))
this.del(id)
}
}
}
</script>
<style scoped>
/*item*/
li {
list-style: none;
height: 36px;
line-height: 36px;
padding: 0 5px;
border-bottom: 1px solid #ddd;
}
li label {
float: left;
cursor: pointer;
}
li label li input {
vertical-align: middle;
margin-right: 6px;
position: relative;
top: -1px;
}
li button {
float: right;
display: none;
margin-top: 3px;
}
li:before {
content: initial;
}
li:last-child {
border-bottom: none;
}
li:hover{
background-color: #ddd;
}
li:hover button{
display: block;
}
</style>
6. 底部统计组件:MyFooter.vue
实现全选 / 取消全选、已完成任务统计、清除已完成任务功能,通过computed
计算全选状态和任务数量。
<template>
<div class="todo-footer" v-show="total">
<label>
<input type="checkbox" v-model="isAll"/>
</label>
<span>
<span>已完成{{ checkNum }}</span> / 全部{{ total }}
</span>
<button class="btn btn-danger" @click="clearFinishedItems">清除已完成任务</button>
</div>
</template>
<script>
export default {
name:'MyFooter',
props:['items','changeAllCheck','del'],
computed:{
isAll:{
get(){
return this.checkNum === this.total && this.total > 0
},
//set必须写上。因为勾选操作修改了isAll的值
set(val){
this.changeAllCheck(val)
}
},
total(){
return this.items.length
},
checkNum(){
return this.items.filter(item=>item.isChecked).length
}
},
methods: {
clearFinishedItems(){
this.items.forEach(item => {
if(item.isChecked)
this.del(item.id)
});
}
},
}
</script>
<style scoped>
/*footer*/
.todo-footer {
height: 40px;
line-height: 40px;
padding-left: 6px;
margin-top: 5px;
}
.todo-footer label {
display: inline-block;
margin-right: 20px;
cursor: pointer;
}
.todo-footer label input {
position: relative;
top: -1px;
vertical-align: middle;
margin-right: 5px;
}
.todo-footer button {
float: right;
margin-top: 5px;
}
</style>
三、核心知识点拆解
1. 组件通信:父传子 + 子传父
TodoList 的组件通信完全遵循 Vue 的单向数据流原则:
- 父传子:通过
props
传递数据(如items
)和方法(如addItem
),子组件通过props
接收后使用。 - 子传父:子组件不直接修改
props
,而是通过调用父组件传递的回调函数(如addItem
、del
),将数据传递给父组件,由父组件修改源数据。
示例:MyHeader 添加任务时,通过this.addItem(newTask)
调用父组件方法,将新任务传递给 App,App 修改items
后,子组件(MyList、MyFooter)通过props
响应式自动更新。
2. computed 计算属性:简化复杂逻辑
- 全选状态(isAll):get 方法计算 “已完成数 === 总任务数”,set 方法响应复选框变化,调用父组件全选方法。
- 任务统计(total、checkNum):动态计算总任务数和已完成数,避免在模板中写复杂表达式(如
{{ items.filter(...) }}
)。
关键:isAll
必须写set
方法,因为v-model
绑定后,复选框的勾选操作会修改isAll
的值,触发set
,进而通知父组件更新所有任务状态。
3. watch 监听:本地存储同步
通过watch
深度监听items
变化(数组内对象属性的变化需要deep: true
),将最新的items
同步到localStorage
,实现数据持久化:
watch: {
items: {
deep: true, // 深度监听
handler(newVal) {
localStorage.setItem('items', JSON.stringify(newVal))
}
}
}
- 页面加载时,从
localStorage
读取数据:items: JSON.parse(localStorage.getItem('items')) || []
。 - 注意:
localStorage
只能存储字符串,需用JSON.stringify
和JSON.parse
处理对象 / 数组。
4. 单向数据流:子组件不直接修改 props
Vue 规定:子组件不能直接修改 props 传递的数据,否则会触发警告且导致数据流向混乱。项目中通过以下方式遵循这一原则:
- MyItem 组件:用
:checked="item.isChecked"
绑定状态,而非v-model
,通过@change
触发父组件changeCheck
方法修改源数据。 - MyFooter 组件:全选状态
isAll
的修改通过set
方法调用父组件changeAllCheck
,不直接修改items
。
5. 事件处理与表单绑定
- 表单绑定:MyHeader 的输入框用
v-model
绑定name
,实现双向数据绑定(仅组件内部使用,不涉及 props)。 - 键盘事件:
@keyup.enter
监听回车,触发任务添加。 - 鼠标事件:
@change
监听复选框状态变化,@click
监听删除和全选操作,v-show
/v-if
控制元素显示隐藏。
四、项目运行流程
- 初始化:页面加载时,App 从
localStorage
读取任务数据,渲染 MyList(有数据则显示任务,无数据则显示提示)。 - 添加任务:MyHeader 输入任务名→按回车→调用
addItem
→App 的items
更新→MyList 自动重新渲染→新任务显示在列表顶部。 - 切换状态:MyItem 勾选复选框→调用
changeCheck
→App 修改对应items的isChecked
→MyItem 和 MyFooter(统计数、全选状态)自动更新。 - 全选操作:MyFooter 勾选全选框→触发
isAll
的set
→调用changeAllCheck
→App 修改所有items的isChecked
→所有 MyItem 的复选框同步状态。 - 删除任务:MyItem 点击删除→确认后调用
del
→App 删除item
→MyList 和 MyFooter 自动更新。 - 本地存储:每次
items
变化(添加、删除、状态切换)→watch
触发→同步到localStorage
→刷新页面数据不丢失。
五、注意事项
- ID 唯一性:用
crypto.randomUUID()
生成唯一 ID,兼容性良好(IE 不支持,可替换为Date.now() + Math.random()
)。 - 命名冲突:组件的
data
、props
、methods
、computed
命名不能重复,因为最终都会挂载到组件实例(vc
)上。 - 边界处理:空任务拦截、删除确认、无任务隐藏底部,提升用户体验。