vue.js实现带表情评论功能前后端实现 (滚动加载效果)
学习链接
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,然后又去请求数据,直到把数据请求完
问题
-
这个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
。
-
当前滚动在第二页时,我发表了一个一级评论,
代码
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++ // 分页参数 +1this.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) // 将数据添加到最后面, 由根据修改后的数据(响应式数据), 更新domthis.$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>