Intersection Observer 的实战方案
- 优化目标:长列表首屏渲染性能优化
1- 用户场景
markdown
用户行为路径:
1. 打开 AI 工具箱页面
2. 浏览热门工具(首屏)
3. 滚动或点击分类导航查看特定分类工具
4. 点击工具卡片查看详情
关键问题:用户首次进入页面时,大部分工具并不在视口内,但初版实现会一次性渲染所有工具。
初版实现与性能瓶颈
2.1 初版代码结构
tsx
// AIToolboxContent.tsx - 初版实现
export default function AIToolboxContent() {
const [allToolsCategories, setAllToolsCategories] = useState<CategoryWithTools[]>([]);
// 获取所有工具数据
useEffect(() => {
const fetchAllTools = async () => {
const response = await toolApi.getAllTools();
if (response.code === 200 && response.data) {
setAllToolsCategories(response.data);
}
};
fetchAllTools();
}, []);
return (
<div className={styles.content}>
{/* 侧边栏 */}
<ToggleButton {...props} />
{/* 主内容区 */}
<div ref={rightContainerRef} className={styles.right}>
{/* 热门工具 */}
<ToolList
title="热门工具"
icon={getCategoryIcon('hot')}
tools={featuredTools}
/>
{/* ! 一次性渲染所有分类 */}
{allToolsCategories.map((category) => {
const categoryTools = category.tools.map((tool) => ({
id: tool.id,
title: tool.toolName,
description: tool.toolDesc,
iconUrl: tool.iconUrl,
}));
return (
<ToolList
key={category.categoryId}
title={category.categoryName}
icon={getCategoryIcon(category.categorySlug)}
tools={categoryTools}
/>
);
})}
</div>
</div>
);
}
2.2 组件层级结构
scss
AIToolboxContent (父组件)
├── ToggleButton (侧边栏)
└── RightContainer (主内容区)
├── ToolList (热门工具)
│ └── ToolCard × 20
├── ToolList (AI 写作)
│ └── ToolCard × 35
├── ToolList (图像处理)
│ └── ToolCard × 42
├── ToolList (视频生成)
│ └── ToolCard × 28
└── ... (共 15+ 个 ToolList)
└── ToolCard × N
2.3 性能瓶颈分析
问题 1:首屏渲染时间长
diff
初版性能指标(1000+ 工具):
- 首次渲染时间:2.5s - 3.5s
- DOM 节点数:3000+
- 内存占用:~80MB
- FCP (First Contentful Paint):1.8s
- TTI (Time to Interactive):3.2s
问题 2:无效渲染
问题描述:
- 用户首屏只能看到 "热门工具" 和前 1-2 个分类(约 50 个工具)
- 但浏览器需要渲染全部 1000+ 个工具卡片
- 剩余 950+ 个不在视口内的组件完全是无效渲染
问题 3:内存浪费
javascript
// 每个 ToolCard 组件的内存占用估算
单个 ToolCard 组件:
- React Fiber 节点:~1KB
- DOM 节点:~2KB
- 图片资源:~50KB (懒加载前)
- 事件监听:~0.5KB
1000 个 ToolCard 总计:
- React 内存:~1MB
- DOM 内存:~2MB
- 图片内存:~50MB (未优化)
- 总计:~53MB (仅工具卡片部分)
问题 4:滚动性能差
- 初次渲染后,滚动时会出现轻微卡顿
- 长列表导致浏览器重排(reflow)计算复杂
3.1 方案设计
核心思路
markdown
1. 初始状态:只渲染首屏内容(热门工具 + 占位符)
2. 监听滚动:使用 Intersection Observer 监听每个分类容器
3. 触发加载:当分类容器即将进入视口时(提前 200px)
4. 渲染内容:替换占位符为实际的 ToolList 组件
5. 保持状态:已加载的分类保持渲染状态
数据流设计
typescript
// 状态设计
interface State {
// 所有工具数据(一次性获取)
allToolsCategories: CategoryWithTools[];
// 记录哪些分类已经可见
visibleCategories: Set<string>; // ['hot', 'writing', 'image']
}
// 渲染逻辑
function render() {
allToolsCategories.map(category => {
if (visibleCategories.has(category.slug)) {
// 已可见 => 渲染完整 ToolList
return <ToolList {...category} />;
} else {
// 未可见 => 渲染占位符
return <Placeholder />;
}
});
}
架构设计
sql
┌─────────────────────────────────────────┐
│ AIToolboxContent (Container) │
│ ┌─────────────────────────────────┐ │
│ │ Intersection Observer Setup │ │
│ │ - rootMargin: 200px │ │
│ │ - threshold: 0.01 │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ State Management │ │
│ │ - visibleCategories: Set │ │
│ │ - toolListRefs: Map │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Render Logic │ │
│ │ - Conditional Rendering │ │
│ │ - Placeholder / ToolList │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓ Scroll Event ↓
┌─────────────────────────────────────────┐
│ IntersectionObserver Callback │
│ - Detect Intersection │
│ - Update visibleCategories │
│ - Trigger Re-render │
└─────────────────────────────────────────┘
核心实现
4.1 状态管理
typescript
export default function AIToolboxContent() {
// 所有工具数据(按分类组织)
const [allToolsCategories, setAllToolsCategories] = useState<CategoryWithTools[]>([]);
// 关键状态:记录哪些分类已经可见(进入过视口)
// 初始值包含 'hot',确保热门工具立即渲染
const [visibleCategories, setVisibleCategories] = useState<Set<string>>(
new Set(['hot'])
);
// 右侧滚动容器的引用(作为 Intersection Observer 的 root)
const rightContainerRef = useRef<HTMLDivElement>(null);
// 存储每个 ToolList 的 DOM 引用(用于滚动和观察)
const toolListRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Intersection Observer 实例
const observerRef = useRef<IntersectionObserver | null>(null);
}
5.2 Ref 管理系统
typescript
// 设置 ToolList ref 的辅助函数
// 使用 useCallback 避免每次渲染都创建新函数
const setToolListRef = useCallback((slug: string) => (element: HTMLDivElement | null) => {
if (element) {
// 元素挂载:保存引用并开始观察
toolListRefs.current.set(slug, element);
// 关键:立即将元素添加到 Observer 监听列表
if (observerRef.current) {
observerRef.current.observe(element);
}
} else {
// 元素卸载:清理引用和监听
const oldElement = toolListRefs.current.get(slug);
if (oldElement && observerRef.current) {
observerRef.current.unobserve(oldElement);
}
toolListRefs.current.delete(slug);
}
}, []);
设计要点:
- 闭包捕获 :使用柯里化(Currying)传递
slug参数 - 自动观察:元素挂载时自动添加到 Observer
- 清理机制:元素卸载时自动移