文章目录
学习链接
vue.js实现带表情评论功能前后端实现(仿B站评论)
实现在vue项目中通过滚动条来滑动加载数据
IntersectionObserver与无限滚动加载
效果图
每次加载2条数据
思路
要实现滚动加载,就是当滚动条滚动到底部的时候,再去请求后端评论数据,然后把数据添加到响应式数据数组中,然后由vue更新dom。
因此,我们需要能有方式能够监测的到滚动条是否滚动到了底部,一般有两种方式:
scrollTop + clientHeight == scrollHeight
-
可以通过 网页被卷去的头部高度 scrollTop 加上 浏览器客户端的高度 clientHeight 和 整个网页的高度 scrollHeight 做比较, 如果
scrollTop + clientHeight == scrollHeight,
那么就可以说明滚动条已经到底了。 -
为了避免误差, 可能还需要给scrollHeight预留一个比较小的范围。
比如 scrollHeight - (scrollTop + clientHeight) <= 10, 这也就是说网页高度在仅剩10像素还在屏幕下面时的条件。- 然后监听window的scroll事件即可
IntersectionObserver
- 也可以通过
IntersectionObserver
这个浏览器提供的api, 在评论的下面,添加一个显示正在加载的div,当这个div出现在屏幕中时,此时需要请求后台数据, - 数据请求回来后,更新dom,新添加的dom会把这个显示正在加载的div挤到最下面,当用户把新添加的内容浏览完,又去滚动,又看到这个显示正在加载的div,然后又去请求数据,直到把数据请求完
问题
问题1
- 这个IntersectionObserver浏览器的api还不太熟悉,如果加载两条之后,那个div如果还在页面,没有被挤到屏幕外面,还会回调观察函数吗?
- 为了测试这个问题,我尝试把loading-area放到comment-wrapper的第一个元素的位置,这样它一开始就是出现的,然后一直都在这个位置,然后发现它很快速的连着发请求,直到把所有分页数据都请求完了。
- 咦,我是不是忽略了一个东西了,我好像在这个正在加载中的div刚进入的比例达到0.5时,触发的请求分页数据,
在发起请求前,我把isLoading置为了true,它如果为true的话,这个正在加载中的div的display就会为none,也就相当于它不被看见,然后,请求完了之后,我又把它置为了false,它又能被看见了,这就意味着,这个过程它从没被看见到看见又是一个变化,那么有可能这个变化也能被浏览器的IntersectionObserver这个api所监测到,所以很快速的引发的一连串的请求
。好像误打误撞的无意中解决了这个问题,因为,对于这个问题,我觉得如果div还在页面的话,那就不算它又进入了,也就不会触发观察函数。就好像给了一个元素动画,但是这个元素是display:none,但是当这个元素不是display:none时,这个元素会立即有个动画。 - 经过又一波测试,发现就是第二点说的那样,
当元素在视口范围内,对display:none进行切换时,它也会触发观察函数
。 - mdn中介绍:在创建IntersectionObserver时,可以指定第二个参数,第二个参数是一个配置对象,其中有一个threshold属性,默认值为0,意思是只要超过1个像素出现在root元素(默认是视口)中,那就执行观察函数,并且如果是超过了,那么isIntersecting就是true,那又分成2种情况,一种是从在视口外进入视口,刚好元素有一个像素出现在视口,此时,观察函数回调,并且isIntersecting为true(因为超过了0),此时,这个元素又向反方向离开视口,又会触发观察函数回调,此时isIntersecting为false(因为没超过0)。如果设置为0.2,当元素刚好超过20%的像素时,isIntersecting为true,此时,当元素反方向离开视口,此时,观察函数又会回调,isIntersecting为false。
所以这个isIntersecting就是看有没有超过指定的阈值,超过了就是true,没超过就是false
。
问题2
假设一共有10条数据,每页2条。当前滚动到第二页时,我发表了一个一级评论。此时,再根据分页参数去拿的话,就会有问题!会有重复的数据,也就是一摸一样的评论出现,原因是原本在第二页的最后一条数据由于新增了一条评论,跑到第三页去了,那这条评论就被重复加载了。
- 在请教群友大佬之后,提出的解决方案:滚动加载不能按照之前常规的根据分页参数来拿,而是每次滚动看完最后一条评论后,
把当前页面的最下面的那个评论id传给后台,后台把小于这个id的最前面2条评论给查询出来,直到所有的评论都查完
。 - 假设滚动到了第2页,此时,还有另外一个人在评论,那这个人的这条评论,按时间排序会在最上面,因此,这条评论不会被查询出来,若用户继续向下滚动只会查询小于当前页面最小id的评论。那另外一个人新增的那条评论什么时候应该更新到页面上呢?可以
当用户又滚动到最上面的时候,再去查询大于最上面的评论id的评论数量,询问用户要不要加载新增的评论,要的话,再加载
。- 感觉上面的思路是可以的,出现上面问题的原因就是之前的数据还存留在页面上,如果不使用滚动加载,仅分页的话,只要用户新增一条评论,就直接请求当前页数据就好了或是回到第一页去。
下面贴的代码是会出现重复评论问题的,有功能问题,但仅作为滚动加载效果动画实现
。
代码
Comment.vue
<style lang="scss">
/* 封面图下移效果 */
@keyframes slidedown {
0% {
opacity: 0.3;
transform: translateY(-60px);
}
100% {
opacity: 1;
transform: translateY(0px);
}
}
.slidedown {
animation: slidedown 1s;
}
/* 内容上移效果 */
@keyframes slideup {
0% {
opacity: 0.3;
transform: translateY(60px);
}
100% {
opacity: 1;
transform: translateY(0px);
}
}
.slideup {
animation: slideup 1s;
}
.banner {
height: 400px;
background-image: url(@/assets/bg5.jpg);
background-size: cover;
background-position: center;
position: relative;
color: #eee;
.banner-content {
position: absolute;
bottom: 25%;
width: 100%;
text-align: center;
text-shadow: 0.05rem 0.05rem 0.1rem rgb(0 0 0 / 30%);
height: 108px;
font-size: 30px;
letter-spacing: 0.3em;
}
}
textarea {
outline: none;
border: none;
background: #f1f2f3;
resize: none;
border-radius: 8px;
padding: 10px 10px;
font-size: 16px;
color: #333333;
}
.height80 {
height: 80px !important;
}
.comment-wrapper {
// border: 1px solid red;
max-width: 1000px;
margin: 40px auto;
background: #fff;
padding: 40px 30px;
border-radius: 10px;
color: #90949e;
.comment-header {
font-size: 20px;
font-weight: bold;
color: #333333;
padding: 0 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
i {
color: #90949e;
margin-right: 5px;
font-size: 20px;
}
}
.loading-area {
.loading-effect {
height: 50px;
// border: 1px solid red;
text-align: center;
&>div {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.loading-animation {
position: relative;
}
}
.bottom-line {
height: 40px;
text-align: center;
position: relative;
display: flex;
align-items: center;
justify-content: center;
// border: 1px solid red;
span {
padding: 0 12px;
background-color: #fff;
z-index: 1;
}
&::before {
content: '';
position: absolute;
width: 100%;
border-bottom: 1px dashed #ccc;
top: 20px;
left: 0;
}
}
}
}
</style>
<template>
<div>
<navbar />
<div class="banner slidedown">
<div style="position: absolute;top: 0;left: 0;width: 100%;height: 100%;backdrop-filter: blur(5px);"></div>
<div class="banner-content">
<div>
评论
</div>
</div>
</div>
<div class="comment-wrapper shadow slideup">
<div class="comment-header">
<i class="iconfont icon-pinglun1"></i>
评论
<el-button @click="switchUser(1)">用户id1-zzhua195</el-button>
<el-button @click="switchUser(2)">用户id2-ls</el-button>
<el-button @click="switchUser(3)">用户id3-zj</el-button>
</div>
<!-- 主评论表情输入框 -->
<emoji-text @comment="comment" :emojiSize="20"></emoji-text>
<!-- 此处为渲染 评论列表, (所有的一级评论渲染列表) -->
<!-- 还有一个比较麻烦的一点:每个一级评论的最下面都有一个评论输入框,
当点击这个一级评论的回复或者这个一级评论的任一子评论的回复时,
应当把其它一级评论下的输入框给隐藏掉。
因此, 必须要能拿到所有的Reply, 并且需要知道哪个不关闭(其它的都要关掉), 所以用ref和标记index解决
所以, 只能在父组件中收集所有的Reply, 然后子组件告诉父组件如何操作。
在风宇博客中, 他是直接通过$ref拿到所有的子组件后, 通过子组件的$el属性, 通过修改$el属性的display来隐藏元素的 -->
<!-- 把当前主评论的id给到子组件的parentId属性 -->
<Reply ref="commentReplyRef" @closeOtherCommentBoxExcept="closeOtherCommentBoxExcept" :index="idx"
v-for="(reply, idx) in replyList" :key="idx" :reply="reply" />
<div class="loading-area">
<div class="loading-effect" v-show="hasMore">
<div class="loading-text" v-show="!isLoading" ref="loadingTextRef">
正在加载中{{ 'isLoading=' + isLoading }} - {{ 'hasMore=' + hasMore }}
</div>
<div class="loading-animation" v-show="isLoading">
<Loading/>
</div>
</div>
<div class="bottom-line" v-show="!hasMore">
<span>我也是有底线的</span>
</div>
</div>
</div>
</div>
</template>
<script>
import Talk from '@/components/Talk/Talk'
import Navbar from './Navbar.vue';
import EmojiText from '@/components/EmojiText/EmojiText'
import Reply from '@/components/Reply/Reply'
import Loading from '@/components/Loading/Loading'
import { getCommentListByPage, addComment } from '@/api/commentApi';
export default {
name: 'Comment',
data() {
return {
replyList: [],
pageNum: 0, /* 分页参数, 第几页, 默认第0页 */
pageSize: 2,/* 分页参数, 页大小 */
hasMore: true, /* 是否还有数据可供加载, 默认有数据 */
isLoading: false, /* 是否加载中 */
}
},
mounted() {
/* 加载评论数据 */
/* 首先尝试加载第一页数据 */
// getCommentListByPage({ pageNum: this.pageNum, pageSize: this.pageSize }).then(res => {
// this.replyList = res.list || []
// this.$nextTick(() => {
// if (res.totalCount > res.pageNum * res.pageSize) {
// this.hasMore = true
// let observer = new IntersectionObserver(entries => {
// for (let entry of entries) {
// console.log(entry);
// if (entry.isIntersecting) { // 当正在加载的文字出现的时候, 开始发起请求, 加载数据
// console.log(this, 'this');
// this.pageNum++ // 分页参数 +1
// this.loadCommentListByPage(observer)
// }
// }
// }, { threshold: 0.5 })
// observer.observe(this.$refs['loadingTextRef'])
// } else {
// this.hasMore = false
// }
// /* // 自动滚动到最下面(方便调试使用的代码)
// let scrollTop = document.documentElement.scrollHeight - document.documentElement.clientHeight
// window.scroll({
// top: scrollTop, // top: 表示移动到距离顶部的位置大小
// behavior: 'smooth'
// }) */
// })
// })
/* 优化: 上面是还没滚动到评论下面,就去加载; 改成等到了看评论的时候,再去加载。 */
let observer = new IntersectionObserver(entries => {
for (let entry of entries) {
console.log(entry);
if (entry.isIntersecting) { // 当正在加载的文字出现的时候, 才开始发起请求, 加载数据
console.log(this, 'this');
this.pageNum++ // 分页参数 +1
this.loadCommentListByPage(observer)
}
}
}, { threshold: 0.5 })
observer.observe(this.$refs['loadingTextRef'])
},
methods: {
loadCommentListByPage(observer) {
this.isLoading = true // 显示加载动画
getCommentListByPage({ pageNum: this.pageNum, pageSize: this.pageSize }).then(res => {
this.isLoading = false // 关闭加载动画
this.replyList.splice(this.replyList.length, 0, ...res.list) // 将数据添加到最后面, 由根据修改后的数据(响应式数据), 更新dom
this.$nextTick(() => {
if (res.totalCount > res.pageNum * res.pageSize) { // 证明还有数据, 还可以继续加载
this.hasMore = true
} else {
this.hasMore = false // 当前页已经是最后一页了, 后面没有更多数据了
}
})
})
},
/* 添加评论 */
comment(content) {
addComment({
userId: localStorage.getItem("userId"),
commentContent: content,
}).then(res => {
this.replyList.splice(0, 0, res)
this.$toast('success', '评论成功')
})
},
/* 模拟不同用户 */
switchUser(userId) {
localStorage.setItem("userId", userId)
this.$toast('success', `切换userId ${userId} 成功`)
},
/* 关闭其它一级评论的评论框 */
closeOtherCommentBoxExcept(index) {
/* 根据索引, 关闭其它的输入框, 除了指定的输入框外 */
this.$refs['commentReplyRef'].forEach((commentReplyRef, idx) => {
if (index != idx) {
commentReplyRef.hideCommentBox()
}
})
}
},
watch: {
},
components: {
Talk,
Navbar,
EmojiText,
Reply,
Loading
}
}
</script>
Loading.vue
<template>
<div class="loader"></div>
</template>
<script>
export default {
name: 'Loading',
components: {
}
}
</script>
<style lang="scss">
$colors:
hsla(337, 84, 48, 0.75)
hsla(160, 50, 48, 0.75)
hsla(190, 61, 65, 0.75)
hsla( 41, 82, 52, 0.75);
$size: 2.5em;
$thickness: 0.5em;
// Calculated variables.
$lat: ($size - $thickness) / 2;
$offset: $lat - $thickness;
.loader {
position: relative;
width: $size;
height: $size;
transform: rotate(165deg);
&:before,
&:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
display: block;
width: $thickness;
height: $thickness;
border-radius: $thickness / 2;
transform: translate(-50%, -50%);
}
&:before {
animation: before 2s infinite;
}
&:after {
animation: after 2s infinite;
}
}
@keyframes before {
0% {
width: $thickness;
box-shadow:
$lat (-$offset) nth($colors, 1),
(-$lat) $offset nth($colors, 3);
}
35% {
width: $size;
box-shadow:
0 (-$offset) nth($colors, 1),
0 $offset nth($colors, 3);
}
70% {
width: $thickness;
box-shadow:
(-$lat) (-$offset) nth($colors, 1),
$lat $offset nth($colors, 3);
}
100% {
box-shadow:
$lat (-$offset) nth($colors, 1),
(-$lat) $offset nth($colors, 3);
}
}
@keyframes after {
0% {
height: $thickness;
box-shadow:
$offset $lat nth($colors, 2),
(-$offset) (-$lat) nth($colors, 4);
}
35% {
height: $size;
box-shadow:
$offset 0 nth($colors, 2),
(-$offset) 0 nth($colors, 4);
}
70% {
height: $thickness;
box-shadow:
$offset (-$lat) nth($colors, 2),
(-$offset) $lat nth($colors, 4);
}
100% {
box-shadow:
$offset $lat nth($colors, 2),
(-$offset) (-$lat) nth($colors, 4);
}
}
/**
* Attempt to center the whole thing!
*/
html,
body {
height: 100%;
}
.loader {
position: absolute;
top: calc(50% - #{$size / 2});
left: calc(50% - #{$size / 2});
}
</style>