评论功能实现 效果图展示
组件结构 vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ├─ src │ ├─ components │ │ ├─ common │ │ │ ├─ comment │ │ │ │ ├─ CommentArea . vue │ │ │ │ ├─ CommentBtn . vue │ │ │ │ ├─ CommentCtn . vue │ │ │ │ ├─ CommentItem . vue │ │ │ │ └─ middle.js │ │ └─ content │ │ ├─ comment │ │ └─ Comment . vue │ ├─ main.js │ ├─ network │ │ ├─ index.js │ │ └─ request.js │ └─ views │ ├─ Main . vue │ └─ user │ └─ UserPage . vue └─ vue.config.js
egg
├─ app │ ├─ controller │ │ └─ comment.js │ ├─ router.js │ └─ service │ └─ comment.js ├─ config ├─ config.default.js └─ plugin.js
数据组织结构
Comment : [ { //主评论者 reviewer: String , // 被回复者 responder: String , date : String , content : String , index : Number , },{},... ]
总体设计思路
CommentArea.vue
: 未输入任何内容时,<textarea>
显示默认提示文本信息,点击发布弹出全局对话框“发布内容不能为空!”。输入内容并点击发布,将信息展示到 CommentItem.vue
中,并写入数据库。写入成功则弹出全局对话框“评论发布成功,已同步到数据库~”,写入失败弹出全局对话框“数据库写入失败”。点击”发布”或“清空”按钮均会删除<textarea>
中的文本信息,并重新聚焦。
CommentItem.vue
: 展示评论信息,目前仅支持二级评论。主评论无缩进,子评论缩进并更改为回复模式。更换至回复模式的方法:点击想要评论的发布者姓名,在发布评论功能栏中提示“正在回复xxx”字样后,输入评论并发布即可。评论展示区设置了滚动条,当评论数量超出区域展示范围时,显示滚动条,在设计过程中,本组件自动在发布评论后将滚动条聚焦到底部。
egg
: 前端发起get请求时,egg读取数据库所有评论并返回前端,在页面展示;前端发起post请求时,将评论信息通过data传输给后端,后端接收后写入数据库内。
代码讲解 此处只针对本人在组件开发时遇到的一些问题或个人认为重要的点进行讲解。不对代码做一一解读,最后会贴出本项目所有代码,以供参考。 代码解读顺序按照一次完整的评论流程进行:
数据请求与传递 Comment.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 <template > <CommentCtn :comment ="comment" :curClient ="curClient" :replyClient ="replyClient" /> </template > <script > import CommentCtn from "@/components/common/comment/CommentCtn" ;import { commentRequest } from "@/network" ;export default { name: "Comment" , components: { CommentCtn, }, data ( ) { return { width: "600px" , height: "700px" , curClient: "WangJT" , replyClient: "xxx" , comment: [], }; }, async created ( ) { const comments = await commentRequest(); comments.map((obj ) => { this .comment.push(obj); }); }, };</script > <style > </style >
重点: 在初始渲染时,我们需要get请求数据库内已存储的评论,并将它传递给组件进行渲染。但由于get请求是异步操作,还未传入数据,<Comment>
组件就渲染完成了,不当的操作会导致评论渲染不成功。主要原因和解决关键在于Vue的响应机制 。(可先看下面关于Vue响应式系统的讲解) 方案一: 言归正传,此处我们在初始化实例前声明了响应式propety – comment:[]
,此时任何向 comment
添加数据的行为有可能触发响应,注意是有可能:this.comment = [new array]
就不会触发。因此,我们要保证请求到数据(一个数组)后,通过遍历的方式通过arr.push()
逐个添加到评论内,从而触发响应式,进行实时渲染。 方案二: 若我们执意用this.comment = [new array]
来替换已被添加至响应式系统里的数组时(替换了就相当于将数组从响应式系统中移除,又重新赋给了变量一个新的数组地址,不再具有响应式),我们需要通过this.$watch
手动监测coment
数组的变化,并实时渲染。以下为Vue官方文档深入响应式原理 的原话: 当一个 Vue 实例被创建时,它将 data 对象中的所有的 property 加入到 Vue 的响应式系统中。当这些 property 的值发生改变时,视图将会产生“响应”,即匹配更新为新的值。值得注意的是只有当实例被创建时就已经存在于 data 中的 property 才是响应式的。也就是说如果你添加一个新的 property,并对其后续做改动将不会触发任何视图的更新。 Vue 不允许动态添加根级响应式 property,所以你必须在初始化实例前声明所有根级响应式 property,哪怕只是一个空值。更深层次理解: 当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data
选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty
把这些 property 全部转为 getter/setter
。这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。 Vue响应式系统的限制: 由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。 对于对象: Vue 无法检测 property 的添加或移除(只能检测修改)。由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的。 解决方法: (逐个添加属性)Vue.set(object, propertyName, value)
vm.$set(object, propertyName, value)
(添加多条属性)this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })
对于数组: Vue 不能检测以下数组的变动:
当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
当你修改数组的长度时,例如:vm.items.length = newLength
解决方法:Vue.set(vm.items, indexOfItem, newValue)
arr.splice()
arr.push()
… 等数组原生内建方法
初始渲染 CommentCtn
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 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 <template > <div id ="comment-container" :style ="{ width, height }" > <h2 > 评论</h2 > <div v-if ="emptyComment" class ="card" > <span > 暂无评论,请发表第一条评论吧</span > </div > <div v-else > <ul class ="card" id ="comment-list" > <li v-for ="(item, idx) in mainComments" :key ="idx" > <CommentItem :allComments="comment" :comment="item" :index="idx" @replyComment="replyComment" /> </li > </ul > </div > <h2 > 发表评论</h2 > <h4 v-show ="curSelect" > 正在回复 {{ curSelect }} </h4 > <CommentArea /> <CommentBtn @addComment ="addComment" /> </div > </template > <script > import CommentItem from "@/components/common/comment/CommentItem"; import CommentArea from "@/components/common/comment/CommentArea"; import CommentBtn from "@/components/common/comment/CommentBtn"; import { commentPost } from "@/network"; export default { name: "CommentCtn", components: { CommentItem, CommentArea, CommentBtn, }, data() { return { selectComment: -1, }; }, props: { /* { reviewer:String, responder:String, date:String, content:String, index:Number } [] */ // 评论列表 comment: Array, curClient: String, replyClient: String, width: { type: String, default: "600px", }, height: { type: String, default: "700px", }, }, computed: { // 判断评论是否为空,为空则显示占位文本 emptyComment() { return !this.comment.length; }, // 通过选中项的idx与主评论的idx匹配,确定选择项的被回复者ID(目前只支持二级评论) curSelect() { if (this.selectComment !== -1) { // (imp)对象数组常用处理方法 return this.mainComments.find((obj) => { return obj.index === this.selectComment; }).reviewer; } }, // 主评论 mainComments() { // (imp)对象数组常用处理方法 return this.comment.filter((obj) => { return obj.responder == "undefined"; }); }, mainCommentsNum() { return this.comment.filter((obj) => { return obj.responder == "undefined"; }).length; }, }, methods: { // 日期转换(Date对象转字符串) dateTransform(date) { let y = date.getFullYear(); let m = date.getMonth() + 1; m = m < 10 ? "0 " + m : m ; let d = date.getDate(); d = d < 10 ? "0 " + d : d ; let h = date.getHours(); let minute = date.getMinutes(); minute = minute < 10 ? "0 " + minute : minute ; let second = date.getSeconds(); second = minute < 10 ? "0 " + second : second ; return y + "-" + m + "-" + d + " " + h + ":" + minute + ":" + second; }, addComment(content) { if (this.selectComment === -1) { // 添加主评论 const mainComment = { reviewer: this.curClient, responder: "undefined", date: this.dateTransform(new Date()), content, index: this.mainCommentsNum, }; // (imp)通过arr.push()更新comment列表,Vue会响应式更新 this.comment.push(mainComment); commentPost(mainComment).then((res) => { if (res) { this.$Message.info("评论发布成功,已同步到数据库~"); } else { this.$Message.info("数据库写入失败"); } }); } else { // 添加子评论 const subComment = { reviewer: this.replyClient, responder: this.comment.find( (obj) => obj.index === this.selectComment ).reviewer, date: this.dateTransform(new Date()), content, index: this.selectComment, }; this.comment.push(subComment); this.selectComment = -1; // 提交到数据库 commentPost(subComment).then((res) => { if (res) { this.$Message.info("评论发布成功,已同步到数据库~"); } else { this.$Message.info("数据库写入失败"); } }); } }, // (imp)自动聚焦评论区底部 setScrollBottom() { let event = document.getElementById("comment-list"); event.scrollTop = event.scrollHeight; }, replyComment(selectIdx) { this.selectComment = selectIdx; }, }, // (imp)组件内watch侦听模式 watch: { // 监听评论,评论发生变化,拉动滚动条到最底端 comment: { // (imp)this指向问题: // function() {} 内 this 取决于上下文调用环境; // 箭头函数没有 this,取最近上层块级作用域;点符号调用中,取点符号前对象 handler: function () { // 此处要用零延时setTimeout将滚动条重置滞后,确保响应系统更新后,再计算滚动条高度 // 若不用,滚动条停留在最新一条评论的前一条上方 setTimeout(() => { this.setScrollBottom(); }, 0); }, // 监听深层嵌套 deep: true, }, }, }; </script > <style scoped > ... </style >
重点: 一:处理对象数组的常用方法(map,filter,find,findIdx) 遍历所有元素并对各元素执行回调,所有回调结果返回组成新数组map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
遍历所有元素并对各元素执行回调,回调返回boolean值,若为true,则将该参与遍历的元素添加至新数组filter(predicate: (value: T, index: number, array: T[]) => unknown, thisArg?: any): T[];
遍历所有元素并对各元素执行回调,回调返回boolean值,若为true,则返回该元素并停止遍历arr.find(callback[, thisArg])
遍历所有元素并对各元素执行回调,回调返回boolean值,若为true,则返回该元素索引并停止遍历arr.findIdx(callback[, thisArg])
二:Vue 的 watch 侦听模式 官方文档:组件内watch侦听 全局$watch侦听 组件内写法(本质上是调用了全局$watch): watch是一个对象类型,接收 String 类型作为 key,其可以是需要观察的字符串或者表达式,值是对应回调函数 | 方法名 | 包含选项的对象。Vue 实例将会在实例化时调用 $watch()
,遍历 watch 对象的每一个 property。值得注意的是,watch内回调函数不要用箭头函数,因为箭头函数this绑定了父级块作用域上下文,而不是明确指向vue实例。此处用普通函数,普通函数this绑定调用上下文,watch内函数被vue实例调用,因此this始终指向vue实例
watch : { [key : string ]: string | Function | Object | Array }
实例:
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 watch: { // 1 .若key为正常字符串,可省略引号;回调函数默认接收且仅接收两个参数(侦听目标新值,侦听目标原值) abc: function(newValue,oldValue) {...} , // 2 .可用ES6 语法改写为: abc(newValue,oldValue) {...} , // 3 .直接传入已定义方法的方法名 b: 'someMethod', // 4 .特殊字符串需要加上引号 '$store.state.xxx'(newValue,oldValue){...} , // 5 .表达式(参照js计算属性使用) ['abc'+'edf'](newValue,oldValue){...} , // 6 .包含选项的对象{handler: (newValue:any , oldValue:any ) => any , deep: boolean, immediate: boolean} a:{ handler: function(newValue, oldValue) {...} , // 回调会在任何被侦听的对象的 property 改变时被调用,不论其被嵌套多深 deep: true , // 回调将会在侦听开始之后被立即调用 immediate: true , }, // 7 .若传入回调数组,它们会被逐一调用 e: [ 'handle1', function handle2 (val, oldVal) { /* ... */ }, { handler: function handle3 (val, oldVal) { /* ... */ }, /* ... */ } ], }
全局$watch : 参照官方文档,与组件内大致相同,vm.$watch
分成三部分,其中侦听目标可以是string | Function
,回调函数可以是Function | Object
,配置参数传入Object
,属性为deep & immediate
。此外 vm.$watch
还返回一个函数unwatch: Function
,调用unwatch
可以结束相应vm.$watch
的侦听(类似于setTimeout & clearTimeout)
中转EventBus:实现兄弟通信,跨级通信 CommentArea.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 <template > <textarea id ="comment-textarea" cols ="30" rows ="10" autofocus placeholder ="请发表您的看法 ..." v-model ="value" > </textarea > </template > <script > import Middle from "./middle" ;export default { name: "CommentArea" , data ( ) { return { value: "" , }; }, watch: { value (newValue, oldValue ) { Middle.$emit("textChange" , newValue); }, }, created ( ) { Middle.$on("clearContent" , () => { this .value = "" ; }); }, };</script > <style scoped > ...</style >
CommentBtn.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 <template > <div class ="row-display btn-bar" > <button @click ="clear" > <strong > 清空</strong > </button > <button @click ="submit" > <strong > 发布</strong > </button > </div > </template > <script > import Middle from "./middle" ;export default { name: "CommentBtn" , data ( ) { return { content: "" , }; }, methods: { submit ( ) { if (this .content) { this .$emit("addComment" , this .content); Middle.$emit("clearContent" ); } else { this .$Message.info('发布内容不能为空!' ) } document .getElementById('comment-textarea' ).focus(); }, clear ( ) { Middle.$emit("clearContent" ); }, }, created ( ) { Middle.$on("textChange" , (value ) => { this .content = value; }); }, };</script > <style scoped > ...</style >
middle.js
import Vue from 'vue' ;// 作为中转站,承担发送事件$emit(传参),监听事件$on 并处罚回调的作用export default new Vue();
重点: 在通用组件编写中,我们碰到兄弟通讯,跨级通讯,往往需要多级父子传递过程,比较麻烦。为了组件的复用性,又不能使用vuex全局管理。此处提出了一种方法:通过空Vue实例实现数据传递。 具体操作:
新建 js 文件,new 一个 Vue 实例并导出
在需要发送事件的地方导入 js,通过 vm.$emit()
方法传递参数 vm.$emit()
在需要接收参数的地方导入 js,通过 vm.$on
方法监听事件并触发回调 vm.$on
该方式的好处在于,新建了一个“可自由放置位置”的中转站,代替了通过父组件(甚至更多上层组件)进行传值的问题。我们只需要在传递数据的源头和接受数据的尽头引入中转站即可。疑问: 我们实例化了一个新的Vue对象,其内部 this 指向承接上下文吗?以CommentBtn.vue
为例,this 指向点符号前对象,即 Middle,但 Middle 作为空 Vue 实例,应该不存在 this.content
才对,但实际上打印出 this 发现,this 与当前组件 <CommentBtn>
相同。猜测:可能空Vue实例没有挂载到某节点,因此this从上下文获取。
2021-04-02 更新 关于上述 eventBus this 指向的疑问, 有如下解释: 虽然 emit 和 on 都在一个全局的 Vue 实例进行的, 但是基于箭头函数 this 指向取决于定义时上层块级作用域. 因此通过分析可知, 此时 on 的回调是在相应的组件中定义的 (使用箭头函数,那么回调函数中的 this 就是组件), 而 bus 则充当了一个挺有趣的中间人
评论功能代码 见 vue-project & vue-project-egg