简介
长列表优化的话题是一个老生常谈的话题,我们常用的解决方案无非就是虚拟列表,分页加载的方案。
其中 分页加载 能够解决返回的数据量过大从而首次渲染慢的问题,但是无法解决DOM数量过多造成的渲染性能问题。 而 虚拟列表 能够解决渲染的DOM数量过多问题,但是无法解决首次渲染请求数据过大的问题,同时虚拟列表还存在js运行时以及动态渲染导致的重排重绘从而列表渲染帧率降低的问题。因此我们常常结合这两种技术方案来渲染长列表。
但是纵观列表数据渲染的流程,我们发现列表渲染体验除了受数据量大小,DOM数量因素影响以外,其实还受 接口响应返回时长 影响。我们一般称前者是影响用户体验的 渲染瓶颈 ,后者是影响用户体验的 网络瓶颈 ,本文将着重网络瓶颈问题给出解决方案。
优化方案
方案一:后端优化
最简单的优化方案当然是敦促后端优化接口返回速度[笑],不可否认这是最简单有效的方案,但是如果后端查询本身依赖其他服务,而其他服务调用花费时长比较长,那这种方案也爱莫能助了。
方案二:前端优化
回想传统的分页加载方案,一般是根据滚动条滚动距离,或者是IntersectionObserver,亦或是小程序环境的OnReachBottom来trigger分页请求,在请求完成之后将返回的数据append到原数据列表最后,那么这种情况下通常需要用户等待请求返回完成且拿到数据之后再渲染,如果接口返回时长超过200ms,可以说对用户体验就有比较大的影响了。
于是,我们立马想到的方案一定是想办法提前trigger请求的时机,我们可以使用intersectionObserver的同时,在更早的时机出发回调发送请求:
优化后:
在接口返回时间不是太慢的情况下,这种提前请求的方式能够使得H5环境做到下拉加载体验非常丝滑,几乎没有等待下拉加载的阻尼停滞感。
但是如果接口返回不够快,同时用户下拉速度比较快呢?显然仅仅只是这样还是不够的。
进一步优化
刚刚我们提到,优化请求的方式其实就是提前触发请求,这种方式其实也是预加载的一种,其实预加载的使用场景非常普遍,刷过抖音的小伙伴都能够体验到,打开抖音之后,我们下拉一个新的视频的时候并不需要在下拉之后加载这个视频,而是直接播放了这个视频,这说明抖音在打开之后就提前加载了多个视频,而且每个视频可以都只加载一部分,如果用户在当前视频停留一定时间之后再继续加载当前视频剩余部分,可是如果是提前加载多个视频,那具体加载多少个呢,用怎样的数据结构管理这些视频呢?很显然,应该是使用队列。
回到长列表的场景中来,我们可以用相似的思路,使用一个队列来管理我们需要加载的列表数据,设计的缓存队列状态图如下:
具体到代码如下:
function pendDispatch(pendGroup) {
return new Promise((resolve, reject) => {
pendGroup.continuePend = resolve;
pendGroup.endPend = reject;
});
}
const modeEnum = {
Serial: 'serial', //入队时机:有空闲位且前一个返回正常
Parallel: 'parallel', //入队时机:有空闲位
}
const queueStatusEnum = { //队列状态枚举值
Ready: 'ready',
Executing: 'executing'
}
const defaultPendGroup = {
continuePend: () => {},
endPend: () => {}
}
const defaultQueueLength = 3; //默认队列长度
const defaultInitialPageIndex = 1; //默认队列初始索引
class CacheQueue {
constructor(option) {
const {
mode = modeEnum.Serial, //队列模式
maxLength = defaultQueueLength, //队列长度
requestUrl, //请求调用的接口
initialPageIndex = defaultInitialPageIndex //初始索引
} = option || {
mode: modeEnum.Serial,
maxLength: defaultQueueLength,
initialPageIndex: defaultInitialPageIndex
};
this.mode = mode;
this.maxLength = Number(maxLength) > 0 ? Number(maxLength) : 1;
this.triggerUpdate = this.requestWrapper(requestUrl) //对请求函数包裹后的请求触发函数
this.initPageIndex = Number.isNaN(Number(initialPageIndex)) ? 1 : Number(initialPageIndex)
this.initialPageIndex = Number.isNaN(Number(initialPageIndex)) ? 1 : Number(initialPageIndex) //队列初始化索引
this.firstQueuePageIndex = this.initialPageIndex; //队列最小索引
this.cacheQueue = []; //队列
this.cacheQueueStatus = queueStatusEnum['Ready'] //队列状态
this.pendGroup = {...defaultPendGroup}
this.lastResult = {}; //上一个队列返回结果
this.lastResultIndex = this.initialPageIndex //上一个返回队列索引值
}
initPendGroup() {
this.pendGroup = {...defaultPendGroup}
}
async inQueue(data, beforeInQueueHook, afterInQueueHook) { //异步入队函数
typeof beforeInQueueHook === 'function' && await beforeInQueueHook(data, this.cacheQueue);
this.cacheQueue.push(data);
typeof afterInQueueHook === 'function' && await afterInQueueHook(data, this.cacheQueue);
return this.cacheQueue;
}
async outQueue(beforeOutQueueHook, afterOutQueueHook) { //异步出队函数
typeof beforeOutQueueHook === 'function' && await beforeOutQueueHook(this.cacheQueue)
let outQueueData = this.cacheQueue.shift();
typeof afterOutQueueHook === 'function' && await afterOutQueueHook(outQueueData, this.cacheQueue)
return outQueueData;
}
inQueueAction(extraParams) { //实际的入队命令
//触发请求后,自增分页索引,然后调用异步入队函数
this.inQueue(this.dataWrapper(this.triggerUpdate(this.initialPageIndex, extraParams)))
}
async outQueueAction() {//实际的出队命令
let {
result,
key
} = await this.outQueue(undefined, (data, cacheQueue) => {
this.firstQueuePageIndex++;//每次出队列之后 最小索引值自增
if (this.cacheQueueStatus === queueStatusEnum['Ready'] && cacheQueue.length === 0) { //停掉队列运行之后只有队列出完了才停止出队任务
WaitEmitterInstance.clearWaiting('outQueue')
}
})
if (this.mode === modeEnum['Parallel']) { //出队之后 并行队列继续入队
this.updateQueue();
} else {
if (this.cacheQueueStatus === queueStatusEnum['Executing'] && this.lastResultIndex === this.initialPageIndex - 1) { //队列运行时且最后一个返回了才可以入队
WaitEmitterInstance.command('inQueue', true, false, this.lastResult) //发送入队命令
}
}
this.pendGroup.continuePend(result);
}
dataWrapper(resultPromise) { //包裹请求数据和索引信息 同时自增索引值(入队列调用)
let data = {
key: this.initialPageIndex,
result: resultPromise
}
this.initialPageIndex++;
return data;
}
requestWrapper(requestFunction) {
return (requestIndex, extraParams) => {
return new Promise((resolve, reject) => {
return typeof requestFunction === 'function' ? requestFunction(requestIndex, extraParams)
.then(result => {
console.log('result', result)
if (requestIndex === this.firstQueuePageIndex) { //队列首个数据返回正常 等待出队
WaitEmitterInstance.waitCommand('outQueue', this.outQueueAction.bind(this)) //等待出队命令,如果已经发送出队命令则立即出队
}
if(this.mode === modeEnum.Serial) { //串行队列保存上一个返回的结果
this.lastResult = result;
this.lastResultIndex = requestIndex;
}
this.updateQueue(); //出队完成后更新队列
resolve(result);
})
.catch(err => {
WaitEmitterInstance.command('inQueue', undefined); //请求结果错误 禁止入队
this.stopQueue(); //停止运行缓存队列,也可以在此跳过该分页
reject(err);
}) :
Promise.resolve(undefined).then(result => {
WaitEmitterInstance.command('inQueue', undefined); //函数错误禁止入队
this.stopQueue(); //停止运行缓存队列
return result;
})
})
}
}
initQueue() { //从这里开始启动
this.initQueueState();
this.cacheQueueStatus = queueStatusEnum['Executing']
this.updateQueue();
if (this.mode === modeEnum.Serial) {
this.updateQueueSerial(); //串行更新
} else if (this.mode === modeEnum.Parallel) {
this.updateQueueParallel(); //并行更新
}
}
initQueueState() { //初始化队列
this.cacheQueue = []
this.initialPageIndex = this.initPageIndex
this.firstQueuePageIndex = this.initPageIndex
this.lastResultIndex = this.initPageIndex
this.pendGroup = {...defaultPendGroup}
this.lastResult = {}
this.stopQueue()
}
stopQueue() { //停止运行队列
this.cacheQueueStatus = queueStatusEnum['Ready']
WaitEmitterInstance.clearWaiting('inQueue');
}
updateQueue() {
if (this.cacheQueue.length < this.maxLength) { //存在空闲位
if (this.cacheQueueStatus === queueStatusEnum['Executing']) { //队列运行时才可以入队
WaitEmitterInstance.command('inQueue', true, false, this.lastResult)
}
} else { //队列饱和 禁止入队
if (this.cacheQueueStatus === queueStatusEnum['Executing']) {
WaitEmitterInstance.command('inQueue', undefined, false, this.lastResult)
}
}
}
updateQueueSerial() {//串行更新队列,等待入队命令
WaitEmitterInstance.waitCommand('inQueue', this.inQueueAction.bind(this))
}
updateQueueParallel() {//并行更新队列,如果队列不饱和等待入队
while(this.cacheQueue.length < this.maxLength) {
if (this.cacheQueueStatus !== queueStatusEnum['Executing']) return;
WaitEmitterInstance.waitCommand('inQueue', this.inQueueAction.bind(this));
}
if (this.cacheQueueStatus === queueStatusEnum['Executing']) { //队列运行时才可以入队
WaitEmitterInstance.command('inQueue', undefined)
}
}
async getDataFromQueue(expireTime) { //获取数据
let timeoutController;
let getDataController;
console.log('getDataFromQueue')
WaitEmitterInstance.command("outQueue", true)
if (typeof expireTime === 'number' && expireTime > 0) {
clearTimeout(timeoutController)
timeoutController = setTimeout(() => {
this.pendGroup.endPend(undefined)
}, expireTime)
}
try {
getDataController = await pendDispatch(this.pendGroup); //阻塞 等待出队数据
} catch (err) {
getDataController = err;
}
this.initPendGroup();
WaitEmitterInstance.command("outQueue", undefined)
return getDataController;
}
}
上面代码比较多,但实际使用方法,只需要初始化cacheQueue对象,传入初始化页面索引,请求方法,然后调用cacheQueueInstance的initQueue方法即可启动队列。然后根据队列类型不同(如果是串行队列,则只有等待上一个数据请求返回成功且队列存在空闲才入队,否则如果是并行队列,则存在空闲就立即入队),入队方式不同。
trigger触发请求数据只需要通过getDataFromQueue这个方法就能拿到数据,如果请求未返回则阻塞等待,返回后立即获取并接着执行后续逻辑,如果请求已经返回了,则同样立即获取数据并执行后续逻辑。这样以来就通过将接口定义为生产者,列表定义为消费者,解除了请求数据和触发时机代码的耦合。
WaitEmitterInstance是WaitEmitter这个类的一个实例,WaitEmitter是EventEmitter的继承类,主要功能是定义一个指令事件总线对象,使用command规定对应事件id是否允许执行,waitCommand用来定义等待某事件执行的回调,可以理解为command是电路中的总开关,waitCommand是电路中的电源动力,定义如下:
class EventEmitter {
constructor(isSingleTask) {
this.isSingleTask = isSingleTask;
this.cache = {};
}
off(name, fn) {
let tasks = this.cache[name];
if (tasks) {
tasks = this.isSingleTask ? undefined : tasks.filter((item) => item !== fn);
}
this.cache[name] = tasks;
}
clear(name) {
this.cache[name] = this.isSingleTask ? undefined : [];
}
on(name, fn) {
if (this.cache[name]) {
if (!this.isSingleTask && this.cache[name].indexOf(fn) === -1) {
this.cache[name].push(fn);
} else if (this.isSingleTask) {
this.cache[name] = fn;
}
} else {
this.cache[name] = this.isSingleTask ? fn : [fn];
}
}
emit(name, once = false, ...args) {
if (this.cache[name]) {
if (this.isSingleTask) {
typeof this.cache[name] == 'function' && this.cache[name](...args);
} else {
let tasks = this.cache[name].slice();
for (let task of tasks) {
task(...args);
}
}
if (once) {
delete this.cache[name];
}
}
}
}
class WaitEmitter extends EventEmitter {
constructor(props) {
super(props);
this.commandCaches = {}
}
command(name, isAllowed, once = false, ...args) {
this.commandCaches[name] = {
isAllowed: isAllowed,
arguments: args,
isWaiting: false
}
if (isAllowed) {
this.emit(name, once, ...args);
}
}
stopWaiting(name, callback) {
this.off(name, callback);
}
clearWaiting(name) {
this.clear(name);
}
clearCaches(name) {
if (typeof name == 'undefined') {
this.commandCaches = {};
} else {
this.commandCaches[name] = {}
}
}
waitCommand(name, callback = () => {}) {
if (this.commandCaches[name] && this.commandCaches[name].isAllowed) {
typeof callback == 'function' && callback(...this.commandCaches[name].arguments);
this.commandCaches[name] = {
...(this.commandCaches[name] || {}),
isWaiting: false
}
this.on(name, callback);
} else if (this.commandCaches[name] && typeof this.commandCaches[name].isAllowed !== 'undefined' && !this.commandCaches[name].isAllowed) {
this.commandCaches[name] = {
...(this.commandCaches[name] || {}),
isWaiting: false
}
this.off(name, callback);
} else {
this.commandCaches[name] = {
...(this.commandCaches[name] || {}),
isWaiting: true
}
this.on(name, callback);
}
}
}
优化前后对比
优化前,线上交互如下:
使用缓存队列优化后,线上交互如下:
从图中可以看到,整体的阻尼感会少很多。
总结
使用缓存队列本质上是利用预加载来实现请求数据管理,整体而言优点主要是能够减少网络瓶颈带来分页加载不顺畅的问题,以及将调用请求接口的代码和触发请求时机代码进行解耦,从这个角度而言和消息队列也有点类似,但是这种方案也存在缺点,那便是对服务器压力相对会增大,同时不利于埋点分析用户实际下拉的分页索引。