评论功能实现

评论功能实现

效果图展示

组件结构

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 // 通用comment组件
│ │ │ │ ├─ CommentArea.vue
│ │ │ │ ├─ CommentBtn.vue
│ │ │ │ ├─ CommentCtn.vue
│ │ │ │ ├─ CommentItem.vue
│ │ │ │ └─ middle.js // 空Vue实例,作为通信中转
│ │ └─ content
│ │ ├─ comment
│ │ └─ Comment.vue // 项目comment组件
│ ├─ main.js
│ ├─ network // Post请求
│ │ ├─ index.js
│ │ └─ request.js
│ └─ views
│ ├─ Main.vue // 项目入口
│ └─ user
│ └─ UserPage.vue // 组件展示页面
└─ vue.config.js

egg

1
2
3
4
5
6
7
8
9
├─ app
│ ├─ controller
│ │ └─ comment.js
│ ├─ router.js
│ └─ service
│ └─ comment.js
├─ config
├─ config.default.js
└─ plugin.js

数据组织结构


1
2
3
4
5
6
7
8
9
10
11
Comment: [
{
//主评论者
reviewer: String,
// 被回复者
responder: String,
date: String,
content: String,
index: Number,
},{},...
]

总体设计思路

  1. CommentArea.vue: 未输入任何内容时,<textarea>显示默认提示文本信息,点击发布弹出全局对话框“发布内容不能为空!”。输入内容并点击发布,将信息展示到 CommentItem.vue 中,并写入数据库。写入成功则弹出全局对话框“评论发布成功,已同步到数据库~”,写入失败弹出全局对话框“数据库写入失败”。点击”发布”或“清空”按钮均会删除<textarea>中的文本信息,并重新聚焦。
  2. CommentItem.vue: 展示评论信息,目前仅支持二级评论。主评论无缩进,子评论缩进并更改为回复模式。更换至回复模式的方法:点击想要评论的发布者姓名,在发布评论功能栏中提示“正在回复xxx”字样后,输入评论并发布即可。评论展示区设置了滚动条,当评论数量超出区域展示范围时,显示滚动条,在设计过程中,本组件自动在发布评论后将滚动条聚焦到底部。
  3. 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() {
// (imp)基于响应式的数据请求:通过逐个添加入数组(arr.push),保证Vue对数据的响应
// 不能用 comment = [xxx], 相当于修改了指向, 将Vue初始化时对数组添加的watcher覆盖了
// 若没有响应式, 则需要在 <CommentCtn> 组件内添加 $watch 方法监听 comment (之前在用的方法,现在找到原因了)
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 不能检测以下数组的变动:

  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如: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实例

1
2
3
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: {
// (imp)利用中转EventBus实现兄弟组件通信,跨级组件通信
// 本质: new 一个空 Vue 实例,向它内部添加事件发送和事件监听
// vm.$emit()发送,vm.$on()监听并执行回调
value(newValue, oldValue) {
Middle.$emit("textChange", newValue);
},
},
created() {
// (imp)回调的监听可在组件创建的一开始就开启
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() {
// this -> 当前组件 vm
// console.log(this);
if (this.content) {
this.$emit("addComment", this.content);
Middle.$emit("clearContent");
} else {
this.$Message.info('发布内容不能为空!')
}
// 点击发布后,重新聚焦到textarea
document.getElementById('comment-textarea').focus();
},
clear() {
Middle.$emit("clearContent");
},
},
created() {
// 监听事件并回调在组件创建阶段开启
Middle.$on("textChange", (value) => {
// (imp)中转Vue实例的this指向:
// 此处 this -> Middle (vm),Middle 作为中转,this 取决上下文? 所以 this -> 当前组件 vm ?
// console.log(this);
this.content = value;
});
},
};
</script>

<style scoped>
...
</style>

middle.js

1
2
3
import Vue from 'vue';
// 作为中转站,承担发送事件$emit(传参),监听事件$on并处罚回调的作用
export default new Vue();

重点:
在通用组件编写中,我们碰到兄弟通讯,跨级通讯,往往需要多级父子传递过程,比较麻烦。为了组件的复用性,又不能使用vuex全局管理。此处提出了一种方法:通过空Vue实例实现数据传递。
具体操作:

  1. 新建 js 文件,new 一个 Vue 实例并导出
  2. 在需要发送事件的地方导入 js,通过 vm.$emit() 方法传递参数 vm.$emit()
  3. 在需要接收参数的地方导入 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


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!