渲染一个持续不断的、每秒可能新增上千条的日志流,同时要保证浏览器界面流畅不卡顿,这是一个相当棘手的挑战。传统的做法,即简单地将新日志追加到DOM列表中,当节点数量超过几千个时,会导致内存占用飙升和UI线程阻塞,最终页面崩溃。在最近一个项目中,我们面临的正是这样一个需求:构建一个高性能的实时监控面板。
最初的构想很直接:用一个React state数组存储日志,通过WebSocket接收新数据,然后setLogs(prev => [...newLogs, ...prev])。这个方案在演示环境中运行良好,但在接入真实数据流后,不到一分钟,页面就变得无法响应。这迫使我们必须从数据存储、后端接口、前端状态管理到渲染机制进行一次彻底的重新设计。
技术选型决策:一套应对极端场景的组合拳
要解决这个问题,必须在技术栈的每一层都做出正确的选择。
后端存储: Apache Cassandra
日志数据是典型的时间序列数据,写入量远大于读取量,且几乎没有更新操作。Cassandra的无主架构和基于LSM树的存储引擎为高吞t量写入提供了完美的保障。更关键的是它的数据模型,我们可以通过精心设计的PRIMARY KEY来高效查询时间范围内的日志,这对于实现日志回溯和分页至关重要。UI组件库: Shadcn UI
我们不需要一个重量级、样式固化的UI框架。Shadcn UI提供的是一系列无样式的、可通过组合构建复杂UI的基础组件。它的ScrollArea和Table为我们提供了构建虚拟化列表所需的、可靠且无障碍的“骨架”,而无需引入额外的样式冲突或性能开销。前端状态管理: Jotai
虚拟化列表的内部状态相当复杂:总数据、滚动偏移、视口尺寸、每项的估算高度、已加载的数据窗口等等。如果使用useState,会导致状态散乱,逻辑难以追踪。如果使用Redux这类集中式存储,任何微小的状态更新(如滚动偏移)都可能触发整个组件树的不必要重渲染。Jotai的原子化(atomic)状态模型在这里堪称绝配。每个状态都是一个独立的atom,组件可以精确订阅自己依赖的atom,只有当该atom变化时才会重渲染。这使得管理复杂、高频更新的状态变得极其高效。测试库: React Testing Library (RTL)
对于虚拟化这种重逻辑的UI组件,我们关心的不是其内部状态变量的值,而是它在用户交互下的行为是否正确。用户滚动时,DOM中是否只渲染了可见窗口内的数据?滚动到底部时,是否正确触发了加载更多的逻辑?RTL鼓励我们编写这种面向用户行为的测试,确保组件的健壮性。
第一步:Cassandra数据建模与后端API
在真实项目中,一个常见的错误是没有为查询模式优化数据模型。对于日志系统,最常见的查询是“获取某个时间段内的最新N条日志”。
我们的Cassandra表结构设计如下:
-- astra.cql
CREATE TABLE IF NOT EXISTS logs.realtime_logs (
app_id text,
time_bucket text, // e.g., '2023-11-20'
log_id timeuuid,
level text,
message text,
metadata map<text, text>,
PRIMARY KEY ((app_id, time_bucket), log_id)
) WITH CLUSTERING ORDER BY (log_id DESC);
这里的关键在于PRIMARY KEY的设计:
- Partition Key
(app_id, time_bucket): 我们按应用ID和日期对数据进行分区。这确保了同一应用一天内的所有日志都落在同一个分区(或少数几个分区)内,便于按天查询。 - Clustering Key
log_id: 使用timeuuid作为日志ID,并按其DESC(降序)排列。这使得获取最新的日志成为一个极其高效的操作,Cassandra只需读取每个分区顶部的少数几行数据即可。
基于这个模型,我们用Node.js和官方cassandra-driver库构建后端API。一个常见的坑是,对于海量数据的分页,绝不能使用OFFSET,而应使用Cassandra提供的pagingState。
// server/routes/logs.js
const express = require('express');
const cassandra = require('cassandra-driver');
const router = express.Router();
// 生产环境中,配置应从环境变量或配置文件中读取
const client = new cassandra.Client({
cloud: {
secureConnectBundle: './secure-connect-bundle.zip',
},
credentials: {
username: 'YOUR_USERNAME',
password: 'YOUR_PASSWORD',
},
keyspace: 'logs',
});
// 日志记录器,实际项目中应使用Winston等库
const logger = {
error: console.error,
info: console.log,
};
router.get('/:appId', async (req, res) => {
const { appId } = req.params;
// pageState是Cassandra用于高效分页的token
const { pageState, limit = 100 } = req.query;
// 简单地使用当天的time_bucket,真实场景可能需要跨日期查询
const today = new Date().toISOString().split('T')[0];
const query = 'SELECT log_id, level, message, metadata FROM realtime_logs WHERE app_id = ? AND time_bucket = ?';
const params = [appId, today];
const options = {
prepare: true,
fetchSize: parseInt(limit, 10),
pageState: pageState ? Buffer.from(pageState, 'hex') : undefined,
};
try {
const result = await client.execute(query, params, options);
res.json({
logs: result.rows,
// 将下一页的pageState返回给前端
nextPageState: result.pageState ? result.pageState.toString('hex') : null,
});
} catch (error) {
logger.error(`Failed to fetch logs for app [${appId}]:`, error);
res.status(500).json({ message: 'Internal server error while fetching logs.' });
}
});
module.exports = router;
这个API实现了基于pagingState的稳定分页,这是构建无限滚动前端的基础。
第二步:Jotai原子化状态设计
现在进入前端的核心。我们将虚拟化列表所需的所有状态都分解为独立的Jotai atom。
graph TD
A[user scrolls] --> B(onScrollHandler);
B --> C(scrollOffsetAtom);
subgraph Derived State
D(startIndexAtom) -- reads --> C;
E(endIndexAtom) -- reads --> C;
F(visibleLogsAtom) -- reads --> D;
F -- reads --> E;
F -- reads --> G[allLogsAtom];
end
H(React Component) -- renders --> I{DOM};
H -- subscribes to --> F;
J[API Fetch] --> K(update allLogsAtom);
K --> G;
这种数据流非常清晰,每个atom只负责一件事。
// src/atoms/logAtoms.ts
import { atom } from 'jotai';
// 定义日志条目的类型
export interface LogEntry {
log_id: string;
level: string;
message: string;
metadata: Record<string, string>;
}
// -- 核心状态原子 --
// 存储所有已加载的日志
export const allLogsAtom = atom<LogEntry[]>([]);
// 服务器下一页的token
export const nextPageStateAtom = atom<string | null>(null);
// 加载状态
export const isLoadingAtom = atom<boolean>(false);
// 滚动容器的引用
export const scrollContainerRefAtom = atom<React.RefObject<HTMLDivElement> | null>(null);
// -- UI交互状态原子 --
// 滚动偏移量,由onScroll事件驱动
export const scrollOffsetAtom = atom<number>(0);
// 视口高度
export const viewportHeightAtom = atom<number>(0);
// -- 配置与估算值 --
// 每行日志的估算高度,这是虚拟化的关键
export const itemHeightAtom = atom<number>(25); // 估算一个初始值 (px)
// 渲染缓冲区大小,用于在视口上下方预渲染一些项目,避免滚动时出现白屏
export const overscanCountAtom = atom<number>(10);
// -- 派生原子 (Derived Atoms) --
// 这部分是Jotai的精髓,它们是无状态的,其值由其他atom计算而来
// 计算总内容高度
export const totalContentHeightAtom = atom((get) => get(allLogsAtom).length * get(itemHeightAtom));
// 计算当前渲染窗口的起始索引
export const startIndexAtom = atom((get) => {
const scrollOffset = get(scrollOffsetAtom);
const itemHeight = get(itemHeightAtom);
const overscan = get(overscanCountAtom);
const start = Math.floor(scrollOffset / itemHeight);
return Math.max(0, start - overscan);
});
// 计算当前渲染窗口的结束索引
export const endIndexAtom = atom((get) => {
const viewportHeight = get(viewportHeightAtom);
const itemHeight = get(itemHeightAtom);
const overscan = get(overscanCountAtom);
const start = get(startIndexAtom);
const visibleItemCount = Math.ceil(viewportHeight / itemHeight);
return start + visibleItemCount + overscan * 2;
});
// 根据起止索引,从总日志中切片出需要渲染的日志
export const visibleLogsAtom = atom((get) => {
const allLogs = get(allLogsAtom);
const startIndex = get(startIndexAtom);
const endIndex = get(endIndexAtom);
// 使用slice创建新数组,只包含可见项
return allLogs.slice(startIndex, Math.min(endIndex, allLogs.length));
});
第三步:构建虚拟化React组件
有了原子化的状态,组件的实现就变得非常直观。我们使用Shadcn UI的ScrollArea作为容器,并在其内部实现虚拟化逻辑。
// src/components/VirtualizedLogViewer.tsx
import React, { useRef, useEffect, useCallback } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'; // 来自 Shadcn UI
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { useResizeObserver } from '@/hooks/useResizeObserver'; // 一个自定义hook
import {
allLogsAtom,
nextPageStateAtom,
isLoadingAtom,
scrollOffsetAtom,
viewportHeightAtom,
totalContentHeightAtom,
startIndexAtom,
visibleLogsAtom,
LogEntry,
} from '@/atoms/logAtoms';
const APP_ID = 'your-app-id'; // 在真实应用中,这应是动态的
export const VirtualizedLogViewer = () => {
const [allLogs, setAllLogs] = useAtom(allLogsAtom);
const [nextPageState, setNextPageState] = useAtom(nextPageStateAtom);
const [isLoading, setIsLoading] = useAtom(isLoadingAtom);
const setScrollOffset = useSetAtom(scrollOffsetAtom);
const setViewportHeight = useSetAtom(viewportHeightAtom);
const totalContentHeight = useAtom(totalContentHeightAtom)[0];
const startIndex = useAtom(startIndexAtom)[0];
const visibleLogs = useAtom(visibleLogsAtom)[0];
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 加载更多数据的函数
const fetchMoreLogs = useCallback(async () => {
if (isLoading || !nextPageState) return;
setIsLoading(true);
try {
const response = await fetch(`/api/logs/${APP_ID}?limit=100&pageState=${nextPageState}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setAllLogs((prev) => [...prev, ...data.logs]);
setNextPageState(data.nextPageState);
} catch (error) {
console.error("Failed to fetch more logs:", error);
} finally {
setIsLoading(false);
}
}, [isLoading, nextPageState, setAllLogs, setNextPageState, setIsLoading]);
// 组件挂载时加载初始数据
useEffect(() => {
const initialFetch = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/logs/${APP_ID}?limit=100`);
const data = await response.json();
setAllLogs(data.logs);
setNextPageState(data.nextPageState);
} catch (error) {
console.error("Failed to fetch initial logs:", error);
} finally {
setIsLoading(false);
}
};
initialFetch();
}, [setAllLogs, setNextPageState, setIsLoading]);
// 滚动事件处理
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
const scrollTop = event.currentTarget.scrollTop;
setScrollOffset(scrollTop);
// 检查是否滚动到底部附近以触发加载更多
if (
event.currentTarget.scrollHeight - scrollTop - event.currentTarget.clientHeight < 200 &&
!isLoading &&
nextPageState
) {
fetchMoreLogs();
}
};
// 使用 ResizeObserver 动态更新视口高度
const onResize = useCallback((target: HTMLDivElement) => {
setViewportHeight(target.clientHeight);
}, [setViewportHeight]);
useResizeObserver(scrollContainerRef, onResize);
return (
<ScrollArea
ref={scrollContainerRef}
className="h-[80vh] w-full border rounded-md"
onScroll={handleScroll}
>
<div style={{ height: `${totalContentHeight}px`, position: 'relative' }}>
<Table
style={{
position: 'absolute',
top: `${startIndex * 25}px`, // 25是估算的itemHeight
width: '100%',
}}
>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead className="w-[80px]">Level</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleLogs.map((log) => (
<LogItem key={log.log_id} log={log} />
))}
</TableBody>
</Table>
{isLoading && <div className="text-center p-4">Loading more logs...</div>}
</div>
<ScrollBar orientation="vertical" />
</ScrollArea>
);
};
// 单独的LogItem组件,以优化重渲染
const LogItem = React.memo(({ log }: { log: LogEntry }) => (
<TableRow>
<TableCell className="font-mono text-xs">{new Date(parseInt(log.log_id.substring(0, 8), 16) * 1000).toISOString()}</TableCell>
<TableCell>{log.level}</TableCell>
<TableCell className="font-mono text-sm">{log.message}</TableCell>
</TableRow>
));
这里的核心是CSS position 的运用:
- 外层
div的高度由totalContentHeight撑开,这为浏览器提供了正确的滚动条。 - 内部的
Table使用position: absolute,其top值根据startIndex动态计算,从而将渲染的DOM节点精确地“移动”到视口中。
第四步:使用React Testing Library验证行为
虚拟化组件的逻辑很容易出错。一个好的测试套件是必不可少的。
// src/components/VirtualizedLogViewer.test.tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { Provider } from 'jotai';
import { VirtualizedLogViewer } from './VirtualizedLogViewer';
import { allLogsAtom, nextPageStateAtom } from '@/atoms/logAtoms';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// 生成模拟日志数据
const generateMockLogs = (count: number, offset = 0) =>
Array.from({ length: count }, (_, i) => ({
log_id: `0000000${i + offset}-...`, // 简化版timeuuid
level: 'INFO',
message: `Log message ${i + offset}`,
metadata: {},
}));
// 使用 MSW (Mock Service Worker) 拦截API请求
const server = setupServer(
http.get('/api/logs/your-app-id', ({ request }) => {
const url = new URL(request.url);
const pageState = url.searchParams.get('pageState');
if (pageState === 'page2') {
return HttpResponse.json({
logs: generateMockLogs(50, 50),
nextPageState: null, // 最后一页
});
}
return HttpResponse.json({
logs: generateMockLogs(50, 0),
nextPageState: 'page2',
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('VirtualizedLogViewer', () => {
it('should render initial logs and only the visible ones', async () => {
// 渲染在Jotai Provider中
render(
<Provider>
<VirtualizedLogViewer />
</Provider>
);
// 等待初始数据加载完成
await waitFor(() => {
expect(screen.getByText('Log message 0')).toBeInTheDocument();
});
// 假设视口只能显示20条
// 验证第一条可见
expect(screen.getByText('Log message 0')).toBeInTheDocument();
// 验证视口外的第一条 *不* 在DOM中,这是虚拟化的核心
expect(screen.queryByText('Log message 40')).not.toBeInTheDocument();
});
it('should load more logs on scroll to bottom', async () => {
// 使用可覆盖的atom初始值进行测试
const initialLogs = generateMockLogs(50, 0);
const JotaiProvider = ({ children }: { children: React.ReactNode }) => (
<Provider>
{children}
</Provider>
);
const { container } = render(
<JotaiProvider>
<VirtualizedLogViewer />
</JotaiProvider>
);
await waitFor(() => {
expect(screen.getByText('Log message 49')).toBeInTheDocument();
});
// 模拟滚动
const scrollArea = container.querySelector('[data-radix-scroll-area-viewport]')!;
fireEvent.scroll(scrollArea, { target: { scrollTop: 10000 } }); // 滚动到底部
// 等待下一页数据加载和渲染
await waitFor(() => {
expect(screen.getByText('Log message 50')).toBeInTheDocument();
expect(screen.getByText('Log message 99')).toBeInTheDocument();
});
// 验证总日志数是否正确
expect(screen.getAllByRole('row').length).toBeGreaterThan(50);
});
});
这个测试验证了两个关键行为:
- 初始渲染时,只有视口内的元素存在于DOM中。
- 当用户滚动到底部时,会触发API调用并渲染新的数据。
当前方案的局限性与未来迭代
这套方案有效地解决了海量数据渲染的性能问题,但在生产环境中,仍有几个可以优化的点。
首先,动态行高是一个挑战。当前实现依赖于固定的itemHeight。如果日志消息长度不一导致行高动态变化,就需要引入更复杂的机制,如使用react-window或react-virtual这类库,它们内部实现了对动态高度的测量和缓存。
其次,实时数据注入。当前模型是拉(pull)模型,通过轮询或滚动加载。对于真正的实时流,应采用推(push)模型,如WebSocket。当新日志通过WebSocket推送到前端时,我们需要决定如何将其“注入”allLogsAtom。通常是添加到数组的开头,但这会改变所有后续元素的索引和位置,需要小心处理滚动定位,避免用户正在查看的旧日志位置发生跳动。
最后,后端查询优化。虽然按天分区很有效,但对于需要跨天查询的场景,后端API需要实现并发查询多个分区并合并结果的逻辑。此外,若要支持全文搜索,仅靠Cassandra本身能力有限,通常会结合Elasticsearch等外部索引来解决。