跳到主要内容

设计 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 粉丝读时拉取
读延迟极低(直接读收件箱)较高(需要聚合排序)折中
存储成本高(数据冗余)
实时性
典型应用微信朋友圈微博大 VTwitter/X
推荐方案:推拉结合

对于大多数社交产品,采用 推拉结合 的混合模型:

  • 普通用户(粉丝数 < 阈值):发布时推送到所有粉丝收件箱
  • 大 V 用户(粉丝数 > 阈值):粉丝请求 Feed 时实时拉取并合并
  • 阈值通常为 1000 ~ 10000 粉丝

推拉结合的前端实现

feed-service.ts
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 前端架构分为三层:列表容器层卡片组件层数据管理层

卡片组件注册与工厂模式

使用 工厂模式 根据内容类型渲染不同卡片,便于扩展新类型:

card-factory.ts
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 容器组件

FeedContainer.tsx
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 方案

useInfiniteScroll.ts
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 };
}

列表组件中使用

InfiniteList.tsx
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>
);
}
注意

无限滚动的常见陷阱:

  1. 并发请求:滚动过快可能多次触发加载,需用 isLoadingRef 做互斥锁
  2. 内存泄漏:组件卸载时必须 observer.disconnect()
  3. 空白闪烁rootMargin 设太小,加载不及时导致底部空白,建议至少 300-500px
  4. 浏览器回退:用户点击内容详情后返回,需要恢复滚动位置

3.4 虚拟列表(动态高度)

当 Feed 列表持续增长,DOM 节点数量会导致严重的性能问题。虚拟列表只渲染可视区域内的元素,将 DOM 节点数控制在常数级别。

核心难点:动态高度

Feed 卡片的高度因内容不同而不同(纯文字 vs 多图 vs 视频),无法预先确定。解决方案是 预估高度 + 渲染后修正

虚拟列表引擎实现

VirtualListEngine.ts
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 虚拟列表组件

VirtualFeedList.tsx
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>
);
}
动态高度虚拟列表核心要点
  1. 预估高度:根据卡片类型给出不同预估值(纯文字 120px、图片 350px、视频 400px)
  2. 测量修正:用 ResizeObserver 获取真实高度并更新缓存
  3. 二分查找:通过已知的 offset 数组快速定位可见范围,时间复杂度 O(logn)O(\log n)
  4. overscan:上下多渲染 3-5 个元素,避免快速滚动时出现白屏

四、关键技术实现

4.1 数据管理:Cursor 分页 vs Offset 分页

Feed 流推荐使用 Cursor 分页,而非传统的 Offset 分页。

特性Offset 分页Cursor 分页
请求方式?page=3&size=20?cursor=abc123&limit=20
数据变动新插入数据导致重复/遗漏基于游标定位,不受影响
性能OFFSET 大时数据库跳过大量行索引直接定位,性能稳定
可预测性页码可跳转只能向前/向后翻
适用场景后台管理列表Feed 流、聊天记录
useFeedStore.ts
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 再发请求,失败时回滚:

useOptimisticLike.ts
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 流采用 三级缓存 架构,兼顾性能和离线体验。

FeedCacheManager.ts
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 推送新内容通知,前端展示提示气泡,用户手动刷新:

useFeedRealtime.ts
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 流中图片数量巨大,懒加载是必须的优化手段。

useLazyImage.ts
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 };
}

渐进式图片加载方案

ProgressiveImage.tsx
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 骨架屏

首屏加载和加载更多时展示骨架屏,减少用户感知等待时间:

FeedSkeleton.tsx
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>
);
}
skeleton.css
/* 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 交互优化

下拉刷新

usePullToRefresh.ts
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 };
}

阅读位置记忆

用户从详情页返回时,恢复到之前的阅读位置:

useScrollRestore.ts
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 实现:

useExposureTracker.ts
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 关键性能优化代码

图片内存释放

长列表中,不可见的图片应释放内存:

image-memory.ts
/** 当卡片离开虚拟列表可见范围时,释放图片内存 */
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 数据:

deduplicatedFetch.ts
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 离线缓存

sw-feed.ts
/// <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 流中需要在固定位置插入广告卡片,同时不影响正常内容的分页逻辑:

adInsertion.ts
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,切换时保持各自状态:

useMultiFeed.ts
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拉 + 推荐基于算法推荐,不依赖关注关系
企业内部群组纯推用户量小,推模型最简单

推拉结合的典型策略

push-pull-strategy.ts
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: 动态高度的虚拟列表怎么实现?

答案

动态高度虚拟列表的核心是 「预估 → 渲染 → 测量 → 修正」 的循环:

第一步:预估高度

根据卡片类型给出不同的预估高度值:

estimateHeight.ts
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 测量真实高度

useMeasure.ts
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。为了避免 滚动跳动,关键优化是:

scroll-anchor.ts
/**
* 滚动锚定:当可见区域上方的 item 高度变化时,
* 调整 scrollTop 保持当前阅读位置不变
*/
function adjustScrollForHeightChange(
container: HTMLElement,
changedIndex: number,
heightDiff: number,
firstVisibleIndex: number
): void {
// 只有变化的 item 在当前可见区域上方时,才需要修正 scrollTop
if (changedIndex < firstVisibleIndex) {
container.scrollTop += heightDiff;
}
}
常见坑点
  1. 图片加载导致高度变化:图片加载完成前后高度不同,需要用 aspectRatio 占位或 ResizeObserver 检测变化
  2. 评论展开/收起:用户交互改变了卡片高度,需要实时测量并更新
  3. 滚动跳动:上方 item 高度变化会导致当前位置跳动,需要 滚动锚定(scroll anchoring)修正 scrollTop

Q4: 如何优化 Feed 流的首屏加载?

答案

Feed 流的首屏优化是一个 端到端 的系统工程,从服务端到客户端需要全链路优化:

SSR 首屏渲染
// 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} />;
}

完整优化清单

阶段优化手段优先级
网络层CDN 加速、HTTP/2 多路复用、gzip/brotli 压缩P0
服务端SSR 首屏直出、接口聚合(BFF)、Redis 缓存热门 FeedP0
资源层代码分割、关键 CSS 内联、图片预加载P0
渲染层骨架屏、分阶段渲染、虚拟列表P1
缓存层Service Worker 缓存、IndexedDB 离线数据P1
感知优化占位符、渐进式图片加载、加载动画P2

相关链接