构建对接列式数据库的百万行数据虚拟化表格:TypeScript 与 Shadcn UI 的深度实践


我们面临一个棘手的问题。新的内部可观测性平台需要一个日志查询界面,它必须能处理每日新增数十亿条的日志数据。后端选用了列式数据库(ClickHouse),查询聚合性能无与伦比,单次查询返回上千条日志在50毫秒内就能完成。但问题卡在了前端:用户要求像在本地IDE里滚动日志文件一样,流畅、无感知地浏览数百万甚至上千万行的查询结果。任何形式的分页都被视为不可接受的糟糕体验。

最初的尝试是灾难性的。一个简单的map渲染,在浏览器里加载超过5000个DOM节点后,整个页面开始出现可感知的卡顿,滚动操作的响应延迟超过200毫-秒。当试图加载20000行时,浏览器标签页直接崩溃。我们清楚,问题的核心不在于数据获取速度,而在于DOM渲染的物理限制。我们需要一种能在常数时间内渲染任意大数据集的技术——虚拟滚动(Virtual Scrolling)。

技术选型决策的背后

在正式动手前,我们评估了几个方向。

  1. 后端选型:为何是列式数据库?
    日志、指标这类数据有典型的OLAP(在线分析处理)特征:写入频繁、单次查询涉及大量数据行但可能只关心少数几列(例如,timestamp, level, message),并且经常需要进行聚合统计。在这种场景下,传统的行式数据库(如PostgreSQL, MySQL)性能会急剧下降,因为它们需要读取整行数据,即使你只查询其中三列。列式数据库将每一列的数据连续存储,查询时只需读取所需列,I/O开销呈数量级下降。这是我们能实现毫秒级数据查询的基础。

  2. UI基础:为何选择Shadcn UI?
    我们没有选择Material-UI或Ant Design这类大而全的组件库。在真实项目中,这些库的深度定制往往是一场噩梦,你总是在和它们预设的样式与行为作斗争。Shadcn UI不同,它不是一个“库”,而是一系列你可以直接复制到自己项目中的、基于Tailwind CSS和Radix UI的组件源码。这意味着我们对组件拥有100%的控制权,可以轻易地将其拆解、重组,完美契合我们构建底层渲染引擎的需求。我们将使用它的Table组件作为视觉外壳,但内部的<tbody>渲染逻辑将完全由我们自己接管。

  3. 虚拟滚动:造轮子还是用现成的?
    社区有react-windowreact-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:支持offsetlimit,这是实现按需加载数据的基石。在真实项目中,这里的查询会更复杂,可能包含过滤、排序等条件。

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的逻辑纯粹且高效:

  • 输入:总行数、预估行高、容器高度。
  • 核心计算:通过scrollTopitemHeight计算出当前应该渲染的startIndexendIndex
  • Overscan:这是提升体验的关键。我们不只渲染屏幕内可见的行,还会额外渲染上下方几行(overscan),这样用户在慢速滚动时,下一屏的内容已经准备好了,不会看到闪烁的白屏。
  • 输出:一个ref用于绑定滚动容器,一个totalHeight用于撑开容器产生正确的滚动条,以及一个virtualItems数组,包含了每个要渲染的项的索引和CSS top值。

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撑开,这个高度是(总行数 * 预估行高),它创建了一个虚拟的、巨大的滚动空间。它的positionrelative,作为所有虚拟行的定位父级。
  • **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。这会增加实现的复杂度,但在需要精确显示动态内容的场景下是必要的。

其次,缓存策略相对简单。当前的内存缓存会在页面刷新后丢失。对于一个频繁使用的内部工具,可以考虑使用IndexedDBlocalStorage进行更持久的缓存,或者在useLogFetcher中集成更强大的状态管理库,如SWRReact Query,它们提供了更复杂的缓存失效、后台重新验证等高级功能。

最后,当前的实现只处理了垂直虚拟化。如果表格有非常多的列(例如超过30列),水平滚动同样会遇到性能瓶颈。要解决这个问题,就需要实现水平虚拟化,即只渲染可视区域内的<TableCell>,这会进一步增加虚拟化引擎的复杂度。


  目录