react-virtual能否适配SignalR驱动的无限实时日志流场景?
实时日志流场景下react-virtual的适配方案
问题背景
我们有一个基于SignalR的日志查看器,可实时展示来自多个服务和网站的日志。随着DOM元素不断增加,查看器出现卡顿,因此希望通过虚拟化库缓解性能压力。目前尝试使用react-virtual,但无法适配该场景,POC代码如下:
import { Button, ButtonGroup, CardBody, CardHeader, Icon, Input } from 'our-common-lib'; import * as signalR from '@microsoft/signalr'; import { useVirtualizer } from '@tanstack/react-virtual'; import React, { useCallback, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import Constants from '../../constants'; import { IApplicationState } from '../../store'; import { LogRow } from './LogRow'; const CustomCard = styled.div` width: 96%; margin: 30px auto 50px auto; position: relative; display: flex; flex-direction: column; min-width: 0; word-wrap: break-word; background-clip: border-box; border-radius: 4px; background-color: #ffffff !important; color: #444444 !important; `; const ButtonsContainer = styled.div` float: right; `; const InputContainer = styled.span` margin-left: 15px; `; const StyledCardBody = styled(CardBody)` height: 700px; overflow: auto; `; export default function ViewLogs() { const [autoScroll, setAutoScroll] = React.useState(false); const allRows = useSelector((state: IApplicationState) => state.logStream.messages || []); const user = useSelector((state: IApplicationState) => state.oidc.user); const dispatch = useDispatch(); const parentRef = useRef<HTMLDivElement>(null); const scrollCount = allRows.length - 1; const rowVirtualizer = useVirtualizer({ count: allRows.length, getScrollElement: () => parentRef.current, estimateSize: () => 35, overscan: 5, }); const openConnection = useCallback(() => { const hubConnection = new signalR.HubConnectionBuilder() .withUrl(Constants.API_SIGNALR_URL, { accessTokenFactory: () => user?.access_token, } as signalR.IHttpConnectionOptions) .build(); hubConnection.serverTimeoutInMilliseconds = Constants.SignalRServerTimeoutInMilliseconds; hubConnection.keepAliveIntervalInMilliseconds = Constants.SignalRKeepAliveIntervalInMilliseconds; hubConnection.start(); hubConnection.on('logStreamMessage', (receivedMessage: string) => { dispatch({ type: 'logStream/receiveMessage', payload: receivedMessage }); }); return hubConnection; }, [dispatch, user]); useEffect(() => { const hubConnection = openConnection(); return () => { hubConnection.stop(); }; }, [openConnection]); useEffect(() => { if (autoScroll) { // eslint-disable-next-line no-console console.log(`scrolling to ${scrollCount}`); rowVirtualizer.scrollToIndex(scrollCount); } }, [autoScroll, rowVirtualizer, scrollCount]); const rows = rowVirtualizer.getVirtualItems(); return ( <CustomCard> <CardHeader size='md'> <InputContainer> <Input size='md' id='correlationid-filter' placeholder='CorrelationId Filter' /> </InputContainer> <ButtonsContainer> <ButtonGroup type='column'> <Button id='goto-top' onClick={() => rowVirtualizer.scrollToIndex(0)} color='primary'> Start </Button> <Button id='goto-bottom' onClick={() => rowVirtualizer.scrollToIndex(scrollCount)} color='primary'> End </Button> {autoScroll ? ( <Button id='pause-scroll' onClick={() => setAutoScroll(false)} color='primary'> || Pause scroll </Button> ) : ( <Button id='auto-scroll' onClick={() => setAutoScroll(true)} color='primary'> <Icon name='caret-right' /> Auto Scroll </Button> )} </ButtonGroup> </ButtonsContainer> </CardHeader> <StyledCardBody> <div ref={parentRef}> <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, width: '100%', position: 'relative', }} > {rows.map((virtualRow) => { const text = JSON.parse(allRows[virtualRow.index]); return ( <div key={virtualRow.index} className={virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualRow.size}px`, transform: `translateY(${virtualRow.start}px)`, }} > <LogRow logMessage={text} rowNumber={virtualRow.index} /> </div> ); })} </div> </div> </StyledCardBody> </CustomCard> ); }
目前观察到的问题是:Feed推送的每条日志都会在DOM中创建对应行,滚动功能完全失效。推测原因是每次渲染时都会向useVirtualizer钩子传入新的总条数(日志通过Redux thunk分批接收)。
适配无限Feed场景的解决方案
1. 稳定useVirtualizer配置引用
当前代码中useVirtualizer的配置对象在组件每次渲染时都会重新创建,导致虚拟器实例频繁重置。用useMemo包裹配置项,仅在依赖变化时更新:
import { useMemo } from 'react'; // ... const rowVirtualizer = useVirtualizer( useMemo(() => ({ count: allRows.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 35, overscan: 5, }), [allRows.length]) );
2. 修正滚动容器绑定层级
原代码中滚动溢出设置在StyledCardBody,但虚拟器绑定的是内部嵌套容器,导致滚动事件无法被正确捕获:
- 给
StyledCardBody添加ref:
const scrollContainerRef = useRef<HTMLDivElement>(null); // ... <StyledCardBody ref={scrollContainerRef}>
- 更新
useVirtualizer的getScrollElement指向滚动容器:
getScrollElement: () => scrollContainerRef.current,
- 移除冗余的内部容器,直接在滚动容器内渲染虚拟列表占位元素:
<StyledCardBody ref={scrollContainerRef}> <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, width: '100%', position: 'relative', }} > {rows.map((virtualRow) => { // 原有渲染逻辑 })} </div> </StyledCardBody>
3. 优化自动滚动逻辑
每次新日志到来就调用scrollToIndex会导致虚拟器状态混乱,可通过判断滚动位置是否接近底部再触发自动滚动:
useEffect(() => { if (!autoScroll) return; const container = scrollContainerRef.current; if (!container) return; // 距离底部100px以内时自动滚动 const isNearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100; if (isNearBottom) { rowVirtualizer.scrollToIndex(scrollCount, { behavior: 'smooth' }); } }, [autoScroll, rowVirtualizer, scrollCount]);
4. 使用日志唯一标识作为key
日志追加会导致索引不断变化,用日志本身的唯一ID(如CorrelationId、消息ID)作为key,避免不必要的组件重渲染:
const text = JSON.parse(allRows[virtualRow.index]); return ( <div key={text.correlationId} // 替换为日志对象中的唯一标识字段 className={virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualRow.size}px`, transform: `translateY(${virtualRow.start}px)`, }} > <LogRow logMessage={text} rowNumber={virtualRow.index} /> </div> );
5. 动态缓存行高(可选)
如果日志行高不固定,固定的estimateSize会导致滚动偏移,可通过measureElement动态测量并缓存行高:
const sizeCache = useRef<Record<number, number>>({}); const rowVirtualizer = useVirtualizer( useMemo(() => ({ count: allRows.length, getScrollElement: () => scrollContainerRef.current, estimateSize: (index) => sizeCache.current[index] || 35, overscan: 5, measureElement: (element) => { if (element) { const index = parseInt(element.dataset.index || '0', 10); sizeCache.current[index] = element.getBoundingClientRect().height; } }, }), [allRows.length]) ); // 在虚拟项上添加data-index属性 <div data-index={virtualRow.index} // 其他属性 > <LogRow logMessage={text} rowNumber={virtualRow.index} /> </div>
内容的提问来源于stack exchange,提问作者Stuart
相关产品推荐
相关产品推荐

