在IM会话消息列表体验优化事项中我们对“上拉加载”、“下拉加载”、“下拉刷新”的技术特点和使用场景做了分析,然后对于下拉加载精确回滚这个场景,提出了三种解决方案:“定时器方案”、“等待图片/视频资源onload完成方案”、“反向渲染方案”;这三种方案各有利弊,希望能对读者带来一些启发和帮助。
1、场景分析
在IM系统中,核心事件都是围绕着“聊天”这个主题展开的,在聊天的过程中,获悉用户的需求,再通过系统集成的各种工具,帮助用户完成诉求;“聊天”在IM业务中就是“会话消息”,当客服与用户之间存在大量聊天消息的时候,如何更好的去加载用户历史消息,提升客服查看消息体验,是一个值得研究的方向。
由于聊天室的特殊布局,历史消息加载需要用到虚拟滚动的方式去实现,如果想要更好的性能,还需要使用虚拟列表技术,而虚拟滚动技术又分为“上拉加载”和“下拉加载”,在移动端领域,还需要“下拉刷新”,如何选择合适的技术方案是我们接下来需要讨论的问题。
2、虚拟滚动技术调研
虚拟滚动技术的使用场景主要是在布局空间较小,不方便添加分页器的页面,例如移动端列表页,IM系统左侧进线会话列表,会话消息列表,右侧功能区域订单/商品查询列表等。
例如:会话进线列表,商品查询列表可以用到上拉加载,会话消息列表需要用到下拉加载,在移动端,页面刷新还需要用到下拉刷新。
下拉加载、上拉加载、下拉刷新方案对比:
技术方案 | 触发方式 | 应用场景 | 技术特点/难点 |
下拉加载 | 滚动到页面顶部触发 | 会话消息列表数据加载 | 需要解决回滚定位不准的问题,还需要关注页面图片/视频资源的对滚动定位的影响 |
上拉加载 | 滚动到页面底部触发 | 订单/商品列表数据加载,select下拉框,移动端列表页面 | 需要计算滚动到页面底部,加载滚动体验较好,更符合用户的视觉感受 |
下拉刷新 | 拖动页面顶部向下移动一定距离触发 | H5页面刷新 | 需要处理好下拉橡皮筋效果,成功后刷新页面 |
上面对我们系统中需要用到的加载/刷新技术做了简单的实现和应用场景对比,其中上拉加载,下拉刷新不作为此次讨论的重点,且社区中实现的方案和博客也较多,我们此次重点讨论的是下拉加载在IM会话消息中的应用和体验优化。
3、下拉加载在会话消息的应用
3.1 会话消息历史数据下拉加载流程
历史数据拉取会经历三个过程:
- 用户滚动消息到页顶,触发加载机制,在拉取数据的过程中,顶部展示一个“数据正在加载中”的loading文案,告知用户需要等待加载结果的完成;
- 数据返回之后,会被置于原数据的顶部(array.unshift(newArray)),渲染后原来的内容就会被新的内容压到页面底部;
- 为了提高用户的体验,还需要将页面滚动到滚动条最后停留的位置(加载前最后一条消息位置)
3.2 如何实现下拉加载
// 监听会话消息区域添加滚动监听事件
const listenScrollEvent = () => {
chatMsgContainer.value.addEventListener('scroll', scrolHandle)
}
// 滚动逻辑处理回调函数
const scrolHandle = throttle(event => {
const { scrollHeight, scrollTop } = chatMsgContainer.value || {}
const { target } = event || {}
// 记录下当前会话滚动位置,切换会话的时候需要回滚到最后停留的位置
userInfo.value.scrollPosition = scrollHeight - scrollTop || 0
// 超出一屏,滚动到顶部,且没有拉取完所有的数据
if (
target.scrollTop === 0 &&
target.scrollHeight > target.clientHeight &&
!userInfo.value?.isComplete
) {
handleScrollEvent(event) // 拉取历史消息
}
}, 300)
// 消息滚动
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
// 获取到加载后最后一条数据位置
const recentlyMsg = messagePools[len - 1]
// 计算新加载数据条数
const calcMsgLenDiff = len - oldLen
// 首次加载数据的时候让滚动条滚动到最底部
if (len <= LIMIT_MESSAGE) {
// msgid是会话中的唯一标识,可以用此作为唯一ID
targetDom = document.querySelector(recentlyMsg.msgid)
// true 元素的顶部将对齐到可滚动祖先的可见区域的顶部。对应于scrollIntoViewOptions: {block: "start", inline: "nearest"}
firstDom?.scrollIntoView?.(true)
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 这里用来处理用户/客服发送消息滚动逻辑
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取历史消息逻辑
// 获取到加载前最后一条数据位置
const prevLastMsg = messagePools[calcMsgLenDiff - 1]
targetDom = document.querySelector(prevLastMsg.msgid)
targetDom?.scrollIntoView?.()
}
userInfo.value.isShowLoading = false
})
}
// 监听会话消息数据变化
watch(
() => messagePools.length,
(len, oldLen) => {
handleMessageScroll(len, oldLen)
},
{
immediate: true
}
)
如果只是按照上面的方式去处理,当页面中存在图片/视频的情况下,由于图片/视频渲染慢于普通文本,在加载图片/视频类型的消息的时候,回滚的位置就会有偏差,不能准确的回滚到预期的位置,我们对以下三种方案进行了对比实现,最终选择了反向渲染加载的方案,如下:
3.2.1 setTimeout延时回滚方案
- 优点:简单易实现,只需要设置一个合适的定时器时间,对于大部分场景都能回滚正确;
- 缺点:可靠性较低,资源加载慢的情况下,也会出现回滚不准确的情况,且setTimeout会带来页面闪烁的问题;
// 消息滚动
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
// 获取到加载后最后一条数据位置
const recentlyMsg = messagePools[len - 1]
// 计算新加载数据条数
const calcMsgLenDiff = len - oldLen
// 首次加载数据的时候让滚动条滚动到最底部
if (len <= LIMIT_MESSAGE) {
...
// 针对图片/视频渲染慢的场景做个补偿
msgScrollTimer = setTimeout(() => {
clearTimeout(msgScrollTimer)
firstDom?.scrollIntoView?.(true)
}, SCROLL_THRESHOLD)
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 这里用来处理用户/客服发送消息滚动逻辑
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取历史消息逻辑
// ...
// 针对图片/视频渲染慢的场景做个补偿
msgScrollTimer = setTimeout(() => {
clearTimeout(msgScrollTimer)
targetDom?.scrollIntoView?.()
}, SCROLL_THRESHOLD)
}
userInfo.value.isShowLoading = false
})
}
3.2.2 监听img/vedio的onload事件方案
- 优点:可以回滚的精准度较高,没有页面闪烁的问题;
- 缺点:如果不是虚拟列表,每次滚动的时候可能会有大量的DOM节点查询操作,造成页面滚动卡顿;
const allImgOrVedioLoaded = async() => {
const imgNodes = document.querySelectorAll('.messageWrapper img') || []
const vedioNodes = document.querySelectorAll('.messageWrapper vedio') || []
const promises = [...imgNodes, ...vedioNodes]
// 等待所有的资源加载完成,无论成功还是失败
return await Promise.allSettled(
promises.map(source => {
new Promise(resolve => {
source.addEventListener('load', () => resolve(source))
})
})
)
}
// 消息滚动
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
...
// 等待img/vedio所有资源加载完成,执行回滚操作
allImgOrVedioLoaded().then(() => {
firstDom.scrollIntoView(true)
})
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 这里用来处理用户/客服发送消息滚动逻辑
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取历史消息逻辑
// ...
// 等待img/vedio所有资源加载完成,执行回滚操作
allImgOrVedioLoaded().then(() => {
targetDom.scrollIntoView()
})
}
userInfo.value.isShowLoading = false
})
}
定时器/onload方案下拉加载回滚流程图:
3.2.3 反向渲染加载方案
前面我们有提到过“上拉加载”,当滚动到底部加载新的一页的数据,数据从底部添加,无需执行回滚动作,整体的体验更加流畅自然。
既然“上拉加载”有这么多好处,那我们可不可以使用这样的方式来模仿我们的“下拉加载”呢?显然是可以的,我们页面布局在使用flex布局的情况下,可以反转主轴,这样我们就可以像“上拉加载”一样,触发到页面底部的时候,就去拉取新的历史数据,且反向渲染只是数据的反转,并不会带来视觉上的反转;
display: flex;
flex-direction: column-reverse;
3.3 带来的效果
4、总结
在IM应用中,会话消息列表扮演着很重要的角色,是用户与客服沟通结果最终呈现的地方,所以想要提升页面的加载性能和用户体验,下拉加载性能和体验一直是一个重要的指标,当然对于大列表组件最好结合使用虚拟列表技术,尽量少的DOM渲染和尽量精准的滚动效果才能给客服带来最极致的体验。
最后做个总结:在IM会话消息列表体验优化事项中我们对“上拉加载”、“下拉加载”、“下拉刷新”的技术特点和使用场景做了分析,然后对于下拉加载精确回滚这个场景,提出了三种解决方案:“定时器方案”、“等待图片/视频资源onload完成方案”、“反向渲染方案”;这三种方案各有利弊,希望能对读者带来一些启发和帮助。