我们面临一个棘手的问题。新的内部可观测性平台需要一个日志查询界面,它必须能处理每日新增数十亿条的日志数据。后端选用了列式数据库(ClickHouse),查询聚合性能无与伦比,单次查询返回上千条日志在50毫秒内就能完成。但问题卡在了前端:用户要求像在本地IDE里滚动日志文件一样,流畅、无感知地浏览数百万甚至上千万行的查询结果。任何形式的分页都被视为不可接受的糟糕体验。
最初的尝试是灾难性的。一个简单的map渲染,在浏览器里加载超过5000个DOM节点后,整个页面开始出现可感知的卡顿,滚动操作的响应延迟超过200毫-秒。当试图加载20000行时,浏览器标签页直接崩溃。我们清楚,问题的核心不在于数据获取速度,而在于DOM渲染的物理限制。我们需要一种能在常数时间内渲染任意大数据集的技术——虚拟滚动(Virtual Scrolling)。
技术选型决策的背后
在正式动手前,我们评估了几个方向。
后端选型:为何是列式数据库?
日志、指标这类数据有典型的OLAP(在线分析处理)特征:写入频繁、单次查询涉及大量数据行但可能只关心少数几列(例如,timestamp,level,message),并且经常需要进行聚合统计。在这种场景下,传统的行式数据库(如PostgreSQL, MySQL)性能会急剧下降,因为它们需要读取整行数据,即使你只查询其中三列。列式数据库将每一列的数据连续存储,查询时只需读取所需列,I/O开销呈数量级下降。这是我们能实现毫秒级数据查询的基础。UI基础:为何选择Shadcn UI?
我们没有选择Material-UI或Ant Design这类大而全的组件库。在真实项目中,这些库的深度定制往往是一场噩梦,你总是在和它们预设的样式与行为作斗争。Shadcn UI不同,它不是一个“库”,而是一系列你可以直接复制到自己项目中的、基于Tailwind CSS和Radix UI的组件源码。这意味着我们对组件拥有100%的控制权,可以轻易地将其拆解、重组,完美契合我们构建底层渲染引擎的需求。我们将使用它的Table组件作为视觉外壳,但内部的<tbody>渲染逻辑将完全由我们自己接管。虚拟滚动:造轮子还是用现成的?
社区有react-window、react-virtual这类成熟的虚拟滚动库。但在我们的场景中,数据是远程无限加载的,并且行高可能并非完全固定。直接使用这些库需要编写大量的适配层代码,去处理数据加载、缓存、占位符等逻辑。为了让数据流和渲染逻辑结合得更紧密,并完全掌控性能瓶颈,我们决定从零开始实现一个专用的、基于React Hooks的虚拟化引擎。这在工程上是合理的,因为核心逻辑并不庞大,但带来的控制力和可维护性收益是巨大的。
核心实现:从数据层到渲染层
我们的目标是构建一个<VirtualizedTable />组件。它对外接收列定义和数据获取函数,对内则处理所有复杂的虚拟化逻辑。
1. 后端API接口设计
首先,我们需要一个能高效分页查询列式数据库的API。接口必须简洁、高性能。我们使用TypeScript和Express来构建这个服务。
// server/api.ts
import { ClickHouseClient, createClient } from '@clickhouse/client-browser';
import express, { Request, Response } from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
app.use(express.json());
// 在真实项目中,这些配置应来自环境变量
const clickhouseClient: ClickHouseClient = createClient({
host: 'http://localhost:8123',
database: 'logs',
// ... 其他认证配置
});
interface QueryParams {
offset?: number;
limit?: number;
}
// 获取日志总数的接口
app.get('/logs/count', async (req: Request, res: Response) => {
try {
const resultSet = await clickhouseClient.query({
query: 'SELECT count() as total FROM logs.my_log_table',
format: 'JSONEachRow',
});
const data = await resultSet.json<{ total: string }[]>();
res.status(200).json({ total: parseInt(data[0].total, 10) });
} catch (error) {
console.error('[ClickHouse Error] /logs/count:', error);
res.status(500).json({ message: 'Failed to fetch log count' });
}
});
// 分页获取日志数据的接口
app.get('/logs', async (req: Request<{}, {}, {}, QueryParams>, res: Response) => {
// 对参数进行校验和设置默认值
const offset = Math.max(0, parseInt(String(req.query.offset) || '0', 10));
const limit = Math.min(100, Math.max(10, parseInt(String(req.query.limit) || '50', 10)));
try {
const query = `
SELECT timestamp, level, message, source
FROM logs.my_log_table
ORDER BY timestamp DESC
LIMIT ${limit} OFFSET ${offset}
`;
const resultSet = await clickhouseClient.query({ query, format: 'JSONEachRow' });
const data = await resultSet.json();
res.status(200).json(data);
} catch (error) {
console.error(`[ClickHouse Error] /logs (offset: ${offset}, limit: ${limit}):`, error);
res.status(500).json({ message: 'Failed to fetch logs' });
}
});
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Log service API listening on port ${PORT}`);
});
这个API有两个关键点:
-
/logs/count:前端需要预先知道总共有多少条数据,以便计算滚动条的总高度。 -
/logs:支持offset和limit,这是实现按需加载数据的基石。在真实项目中,这里的查询会更复杂,可能包含过滤、排序等条件。
2. 数据获取与缓存Hook (useLogFetcher.ts)
在前端,我们需要一个智能的Hook来管理数据。它不仅仅是发起API请求,更重要的是处理数据缓存和请求节流,避免在快速滚动时发送大量冗余请求。
// hooks/useLogFetcher.ts
import { useState, useEffect, useCallback, useRef } from 'react';
// 定义日志条目的类型
export interface LogEntry {
timestamp: string;
level: string;
message: string;
source: string;
}
const API_BASE_URL = 'http://localhost:3001';
const PAGE_SIZE = 50; // 每次从后端请求的数据量
type Cache = Map<number, LogEntry>;
export function useLogFetcher() {
const [total, setTotal] = useState(0);
const [isFetching, setIsFetching] = useState(true);
// 使用Map作为缓存,键是行索引,值是日志数据
const cacheRef = useRef<Cache>(new Map());
// 使用一个状态来触发组件重绘
const [version, setVersion] = useState(0);
const forceUpdate = useCallback(() => setVersion(v => v + 1), []);
// 初始化时获取总数
useEffect(() => {
const fetchTotal = async () => {
try {
const response = await fetch(`${API_BASE_URL}/logs/count`);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
setTotal(data.total || 0);
} catch (error) {
console.error("Failed to fetch total count:", error);
// 在生产环境中应有更完善的错误处理逻辑,例如错误边界
} finally {
setIsFetching(false);
}
};
fetchTotal();
}, []);
const fetchRange = useCallback(async (startIndex: number, endIndex: number) => {
const startPage = Math.floor(startIndex / PAGE_SIZE);
const endPage = Math.floor(endIndex / PAGE_SIZE);
for (let page = startPage; page <= endPage; page++) {
// 检查缓存中是否已存在该页的任何数据,避免重复请求
if (cacheRef.current.has(page * PAGE_SIZE)) {
continue;
}
// 这里可以加入请求状态管理,防止同一页面被并发请求
// 在实际项目中,可以使用一个 Set 来记录正在请求的页面
try {
const response = await fetch(`${API_BASE_URL}/logs?offset=${page * PAGE_SIZE}&limit=${PAGE_SIZE}`);
if (!response.ok) throw new Error(`Failed to fetch page ${page}`);
const data: LogEntry[] = await response.json();
data.forEach((item, index) => {
const itemIndex = page * PAGE_SIZE + index;
cacheRef.current.set(itemIndex, item);
});
} catch (error) {
console.error(error);
// 标记该页加载失败,可以考虑重试机制
}
}
// 请求完成后,强制更新UI以显示新加载的数据
forceUpdate();
}, [forceUpdate]);
const getLogEntry = useCallback((index: number): LogEntry | null | undefined => {
// undefined: 正在加载或未请求
// null: 加载失败
// LogEntry: 加载成功
return cacheRef.current.get(index);
}, []);
return { total, isFetching, fetchRange, getLogEntry };
}
这个Hook做了几件关键的事:
- 缓存策略:使用
Map对象作为简单的内存缓存,避免用户来回滚动时重复请求相同的数据。 - 分页对齐:将零散的行索引请求(
startIndex,endIndex)对齐到后端的PAGE_SIZE,减少API请求次数。 - 状态分离:
isFetching只在初始化时使用,滚动加载的状态由getLogEntry的返回值(undefined代表加载中)来体现,这让UI层逻辑更清晰。
3. 虚拟化引擎Hook (useVirtualizer.ts)
这是整个系统的核心。它不关心数据是什么,只负责计算在当前的滚动位置下,哪些元素应该被渲染,以及它们应该被放在什么位置。
// hooks/useVirtualizer.ts
import React, { useState, useCallback, useRef, useEffect } from 'react';
interface UseVirtualizerOptions {
totalItems: number;
itemHeight: number; // 预估的行高
containerHeight: number;
overscan?: number; // 在可视区域外额外渲染的行数,用于提升滚动体验
}
export function useVirtualizer({
totalItems,
itemHeight,
containerHeight,
overscan = 5,
}: UseVirtualizerOptions) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop);
}, []);
// 我们需要一个稳定的引用来传递给事件监听器
const stableHandleScroll = useCallback(handleScroll, [handleScroll]);
useEffect(() => {
const containerNode = containerRef.current;
if (containerNode) {
containerNode.addEventListener('scroll', stableHandleScroll);
}
return () => {
if (containerNode) {
containerNode.removeEventListener('scroll', stableHandleScroll);
}
};
}, [stableHandleScroll]);
const totalHeight = totalItems * itemHeight;
// 计算可视范围的起始索引
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
// 计算可视范围的结束索引
const endIndex = Math.min(
totalItems - 1,
Math.floor((scrollTop + containerHeight) / itemHeight) + overscan
);
const virtualItems = [];
for (let i = startIndex; i <= endIndex; i++) {
virtualItems.push({
index: i,
// 计算每个item的绝对定位top值
offsetTop: i * itemHeight,
});
}
return {
containerRef,
// 外部容器需要设置的总高度,用于撑开滚动条
totalHeight,
// 实际需要渲染的虚拟项数组
virtualItems,
};
}
这个Hook的逻辑纯粹且高效:
- 输入:总行数、预估行高、容器高度。
- 核心计算:通过
scrollTop和itemHeight计算出当前应该渲染的startIndex和endIndex。 - Overscan:这是提升体验的关键。我们不只渲染屏幕内可见的行,还会额外渲染上下方几行(
overscan),这样用户在慢速滚动时,下一屏的内容已经准备好了,不会看到闪烁的白屏。 - 输出:一个
ref用于绑定滚动容器,一个totalHeight用于撑开容器产生正确的滚动条,以及一个virtualItems数组,包含了每个要渲染的项的索引和CSStop值。
4. 组装最终组件 <VirtualizedTable />
现在,我们将Shadcn UI的Table组件、数据获取Hook和虚拟化Hook组装在一起。
// components/VirtualizedTable.tsx
import React, { useState, useEffect, useRef } from 'react';
import { useLogFetcher, LogEntry } from '@/hooks/useLogFetcher';
import { useVirtualizer } from '@/hooks/useVirtualizer';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'; // 从Shadcn UI导入
import { Skeleton } from '@/components/ui/skeleton'; // 加载占位符
const ROW_HEIGHT = 45; // 预估的行高,px
export function VirtualizedTable() {
const { total, isFetching, fetchRange, getLogEntry } = useLogFetcher();
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState(1000);
// 使用 ResizeObserver 动态获取容器高度
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver(() => {
setContainerHeight(element.clientHeight);
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, []);
const { virtualItems, totalHeight } = useVirtualizer({
totalItems: total,
itemHeight: ROW_HEIGHT,
containerHeight: containerHeight,
overscan: 10,
});
// 当虚拟项范围变化时,触发数据获取
useEffect(() => {
if (virtualItems.length > 0) {
const startIndex = virtualItems[0].index;
const endIndex = virtualItems[virtualItems.length - 1].index;
fetchRange(startIndex, endIndex);
}
}, [virtualItems, fetchRange]);
if (isFetching) {
return <div>Loading initial data...</div>;
}
return (
<div
ref={containerRef}
className="h-[calc(100vh-200px)] overflow-auto border rounded-md"
>
<div style={{ height: `${totalHeight}px`, position: 'relative' }}>
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[200px]">Timestamp</TableHead>
<TableHead className="w-[100px]">Level</TableHead>
<TableHead>Message</TableHead>
<TableHead className="w-[150px]">Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{virtualItems.map(({ index, offsetTop }) => {
const log = getLogEntry(index);
return (
<TableRow
key={index}
style={{
position: 'absolute',
top: `${offsetTop}px`,
width: '100%',
height: `${ROW_HEIGHT}px`,
}}
>
{log ? (
<>
<TableCell>{log.timestamp}</TableCell>
<TableCell>{log.level}</TableCell>
<TableCell className="truncate">{log.message}</TableCell>
<TableCell>{log.source}</TableCell>
</>
) : (
// 如果数据还没加载回来,显示骨架屏
<>
<TableCell><Skeleton className="h-4 w-full" /></TableCell>
<TableCell><Skeleton className="h-4 w-full" /></TableCell>
<TableCell><Skeleton className="h-4 w-full" /></TableCell>
<TableCell><Skeleton className="h-4 w-full" /></TableCell>
</>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
这个组件的渲染逻辑非常关键:
- **外层容器 (
div)**:负责提供滚动条 (overflow-auto),并通过ref绑定到useVirtualizer。 - **内层容器 (
div)**:高度由totalHeight撑开,这个高度是(总行数 * 预估行高),它创建了一个虚拟的、巨大的滚动空间。它的position是relative,作为所有虚拟行的定位父级。 - **
TableRow**:每个渲染的行都使用position: absolute进行绝对定位,其top值由virtualItems中的offsetTop决定。这确保了无论滚动到哪里,DOM中始终只有几十个<tr>元素,它们只是在父容器中“瞬移”。 - **骨架屏 (
Skeleton)**:当用户快速滚动,数据还未从API返回时,getLogEntry(index)会返回undefined。此时我们渲染一个骨架屏占位符。这极大地改善了用户体验,避免了白屏闪烁。
架构与数据流
整个系统的交互流程可以用下面的图来清晰地展示。
sequenceDiagram
participant User
participant Component as VirtualizedTable
participant Virtualizer as useVirtualizer Hook
participant Fetcher as useLogFetcher Hook
participant API
participant DB as Columnar Database
User->>Component: 滚动页面
Component->>Virtualizer: 触发onScroll事件,更新scrollTop
Virtualizer-->>Component: 计算并返回新的virtualItems数组
Note right of Component: React重新渲染
Component->>Fetcher: useEffect依赖virtualItems变化, 调用fetchRange(start, end)
Fetcher->>Fetcher: 检查缓存, 对未缓存的页码发起请求
opt 数据未在缓存中
Fetcher->>API: GET /logs?offset=...&limit=...
API->>DB: SELECT ... LIMIT ... OFFSET ...
DB-->>API: 返回数据子集
API-->>Fetcher: 返回JSON数据
Fetcher->>Fetcher: 更新内部缓存, 并强制更新UI
end
Component->>Fetcher: 在渲染循环中调用getLogEntry(index)
alt 数据已加载
Fetcher-->>Component: 返回LogEntry对象
Component->>User: 渲染带有真实数据的TableRow
else 数据加载中
Fetcher-->>Component: 返回undefined
Component->>User: 渲染带有骨架屏的TableRow
end
局限性与未来迭代路径
这套方案在生产环境中运行良好,但它并非完美,还存在一些可以优化的点。
首先,行高预估是当前实现的一个弱点。我们假设所有行高都是固定的ROW_HEIGHT。当日志消息长短不一导致行高动态变化时,滚动条的位置和实际内容的位置会产生偏差。一个更复杂的实现是,在行渲染后,使用ResizeObserver去测量其实际高度,并更新一个高度缓存,然后用这个缓存去计算offsetTop。这会增加实现的复杂度,但在需要精确显示动态内容的场景下是必要的。
其次,缓存策略相对简单。当前的内存缓存会在页面刷新后丢失。对于一个频繁使用的内部工具,可以考虑使用IndexedDB或localStorage进行更持久的缓存,或者在useLogFetcher中集成更强大的状态管理库,如SWR或React Query,它们提供了更复杂的缓存失效、后台重新验证等高级功能。
最后,当前的实现只处理了垂直虚拟化。如果表格有非常多的列(例如超过30列),水平滚动同样会遇到性能瓶颈。要解决这个问题,就需要实现水平虚拟化,即只渲染可视区域内的<TableCell>,这会进一步增加虚拟化引擎的复杂度。