在IM會(huì)話消息列表體驗(yàn)優(yōu)化事項(xiàng)中我們對(duì)“上拉加載”、“下拉加載”、“下拉刷新”的技術(shù)特點(diǎn)和使用場(chǎng)景做了分析,然后對(duì)于下拉加載精確回滾這個(gè)場(chǎng)景,提出了三種解決方案:“定時(shí)器方案”、“等待圖片/視頻資源onload完成方案”、“反向渲染方案”;這三種方案各有利弊,希望能對(duì)讀者帶來(lái)一些啟發(fā)和幫助。?
1、場(chǎng)景分析
在IM系統(tǒng)中,核心事件都是圍繞著“聊天”這個(gè)主題展開的,在聊天的過(guò)程中,獲悉用戶的需求,再通過(guò)系統(tǒng)集成的各種工具,幫助用戶完成訴求;“聊天”在IM業(yè)務(wù)中就是“會(huì)話消息”,當(dāng)客服與用戶之間存在大量聊天消息的時(shí)候,如何更好的去加載用戶歷史消息,提升客服查看消息體驗(yàn),是一個(gè)值得研究的方向。
由于聊天室的特殊布局,歷史消息加載需要用到虛擬滾動(dòng)的方式去實(shí)現(xiàn),如果想要更好的性能,還需要使用虛擬列表技術(shù),而虛擬滾動(dòng)技術(shù)又分為“上拉加載”和“下拉加載”,在移動(dòng)端領(lǐng)域,還需要“下拉刷新”,如何選擇合適的技術(shù)方案是我們接下來(lái)需要討論的問(wèn)題。
2、虛擬滾動(dòng)技術(shù)調(diào)研
虛擬滾動(dòng)技術(shù)的使用場(chǎng)景主要是在布局空間較小,不方便添加分頁(yè)器的頁(yè)面,例如移動(dòng)端列表頁(yè),IM系統(tǒng)左側(cè)進(jìn)線會(huì)話列表,會(huì)話消息列表,右側(cè)功能區(qū)域訂單/商品查詢列表等。
例如:會(huì)話進(jìn)線列表,商品查詢列表可以用到上拉加載,會(huì)話消息列表需要用到下拉加載,在移動(dòng)端,頁(yè)面刷新還需要用到下拉刷新。

下拉加載、上拉加載、下拉刷新方案對(duì)比:
技術(shù)方案 | 觸發(fā)方式 | 應(yīng)用場(chǎng)景 | 技術(shù)特點(diǎn)/難點(diǎn) |
下拉加載 | 滾動(dòng)到頁(yè)面頂部觸發(fā) | 會(huì)話消息列表數(shù)據(jù)加載 | 需要解決回滾定位不準(zhǔn)的問(wèn)題,還需要關(guān)注頁(yè)面圖片/視頻資源的對(duì)滾動(dòng)定位的影響 |
上拉加載 | 滾動(dòng)到頁(yè)面底部觸發(fā) | 訂單/商品列表數(shù)據(jù)加載,select下拉框,移動(dòng)端列表頁(yè)面 | 需要計(jì)算滾動(dòng)到頁(yè)面底部,加載滾動(dòng)體驗(yàn)較好,更符合用戶的視覺(jué)感受 |
下拉刷新 | 拖動(dòng)頁(yè)面頂部向下移動(dòng)一定距離觸發(fā) | H5頁(yè)面刷新 | 需要處理好下拉橡皮筋效果,成功后刷新頁(yè)面 |
上面對(duì)我們系統(tǒng)中需要用到的加載/刷新技術(shù)做了簡(jiǎn)單的實(shí)現(xiàn)和應(yīng)用場(chǎng)景對(duì)比,其中上拉加載,下拉刷新不作為此次討論的重點(diǎn),且社區(qū)中實(shí)現(xiàn)的方案和博客也較多,我們此次重點(diǎn)討論的是下拉加載在IM會(huì)話消息中的應(yīng)用和體驗(yàn)優(yōu)化。
3、下拉加載在會(huì)話消息的應(yīng)用
3.1 會(huì)話消息歷史數(shù)據(jù)下拉加載流程
歷史數(shù)據(jù)拉取會(huì)經(jīng)歷三個(gè)過(guò)程:
- 用戶滾動(dòng)消息到頁(yè)頂,觸發(fā)加載機(jī)制,在拉取數(shù)據(jù)的過(guò)程中,頂部展示一個(gè)“數(shù)據(jù)正在加載中”的loading文案,告知用戶需要等待加載結(jié)果的完成;
- 數(shù)據(jù)返回之后,會(huì)被置于原數(shù)據(jù)的頂部(array.unshift(newArray)),渲染后原來(lái)的內(nèi)容就會(huì)被新的內(nèi)容壓到頁(yè)面底部;
- 為了提高用戶的體驗(yàn),還需要將頁(yè)面滾動(dòng)到滾動(dòng)條最后停留的位置(加載前最后一條消息位置)

3.2 如何實(shí)現(xiàn)下拉加載
// 監(jiān)聽會(huì)話消息區(qū)域添加滾動(dòng)監(jiān)聽事件
const listenScrollEvent = () => {
chatMsgContainer.value.addEventListener('scroll', scrolHandle)
}
// 滾動(dòng)邏輯處理回調(diào)函數(shù)
const scrolHandle = throttle(event => {
const { scrollHeight, scrollTop } = chatMsgContainer.value || {}
const { target } = event || {}
// 記錄下當(dāng)前會(huì)話滾動(dòng)位置,切換會(huì)話的時(shí)候需要回滾到最后停留的位置
userInfo.value.scrollPosition = scrollHeight - scrollTop || 0
// 超出一屏,滾動(dòng)到頂部,且沒(méi)有拉取完所有的數(shù)據(jù)
if (
target.scrollTop === 0 &&
target.scrollHeight > target.clientHeight &&
!userInfo.value?.isComplete
) {
handleScrollEvent(event) // 拉取歷史消息
}
}, 300)
- 監(jiān)聽數(shù)據(jù)變化執(zhí)行回滾動(dòng)作
// 消息滾動(dòng)
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
// 獲取到加載后最后一條數(shù)據(jù)位置
const recentlyMsg = messagePools[len - 1]
// 計(jì)算新加載數(shù)據(jù)條數(shù)
const calcMsgLenDiff = len - oldLen
// 首次加載數(shù)據(jù)的時(shí)候讓滾動(dòng)條滾動(dòng)到最底部
if (len <= LIMIT_MESSAGE) {
// msgid是會(huì)話中的唯一標(biāo)識(shí),可以用此作為唯一ID
targetDom = document.querySelector(recentlyMsg.msgid)
// true 元素的頂部將對(duì)齊到可滾動(dòng)祖先的可見區(qū)域的頂部。對(duì)應(yīng)于scrollIntoViewOptions: {block: "start", inline: "nearest"}
firstDom?.scrollIntoView?.(true)
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 這里用來(lái)處理用戶/客服發(fā)送消息滾動(dòng)邏輯
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取歷史消息邏輯
// 獲取到加載前最后一條數(shù)據(jù)位置
const prevLastMsg = messagePools[calcMsgLenDiff - 1]
targetDom = document.querySelector(prevLastMsg.msgid)
targetDom?.scrollIntoView?.()
}
userInfo.value.isShowLoading = false
})
}
// 監(jiān)聽會(huì)話消息數(shù)據(jù)變化
watch(
() => messagePools.length,
(len, oldLen) => {
handleMessageScroll(len, oldLen)
},
{
immediate: true
}
)
如果只是按照上面的方式去處理,當(dāng)頁(yè)面中存在圖片/視頻的情況下,由于圖片/視頻渲染慢于普通文本,在加載圖片/視頻類型的消息的時(shí)候,回滾的位置就會(huì)有偏差,不能準(zhǔn)確的回滾到預(yù)期的位置,我們對(duì)以下三種方案進(jìn)行了對(duì)比實(shí)現(xiàn),最終選擇了反向渲染加載的方案,如下:
3.2.1 setTimeout延時(shí)回滾方案
- 優(yōu)點(diǎn):簡(jiǎn)單易實(shí)現(xiàn),只需要設(shè)置一個(gè)合適的定時(shí)器時(shí)間,對(duì)于大部分場(chǎng)景都能回滾正確;
- 缺點(diǎn):可靠性較低,資源加載慢的情況下,也會(huì)出現(xiàn)回滾不準(zhǔn)確的情況,且setTimeout會(huì)帶來(lái)頁(yè)面閃爍的問(wèn)題;
// 消息滾動(dòng)
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
// 獲取到加載后最后一條數(shù)據(jù)位置
const recentlyMsg = messagePools[len - 1]
// 計(jì)算新加載數(shù)據(jù)條數(shù)
const calcMsgLenDiff = len - oldLen
// 首次加載數(shù)據(jù)的時(shí)候讓滾動(dòng)條滾動(dòng)到最底部
if (len <= LIMIT_MESSAGE) {
...
// 針對(duì)圖片/視頻渲染慢的場(chǎng)景做個(gè)補(bǔ)償
msgScrollTimer = setTimeout(() => {
clearTimeout(msgScrollTimer)
firstDom?.scrollIntoView?.(true)
}, SCROLL_THRESHOLD)
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 這里用來(lái)處理用戶/客服發(fā)送消息滾動(dòng)邏輯
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取歷史消息邏輯
// ...
// 針對(duì)圖片/視頻渲染慢的場(chǎng)景做個(gè)補(bǔ)償
msgScrollTimer = setTimeout(() => {
clearTimeout(msgScrollTimer)
targetDom?.scrollIntoView?.()
}, SCROLL_THRESHOLD)
}
userInfo.value.isShowLoading = false
})
}
3.2.2 監(jiān)聽img/vedio的onload事件方案
- 優(yōu)點(diǎn):可以回滾的精準(zhǔn)度較高,沒(méi)有頁(yè)面閃爍的問(wèn)題;
- 缺點(diǎn):如果不是虛擬列表,每次滾動(dòng)的時(shí)候可能會(huì)有大量的DOM節(jié)點(diǎn)查詢操作,造成頁(yè)面滾動(dòng)卡頓;
const allImgOrVedioLoaded = async() => {
const imgNodes = document.querySelectorAll('.messageWrapper img') || []
const vedioNodes = document.querySelectorAll('.messageWrapper vedio') || []
const promises = [...imgNodes, ...vedioNodes]
// 等待所有的資源加載完成,無(wú)論成功還是失敗
return await Promise.allSettled(
promises.map(source => {
new Promise(resolve => {
source.addEventListener('load', () => resolve(source))
})
})
)
}
// 消息滾動(dòng)
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
...
// 等待img/vedio所有資源加載完成,執(zhí)行回滾操作
allImgOrVedioLoaded().then(() => {
firstDom.scrollIntoView(true)
})
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 這里用來(lái)處理用戶/客服發(fā)送消息滾動(dòng)邏輯
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取歷史消息邏輯
// ...
// 等待img/vedio所有資源加載完成,執(zhí)行回滾操作
allImgOrVedioLoaded().then(() => {
targetDom.scrollIntoView()
})
}
userInfo.value.isShowLoading = false
})
}
定時(shí)器/onload方案下拉加載回滾流程圖:

3.2.3 反向渲染加載方案
前面我們有提到過(guò)“上拉加載”,當(dāng)滾動(dòng)到底部加載新的一頁(yè)的數(shù)據(jù),數(shù)據(jù)從底部添加,無(wú)需執(zhí)行回滾動(dòng)作,整體的體驗(yàn)更加流暢自然。
既然“上拉加載”有這么多好處,那我們可不可以使用這樣的方式來(lái)模仿我們的“下拉加載”呢?顯然是可以的,我們頁(yè)面布局在使用flex布局的情況下,可以反轉(zhuǎn)主軸,這樣我們就可以像“上拉加載”一樣,觸發(fā)到頁(yè)面底部的時(shí)候,就去拉取新的歷史數(shù)據(jù),且反向渲染只是數(shù)據(jù)的反轉(zhuǎn),并不會(huì)帶來(lái)視覺(jué)上的反轉(zhuǎn);
display: flex;
flex-direction: column-reverse;


3.3 帶來(lái)的效果

4、總結(jié)
在IM應(yīng)用中,會(huì)話消息列表扮演著很重要的角色,是用戶與客服溝通結(jié)果最終呈現(xiàn)的地方,所以想要提升頁(yè)面的加載性能和用戶體驗(yàn),下拉加載性能和體驗(yàn)一直是一個(gè)重要的指標(biāo),當(dāng)然對(duì)于大列表組件最好結(jié)合使用虛擬列表技術(shù),盡量少的DOM渲染和盡量精準(zhǔn)的滾動(dòng)效果才能給客服帶來(lái)最極致的體驗(yàn)。
最后做個(gè)總結(jié):在IM會(huì)話消息列表體驗(yàn)優(yōu)化事項(xiàng)中我們對(duì)“上拉加載”、“下拉加載”、“下拉刷新”的技術(shù)特點(diǎn)和使用場(chǎng)景做了分析,然后對(duì)于下拉加載精確回滾這個(gè)場(chǎng)景,提出了三種解決方案:“定時(shí)器方案”、“等待圖片/視頻資源onload完成方案”、“反向渲染方案”;這三種方案各有利弊,希望能對(duì)讀者帶來(lái)一些啟發(fā)和幫助。