目录优化可能是一个很容易被忽略的问题,但是很多情况下会带来很多的问题,比如卡屏,比如交互体验差等。
目录的常见实现方案
1.真实分页:每次只加载一页目录,用户通过一定的操作(点击按钮或者上拉下拉)来请求服务器并获得新页面的目录内容。其缺点也是比较多的:
- 请求服务器的频率太高,用户通常情况下很难精确的知道想要看的章节在哪一页,那么势必会导致每次查阅目录都会请求多次服务器。
- 用户体验差,通常情况下用户是希望快速浏览所有目录内容并定位自己目标章节的,这样的形式显然降低了用户的体验。
- 页面卡顿,在弱网络环境下每次请求的响应时间都比较长,可能用户大部分的时间都花在等待请求结果上。
2.虚拟分页:一次性拉取全部目录,前端虚拟模拟分页来实现分页机制。这也是存在一些缺点的:
- 如果是按钮的形式会显得很鸡肋,既然都已经加载了全部目录,为什么还要通过不停的点击来一页页地展示目录内容?
- 如果是上拉加载下一页的,那么是否需要保留已加载的目录?如果不保留,当下拉过多时仍然会卡顿;如果保留,那么保留多少?
- 用户可能想要快速地浏览一遍目录内容,那么一页页地加载势必会阻挡用户浏览的速度。
3.全部加载:最简单粗暴的就是一次拉取所有的目录并且展示到页面上。但这个缺点也是很明显的:
- 目录可能动辄几千章,这么多的dom元素,很容易出现卡屏现象,在安卓或者老版手机上可能就直接卡死了(我不是想黑安卓,但在测试中确实有这种情况)
- 浪费严重,目录有几千章,但是同一时刻屏幕上也就显示几十章,那么剩余的百分之九十九的dom元素其实都是无用浪费的。
4.动态加载:根据用户的滚动操作去动态计算要展示的内容,从用户角度考虑目录的翻滚是连续的,仿佛所有的目录内容都是展示的。从性能考虑,其实展示的目录条也就几十个,不会出现卡屏现象。
动态加载需要考虑的因素
- 上下要做缓存,元素渲染是需要时间的,用户不能每次拉动一点点都要出现空白区域。一般可考虑上下各缓存一屏的大小,当用户向上滚动半屏的时候,上面再增加半屏的内容,下面减少半屏的内容,这样给用户的感觉就是连续的,是一直能看到内容的,不会出现空白的区域。但是当用户快速滑动的时候,仍然会出现短暂的空白时间(毫秒级),但这个是可以接受的。
- 滚动区域的大小是完整的,是不变的。假设所有目录全部展示的时候高度为1000px,那么无论任何时候,可滚动区域都是1000px,也就是说可视目录的上下都需要有空白的占位块。
- 滚动触发的频率是很快的,需要做节流。因为需要根据当前滚动位置去动态计算可视目录的内容,而滚动事件触发的频率是很快的,所以需要做一定的节流,节流时间可以是500ms。
动态加载实现方案(一)
在滚动元素(scroll-view)中添加一个块级元素(container),高度为全部目录内容高度总和,用于占位,在container有一个可视区域块(viewer),用于放置展示的目录内容,这个可视区域块是绝对定位的,会根据滚动位置动态修改top值来保证用户永远可以看到这个可视区域,可视区域内部的目录内容也是动态计算的。
动态加载实现方案(二)
在滚动元素(scroll-view)中添加一个块级元素(container),高度为全部目录内容高度总和,用于占位,在container中放置所有的目录内容,每一个目录条都是绝对定位的,如果总共有一千章,那么这一千个目录都是绝对定位的,各自决定自己的位置。然后通过隐藏不可见区域的目录即可(v-if)
Demo(mpvue)
<template>
<scroll-view
scroll-y
:scroll-with-animation="true"
@scroll="scroll"
class="container menu menu-container ">
<view :style="{height: maxHeight + 'px', position: 'relative'}" v-if="!showLoading">
<view
v-for="item in menuList"
:style="{position: 'absolute', top: item.top + 'px', width: '100%'}"
:key="item.name"
v-if="item.top > scrollTop - clientHeight && item.top < scrollTop + 2 * clientHeight">
<!--目录有卷名和章节之分,两个的高度是不一样的-->
<view class="title " :class="{'title-dark': isDark}" v-if="item.type === 'vs'">{{item.name}}</view>
<view class="list " :class="{'isVip': !item.sS}" v-else @tap="go(!item.sS, item.id)">
<view class="list-title" :class="{'current-chapter': item.id == currentChapterId }">{{item.name}}</view>
<view class="list-time">{{item.time}}</view>
<image class="vip-icon" v-if="!item.sS" :src="isDark ? lockGray : lockBlack"></image>
</view>
</view>
</view>
<view class="loading-container" v-if="showLoading">
<loading></loading>
<label class="label-loading">目录加载中</label>
<!--因为scroll返回的top单位是px,而mpvue中的单位rpx,所以需要先获取到样板的高度,并计算占位container的高度-->
<view class="title" style="position: absolute; opacity: 0;"></view>
<view class="list" style="position: absolute; opacity: 0;"></view>
</view>
</scroll-view>
</template>
<script>
import $http from '@/plugins/http';
import loading from './loading';
export default {
props: {
bookId: {
type: Number,
default: 0,
},
currentChapterId: {
type: Number,
default: 0,
},
},
components: {
loading: loading,
},
data() {
return {
menuList: [], // 目录列表
listHeight: 135, // list对应的px高度(不同屏幕动态计算)
titleHeight: 72, // title对应的px高度(不同屏幕动态计算)
maxHeight: 1000, // 滚动区域的高度
scrollTop: 0, // 当前的滚动位置
clientHeight: 1300, // 屏幕的高度,这里就随便取了个值
throttleTime: 500, // 节流时间
scrollStartTime: null, // 节流开始时间
scrollTimeout: null, // 滚动计时器
showLoading: true, // 是否展示loading动画
lockGray: '../../static/images/menu/lock-gray.svg',
lockBlack: '../../static/images/menu/lock.svg',
};
},
methods: {
/*
* 初始化目录列表,把后台返回的数据重新整理成需要的结构
*/
initMenuList(vs) {
this.menuList = [];
for (let i = 0; i < vs.length; i++) {
this.menuList.push({type: 'vs', name: vs[i].vN});
for (let j = 0; j < vs[i].cs.length; j++) {
this.menuList.push({
type: 'cs',
name: vs[i].cs[j].cN,
sS: vs[i].cs[j].sS,
id: vs[i].cs[j].id,
time: vs[i].cs[j].uT,
});
}
}
},
/*
* 计算滚动区域的最大高度
*/
computeMaxHeight() {
this.maxHeight = 0;
// this.maxHeight += this.$root.$parent.$root.$parent.globalData.isIphoneX ? 50 : 0; // 兼容iphoneX
for (let i = 0; i < this.menuList.length; i++) {
this.menuList[i]['top'] = this.maxHeight;
this.maxHeight += this.menuList[i].type === 'vs' ? this.titleHeight : this.listHeight;
}
},
/*
* 获取目录章节信息
*/
getMenu() {
this.showLoading = true;
// 根据书本ID获取书本信息
$http.get(`/ajax/book/category?bookId=${this.bookId}`).then((data) => {
this.initMenuList(data.vs);
this.showLoading = false;
let query = wx.createSelectorQuery();
query.select('.title').boundingClientRect();
query.select('.list').boundingClientRect();
query.exec((res) => {
// 动态获取目录条的高度(px单位)
this.titleHeight = res[0].height;
this.listHeight = res[1].height;
this.computeMaxHeight();
});
}).catch(() => {
this.showLoading = false;
wx.showModal({
title: '目录加载失败',
content: '目录获取失败,是否重新尝试?',
}).then((res) => {
if (res.confirm) {
this.getMenu();
}
});
});
},
/*
* 滚动事件监听
*/
scroll(evt) {
const tCurr = new Date();
clearTimeout(this.scrollTimeout);
if (!this.scrollStartTime) this.scrollStartTime = tCurr;
if (tCurr - this.scrollStartTime > this.throttleTime) {
this.scrollTop = evt.mp.detail.scrollTop;
} else {
this.scrollTimeout = setTimeout(() => {
this.scrollTop = evt.mp.detail.scrollTop;
}, this.throttleTime);
}
},
// 跳转到对应章节
go(isVip, vid) {
this.$emit('goChapter', vid);
},
},
mounted() {
this.getMenu();
},
};
</script>
<style lang="scss">
.menu{
font-size: 24rpx;
height: 100%;
background: #fff;
color: black;
box-sizing: border-box;
padding-left: 32rpx;
&-dark { background: #3E3E3E;
color: #808080;
}
}
.loading-container {
padding-right: 32rpx;
display: flex;
justify-content: center;
align-items: center;
font-size: 28rpx;
font-weight: 300;
height: 100%;
.label-loading { width: 200rpx;
}
}
.title {
height: 72rpx;
line-height: 72rpx;
border-bottom: 1rpx solid #E6EBF2;
border-top: 1rpx solid #E6EBF2;
&-dark { border-bottom: 1rpx solid #4d4d4d;
border-top: 1rpx solid #4d4d4d;
}
}
.list {
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
padding-right: 80rpx;
box-sizing: border-box;
line-height: 1.2;
height: 136rpx;
.list-title { font-size: 32rpx;
font-weight: 400;
margin-bottom: 8rpx;
width: 100%;
text-overflow: ellipsis;
&.current-chapter { color: #ED424B;
}
}
.list-time {
font-size: 24rpx;
line-height: 1.5;
font-weight: 300;
}
&.isVip .vip-icon {
float: right;
width: 36rpx;
height: 36rpx;
position: absolute;
top: 45rpx;
right: 45rpx;
}
}
</style>