You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.21 02:02:32