1.需求背景
当前页面存在以tab形式的多个分页,分页内展示全部列表、常用列表、收藏列表,这些列表均支持滚动分页加载,列表项的主体是图片。当前需求为鼠标悬浮图片显示遮罩层,遮罩层内提供按钮支持对列表项的收藏以及取消收藏,收藏/取消收藏后列表项对应的状态显示不同;收藏列表内取消收藏后要移除对应的列表项
2.关键点
- 取消收藏以及收藏时,列表项的信息如何刷新
- 收藏列表内,对列表项取消收藏时如何处理列表项的移除以及列表刷新
3.分析
1.全量/单独接口更新
- 实现方法:采用全量/单独的接口更新,即每次收藏/取消收藏之后都调用接口更新当前的列表项,完全保证数据的同步
- 方法分析:全量更新的方法对有很多不必要的接口调用损耗,单独获取更新后的列表项详情能够避免这种不必要的接口调用损耗。但业务场景中同时存在全部列表、常用列表、收藏列表的渲染,也就是在某个列表执行完收藏/取消收藏的操作后,需要将该列表项的最新状态传递到各个列表中。这个过程中还需要在其他列表中遍历对比锁定变化的列表项并进行相应更新,逻辑处理方面较为麻烦
- 结论:全量获取接口损耗大;单独获取需同步锁定变化列表项进行相应更新,处理复杂
2.分离接口获取与用户操作
- 实现方法:将各列表项的收藏/取消收藏操作存储在变量map当中,建立id与收藏状态的映射,在统一的父组件中管理,并传递到列表项中用作显示判断。用户收藏/取消收藏后,仅需在map中新建/修改对应的映射关系。而列表项首先判断自己的id是否在映射关系中,若有则以映射关系中的状态优先,若无则以一开始接口返回的列表项状态为准
- 方法分析:相比起全量/单独接口更新,无需任何的接口调用,接口损耗为0。前端存储用户的操作,若有用户操作则以用户操作优先,若无用户操作则以接口信息为准,很大程度上是能保证数据的同步。但若是多用户操作等情况需要保证数据的严格一致,则需进一步考虑是否要重新调用接口确保数据的一致性
- 结论:接口损耗为0,前端存储用户操作状态,在当前需求中是能完全保证数据一致性,但涉及特殊情境仍需具体分析
3.收藏列表的全量更新
- 实现方法:收藏列表中取消收藏后,先记录当前滚动位置。对列表内的数据全部重新获取,等获取完后设置将列表的滚动位置设置为记录的滚动位置
- 方法分析:全量获取会造成接口损耗,若当前已加载多页则需将多页数据重新获取,接口损耗可大可小。同时在重新设置滚动位置时会明显看到页面的滚动闪烁过程,体验较差
- 结论:接口流量可大可小、交互体验较差
4.收藏列表的尾页更新
- 实现方法:收藏列表中取消收藏后,前端在当前列表中移除对应列表项,然后重新接口获取当前加载的最后一页数据并对该页进行覆盖式更新
- 方法分析:取消收藏后,前端在当前列表中移除对应列表项,此时显示数据如同后端真实数据般将后续的列表项依次往前移一格。唯一需要关注的只是当前加载过的最后一页的数据,因为该页数据的最后一项可能由未加载的下一页中的第一项补上。因此需要在移除对应项后,重新加载最后一页的数据并进行覆盖式更新确保数据一致。这种方法的实现前提是后端真实数据的排序规则与前端数据更新方式一致
- 结论:仅重新加载一页数据,接口流量小;页面无闪动,交互体验平滑自然
4.实现
方案
- 将用户收藏/取消收藏操作存储为map,列表项状态优先以map状态为准,若无操作状态则以接口状态为准
- 收藏列表取消收藏后移除对应项,并重新获取当前加载的最后一页进行覆盖式更新
核心代码
<!-- 父组件templateList,可涵盖全部列表、常用列表等,此处以收藏列表为例 -->
<template>
<div>
<!-- 使用vue内置过渡列表实现补位动画效果 -->
<transition-group name="template" tag="div" class="templates">
<templateItem
v-for="item in favoriteTemplate.list"
:key="item.id"
:item="item"
:favoriteMap="favoriteMap"
@favorite="handleFavorite"
>
</templateItem>
</transition-group>
</div>
</template>
<script>
export default {
data() {
return {
favoriteTemplate: {
list: [],
pageSize: 20,
pageIndex: 1
},
favoriteMap: {} // 存储接口获取之后的收藏/取消收藏操作,减少接口同步获取的调用
}
},
watch: {
// 组件需要根据收藏状态变化而更新的情境中,可监听map的变动进行更新
favoriteMap: {
handler() {
/* 对应方法/接口调用 */
},
deep: true
}
},
methods: {
async fetchAllFavorite(type = 'init') {
try {
const pageIndex = favoriteTemplate.pageIndex
const pageSize = favoriteTemplate.pageSize
let page = 1
// 刷新时仅获取当前加载过的最后一页数据
if (type == 'refresh') {
page = pageIndex
}
const params = {
/* 请求参数 */
}
const res = await getFavoriteList(params)
if (res.code == 'SUCCESS' && res.data) {
const list = res.data.dataList
/* 其他请求状态处理 */
// 刷新时对末尾一页进行覆盖式更新
if (type == 'refresh') {
const startIndex = (pageIndex - 1) * pageSize
favoriteTemplate.list.splice(startIndex, pageSize, ...list)
}
}
} catch (err) {
this.$message.error('获取失败:' + err)
}
},
handleFavorite(data) {
const favoriteMap = this.favoriteMap
if (favoriteMap[data.id]) {
this.favoriteMap[data.id] = data.favorite
} else {
this.$set(this.favoriteMap, data.id, data.favorite)
}
if(!data.favorite) {
this.fetchAllFavorite('refresh')
}
},
}
}
</script>
<!-- 子组件templateItem -->
<template>
<div class="template">
<img :src="item.url" />
<div class="overlay">
<div class="icon_bg favorite_icon" @click.stop="handleFavorite">
<div
class="icon iconfont"
:class="{
'icon-star': !favorite,
'icon-star-fill': favorite
}"
></div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object
}
favoriteMap: {
type: Object
}
},
computed: {
// 核心代码:判断当前列表项的收藏状态
favorite() {
return this.favoriteMap[this.item.id] !== undefined
? this.favoriteMap[this.item.id]
: this.item.isFavorite
}
},
methods: {
async handleFavorite() {
if (this.favorite) {
/* 调用取消收藏接口 */
// 用于触发map映射信息的更新
this.$emit('favorite', {
id: this.item.id,
favorite: false
})
} else {
/* 调用收藏接口 */
// 用于触发map映射信息的更新
this.$emit('favorite', {
id: this.item.id,
favorite: true
})
}
},
}
}
</script>
<style lang="scss" scoped>
// 设置过渡列表中的动画样式,用于移除后的补位动画
.template-enter-active,
.template-leave-active {
transition: all 0.5s ease;
}
.template-leave-to {
opacity: 0;
transform: scale(0.8);
}
/* 确保离开的元素不占用布局空间 */
.template-leave-active {
position: absolute;
}
/* 移动动画 */
.template-move {
transition: transform 0.5s ease;
}
</style>