QX-AI初始化中...
暂无预设简介,请点击下方生成AI简介按钮。
查看和运行 github仓库
在1024code上在线查看和运行ToDoList-Vue
组件化编码流程 组件化编码流程:
实现静态组件: 设计页面,根据功能 或区域 分割页面板块设计组件,使用组件实现静态页面效果
展示动态数据: 设计每个组件应有的数据,以及数据之间的联系,将数据样例应用到组件中
交互 从绑定事件监听开始
ToDoList 页面设计与组件:
将页面整体分为三大块,header(头部)、list(内容)、footer(尾部)
list 中重复的部分提取为 item 组件
实现静态组件 写好静态页面和样式
App.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 <template > <div id ="app" > <div id ="todo-container" > <div id ="todo-wrap" > <ToDoHeader > </ToDoHeader > <ToDoList > </ToDoList > <ToDoFooter > </ToDoFooter > </div > </div > </div > </template > <script > import ToDoHeader from "./components/ToDoHeader.vue" ;import ToDoList from "./components/ToDoList.vue" ;import ToDoFooter from "./components/ToDoFooter.vue" ;export default { name : "App" , components : { ToDoHeader , ToDoList , ToDoFooter , }, }; </script > <style lang ="less" > @todo-border: 1px solid #ccc; @todo-border-radius: 6px; * { margin: 0; padding: 0; box-sizing: border-box; } ul { list-style: none; } #app { margin: 20px 10px; font-size: 16px; } #todo-container { max-width: 600px; min-width: 300px; height: auto; margin: 0 auto; padding: 10px; border: @todo-border; border-radius: @todo-border-radius; #todo-wrap { width: 100%; } } </style >
ToDoHeader.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 <template > <div class ="todo-header" > <input type ="text" placeholder ="回车新增ToDo" /> <button > 新增</button > </div > </template > <script > export default { name : "ToDoHeader" , }; </script > <style lang ="less" scoped > @todo-border: 1px solid #ccc; @todo-border-radius: 6px; .input-focus { box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); border-color: rgba(82, 168, 236, 0.8); outline: none; } .todo-header { display: flex; flex-direction: row; flex-wrap: nowrap; margin-bottom: 15px; input { width: 100%; font-size: 16px; padding: 8px 10px; border: @todo-border; border-radius: @todo-border-radius; &:focus { .input-focus(); } } button { margin-left: 10px; width: 65px; border-radius: @todo-border-radius; border: @todo-border; background: rgb(52, 201, 238); font-size: 16px; color: #fff; } } </style >
ToDoList.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <template > <div class ="todo-list" > <ul > <ListItem > </ListItem > <ListItem > </ListItem > <ListItem > </ListItem > <ListItem > </ListItem > </ul > </div > </template > <script > import ListItem from "./ListItem.vue" ;export default { name : "ToDoList" , components : { ListItem , }, }; </script > <style lang ="less" scoped > @todo-border: 1px solid #ccc; @todo-border-radius: 6px; .todo-list { margin-bottom: 15px; border-radius: @todo-border-radius; border: @todo-border; } </style >
ListItem.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 <template > <li > <label > <input type ="checkbox" > <span > 这是一个ToDo</span > </label > <button class ="list-item-btn" > 删除</button > </li > </template > <script > export default { name : 'ListItem' , } </script > <style lang ="less" scoped > @todo-border: 1px solid #ccc; @todo-border-radius: 6px; li { border-bottom: @todo-border; display: flex; height: 38px; line-height: 37px; justify-content: space-between; &:last-child{ border-bottom: none; } label { margin-left: 10px; input { top: -1px; position: relative; vertical-align: middle; margin-right: 10px; } } button { border-radius: @todo-border-radius; border: @todo-border; font-size: 14px; margin: 5px; padding: 0 5px; background: #dc7878; color: #fff; } } </style >
ToDoFooter.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 <template > <div id ="todo-footer" > <input type ="checkbox" > <div class ="statistics" > 已完成0 / 全部4</div > <button > 清除已完成</button > </div > </template > <script > export default { name : 'ToDoFooter' , } </script > <style lang ="less" scoped > @todo-border: 1px solid #ccc; @todo-border-radius: 6px; #todo-footer { width: 100%; height: 40px; line-height: 40px; padding-left: 10px; input { top: 1px; position: relative; } .statistics { width: fit-content; display: inline-block; margin-left: 20px; } button { float: right; padding: 5px 10px; margin: 5px 0; border-radius: @todo-border-radius; border: @todo-border; color: #fff; background: rgb(220, 120, 120); font-size: 15px; } } </style >
设计数据 数据在 List 中展示,所以下面操作 ToDoList.vue 组件
有许多todo,而每个todo都有许多属性,如内容、是否完成、以及唯一标识该 todo 的id
所以数据的结构应该是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 todos: [ { id: "001", title: "第一件事", complete: false }, { id: "002", title: "第二件事", complete: true }, { id: "003", title: "第三件事", complete: false } ], }
有了动态的数据,使用 v-for 遍历数据,将原来写死的模板替换掉,
并且将遍历的每个 todo 通过组件标签 传给子组件 ToDoItem.vue
ToDoList.vue 1 2 3 <ul > <ListItem v-for ="item in todos" :key ="item.id" :todo ="item" > </ListItem > </ul >
子组件使用 props 接收父组件传来的 todo 进行内容展示
ToDoItem.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <template > <li > <label > <input type ="checkbox" :checked ="todo.complete" > <span > {{ todo.title }}</span > </label > <button class ="list-item-btn" > 删除</button > </li > </template > <script > export default { name : 'ListItem' , props : ['todo' ], } </script >
完善添加功能 vue 是以数据驱动 的,所以不应该直接操控dom,而是让数据发生变化,触发视图的更新
添加一个todo分为两步:
按下回车或按钮时获取输入框的内容
将获取到的内容添加到 ToDoList.vue 组件 data 中的 todos 数组中
获取输入框内容 这里还是要写点原生js代码的,但不多
ToDoHeader.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <template > <div class ="todo-header" > <input type ="text" placeholder ="回车新增ToDo" @keyup.enter ="addToDo" /> <button @click ="addToDo" > 新增</button > </div > </template > <script > import {nanoid} from "nanoid" ;export default { name : "ToDoHeader" , methods : { addToDo (e ){ let input = e.target .tagName === "INPUT" ? e.target : e.target .parentNode .firstElementChild if (input.value === "" ){ return } let todo = { id : nanoid (), title : input.value , complete : false } input.value = "" ; console .log (todo); } } }; </script >
修改todos数组 通过 props 子组件能接收父组件传来的数据,但现在 ToDoHeader.vue 与 ToDoList.vue 是兄弟组件
实现兄弟组件之间的通信有很多种办法:全局事件总线、消息对外发布、vuex等
这些高级的办法后面再学,现在先用一种老办法:让兄弟组件共同的父组件(App.vue)去管理数据(todos),通过 props 传数据给子组件使用,父组件通过传一个函数来接收子组件的数据(让子组件在合适时候调用该接口函数,并传入父组件所需的数据)
修改 App.vue:
App.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <template > <div id ="app" > <div id ="todo-container" > <div id ="todo-wrap" > <ToDoHeader :receive ="receive" > </ToDoHeader > <ToDoList :todos ="todos" > </ToDoList > <ToDoFooter > </ToDoFooter > </div > </div > </div > </template > <script > import ToDoHeader from "./components/ToDoHeader.vue" ;import ToDoList from "./components/ToDoList.vue" ;import ToDoFooter from "./components/ToDoFooter.vue" ;export default { name : "App" , components : { ToDoHeader , ToDoList , ToDoFooter , }, data ( ){ return { todos : [ { id : "001" , title : "第一件事" , complete : false }, { id : "002" , title : "第二件事" , complete : true }, { id : "003" , title : "第三件事" , complete : false } ], } }, methods : { receive (value ){ this .todos .unshift (value); } } }; </script >
自定义事件与$emit() 子组件调用父组件的函数,以向父组件传数据 ,更规范的做法是用组件的自定义事件
用法: 父组件给子组件实例绑定一个自定义事件 ,并且指定回调 (父组件中的函数),子组件在合适的时候 使用 $emit()
触发该自定义事件,并且给该事件的回调函数传入所需的参数,以此实现子组件向父组件传数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <ToDoHeader @receive ="receive" > </ToDoHeader > <script > export default { methods : { receive (value ){ this .todos .unshift (value); } } } </script > <button @click ="addToDo" > 新增</button > <script > export default { methods : { addToDo (e ){ let todo = {} this .$emit('receive' ,todo) } } } </script >
当一个子组件要绑定多个 事件时,在组件标签上写太多绑定就过于臃肿了,可以使用 $refs
获取子组件实例对象,再在父组件生命周期 mounted()
上用 $on()
绑定自定义事件
并且这样动态绑定 事件更加灵活
注意: 绑定在组件标签上的事件都会被当做自定义事件,所以若要绑定原生事件 (如click),需加上 native 修饰符
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <ToDoHeader ref ="ToDoHeader" @click.native ="alter('原生事件')" > </ToDoHeader > <script > export default { methods : { receive (value ){ this .todos .unshift (value); } }, mounted ( ){ this .$refs .ToDoHeader .$on('receive' ,this .receive ); } } </script >
注意: 看起来是子组件触发父组件的回调,但 $emit 底层并不是触发 ,而是冒泡
有时要在合适时刻用vc 上的 $off(<事件名>)
解绑事件,解绑多个事件则传入事件名数组 ,若不传入参数则解绑所有事件
完善勾选功能 功能: 页面中勾选 todo 后,修改对应数据的 complete 属性,以表示 todo 是否已完成
实现方式: 点击勾选后,拿到该 todo 的 id 值,传给管数据的 App.vue,找到对应的 todo,让其 complete 取反
由于ListItem是App的孙子,所以要先通过 $emit()
将数据传给父组件(ToDoList),再由父组件传给爷爷组件(App)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 <ToDoList :todos ="todos" @updateComplete ="updateComplete" > </ToDoList > <script > updateComplete (id ){ this .todos .forEach ((item )=> { if (item.id === id){ item.complete = !item.complete ; } }); } </script > <ListItem v-for ="item in todos" :key ="item.id" :todo ="item" @receiveId ="receiveId" > </ListItem > <script > receiveId (id ){ this .$emit('updateComplete' , id) } </script > <input type ="checkbox" :checked ="todo.complete" @change ="changeComplete(todo.id)" > <script > changeComplete (id ){ this .$emit('receiveId' , id) } </script >
完善删除功能 功能: 点击每个todo对应的删除按钮就在数据中删除该todo
实现起来和勾选功能差不多,也是逐层传id给App,再遍历查找删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 <ToDoList :todos ="todos" @updateComplete ="updateComplete" @deleteToDo ="deleteToDo" > </ToDoList > <script > deleteToDo (id ) { this .todos .forEach ((item, index, arr ) => { if (item.id === id) { arr.splice (index, 1 ) } }); } </script > <ListItem v-for ="item in todos" :key ="item.id" :todo ="item" @receiveId ="receiveId" @deleteToDo ="deleteToDo" > </ListItem > <script > deleteToDo (id ){ this .$emit('deleteToDo' , id) } </script > <button class ="list-item-btn" @click ="deleteToDo(todo.id)" > 删除</button > <script > deleteToDo (id ) { if (!confirm ('确认删除吗?' )) { return } this .$emit('deleteToDo' , id) } </script >
完善底部 功能: 全选按钮、已完成统计、清除所有已完成按钮
实现: 使用计算属性并遵循数据驱动,结合之前的父子组件通信方法很容易实现
ToDoFooter.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 <template > <div id ="todo-footer" > <div v-show ="this.todos.length" > <input type ="checkbox" v-model ="checkValue" > <div class ="statistics" > 已完成{{ doneToDo }} / 全部{{ todos.length }}</div > <button @click ="deleteComplete" > 清除已完成</button > </div > <div class ="tip" v-show ="!this.todos.length" > 没有ToDo</div > </div > </template > <script > export default { name : 'ToDoFooter' , props : ['todos' ], computed : { doneToDo ( ) { return this .todos .reduce ((pre, item ) => { return pre + (item.complete ? 1 : 0 ) }, 0 ) }, checkValue : { get ( ) { return this .doneToDo === this .todos .length && this .todos .length > 0 ; }, set ( ) { this .changeAll () return } } }, methods : { changeAll ( ) { this .$emit('changeAll' , !this .checkValue ) }, deleteComplete ( ) { this .$emit('deleteComplete' ) } } } </script >
App.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <ToDoFooter :todos ="todos" @changeAll ="changeAll" @deleteComplete ="deleteComplete" > </ToDoFooter > <script > changeAll (checkValue ){ this .todos .forEach ((item ) => { if (item.complete !== checkValue) { item.complete = checkValue; } }); }, deleteComplete ( ){ this .todos = this .todos .filter ((item ) => { return !item.complete ; }); } </script >
ToDo本地存储 使用 localStorage 保存todos,初始化todos,本地存储有则拿,没有则赋值空数组
watch 监视 todos 的变化,数据多层开启深度监视,只要数据发生改变就更新 本地存储的数据
App.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 export default { data ( ) { return { todos : JSON .parse (localStorage .getItem ("todos" )) || [], } }, watch : { todos : { deep : true , handler (value ) { localStorage .setItem ("todos" , JSON .stringify (value)); } } } };
全局事件总线 在之前,使用 props 、自定义事件、$emit()
来实现父组件 与子组件 的通信(数据传输)
现在可以通过全局事件总线 实现任意组件 之间的通信
实现全局事件总线并不需要学习新的API,本质还是通过自定义事件来实现的,是一种经验
实现: 1、总线: vm 和所有 vc 都能获取的东西,所以要将其放在 Vue 的原型 上2、事件总线: 这个总线应用要能够调用 $on()
绑定各种事件,能够调用 $emit()
触发事件,因为 $on()
和 $emit()
两个方法都在 Vue 原型 上,所以使用现成的 vm 作为总线即可,在生命周期 beforeCreate()
将 vm 放到 Vue.prototype 上,Vue.prototype.$bus = this
1 2 3 4 5 6 new Vue ({ render : h => h (App ), beforeCreate ( ){ Vue .prototype .$bus = this ; } }).$mount('#app' )
使用全局事件总线$bus
: A 组件需要 B 组件传来某些数据,则 A 组件调用 $bus
的 $on()
方法绑定一个自定义事件 ,事件的回调函数写在 A组件中,然后在 B 组件中调用 $bus
的 $emit()
方法,触发对应的事件 ,并传入 A 组件所需的数据,于是 A 组件就能通过回调函数收到 B 组件传来数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 export default { name : 'A' methods : { receive (data ){ console .log (`收到了数据:${data} ` ); } }, mounted ( ){ this .$bus .$on('receive' , this .receive ) } } export default { name : 'B' methods : { sendOut ( ){ let data = 'chuckle' this .$bus .$emit('receive' , data) } } }
简单得说,就是在 Vue 原型上的 $bus 绑定了一堆自定义事件,通过自定义事件的回调接收与触发,就实现了任意组件间的通信
当然,若事件较多,可以不使用vm作为全局事件总线,不同类型组件的通信,单独 new 一个 vc 出来当事件总线
注意: 规范起见,每个组件应该在生命周期 beforeDestroy()
时解绑事件
1 2 3 beforeDestroy ( ){ this .$bus .$off('<要解绑的事件名>' ) }
Vue3中没有Vue(),而是createApp(),且$on $off $once都已废弃,所以Vue3中使用全局事件总线要安装第三方库:mitt
TodoList使用$bus 让 ListItem 组件通过全局事件总线,直接和管理 todos 数据的 App 组件通信,不再经过 ToDoList 组件
修改 App.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <ToDoList :todos ="todos" > </ToDoList > <script > export default { name : "App" , mounted ( ){ this .$bus .$on('updateComplete' , this .updateComplete ) this .$bus .$on('deleteToDo' , this .deleteToDo ) } } </script >
修改 ListItem.vue 的相关函数
1 2 3 4 5 6 7 8 9 10 11 12 changeComplete (id ) { this .$bus .$emit('updateComplete' , id) }, deleteToDo (id ) { if (!confirm ('确认删除吗?' )) { return } this .$bus .$emit('deleteToDo' , id) }
消息发布与订阅 消息发布与订阅 可以实现任意组件 间的通信
提供 数据的组件发布消息 ,接收 数据的组件订阅该消息
vue并不自带消息库,原生js实现起来也麻烦,所以一般调用第三方库,这里使用 pubsub-js
安装: npm i pubsub-js
使用: subscribe()
订阅消息、publish()
发布消息、unsubscribe()
根据id取消订阅
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import pubsub from 'pubsub-js' ;const subID = pubsub.subscribe ('<消息名>' , (msgName, data )=> { console .log (data); }) pubsub.publish ('<消息名>' , '<数据>' ) pubsub.unsubscribe (subID)
对比全局事件总线,两者非常相似
TodoList使用pubsub 在ListItem 与 App 组件通信,将每个todo的删除功能改为消息发布与订阅 实现,勾选功能仍然保留全局事件总线的实现,方便对比学习
修改 App.vue 的相关函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export default { name : "App" , mounted ( ){ this .$bus .$on('updateComplete' , this .updateComplete ) pubsub.subscribe ('deleteToDo' , (magName, data )=> { magName; this .deleteToDo (data) }) } }
修改 ListItem.vue 的相关函数
1 2 3 4 5 6 7 8 9 10 11 deleteToDo (id ) { if (!confirm ('确认删除吗?' )) { return } pubsub.publish ('deleteToDo' , id); }
添加编辑功能 这个功能较为综合
功能: 点击编辑按钮,title变成可编辑的input框并获取焦点,编辑按钮变取消按钮,并显示保存按钮,输入框失去焦点后也能保存修改
修改 App.vue 相关函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 methods : { editToDo (id,value ){ this .todos .forEach ((item, index, arr ) => { if (item.id === id) { arr[index].title = value; } }); } } mounted ( ){ pubsub.subscribe ('editToDo' , (magName,arr )=> { magName; this .editToDo (arr[0 ],arr[1 ]) }) }
大改 ListItem.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 <template > <li > <label > <input v-show ="!isEdit" type ="checkbox" :checked ="todo.complete" @change ="changeComplete(todo.id)" > <span v-show ="!isEdit" ref ="titleToDo" > {{ todo.title }}</span > <input v-show ="isEdit" class ="todo-title-input" type ="text" :value ="todo.title" ref ="titleInput" @blur ="editToDo(todo.id)" contenteditable ="true" > </label > <div class ="btn-box" > <button v-show ="isEdit" class ="list-item-btn" @click ="editToDo(todo.id)" > 保存</button > <button v-show ="isEdit" class ="list-item-btn" @click ="completeEdit()" > 取消</button > <button v-show ="!isEdit" class ="list-item-btn" @click ="edit()" > 编辑</button > <button class ="btn-delete" @click ="deleteToDo(todo.id)" > 删除</button > </div > </li > </template > <script > import pubsub from "pubsub-js" ;export default { name : 'ListItem' , props : ['todo' ], data ( ) { return { isEdit : false , } }, methods : { changeComplete (id ) { this .$bus .$emit('updateComplete' , id) }, deleteToDo (id ) { if (!confirm ('确认删除吗?' )) { return } pubsub.publish ('deleteToDo' , id); }, edit ( ) { this .isEdit = true ; this .$nextTick(()=> { this .$refs .titleInput .focus (); }); }, completeEdit ( ) { this .isEdit = false ; this .ifEdit = false ; }, editToDo (id ) { this .completeEdit () let input = this .$refs .titleInput ; if (input.value === this .todo .title ) return ; if (input.value .trim () === "" ) { alert ('ToDo内容为空!请重新修改' ) return ; } pubsub.publish ('editToDo' , [id, input.value ]); } } } </script >
$nextTick 在编辑功能中,通过 $nextTick()
方法实现了input框出现时自动获取焦点
作用: 在下一次DOM更新结束后执行其指定的回调
何时使用: 当改变数据后,要基于更新后的新DOM进行某些操作时,要在 $nextTick()
的回调函数中进行操作
1 2 3 4 5 6 7 8 9 edit ( ) { this .isEdit = true ; this .$nextTick(()=> { this .$refs .titleInput .focus (); }); }
也可以不使用这个 API,直接包裹一个没设定时间的定时器,由于事件循环 的机制,也能成功对更新后的新DOM进行某些操作