背景
项目中涉及到一个聊天页面(微信小程序),聊天页上需要呈现多种类型的消息,不同消息类型的样式不一样(高度不同);同时用户可以从上往下加载更多消息。
当前项目会将用户所有已加载的消息都渲染到页面上,这种做法在消息量少的时候没啥问题,但消息条数超过一定数量后(比如超过1000条),小程序直接崩溃了。原因是微信对每个小程序的内存使用有限制,渲染过多的内容到页面上会导致内存溢出(可以通过小程序API onMemoryWarning监控到相关的内存告警)。为此第一个想到了解决方案是用虚拟列表来优化这个问题。
这里,我们简单抽象并实现如下功能:
- 不定高度虚拟列表:因为不同消息类型的样式和高度不同,因此必须为不定高度的虚拟列表;
- 支持分页加载:项目中是从上往下滑动触发分页加载,为了简化,本文我们以从下往上触底触发分页加载;
代码
一些工具方法
在开始写虚拟列表相关代码之前,我们首先定义一些模拟的工具方法
js
js/utils.js
const MAX_LENGTH = Infinity; // 最大消息个数
const MSG_TYPE_COUNT = 4; // 消息类型个数
/**
* 生成一条消息对象
* @param {*} value
* @returns 类型:{id: string, value: any, type: number}
*/
function createMsg(value) {
return {
id: `p2p_${Date.now()}_${Math.floor(Math.random() * 1000)}_${Math.floor(Math.random() * 100000000000)}`,
value,
type: Math.floor(Math.random() * MSG_TYPE_COUNT),
}
}
/**
* 自定义消息类型高度
* @param {*} type
* @returns 高度值(px)
*/
function getMsgHeight(type) {
let ret = 0;
switch (type) {
case 0: // 文字
ret = 50;
break;
case 1: // 图片
ret = 150;
break;
case 2: // 视频
ret = 48;
break;
case 3: // 音频
ret = 80;
break;
default: // 其他
ret = 43;
break;
}
return ret;
}
createMsg
用来模拟生成一条消息对象,它有三个属性:id为消息id; value为消息体内容;type为消息类型。 getMsgHeight
用来模拟每种消息类型的样式高度值。
js
/**
* 模拟获取分页消息
* @param {*} page
* @param {*} size
* @returns
*/
export function fetchData(page, size) {
return new Promise((resolve, reject) => {
if (page * size >= MAX_LENGTH) {
console.log("无法加载更多数据了");
reject();
} else {
console.log("加载数据中...");
setTimeout(() => {
const ret = [];
for (let i = 0; i < size; i++) {
ret.push(createMsg(page * size + i))
}
resolve(ret);
console.log("数据加载完成", page, size, ret);
}, Math.random() * 500); // 随机0~500ms
}
});
}
fetchData
用来模拟分页加载数据API,同时模拟请求延迟为0~500ms的效果。
js
/**
* 判断是否滑动到底部了
* @param {*} outerElement: 外层容器节点
* @param {*} bottom: 滑动到离底部距离
* @returns
*/
export function isScrollToBottom(outerElement, bottom = 10) {
if (outerElement.scrollTop + outerElement.clientHeight + bottom >= outerElement.scrollHeight) {
return true;
} else {
return false;
}
}
isScrollToBottom
用来判断是否滑动到距离底部为bottom
的距离了。
js
/**
* 渲染数据到页面element节点内
* @param {*} element: 列表容器节点
* @param {*} list: 消息列表数据
*/
export function render(element, list) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < list.length; i++) {
const node = document.createElement("div");
const height = getMsgHeight(list[i].type);
node.style.height = height + "px";
node.style.border = `1px solid green`;
node.id = list[i].id
node.innerText = list[i].value;
fragment.appendChild(node);
}
element.appendChild(fragment);
}
render
用来实现将list中的消息列表数据,渲染到element节点内。这里我们用fragment来优化相关渲染。
模版
html
<head>
<style>
#container {
border: 1px solid red;
height: 300px;
overflow: scroll;
}
</style>
</head>
<body>
<div id="container">
<div id="list">
</div>
</div>
</body>
模版用两层div来实现虚拟列表的框架。外层div的高度我们设置为300px,且设置overflow:scroll,内层div渲染需要显示的消息流。
基本结构
js
import { isScrollToBottom } from './js/utils.js'
let isLoading = false // 是否在加载中
const outerElement = document.getElementById('container') // 外层父容器元素
// 初始加载数据
loadData()
// 监听外层父容器滚动事件
outerElement.addEventListener('scroll', (e) => {
if(isLoading) return
if(isScrollToBottom(outerElement, 100)) {
loadData()
}
scrollHandler(e.target.scrollTop)
})
// 封装加载数据
function loadData() {
...
}
// 滑动事件处理
function scrollHandler(scrollTop) {
...
}
上面的代码为虚拟列表主要的代码流程:
- 首先初始化获取消息loadData。
- 获取外层父容器节点对象,然后监听它的scroll事件。在监听scroll事件的回调中,如果当前正在加载数据loadData,则直接返回;如果滑动的距离触发了加载数据,则调用loadData; 其他情况执行具体的滑动监听响应逻辑。
封装加载数据过程
js
import { fetchData, render } from './js/utils.js'
let page = 0; // 初始页数
let size = 10; // 每页消息数量
let loadedList = []; // 已加载的数据列表
let loadedListMap = new Map(); // 缓存每个消息的高度信息
let isLoading = false // 是否在加载中
let totalLoadedHeight = 0 // loadedList所有消息如果渲染到屏幕上的总高度
// 封装加载数据
function loadData() {
isLoading = true
fetchData(page, size).then(res => {
page++;
loadedList = loadedList.concat(res)
render(innerElement, res);
cacheNodeHeight(res);
}).finally(() => {
isLoading = false
})
}
// 缓存每个消息节点的高度到Map中
function cacheNodeHeight(list) {
list.forEach(item => {
if(!loadedListMap.has(item.id)) {
// 从页面上获取对应ID的DOM节点的高度
const height = document.getElementById(item.id).clientHeight
loadedListMap.set(item.id, height)
totalLoadedHeight += height
}
})
}
loadData
用于封装分页加载数据过程。数据加载成功之后,一方面合并到loadedList中,另一方面直接渲染到页面上。针对新渲染的消息,获取从页面上每条消息的高度值,缓存到Map类型中,方便后续直接获取。
scroll事件处理
js
import { fetchData, render, isScrollToBottom } from './js/utils.js'
let page = 0; // 初始页数
let size = 10; // 每页消息数量
let loadedList = []; // 已加载的数据列表
let loadedListMap = new Map(); // 缓存每个消息的高度信息
let isLoading = false // 是否在加载中
let totalLoadedHeight = 0 // loadedList所有消息如果渲染到屏幕上的总高度
const outerElement = document.getElementById('container') // 外层父容器元素
const innerElement = document.getElementById('list') // 列表容器元素
const containerHeight = outerElement.clientHeight; // 外层父容器高度
// 滑动事件处理
function scrollHandler(scrollTop) {
scrollTop = scrollTop < 0 ? 0 : scrollTop
let accumulateHeight = 0; // 累计垂直偏移高度
let startIndex = null; // 显示在container中的起始下表
let endIndex = null; // 显示在container中的结束下标
let paddingTop = 0; // 顶部留白高度
let paddingBottom = 0; // 底部留白高度
// 计算startIndex和paddingTop
for (let i = 0; i < loadedList.length; i++) {
const height = loadedListMap.get(loadedList[i].id)
accumulateHeight += height;
if (accumulateHeight > scrollTop) {
startIndex = i;
paddingTop = accumulateHeight - height;
break;
}
}
// 计算endIndex和paddingBottom
for (let i = startIndex + 1; i < loadedList.length; i++) {
const height = loadedListMap.get(loadedList[i].id)
accumulateHeight += height;
if (accumulateHeight >= scrollTop + containerHeight) {
endIndex = i;
break;
}
}
// 边界情况处理
if (endIndex === null) {
endIndex = loadedList.length - 1;
accumulateHeight = totalLoadedHeight;
}
paddingBottom = totalLoadedHeight - accumulateHeight;
console.log(startIndex, endIndex, paddingTop, paddingBottom, scrollTop, containerHeight, totalLoadedHeight, loadedList)
// 设置paddingTop和paddingBottom
innerElement.setAttribute("style", "padding-top: " + paddingTop + "px; padding-bottom: " + paddingBottom + "px;");
innerElement.innerHTML = "";
render(innerElement, loadedList.slice(startIndex, endIndex + 1))
}
- startIndex和endIndex分别表示真正渲染的消息下标,即loadedList中下标在[startIndex, endIndex]范围内的消息元素才会真正渲染。
- 因为要实现滚动条效果,因此需要在渲染消息流前后增加一些空白的padding,以此来撑大整个消息流。paddingTop和paddingBottom就是渲染的消息流上下的空白高度值。
- 每次滑动监听的时候,都需要重置innerElement.innerHTML = ''。
效果
可以看到在上滑过程中,消息列表的总消息数保持在一个相对稳定的水平,而不是无限增加。同时滑动触发的时候也能正常分页加载。