# 项目场景
最近在开发一款聊天项目,其中需要加载用户之间的消息列表,正常逻辑是从后端 - 数据库获取对应消息并渲染,但是如果消息过多会使得网络请求压力大,页面 DOM 渲染量大,导致性能不佳,所以了解到这两种优化方式。
# 什么是分页加载
分页加载是一种按需获取数据的策略,只在需要的时候加载数据,减少初次加载时间和内存占用。
在消息列表中,由于用户视窗内只能看到少数消息,我们可以一次只加载 15 条消息,当用户需要查看更多(上拉)时再从后端获取一次消息。
# 实现逻辑
我们在获取消息的时候可以传入 lastId 和 limit,分别代表最后获取的一条消息 id(如果按 id 递增)和消息限制。这里分页的逻辑更简单的是使用 offset 跳过前 xx 条消息,属于 SQL 语言范畴。然后后端可以通过数据量是否 < limit 来判断消息是否全获取完了,给前端返回一个结果。
# 什么是虚拟列表
虚拟列表是一种只渲染可见区域内元素的优化技术,避免渲染所有列表项导致页面内 DOM 数量过多,提高渲染性能,提高滚动流畅性。
在消息列表中,我们只需要让用户看到视窗内的消息即可,当滚动时,实时渲染视窗内元素即可。
# 实现逻辑
对于一个存在滚动条的容器,scrollTop 属性是滚动条距离容器顶部的距离。那么我们就只需要把元素放在距离顶部 [scrollTop, scrollTop + windowHeight] 的范围内,它就会被渲染出来。
所以我们需要知道哪些元素应该被放在这个范围内,假设每个元素的 height 为 50px, scrollTop 为 100px 以及 windowHeight 为 100px。那么第 3,4 条消息就应该被渲染出来。所以我们需要知道每条消息相对于外部容器顶部的高度,需要设定外部容器为 position: relative,内部元素为 position: absolute,计算内部元素的 top 属性来使元素渲染在正确的位置,这是比较简单常用的方法。
还有一种方法是获取元素和外部容器相对于全局的距离 getBoundingClientRect () 再相减,获取相对高度。
这里面还有分为元素固定高度为元素不定高度,固定高度只需要使用下标计算即可,不定元素需要获取每项渲染后的高度实时计算渲染。
# 项目演示
我这里使用了 react-infinite-scroll-component 库的 InfiniteScroll 组件来实现下拉加载和分页逻辑,虚拟列表是手搓,也可以使用成熟的 react-window 库。
另外,正常列表是往下加载更多的消息,但聊天列表是需要从上面获取更多的消息,也就是下拉加载,恰好 InfiniteScroll 有这个属性,非常方便 ^^
1 2 3 4 5 6 7 8 9 10 11 12
| import InfiniteScroll from 'react-infinite-scroll-component';
<InfiniteScroll dataLength={messages.length} // 数据数组长度 next={loadMessages} // 触发加载后执行的函数 向后端请求新消息 只有hasMore = true时才会触发 hasMore={hasMore} // 是否还有更多消息 loader={<h4>Loading...</h4>} // 当hasMore = true 且触发加载函数时 展示的内容 scrollableTarget='container' // 外部容器id inverse={true} // 变为上拉逻辑 style={{ display: 'flex', flexDirection: 'column-reverse' }} // 列表反向加载 搭配inverse endMessage={<h4>没有更多消息啦!</h4>} // 当hasMore = false 且触发加载函数的时候 展示的内容 >
|
还有一些属性可以在官方文档查看,这里没有用到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const lastMessageId = useRef<number | null>(null); const pageSize = 15;
if (last_message_id) { query += ` AND id < ?`; params = [...params, last_message_id]; }
query += ` ORDER BY id DESC LIMIT ? `; params = [...params, page_size];
|
这是分页的部分代码逻辑。
所以有了 InfiniteScroll 组件就可以很容易的实现上 / 下拉加载结合分页,如果手写的话,需要监听 scrollTop 到一定阈值触发加载函数,代码会比较麻烦。
# 手写不定元素高度虚拟列表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
| const [visibleMessages, setVisibleMessages] = useState<WebSocketMessage[]>([]); const itemHeight = 70; const totalHeight = useRef<number>(0); const heightCache = useRef<{ [id: number]: number }>({}); const itemOffset = useRef<{ [id: number]: number }>({});
const updateHeight = (id: number, height: number) => { if (heightCache.current[id] === height) return; heightCache.current[id] = height;
let prefix = 0; for (let i = messages.length - 1; i >= 0; i--) { const msgid = messages[i].id; itemOffset.current[msgid] = prefix; prefix += heightCache.current[msgid] || itemHeight; }
totalHeight.current = prefix; calcVisibleMessages(); };
const calcVisibleMessages = () => { if (!containerRef.current) return; const scrollTop = -containerRef.current.scrollTop;
const containerHeight = containerRef.current.clientHeight || 500; let startIndex = 0, endIndex = 0, currentOffset = 0;
while ( startIndex < messages.length - 1 && currentOffset + (heightCache.current[messages[startIndex].id] || itemHeight) < scrollTop ) { currentOffset += heightCache.current[messages[startIndex].id] || 0; startIndex++; }
endIndex = startIndex; while (endIndex < messages.length && currentOffset < scrollTop + containerHeight) { currentOffset += heightCache.current[messages[endIndex].id] || 0; endIndex++; }
startIndex = Math.max(0, startIndex - 2); endIndex = Math.min(messages.length - 1, endIndex + 2);
setVisibleMessages(messages.slice(startIndex, endIndex + 1)); };
const renderMessageItem = (message: WebSocketMessage): ReactNode => { const offset = itemOffset.current[message.id]; return ( <div key={message.id} /** * ref里的函数会在组件挂载 | 更新时执行 获取它的高度并执行更新函数 */ ref={el => { if (!el) return; // 测量实际高度 const height = el.getBoundingClientRect()?.height; updateHeight(message.id, height); }} style={{ position: 'absolute', top: `${offset}px`, width: '100%', }} > <MessageItem message={message.text!} isSelf={message.sender === username} timestamp={message.timestamp} /> </div> ); };
|
上面是主体逻辑代码,在实际写的时候还需要注意它们的执行时机以及更新顺序,避免因为 useState 的异步更新机制读取到旧值,可以使用 useRef 避免,但不要过度使用。
具体代码查阅:MessageList 源码。
最后,如果发现本篇博客有遗漏 | 错误的地方,欢迎指出我会更正。