React中聊天无限滚动场景:useRef命令式DOM操作与状态管理方案的性能对比
咱们来好好唠唠这个问题——在React开发聊天类的无限滚动功能时,到底该选状态驱动的经典方案,还是用useRef直接操作DOM的“反模式”方案?性能差异在哪,所谓的“反模式”是不是真的碰不得?
先拆解下你提到的两种方案的核心问题:
一、经典状态管理方案的痛点
你说的没错,原始的状态管理写法确实有个大问题:每次有新消息到来,都要从数据库重新拉取所有已展示的消息,这不仅平白给服务器加了不必要的压力,React还要基于新的messages数组重新渲染整个消息列表。哪怕有React的diff算法帮忙,当消息数量多了之后,大量元素的比对和重渲染也会拖慢客户端性能。
不过这里要纠正个小误区:其实状态管理方案完全可以优化,不用每次都全量拉取。你可以在现有状态的基础上追加消息:
const chatAppState = () => { const [messages, setMessages] = useState<Message[]>([]); const onNewMessage = async () => { // 只拉取最新的那条/几条消息,不是全量 const newMsg = await getLatestMessage(); // 直接追加到现有列表,不用覆盖 setMessages(prev => [...prev, newMsg]); } const onLoadPrevMsgs = async () => { // 拉取更早的历史消息,基于当前已展示的数量做分页 const historyMsgs = await getOldMessages(messages.length, 10); // 把历史消息加到列表头部 setMessages(prev => [...historyMsgs, ...prev]); } return( <div> {messages.map(msg => <div key={msg.id}>{msg.content}</div>)} </div> ) }
优化后就不用每次全量请求了,服务器压力和客户端渲染开销都会小很多。
二、useRef命令式DOM操作的优劣
直接用useRef操作DOM的方式,确实能解决服务器重复请求的问题——新消息直接append到底部,历史消息直接prepend到顶部,不用再拉取已经展示过的内容,网络开销直接降下来。而且这种局部DOM操作不会触发React的重渲染,客户端性能也会更优,尤其是消息量很大的时候。
但为啥说这是React里的“反模式”?主要是它打破了React状态驱动UI的核心设计理念,会带来几个长期问题:
- 状态与DOM不同步:如果后续要加功能,比如搜索消息、标记已读、批量删除,你会发现没有对应的状态数据可以操作,只能去DOM里捞元素,很容易出现UI和数据不一致的情况。
- 维护成本高:其他开发者接手时,习惯了状态驱动的思路,突然看到直接操作DOM的代码,会很难理解逻辑,调试起来也麻烦——因为所有消息数据都不在状态里,没法直接查看。
- 生态兼容性差:如果后续要做SSR(服务端渲染),直接操作DOM的代码在服务端会报错;要是想用虚拟列表(比如
react-window)这类优化库,也没法和直接操作DOM的逻辑配合。
三、该怎么选?看场景!
所谓的“反模式”不是绝对的,得结合你的应用需求来权衡:
- 如果是简单聊天场景:只有展示消息、加载历史、接收新消息这几个基础功能,没有后续扩展计划,那直接用
useRef操作DOM是更轻量、更性能的选择——毕竟收益(减少服务器请求、提升客户端渲染速度)远大于潜在的维护成本。 - 如果是复杂聊天应用:有搜索、筛选、多端同步状态这类需求,那优先选优化后的状态管理方案,再配合这些手段进一步提升性能:
- 用
React.memo包裹消息组件,减少不必要的重渲染; - 引入虚拟列表库,只渲染可视区域内的消息,避免大量DOM节点的渲染开销;
- 对消息列表做分页缓存,避免重复请求相同的历史消息。
- 用
总结下来:没有绝对最优的方案,只有最适合你当前场景的方案。如果追求极致的轻量和性能,且需求简单,那“反模式”也不是不能用;但如果要考虑长期维护和扩展,优化后的状态管理方案才是更稳妥的选择。
备注:内容来源于stack exchange,提问作者Null Salad

