设计 Feed 信息流系统
问题
如何设计一个高性能的 Feed 信息流系统?从推拉模型选型、前端列表架构、无限滚动与虚拟列表、到数据管理与缓存策略,请详细说明核心模块的设计思路与关键技术实现。
答案
Feed 信息流是社交、资讯类产品的核心场景(微博、Twitter/X、抖音、朋友圈、知乎等),需要同时解决 海量内容分发、流畅无限滚动、动态内容渲染 和 实时更新推送 四大核心挑战。与简单列表不同,Feed 流面临内容高度不一致、数据量无上限、用户交互复杂(点赞/评论/分享/曝光统计)等问题,对前端架构的要求极高。
Feed 流的本质是:在有限的视口中,高效展示无限增长的内容列表,并保持交互流畅与数据实时性。所有架构设计都围绕这个目标展开。
一、需求分析
功能需求
| 模块 | 功能点 |
|---|---|
| 时间线 Feed | 按时间倒序展示关注者的内容、支持多种内容类型(文本/图片/视频/链接) |
| 推荐 Feed | 基于算法推荐个性化内容、支持「不感兴趣」反馈 |
| 关注 Feed | 仅展示已关注用户的内容、支持分组筛选 |
| 无限滚动 | 向下滑动自动加载更多、加载状态提示、错误重试 |
| 实时更新 | 新内容提示气泡、下拉刷新、WebSocket 推送 |
| 互动功能 | 点赞/评论/分享/收藏、乐观更新 |
| 内容管理 | 删除/举报/屏蔽、已读标记 |
非功能需求
| 指标 | 目标 |
|---|---|
| 首屏加载 | FCP < 1.5s、LCP < 2.5s |
| 滚动性能 | 持续 60fps,滚动 1000+ 条内容不卡顿 |
| 内存占用 | 浏览 10000 条后内存增长 < 50MB |
| 流量节省 | 图片懒加载、增量更新,减少 50%+ 无效请求 |
| 离线体验 | 支持离线浏览已缓存内容 |
| 可扩展性 | 支持多种卡片类型、广告插入、A/B 测试 |
Feed 流的内容高度动态变化(图片数量不同、文字长短不一、评论展开/收起),这是虚拟列表实现中最大的挑战。传统固定高度虚拟列表方案无法直接套用,需要 预估高度 + 动态修正 的策略。
二、整体架构
架构分层说明
| 层级 | 职责 | 关键技术 |
|---|---|---|
| UI 层 | Feed 卡片渲染、交互处理、骨架屏 | React/Vue、CSS Grid、Skeleton |
| 虚拟滚动引擎 | 可视区域计算、DOM 回收、动态高度管理 | IntersectionObserver、ResizeObserver |
| 数据管理层 | 分页加载、增量更新、乐观更新 | Cursor 分页、Zustand/Pinia、SWR |
| 缓存层 | 多级缓存、离线支持 | 内存缓存、IndexedDB、Service Worker |
| BFF 层 | 数据聚合、接口编排 | Node.js、GraphQL |
三、核心模块设计
3.1 Feed 流推拉模型
推拉模型决定了 Feed 内容的生成和分发方式,是系统设计的第一个关键决策。
三种模型对比
| 特性 | 推模型(Push) | 拉模型(Pull) | 推拉结合 |
|---|---|---|---|
| 写放大 | 高(每个粉丝都写一份) | 无 | 大 V 用拉,普通用户用推 |
| 读放大 | 无 | 高(每次聚合多个关注者) | 大 V 粉丝读时拉取 |
| 读延迟 | 极低(直接读收件箱) | 较高(需要聚合排序) | 折中 |
| 存储成本 | 高(数据冗余) | 低 | 中 |
| 实时性 | 高 | 中 | 高 |
| 典型应用 | 微信朋友圈 | 微博大 V | Twitter/X |
对于大多数社交产品,采用 推拉结合 的混合模型:
- 普通用户(粉丝数 < 阈值):发布时推送到所有粉丝收件箱
- 大 V 用户(粉丝数 > 阈值):粉丝请求 Feed 时实时拉取并合并
- 阈值通常为 1000 ~ 10000 粉丝
推拉结合的前端实现
interface FeedItem {
id: string;
authorId: string;
content: string;
type: 'text' | 'image' | 'video' | 'link';
images?: string[];
videoUrl?: string;
createdAt: number;
likes: number;
comments: number;
isLiked: boolean;
}
interface FeedResponse {
items: FeedItem[];
nextCursor: string | null;
hasMore: boolean;
newItemsCount: number; // 推模型推送的新内容数量
}
class FeedService {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// Cursor 分页:获取 Feed 流
async getFeed(cursor?: string, limit = 20): Promise<FeedResponse> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set('cursor', cursor);
const response = await fetch(
`${this.baseUrl}/api/feed?${params.toString()}`
);
return response.json();
}
// 获取推荐 Feed
async getRecommendFeed(cursor?: string): Promise<FeedResponse> {
const params = new URLSearchParams();
if (cursor) params.set('cursor', cursor);
const response = await fetch(
`${this.baseUrl}/api/feed/recommend?${params.toString()}`
);
return response.json();
}
// 获取指定用户的 Feed
async getUserFeed(userId: string, cursor?: string): Promise<FeedResponse> {
const params = new URLSearchParams();
if (cursor) params.set('cursor', cursor);
const response = await fetch(
`${this.baseUrl}/api/users/${userId}/feed?${params.toString()}`
);
return response.json();
}
}
3.2 前端架构设计
Feed 前端架构分为三层:列表容器层、卡片组件层 和 数据管理层。
卡片组件注册与工厂模式
使用 工厂模式 根据内容类型渲染不同卡片,便于扩展新类型:
import { ComponentType } from 'react';
// 卡片组件的通用 Props
interface CardProps {
item: FeedItem;
onLike: (id: string) => void;
onComment: (id: string) => void;
onShare: (id: string) => void;
onExposure: (id: string) => void;
}
// 卡片类型注册表
const cardRegistry = new Map<string, ComponentType<CardProps>>();
// 注册卡片组件
function registerCard(type: string, component: ComponentType<CardProps>): void {
cardRegistry.set(type, component);
}
// 获取卡片组件
function getCardComponent(type: string): ComponentType<CardProps> {
const component = cardRegistry.get(type);
if (!component) {
// 降级到默认文本卡片
return cardRegistry.get('text')!;
}
return component;
}
// 注册各类型卡片
registerCard('text', TextCard);
registerCard('image', ImageCard);
registerCard('video', VideoCard);
registerCard('link', LinkCard);
registerCard('ad', AdCard);
Feed 容器组件
import { useCallback, useRef, useEffect } from 'react';
interface FeedContainerProps {
feedType: 'timeline' | 'recommend' | 'following';
}
function FeedContainer({ feedType }: FeedContainerProps) {
const {
items,
isLoading,
hasMore,
loadMore,
refresh,
newCount,
} = useFeedStore(feedType);
const containerRef = useRef<HTMLDivElement>(null);
// 加载更多的回调
const handleLoadMore = useCallback(() => {
if (!isLoading && hasMore) {
loadMore();
}
}, [isLoading, hasMore, loadMore]);
// 点击「N 条新内容」气泡
const handleShowNew = useCallback(() => {
refresh();
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}, [refresh]);
return (
<div ref={containerRef} className="feed-container">
{/* 新内容提示 */}
{newCount > 0 && (
<button className="new-items-tip" onClick={handleShowNew}>
{newCount} 条新内容
</button>
)}
{/* 虚拟列表 */}
<VirtualFeedList
items={items}
onLoadMore={handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
renderItem={(item: FeedItem) => {
const CardComponent = getCardComponent(item.type);
return (
<CardComponent
item={item}
onLike={handleLike}
onComment={handleComment}
onShare={handleShare}
onExposure={handleExposure}
/>
);
}}
/>
</div>
);
}
3.3 无限滚动实现
无限滚动是 Feed 流最基础的交互模式。核心思路是在列表底部放置一个 哨兵元素(Sentinel),当它进入视口时触发加载。
IntersectionObserver 方案
import { useEffect, useRef, useCallback, useState } from 'react';
interface UseInfiniteScrollOptions {
/** 触发加载的回调 */
onLoadMore: () => Promise<void>;
/** 是否还有更多数据 */
hasMore: boolean;
/** 提前触发的距离(px) */
rootMargin?: string;
/** 触发阈值 */
threshold?: number;
}
interface UseInfiniteScrollReturn {
sentinelRef: React.RefObject<HTMLDivElement | null>;
isLoading: boolean;
error: Error | null;
retry: () => void;
}
function useInfiniteScroll(
options: UseInfiniteScrollOptions
): UseInfiniteScrollReturn {
const { onLoadMore, hasMore, rootMargin = '0px 0px 300px 0px', threshold = 0 } = options;
const sentinelRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const isLoadingRef = useRef(false); // 防止并发触发
const loadMore = useCallback(async () => {
if (isLoadingRef.current || !hasMore) return;
isLoadingRef.current = true;
setIsLoading(true);
setError(null);
try {
await onLoadMore();
} catch (err) {
setError(err instanceof Error ? err : new Error('加载失败'));
} finally {
isLoadingRef.current = false;
setIsLoading(false);
}
}, [onLoadMore, hasMore]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
// 哨兵元素进入视口时触发加载
if (entries[0].isIntersecting && hasMore && !isLoadingRef.current) {
loadMore();
}
},
{
rootMargin, // 提前 300px 触发,实现「预加载」效果
threshold,
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore, hasMore, rootMargin, threshold]);
const retry = useCallback(() => {
setError(null);
loadMore();
}, [loadMore]);
return { sentinelRef, isLoading, error, retry };
}
列表组件中使用
interface InfiniteListProps {
items: FeedItem[];
hasMore: boolean;
onLoadMore: () => Promise<void>;
renderItem: (item: FeedItem, index: number) => React.ReactNode;
}
function InfiniteList({ items, hasMore, onLoadMore, renderItem }: InfiniteListProps) {
const { sentinelRef, isLoading, error, retry } = useInfiniteScroll({
onLoadMore,
hasMore,
rootMargin: '0px 0px 500px 0px', // 提前 500px 加载
});
return (
<div className="infinite-list">
{/* 列表内容 */}
{items.map((item, index) => (
<div key={item.id} className="feed-item">
{renderItem(item, index)}
</div>
))}
{/* highlight-start */}
{/* 哨兵元素 —— 滚动到这里时触发加载 */}
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
{/* highlight-end */}
{/* 加载状态 */}
{isLoading && <LoadingSpinner />}
{/* 错误重试 */}
{error && (
<div className="load-error">
<p>加载失败:{error.message}</p>
<button onClick={retry}>点击重试</button>
</div>
)}
{/* 没有更多 */}
{!hasMore && items.length > 0 && (
<div className="no-more">没有更多内容了</div>
)}
</div>
);
}
无限滚动的常见陷阱:
- 并发请求:滚动过快可能多次触发加载,需用
isLoadingRef做互斥锁 - 内存泄漏:组件卸载时必须
observer.disconnect() - 空白闪烁:
rootMargin设太小,加载不及时导致底部空白,建议至少 300-500px - 浏览器回退:用户点击内容详情后返回,需要恢复滚动位置
3.4 虚拟列表(动态高度)
当 Feed 列表持续增长,DOM 节点数量会导致严重的性能问题。虚拟列表只渲染可视区域内的元素,将 DOM 节点数控制在常数级别。
核心难点:动态高度
Feed 卡片的高度因内容不同而不同(纯文字 vs 多图 vs 视频),无法预先确定。解决方案是 预估高度 + 渲染后修正:
虚拟列表引擎实现
interface ItemMetadata {
index: number;
offset: number; // 距离顶部的偏移
height: number; // 实际高度(初始为预估值)
measured: boolean; // 是否已测量真实高度
}
class VirtualListEngine {
private items: ItemMetadata[] = [];
private totalHeight = 0;
private estimatedItemHeight: number; // 预估高度
private containerHeight: number;
private overscan: number; // 上下额外渲染的数量
constructor(options: {
itemCount: number;
estimatedItemHeight: number;
containerHeight: number;
overscan?: number;
}) {
this.estimatedItemHeight = options.estimatedItemHeight;
this.containerHeight = options.containerHeight;
this.overscan = options.overscan ?? 5;
// 初始化:所有 item 使用预估高度
this.items = Array.from({ length: options.itemCount }, (_, index) => ({
index,
offset: index * this.estimatedItemHeight,
height: this.estimatedItemHeight,
measured: false,
}));
this.totalHeight = options.itemCount * this.estimatedItemHeight;
}
/** 核心:根据滚动位置计算可见范围 */
getVisibleRange(scrollTop: number): { start: number; end: number } {
// 二分查找第一个可见的 item
const start = this.findStartIndex(scrollTop);
const end = this.findEndIndex(scrollTop + this.containerHeight, start);
return {
start: Math.max(0, start - this.overscan),
end: Math.min(this.items.length - 1, end + this.overscan),
};
}
/** 二分查找:找到 offset >= scrollTop 的第一个 item */
private findStartIndex(scrollTop: number): number {
let low = 0;
let high = this.items.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = this.items[mid];
if (item.offset + item.height <= scrollTop) {
low = mid + 1;
} else if (item.offset > scrollTop) {
high = mid - 1;
} else {
return mid; // scrollTop 在 item 范围内
}
}
return low;
}
/** 找到最后一个可见的 item */
private findEndIndex(bottomEdge: number, startFrom: number): number {
for (let i = startFrom; i < this.items.length; i++) {
if (this.items[i].offset >= bottomEdge) {
return i;
}
}
return this.items.length - 1;
}
/** 更新某个 item 的真实高度(由 ResizeObserver 触发) */
updateItemHeight(index: number, measuredHeight: number): void {
const item = this.items[index];
if (!item || item.height === measuredHeight) return;
const heightDiff = measuredHeight - item.height;
item.height = measuredHeight;
item.measured = true;
// 更新后续所有 item 的 offset
for (let i = index + 1; i < this.items.length; i++) {
this.items[i].offset += heightDiff;
}
this.totalHeight += heightDiff;
}
/** 获取 item 的位置信息 */
getItemMetadata(index: number): ItemMetadata | undefined {
return this.items[index];
}
/** 获取总高度 */
getTotalHeight(): number {
return this.totalHeight;
}
/** 添加新 item(加载更多时调用) */
appendItems(count: number): void {
const currentLength = this.items.length;
const lastItem = this.items[currentLength - 1];
const baseOffset = lastItem
? lastItem.offset + lastItem.height
: 0;
for (let i = 0; i < count; i++) {
this.items.push({
index: currentLength + i,
offset: baseOffset + i * this.estimatedItemHeight,
height: this.estimatedItemHeight,
measured: false,
});
}
this.totalHeight = baseOffset + count * this.estimatedItemHeight;
}
}
React 虚拟列表组件
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
interface VirtualFeedListProps {
items: FeedItem[];
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
renderItem: (item: FeedItem) => React.ReactNode;
estimatedItemHeight?: number;
overscan?: number;
}
function VirtualFeedList({
items,
onLoadMore,
hasMore,
isLoading,
renderItem,
estimatedItemHeight = 300,
overscan = 5,
}: VirtualFeedListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
// 虚拟列表引擎
const engine = useMemo(
() =>
new VirtualListEngine({
itemCount: items.length,
estimatedItemHeight,
containerHeight,
overscan,
}),
[] // 引擎只初始化一次
);
// 容器尺寸监听
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const ro = new ResizeObserver((entries) => {
setContainerHeight(entries[0].contentRect.height);
});
ro.observe(container);
return () => ro.disconnect();
}, []);
// 滚动事件处理
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
requestAnimationFrame(() => setScrollTop(container.scrollTop));
// 接近底部时触发加载
const { scrollHeight, clientHeight } = container;
if (scrollHeight - container.scrollTop - clientHeight < 500) {
if (hasMore && !isLoading) {
onLoadMore();
}
}
}, [hasMore, isLoading, onLoadMore]);
// 计算可见范围
const { start, end } = engine.getVisibleRange(scrollTop);
// 可见的 items
const visibleItems = items.slice(start, end + 1);
return (
<div
ref={containerRef}
className="virtual-feed-container"
style={{ height: '100vh', overflow: 'auto' }}
onScroll={handleScroll}
>
{/* 撑开总高度的占位元素 */}
<div style={{ height: engine.getTotalHeight(), position: 'relative' }}>
{visibleItems.map((item, i) => {
const index = start + i;
const metadata = engine.getItemMetadata(index);
return (
<VirtualItem
key={item.id}
offset={metadata?.offset ?? 0}
onHeightChange={(height: number) => {
engine.updateItemHeight(index, height);
}}
>
{renderItem(item)}
</VirtualItem>
);
})}
</div>
{isLoading && <LoadingSpinner />}
</div>
);
}
/** 单个虚拟项:用 ResizeObserver 监测实际高度 */
function VirtualItem({
offset,
onHeightChange,
children,
}: {
offset: number;
onHeightChange: (height: number) => void;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const height = entries[0].borderBoxSize[0].blockSize;
onHeightChange(height);
});
ro.observe(el);
return () => ro.disconnect();
}, [onHeightChange]);
return (
<div
ref={ref}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offset}px)`,
willChange: 'transform',
}}
>
{children}
</div>
);
}
- 预估高度:根据卡片类型给出不同预估值(纯文字 120px、图片 350px、视频 400px)
- 测量修正:用
ResizeObserver获取真实高度并更新缓存 - 二分查找:通过已知的 offset 数组快速定位可见范围,时间复杂度
- overscan:上下多渲染 3-5 个元素,避免快速滚动时出现白屏
四、关键技术实现
4.1 数据管理:Cursor 分页 vs Offset 分页
Feed 流推荐使用 Cursor 分页,而非传统的 Offset 分页。
| 特性 | Offset 分页 | Cursor 分页 |
|---|---|---|
| 请求方式 | ?page=3&size=20 | ?cursor=abc123&limit=20 |
| 数据变动 | 新插入数据导致重复/遗漏 | 基于游标定位,不受影响 |
| 性能 | OFFSET 大时数据库跳过大量行 | 索引直接定位,性能稳定 |
| 可预测性 | 页码可跳转 | 只能向前/向后翻 |
| 适用场景 | 后台管理列表 | Feed 流、聊天记录 |
import { create } from 'zustand';
interface FeedState {
items: FeedItem[];
cursor: string | null;
hasMore: boolean;
isLoading: boolean;
newItemsCount: number;
/** 加载更多(向下翻页) */
loadMore: () => Promise<void>;
/** 刷新(拉取最新) */
refresh: () => Promise<void>;
/** 乐观更新某个 item */
optimisticUpdate: (id: string, updates: Partial<FeedItem>) => void;
}
const useFeedStore = create<FeedState>((set, get) => ({
items: [],
cursor: null,
hasMore: true,
isLoading: false,
newItemsCount: 0,
loadMore: async () => {
const { cursor, isLoading, hasMore } = get();
if (isLoading || !hasMore) return;
set({ isLoading: true });
try {
const feedService = new FeedService('/api');
const response = await feedService.getFeed(cursor ?? undefined);
set((state) => ({
items: [...state.items, ...response.items],
cursor: response.nextCursor,
hasMore: response.hasMore,
isLoading: false,
}));
} catch {
set({ isLoading: false });
}
},
refresh: async () => {
set({ isLoading: true });
try {
const feedService = new FeedService('/api');
const response = await feedService.getFeed(undefined);
set({
items: response.items,
cursor: response.nextCursor,
hasMore: response.hasMore,
isLoading: false,
newItemsCount: 0,
});
} catch {
set({ isLoading: false });
}
},
/** 乐观更新:先改 UI,再请求后端 */
optimisticUpdate: (id, updates) => {
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, ...updates } : item
),
}));
},
}));
乐观更新(Optimistic Update)
点赞等高频操作采用乐观更新策略,先更新 UI 再发请求,失败时回滚:
function useOptimisticLike() {
const optimisticUpdate = useFeedStore((s) => s.optimisticUpdate);
const toggleLike = useCallback(async (item: FeedItem) => {
const prevLiked = item.isLiked;
const prevLikes = item.likes;
// 1. 立即更新 UI(乐观)
optimisticUpdate(item.id, {
isLiked: !prevLiked,
likes: prevLiked ? prevLikes - 1 : prevLikes + 1,
});
try {
// 2. 发送请求
await fetch(`/api/feed/${item.id}/like`, {
method: prevLiked ? 'DELETE' : 'POST',
});
} catch {
// 3. 失败时回滚
optimisticUpdate(item.id, {
isLiked: prevLiked,
likes: prevLikes,
});
}
}, [optimisticUpdate]);
return { toggleLike };
}
4.2 缓存设计
Feed 流采用 三级缓存 架构,兼顾性能和离线体验。
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // 毫秒
}
class FeedCacheManager {
private memoryCache = new Map<string, CacheEntry<FeedItem[]>>();
private dbName = 'feed-cache';
private storeName = 'feeds';
/** 内存缓存 —— 最快 */
getFromMemory(key: string): FeedItem[] | null {
const entry = this.memoryCache.get(key);
if (!entry) return null;
// 检查 TTL
if (Date.now() - entry.timestamp > entry.ttl) {
this.memoryCache.delete(key);
return null;
}
return entry.data;
}
setToMemory(key: string, data: FeedItem[], ttl = 5 * 60 * 1000): void {
this.memoryCache.set(key, { data, timestamp: Date.now(), ttl });
// 内存缓存 LRU 淘汰
if (this.memoryCache.size > 100) {
const firstKey = this.memoryCache.keys().next().value;
if (firstKey !== undefined) {
this.memoryCache.delete(firstKey);
}
}
}
/** IndexedDB —— 支持离线 */
async getFromDB(key: string): Promise<FeedItem[] | null> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const request = store.get(key);
request.onsuccess = () => {
const entry = request.result as CacheEntry<FeedItem[]> | undefined;
if (!entry || Date.now() - entry.timestamp > entry.ttl) {
resolve(null);
} else {
resolve(entry.data);
}
};
request.onerror = () => reject(request.error);
});
}
async setToDB(key: string, data: FeedItem[], ttl = 30 * 60 * 1000): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.put({ data, timestamp: Date.now(), ttl }, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
private openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(this.storeName);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** 统一获取:内存 -> IndexedDB -> 网络 */
async get(
key: string,
fetcher: () => Promise<FeedItem[]>
): Promise<FeedItem[]> {
// 1. 内存缓存
const memoryData = this.getFromMemory(key);
if (memoryData) return memoryData;
// 2. IndexedDB
const dbData = await this.getFromDB(key);
if (dbData) {
this.setToMemory(key, dbData);
return dbData;
}
// 3. 网络请求
const freshData = await fetcher();
this.setToMemory(key, freshData);
await this.setToDB(key, freshData);
return freshData;
}
}
4.3 实时更新
通过 WebSocket 推送新内容通知,前端展示提示气泡,用户手动刷新:
interface FeedRealtimeMessage {
type: 'new_items' | 'item_update' | 'item_delete';
payload: {
count?: number;
itemId?: string;
updates?: Partial<FeedItem>;
};
}
function useFeedRealtime(feedType: string) {
const setNewCount = useFeedStore((s) => s.setNewItemsCount);
const optimisticUpdate = useFeedStore((s) => s.optimisticUpdate);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/feed/realtime?type=${feedType}`);
ws.onmessage = (event: MessageEvent) => {
const message: FeedRealtimeMessage = JSON.parse(event.data);
switch (message.type) {
case 'new_items':
// 展示「N 条新内容」提示,不自动刷新列表
setNewCount((prev: number) => prev + (message.payload.count ?? 0));
break;
case 'item_update':
// 实时更新某条内容(如点赞数变化)
if (message.payload.itemId && message.payload.updates) {
optimisticUpdate(
message.payload.itemId,
message.payload.updates
);
}
break;
case 'item_delete':
// 删除某条内容
if (message.payload.itemId) {
removeItem(message.payload.itemId);
}
break;
}
};
// 心跳保活
const heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
return () => {
clearInterval(heartbeat);
ws.close();
};
}, [feedType]);
}
当用户正在阅读某条内容时,自动将新内容插入列表顶部会导致 阅读位置跳动,严重影响体验。因此采用 提示气泡 + 手动刷新 的策略,让用户决定何时查看新内容。这是 Twitter、微博等产品的通用做法。
4.4 图片懒加载
Feed 流中图片数量巨大,懒加载是必须的优化手段。
import { useRef, useState, useEffect } from 'react';
interface UseLazyImageOptions {
src: string;
placeholder?: string; // 低清占位图(如 BlurHash)
rootMargin?: string;
}
function useLazyImage(options: UseLazyImageOptions) {
const { src, placeholder, rootMargin = '200px 0px' } = options;
const imgRef = useRef<HTMLImageElement>(null);
const [currentSrc, setCurrentSrc] = useState(placeholder ?? '');
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const img = imgRef.current;
if (!img) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
// 进入视口:开始加载真实图片
const realImg = new Image();
realImg.onload = () => {
setCurrentSrc(src);
setIsLoaded(true);
};
realImg.src = src;
observer.unobserve(img);
}
},
{ rootMargin } // 提前 200px 开始加载
);
observer.observe(img);
return () => observer.disconnect();
}, [src, rootMargin]);
return { imgRef, currentSrc, isLoaded };
}
渐进式图片加载方案
interface ProgressiveImageProps {
src: string; // 高清图
thumbnail?: string; // 缩略图(10-20KB)
blurhash?: string; // BlurHash 占位
alt: string;
width: number;
height: number;
}
function ProgressiveImage({
src,
thumbnail,
blurhash,
alt,
width,
height,
}: ProgressiveImageProps) {
const { imgRef, currentSrc, isLoaded } = useLazyImage({
src,
placeholder: thumbnail,
});
return (
<div
className="progressive-image"
style={{ aspectRatio: `${width} / ${height}` }}
>
{/* highlight-start */}
{/* 阶段 1:BlurHash 色块占位(极小数据量) */}
{blurhash && !isLoaded && (
<canvas
className="blurhash-placeholder"
data-blurhash={blurhash}
width={32}
height={32}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
filter: 'blur(20px)',
transform: 'scale(1.2)',
}}
/>
)}
{/* highlight-end */}
{/* 阶段 2/3:缩略图 -> 高清图 */}
<img
ref={imgRef}
src={currentSrc}
alt={alt}
loading="lazy"
decoding="async"
style={{
opacity: isLoaded ? 1 : 0.8,
transition: 'opacity 0.3s ease',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</div>
);
}
4.5 骨架屏
首屏加载和加载更多时展示骨架屏,减少用户感知等待时间:
function FeedSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="feed-skeleton">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="skeleton-card">
{/* 头像 + 用户名 */}
<div className="skeleton-header">
<div className="skeleton-avatar shimmer" />
<div className="skeleton-name shimmer" />
</div>
{/* 文本内容 */}
<div className="skeleton-text shimmer" style={{ width: '90%' }} />
<div className="skeleton-text shimmer" style={{ width: '75%' }} />
<div className="skeleton-text shimmer" style={{ width: '60%' }} />
{/* 图片占位 */}
<div className="skeleton-image shimmer" />
{/* 互动栏 */}
<div className="skeleton-actions">
<div className="skeleton-btn shimmer" />
<div className="skeleton-btn shimmer" />
<div className="skeleton-btn shimmer" />
</div>
</div>
))}
</div>
);
}
/* shimmer 动画效果 */
.shimmer {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-card {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.skeleton-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.skeleton-name {
width: 120px;
height: 16px;
}
.skeleton-text {
height: 14px;
margin-bottom: 8px;
}
.skeleton-image {
width: 100%;
height: 200px;
margin: 12px 0;
border-radius: 8px;
}
.skeleton-actions {
display: flex;
gap: 24px;
margin-top: 12px;
}
.skeleton-btn {
width: 60px;
height: 14px;
}
4.6 交互优化
下拉刷新
function usePullToRefresh(onRefresh: () => Promise<void>) {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0);
const threshold = 80; // 触发刷新的下拉距离
const handleTouchStart = useCallback((e: TouchEvent) => {
// 仅在滚动到顶部时允许下拉
if (window.scrollY === 0) {
startY.current = e.touches[0].clientY;
}
}, []);
const handleTouchMove = useCallback((e: TouchEvent) => {
if (startY.current === 0 || isRefreshing) return;
const currentY = e.touches[0].clientY;
const distance = currentY - startY.current;
if (distance > 0) {
// 阻尼效果:下拉越多,阻力越大
const dampedDistance = Math.min(distance * 0.4, 150);
setPullDistance(dampedDistance);
e.preventDefault();
}
}, [isRefreshing]);
const handleTouchEnd = useCallback(async () => {
if (pullDistance >= threshold && !isRefreshing) {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
setPullDistance(0);
}
} else {
setPullDistance(0);
}
startY.current = 0;
}, [pullDistance, isRefreshing, onRefresh, threshold]);
return { pullDistance, isRefreshing, handleTouchStart, handleTouchMove, handleTouchEnd };
}
阅读位置记忆
用户从详情页返回时,恢复到之前的阅读位置:
const scrollPositions = new Map<string, number>();
function useScrollRestore(key: string) {
const containerRef = useRef<HTMLDivElement>(null);
// 离开时保存位置
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
scrollPositions.set(key, container.scrollTop);
};
container.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, [key]);
// 进入时恢复位置
useEffect(() => {
const container = containerRef.current;
const savedPosition = scrollPositions.get(key);
if (container && savedPosition !== undefined) {
// 等待虚拟列表渲染完成后恢复
requestAnimationFrame(() => {
container.scrollTop = savedPosition;
});
}
}, [key]);
return containerRef;
}
4.7 曝光统计
Feed 流的曝光统计用于衡量内容效果和推荐质量,同样使用 IntersectionObserver 实现:
interface ExposureEvent {
itemId: string;
timestamp: number;
duration: number; // 可见时长(ms)
visibleRatio: number; // 可见比例
}
class ExposureTracker {
private observer: IntersectionObserver;
private visibleItems = new Map<string, number>(); // itemId -> 进入视口时间
private buffer: ExposureEvent[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const itemId = (entry.target as HTMLElement).dataset.feedId;
if (!itemId) return;
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
// 可见面积 >= 50%:记录进入时间
this.visibleItems.set(itemId, Date.now());
} else if (this.visibleItems.has(itemId)) {
// 离开视口:计算曝光时长
const enterTime = this.visibleItems.get(itemId)!;
const duration = Date.now() - enterTime;
// 有效曝光:可见时长 >= 1 秒
if (duration >= 1000) {
this.buffer.push({
itemId,
timestamp: enterTime,
duration,
visibleRatio: entry.intersectionRatio,
});
this.scheduleFlush();
}
this.visibleItems.delete(itemId);
}
});
},
{ threshold: [0, 0.5, 1.0] }
);
}
observe(element: HTMLElement): void {
this.observer.observe(element);
}
unobserve(element: HTMLElement): void {
this.observer.unobserve(element);
}
/** 批量上报:攒够 10 条或 5 秒上报一次 */
private scheduleFlush(): void {
if (this.buffer.length >= 10) {
this.flush();
return;
}
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), 5000);
}
}
private flush(): void {
if (this.buffer.length === 0) return;
const events = [...this.buffer];
this.buffer = [];
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// 使用 sendBeacon 保证页面关闭时也能上报
const success = navigator.sendBeacon(
'/api/analytics/exposure',
JSON.stringify({ events })
);
// sendBeacon 失败时降级为 fetch
if (!success) {
fetch('/api/analytics/exposure', {
method: 'POST',
body: JSON.stringify({ events }),
keepalive: true,
}).catch(() => {
// 上报失败,放回缓冲区
this.buffer.unshift(...events);
});
}
}
destroy(): void {
this.flush();
this.observer.disconnect();
}
}
五、性能优化
5.1 优化策略总览
| 优化方向 | 具体手段 | 效果 |
|---|---|---|
| 首屏加载 | SSR/SSG + 骨架屏 + 关键 CSS 内联 | FCP < 1s |
| 滚动性能 | 虚拟列表 + will-change: transform + GPU 合成 | 稳定 60fps |
| 内存控制 | DOM 回收 + 图片释放 + WeakRef 缓存 | 内存增长 < 50MB |
| 网络优化 | Cursor 分页 + 增量更新 + 请求去重 | 减少 50% 请求 |
| 图片优化 | 懒加载 + WebP/AVIF + 响应式 + CDN | 减少 70% 图片流量 |
| 交互体验 | 乐观更新 + 骨架屏 + 位置记忆 | 感知延迟 < 100ms |
5.2 关键性能优化代码
图片内存释放
长列表中,不可见的图片应释放内存:
/** 当卡片离开虚拟列表可见范围时,释放图片内存 */
function releaseImageMemory(container: HTMLElement): void {
const images = container.querySelectorAll('img[data-src]');
images.forEach((img) => {
const imgEl = img as HTMLImageElement;
// 保存原始 src
if (!imgEl.dataset.src) {
imgEl.dataset.src = imgEl.src;
}
// 替换为 1x1 透明像素,释放图片内存
imgEl.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
});
}
/** 卡片重新进入可见范围时,恢复图片 */
function restoreImageMemory(container: HTMLElement): void {
const images = container.querySelectorAll('img[data-src]');
images.forEach((img) => {
const imgEl = img as HTMLImageElement;
if (imgEl.dataset.src) {
imgEl.src = imgEl.dataset.src;
}
});
}
请求去重
避免重复请求相同的 Feed 数据:
const pendingRequests = new Map<string, Promise<unknown>>();
async function deduplicatedFetch<T>(url: string, init?: RequestInit): Promise<T> {
const key = `${init?.method ?? 'GET'}:${url}`;
// 已有相同请求在进行中,共享结果
if (pendingRequests.has(key)) {
return pendingRequests.get(key) as Promise<T>;
}
const promise = fetch(url, init)
.then((res) => res.json() as Promise<T>)
.finally(() => pendingRequests.delete(key));
pendingRequests.set(key, promise);
return promise;
}
5.3 Service Worker 离线缓存
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const FEED_CACHE = 'feed-cache-v1';
const IMAGE_CACHE = 'feed-images-v1';
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
// Feed API 请求:Network First
if (url.pathname.startsWith('/api/feed')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(FEED_CACHE).then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(async () => {
// 离线时使用缓存
const cached = await caches.match(event.request);
return cached ?? new Response(
JSON.stringify({ items: [], hasMore: false }),
{ headers: { 'Content-Type': 'application/json' } }
);
})
);
return;
}
// 图片请求:Cache First
if (url.pathname.match(/\.(jpg|jpeg|png|webp|avif|gif)$/)) {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(IMAGE_CACHE).then((cache) => {
cache.put(event.request, clone);
});
return response;
});
})
);
}
});
六、扩展设计
6.1 广告插入
Feed 流中需要在固定位置插入广告卡片,同时不影响正常内容的分页逻辑:
interface AdItem {
id: string;
type: 'ad';
adData: {
imageUrl: string;
targetUrl: string;
impressionUrl: string;
};
}
type FeedListItem = FeedItem | AdItem;
/** 在 Feed 列表中插入广告 */
function insertAds(
items: FeedItem[],
ads: AdItem[],
interval: number = 5 // 每 5 条内容后插入一条广告
): FeedListItem[] {
const result: FeedListItem[] = [];
let adIndex = 0;
items.forEach((item, i) => {
result.push(item);
// 每隔 interval 条插入一条广告
if ((i + 1) % interval === 0 && adIndex < ads.length) {
result.push(ads[adIndex]);
adIndex++;
}
});
return result;
}
6.2 多 Tab Feed 切换
支持时间线、推荐、关注等多个 Feed Tab,切换时保持各自状态:
import { useRef, useCallback } from 'react';
type FeedTab = 'timeline' | 'recommend' | 'following';
/** 每个 Tab 维护独立的状态和滚动位置 */
function useMultiFeed() {
const feedStates = useRef(
new Map<FeedTab, { items: FeedItem[]; cursor: string | null; scrollTop: number }>()
);
const switchTab = useCallback((tab: FeedTab) => {
// 保存当前 Tab 的滚动位置
const currentTab = getCurrentTab();
const container = document.querySelector('.feed-container');
if (currentTab && container) {
const state = feedStates.current.get(currentTab);
if (state) {
state.scrollTop = (container as HTMLElement).scrollTop;
}
}
// 恢复目标 Tab 的状态
const targetState = feedStates.current.get(tab);
if (targetState && container) {
requestAnimationFrame(() => {
(container as HTMLElement).scrollTop = targetState.scrollTop;
});
}
}, []);
return { switchTab, feedStates: feedStates.current };
}
6.3 架构扩展点
| 扩展方向 | 方案 |
|---|---|
| A/B 测试 | 卡片组件通过工厂模式动态注册,配合 Feature Flag 切换不同 UI 方案 |
| 多端适配 | 核心数据层和虚拟列表引擎与 UI 层解耦,适配 Web / React Native / 小程序 |
| 内容审核 | 前端做基础的敏感词过滤,后端异步审核后通过 WebSocket 更新状态 |
| 推荐反馈 | 记录曝光、点击、停留时长、滑过等行为数据,回传推荐服务优化模型 |
常见面试问题
Q1: 无限滚动和虚拟列表有什么区别?什么时候用哪个?
答案:
两者解决的是 不同层次 的问题,并非互斥关系:
| 特性 | 无限滚动(Infinite Scroll) | 虚拟列表(Virtual List) |
|---|---|---|
| 解决的问题 | 数据的增量加载 | DOM 节点的性能优化 |
| 核心机制 | 滚动到底部时加载下一页数据 | 只渲染可视区域的 DOM 节点 |
| DOM 数量 | 持续增加,不回收 | 保持常数(可视区域 + overscan) |
| 内存表现 | 长时间浏览后内存持续增长 | 内存基本稳定 |
| 实现复杂度 | 低(IntersectionObserver) | 高(高度计算、位置管理) |
| 适用场景 | 数据量有限(< 500 条) | 数据量大或无上限 |
Feed 信息流通常 两者结合使用:
- 无限滚动 负责按需加载数据(触底加载下一页)
- 虚拟列表 负责管理 DOM 渲染(只保留可见的卡片在 DOM 中)
这样既保证了数据按需加载,又避免了大量 DOM 导致的性能问题。
// 无限滚动 + 虚拟列表组合
function FeedWithVirtualScroll() {
// 无限滚动:管理数据加载
const { items, loadMore, hasMore } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// 虚拟列表:管理 DOM 渲染
// items 可能有 1000+ 条,但 DOM 中只有 ~20 个节点
return (
<VirtualFeedList
items={items}
onLoadMore={loadMore}
hasMore={hasMore}
renderItem={(item) => <FeedCard item={item} />}
/>
);
}
Q2: Feed 流的推拉模型怎么选?
答案:
选择推拉模型需要根据 产品特点 和 用户关系网络 来决定:
选型指南:
| 产品类型 | 推荐模型 | 理由 |
|---|---|---|
| 微信朋友圈 | 纯推 | 好友数量有上限(5000),写放大可控 |
| 微博/Twitter | 推拉结合 | 大 V 粉丝数百万,纯推存储和写入成本太高 |
| 抖音/TikTok | 拉 + 推荐 | 基于算法推荐,不依赖关注关系 |
| 企业内部群组 | 纯推 | 用户量小,推模型最简单 |
推拉结合的典型策略:
interface User {
id: string;
followerCount: number;
isActive: boolean;
}
const BIG_V_THRESHOLD = 5000;
function decidePushStrategy(author: User, followers: User[]): void {
if (author.followerCount < BIG_V_THRESHOLD) {
// 普通用户:推送给所有粉丝
pushToAllFollowers(author, followers);
} else {
// 大 V:只推送给活跃粉丝
const activeFollowers = followers.filter((f) => f.isActive);
pushToAllFollowers(author, activeFollowers);
// 不活跃粉丝在请求 Feed 时实时拉取
}
}
function pushToAllFollowers(author: User, followers: User[]): void {
// 将内容 ID 写入每个粉丝的收件箱(Redis Sorted Set)
followers.forEach((follower) => {
writeToInbox(follower.id, author.id);
});
}
function writeToInbox(followerId: string, authorId: string): void {
// Redis ZADD: follower:{followerId}:inbox {timestamp} {contentId}
console.log(`Push to inbox: ${followerId}`);
}
Q3: 动态高度的虚拟列表怎么实现?
答案:
动态高度虚拟列表的核心是 「预估 → 渲染 → 测量 → 修正」 的循环:
第一步:预估高度
根据卡片类型给出不同的预估高度值:
function estimateItemHeight(item: FeedItem): number {
switch (item.type) {
case 'text':
// 纯文字:基础高度 + 每 50 字符增加一行
return 100 + Math.ceil(item.content.length / 50) * 20;
case 'image':
// 图片:基础高度 + 图片区域
return 120 + (item.images?.length ?? 0 > 1 ? 280 : 350);
case 'video':
// 视频:基础高度 + 16:9 播放器
return 120 + 220;
default:
return 200;
}
}
第二步:ResizeObserver 测量真实高度
function useMeasure(
onHeightChange: (height: number) => void
): React.RefObject<HTMLDivElement | null> {
const ref = useRef<HTMLDivElement>(null);
const heightRef = useRef<number>(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const newHeight = entries[0].borderBoxSize[0].blockSize;
// 只在高度变化时更新(避免无意义的重渲染)
if (Math.abs(newHeight - heightRef.current) > 1) {
heightRef.current = newHeight;
onHeightChange(newHeight);
}
});
observer.observe(el);
return () => observer.disconnect();
}, [onHeightChange]);
return ref;
}
第三步:动态修正位置
当真实高度与预估不同时,需要更新后续所有 item 的 offset。为了避免 滚动跳动,关键优化是:
/**
* 滚动锚定:当可见区域上方的 item 高度变化时,
* 调整 scrollTop 保持当前阅读位置不变
*/
function adjustScrollForHeightChange(
container: HTMLElement,
changedIndex: number,
heightDiff: number,
firstVisibleIndex: number
): void {
// 只有变化的 item 在当前可见区域上方时,才需要修正 scrollTop
if (changedIndex < firstVisibleIndex) {
container.scrollTop += heightDiff;
}
}
- 图片加载导致高度变化:图片加载完成前后高度不同,需要用
aspectRatio占位或ResizeObserver检测变化 - 评论展开/收起:用户交互改变了卡片高度,需要实时测量并更新
- 滚动跳动:上方 item 高度变化会导致当前位置跳动,需要 滚动锚定(scroll anchoring)修正
scrollTop
Q4: 如何优化 Feed 流的首屏加载?
答案:
Feed 流的首屏优化是一个 端到端 的系统工程,从服务端到客户端需要全链路优化:
- 服务端优化
- 资源优化
- 感知性能
// Next.js 服务端渲染首屏 Feed
export async function getServerSideProps() {
// 服务端直接获取首屏数据,减少客户端请求
const feed = await fetchFeed({ limit: 10 });
return {
props: {
initialFeed: feed.items,
nextCursor: feed.nextCursor,
},
};
}
function FeedPage({ initialFeed, nextCursor }: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <FeedContainer initialData={initialFeed} initialCursor={nextCursor} />;
}
| 优化手段 | 具体做法 | 效果 |
|---|---|---|
| 关键 CSS 内联 | 首屏卡片样式内联到 HTML,非关键 CSS 异步加载 | 减少渲染阻塞 |
| JS 代码分割 | Feed 页独立 chunk,非首屏组件动态导入 | 减小首屏 JS 体积 |
| 图片预加载 | 首屏前 3 张图片使用 <link rel="preload"> | 图片更早展示 |
| 字体优化 | font-display: swap + 字体子集化 | 避免 FOIT |
| DNS 预解析 | <link rel="dns-prefetch" href="//cdn.example.com"> | 减少 DNS 查询时间 |
function FeedPage() {
const { data, isLoading, isError } = useFeedQuery();
// 三阶段渲染:骨架屏 -> 首屏内容 -> 后续内容
if (isLoading) {
return <FeedSkeleton count={3} />; // 阶段 1:骨架屏
}
if (isError) {
return <ErrorFallback onRetry={refetch} />;
}
return (
<>
{/* 阶段 2:首屏内容立即渲染 */}
<FeedList items={data.items.slice(0, 5)} />
{/* 阶段 3:非首屏内容延迟渲染 */}
<Suspense fallback={<FeedSkeleton count={2} />}>
<DeferredFeedList items={data.items.slice(5)} />
</Suspense>
</>
);
}
完整优化清单:
| 阶段 | 优化手段 | 优先级 |
|---|---|---|
| 网络层 | CDN 加速、HTTP/2 多路复用、gzip/brotli 压缩 | P0 |
| 服务端 | SSR 首屏直出、接口聚合(BFF)、Redis 缓存热门 Feed | P0 |
| 资源层 | 代码分割、关键 CSS 内联、图片预加载 | P0 |
| 渲染层 | 骨架屏、分阶段渲染、虚拟列表 | P1 |
| 缓存层 | Service Worker 缓存、IndexedDB 离线数据 | P1 |
| 感知优化 | 占位符、渐进式图片加载、加载动画 | P2 |