我把 10 万个 React 组件塞进页面,浏览器竟然没崩

我把 10 万个 React 组件塞进页面,浏览器竟然没崩)
三个月前,一场关键演示把我按在地上摩擦。我们的应用需要展示 45,000 行数据:页面载入——卡死,Chrome 提示 “Page Unresponsive”。空气安静到我能听见甲方的失望。
那天夜里我决定一劳永逸解决它。不是再引一个+50KB的虚拟滚动库,也不是抄一个不成体系的片段。我要搞懂浏览器如何渲染,并亲手搭一套能稳跑 100,000+ 项、保持 60 FPS的系统。
残酷事实:你的 10,000 DOM 节点,其实在杀你
你天真的代码:
{users.map(user => )}
浏览器背后发生的事:
Chrome 为 DOM 分配 ~180MB 内存
渲染线程计算 1 万次布局
React 协调 1 万个虚拟节点
滚动掉到 15 FPS 的 PPT 模式
我见过资深踩坑;我本人也踩过。
解法?只渲染可见区域。听上去简单,做起来不简单。
架构:从第一性原理实现虚拟滚动
虚拟滚动(windowing)的本质:只渲染视口可见项 + 小缓冲。难点在边界与细节。
下面是我们在百万用户级应用里跑了很久的实现。
import React, { useState, useRef, useCallback, useMemo } from ‘react’;

interface VirtualScrollerProps {
items: T[];
itemHeight: number | ((item: T, index: number) => number);
renderItem: (item: T, index: number) => React.ReactNode;
height: number;
overscan?: number;
onScroll?: (scrollTop: number) => void;
className?: string;
}

interface ItemPosition {
index: number;
top: number;
height: number;
}

function VirtualScroller({
items,
itemHeight,
renderItem,
height,
overscan = 3,
onScroll,
className = ”,
}: VirtualScrollerProps) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const rafRef = useRef(null);

// 预计算每个 item 的 top/height(只在 items 或 itemHeight 变更时重算)
const itemPositions = useMemo(() => {
const positions: ItemPosition[] = [];
let currentTop = 0;
for (let i = 0; i < items.length; i++) {
const currentHeight = typeof itemHeight === ‘function’
? itemHeight(items[i], i)
: itemHeight;
positions.push({ index: i, top: currentTop, height: currentHeight });
currentTop += currentHeight;
}
return positions;
}, [items, itemHeight]);

// 动态高度场景的平均高度估计
const averageItemHeight = useMemo(() => {
if (itemPositions.length === 0) return 100;
const last = itemPositions[itemPositions.length – 1];
return (last.top + last.height) / itemPositions.length;
}, [itemPositions]);

const totalHeight = useMemo(() => {
if (itemPositions.length === 0) return 0;
const last = itemPositions[itemPositions.length – 1];
return last.top + last.height;
}, [itemPositions]);

// 二分查找首个可见项(O(log n))
const findStartIndex = useCallback((scrollTop: number): number => {
if (itemPositions.length === 0 || scrollTop <= 0) return 0;
let left = 0, right = itemPositions.length – 1;
while (left < right) {
const mid = Math.floor((left + right) / 2);
const pos = itemPositions[mid];
if (pos.top + pos.height <= scrollTop) left = mid + 1;
else right = mid;
}
return Math.max(0, left – overscan);
}, [itemPositions, overscan]);

// 计算可见范围
const visibleRange = useMemo(() => {
if (itemPositions.length === 0) return { start: 0, end: -1 };
const startIndex = findStartIndex(scrollTop);
const viewportBottom = scrollTop + height;
let endIndex = startIndex;
const bufferSize = overscan * averageItemHeight;
for (let i = startIndex; i < itemPositions.length; i++) { const p = itemPositions[i]; if (p.top > viewportBottom + bufferSize) break;
endIndex = i;
}
const finalEnd = Math.min(endIndex + overscan, items.length – 1);
return { start: startIndex, end: finalEnd };
}, [scrollTop, height, itemPositions, findStartIndex, overscan, items.length, averageItemHeight]);

// 用 rAF 节流 scroll,同步浏览器 60Hz 节奏
const handleScroll = useCallback((e: React.UIEvent) => {
const target = e.currentTarget;
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const newTop = target.scrollTop;
setScrollTop(newTop);
onScroll?.(newTop);
rafRef.current = null;
});
}, [onScroll]);

React.useEffect(() => () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
}, []);

// 只渲染可见项
const visibleItems = useMemo(() => {
if (visibleRange.end < visibleRange.start) return []; const nodes = []; for (let i = visibleRange.start; i <= visibleRange.end; i++) { if (i < 0 || i >= items.length || i >= itemPositions.length) continue;
const item = items[i];
const pos = itemPositions[i];
nodes.push(

item-${i}} style={{ position: ‘absolute’, top: pos.top, width: ‘100%’, height: pos.height }} > {renderItem(item, i)}
);
}
return nodes;
}, [visibleRange, items, itemPositions, renderItem]);

return (

{visibleItems}
);
}

export default VirtualScroller;

为什么这套代码真的快:三板斧镇场
1) 二分查找:把查找从 100,000 次砍到 17 次
线性找可见起点最坏 O(n);二分 O(log n):
// O(n)
for (let i = 0; i < items.length; i++) { if (items[i].top >= scrollTop) return i;
}

// O(log n)
while (left < right) { const mid = Math.floor((left + right) / 2); // … } 这一刀下去,滚动从“没法用”变“丝滑”。 2) requestAnimationFrame:跟浏览器一个节拍 滚动事件每秒上百次,直接 setState = 300+ 次/秒。用 rAF 贴合 60Hz 重绘: rafRef.current = requestAnimationFrame(() => {
setScrollTop(target.scrollTop);
rafRef.current = null;
});
前:20 FPS 抽搐后:60 FPS 顺滑
3) 位置缓存:只算一次,读写 O(1)
以前每次滚动都触发布局;现在只在依赖变更时计算,滚动期全是数组下标访问:
const itemPositions = useMemo(() => { /* … */ }, [items, itemHeight]);
真·落地:10 万行用户清单跑起来

import React from ‘react’;
import VirtualScroller from ‘./VirtualScroller’;

function UserList() {
const users = Array.from({ length: 100000 }, (_, i) => ({
id: i,
name: User ${i},
email: user${i}@example.com,
avatar: https://i.pravatar.cc/150?img=${i % 70},
}));

const itemStyle = {
display: ‘flex’, alignItems: ‘center’,
padding: ’12px 16px’, borderBottom: ‘1px solid #eee’,
};

const renderUser = (user: any, index: number) => (

{user.name}

{user.name}

{user.email}
);

return (

);
}
这个实现上月就进了生产,客户最大数据集 87,000 行,毫不费力。
数据不会说谎:DevTools 跑分
朴素渲染(10,000 项)
首渲:3,500ms
内存:180MB
滚动 FPS:15–25
TTI:4.2s
虚拟滚动(100,000 项)
首渲:45ms
内存:12MB
滚动 FPS:58–60
TTI:0.3s
10 倍数据,77 倍更快。CEO 看了只问:“为什么不早点做?”
动态高度也能稳:现实世界不会整齐划一
固定高度简单;真实消息流、动态卡片高度不定。处理方式:
function ChatMessages() {
const messages = [/* … */];
const getItemHeight = (m: any) => {
const base = 60;
const text = Math.ceil(m.text.length / 50) * 20;
return base + text;
};
return (
(

{m.author}

{m.text}
)}
height={500}
/>
);
}
配合上面的 averageItemHeight,滚动依旧顺。
会毁掉性能的三宗罪
❌ 内联样式对象每次新建
// 坑
renderItem={(item) =>

{item}}

// ✅ 复用对象
const itemStyle = { padding: 10 };
renderItem={(item) =>

{item}}
❌ 不稳定的 key
// 坑:强制重渲

// ✅ 稳定键

item-${index}}>
❌ 忘记清理 rAF
// 坑:泄漏
requestAnimationFrame(() => setScrollTop(value));

// ✅ 清理
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => setScrollTop(value));
进阶增强:把它变成“企业级”武器
滚动到指定项
const scrollToIndex = (index: number) => {
if (containerRef.current && itemPositions[index]) {
containerRef.current.scrollTop = itemPositions[index].top;
}
};
无限加载
React.useEffect(() => {
if (visibleRange.end >= items.length – 10) loadMoreItems();
}, [visibleRange.end]);
键盘导航
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === ‘ArrowDown’) scrollToIndex(selectedIndex + 1);
if (e.key === ‘ArrowUp’) scrollToIndex(selectedIndex – 1);
};
我真正学到的
性能优化从来不是“少用库、少写代码”这么肤浅。真性能来自对JS 执行、React 协调、浏览器布局、GPU 合成的整体理解。看见它们如何协同,优化自然会浮出水面。
这套虚拟滚动,不只是“列表更快”。它是尊重浏览器约束,与之共舞的工程哲学。
我们带着 87,000 行回去二次演示,页面秒开、滚动零抖,合同第二天就签了。 有时,赢和输的差距,只有 77ms。
轮到你了
可以挑战:
二维网格虚拟化
吸顶/粘性表头 + 虚拟化
横向虚拟滚动
可变列宽
原理相同,收益同样炸裂。 把你的实现贴在评论区,我每条都看。
如果你正在打造高性能 React 应用,关注我:深入套路、真实案例、生产可用的优化方案,我都讲。

全栈AI·探索:涵盖动效、React Hooks、Vue 技巧、LLM 应用、Python 脚本等专栏,案例驱动实战学习,点击二维码了解更多详情。

声明:来自JavaScript 每日一练,仅代表创作者观点。链接:https://eyangzhen.com/3995.html

JavaScript 每日一练的头像JavaScript 每日一练

相关推荐

关注我们
关注我们
购买服务
购买服务
返回顶部