社交前端 Feed 流架构复盘:无限滚动、缓存策略与离线体验

📅 2026/7/24 17:18:42 👁️ 阅读次数 📝 编程学习
社交前端 Feed 流架构复盘:无限滚动、缓存策略与离线体验

社交前端 Feed 流架构复盘:无限滚动、缓存策略与离线体验

一、Feed 流的前端挑战:不是渲染,而是状态的一致性维护

Feed 流是社交产品最重要也最容易被低估的前端模块。表面上看,它是一个列表:请求数据 → 渲染列表 → 用户滚动到底部 → 请求下一页 → 追加渲染。但实际场景远比这个复杂。

真正的挑战来自以下场景:

  1. 用户在 Feed 流中间点赞:列表第 37 条动态,用户点了赞。UI 需要立即更新(点赞数 +1、图标亮起),但服务端的数据可能还没同步。5 分钟后用户刷新页面,这条动态的点赞数需要与 UI 一致。
  2. 用户发布了一条新动态:发布后希望立即出现在 Feed 流顶部。但如果直接插入顶部,与下一页的数据加载后会产生重复。
  3. 用户在 WiFi 下打开了 Feed 流:切换到 4G 后,未读完的 Feed 应该能继续浏览。
  4. 用户在 A Tab 点赞:切换到 B Tab 后,再切回来,点赞状态需要保持。
  5. Feed 流中有 2000 条数据:DOM 节点过多导致滚动卡顿,需要虚拟滚动。

这五个场景指向同一个核心——Feed 流的状态管理远比渲染复杂

二、无限滚动的三层架构:数据层、状态层、渲染层

2.1 数据层的分页与游标设计

Feed 流的分页不能使用page=1&pageSize=20的偏移分页,原因是:如果你在浏览第 1 页时,有人发布了 3 条新动态,那么第 2 页的前 3 条实际上是第 1 页的最后 3 条(因为数据整体后移了 3 位)。这导致内容重复或遗漏。

正确的做法是使用游标分页(Cursor Pagination):每次请求携带上一页最后一条数据的 ID 或时间戳,服务端从该位置之后返回数据。

/** * Feed 流游标分页管理器 * 使用游标而非偏移分页,避免数据插入导致的重复或遗漏 */ interface FeedPage { items: FeedItem[]; nextCursor: string | null; // 下一页的游标,null 表示已到末尾 hasMore: boolean; } interface FeedItem { id: string; authorId: string; authorName: string; authorAvatar: string; content: string; images: string[]; createdAt: number; // 时间戳,作为游标 likes: number; comments: number; shares: number; isLiked: boolean; } class FeedPaginationManager { private cursor: string | null = null; private hasMore = true; private loading = false; private readonly PAGE_SIZE = 20; /** * 加载首页(或刷新) * 游标为 null 时,拉取最新数据 */ async loadFirstPage(): Promise<FeedPage> { this.loading = true; try { const response = await fetch('/api/feed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cursor: null, size: this.PAGE_SIZE }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data: FeedPage = await response.json(); this.cursor = data.nextCursor; this.hasMore = data.hasMore; return data; } finally { this.loading = false; } } /** * 加载下一页 */ async loadNextPage(): Promise<FeedPage | null> { if (this.loading || !this.hasMore) return null; this.loading = true; try { const response = await fetch('/api/feed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cursor: this.cursor, size: this.PAGE_SIZE }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data: FeedPage = await response.json(); this.cursor = data.nextCursor; this.hasMore = data.hasMore; return data; } catch (err) { console.error('[FeedPagination] 加载失败:', err); return null; } finally { this.loading = false; } } /** * 重置分页状态(用于下拉刷新) */ reset(): void { this.cursor = null; this.hasMore = true; this.loading = false; } /** * 在本地插入一条新动态(发布后立即显示) * 游标不变,但需要标记为"本地插入"以避免与下一页重复 */ insertLocal(item: FeedItem & { isLocal?: boolean }): FeedItem { return { ...item, isLocal: true }; } isLoading(): boolean { return this.loading; } }

2.2 状态层的乐观更新与回滚

乐观更新是 Feed 流体验的关键——用户在点赞后不该等待服务端响应才看到 UI 变化。理想情况下,前端立即更新 UI,同时发送请求。如果请求成功,一切正常;如果失败(网络错误、权限不足等),需要回滚。

/** * Feed 状态管理器 * 处理列表的增删改查,支持乐观更新和回滚 */ interface FeedState { items: FeedItem[]; cursor: string | null; hasMore: boolean; loading: boolean; refreshing: boolean; error: string | null; } type FeedAction = | { type: 'SET_ITEMS'; payload: FeedItem[]; cursor: string | null } | { type: 'APPEND_ITEMS'; payload: FeedItem[]; cursor: string | null } | { type: 'PREPEND_ITEM'; payload: FeedItem } | { type: 'UPDATE_ITEM'; payload: { id: string; changes: Partial<FeedItem> } } | { type: 'REMOVE_ITEM'; payload: string } | { type: 'SET_LOADING'; payload: boolean } | { type: 'SET_ERROR'; payload: string | null }; class FeedStateManager { private state: FeedState = { items: [], cursor: null, hasMore: true, loading: false, refreshing: false, error: null, }; private listeners: Set<(state: FeedState) => void> = new Set(); private pendingRollbacks: Map<string, () => void> = new Map(); getState(): FeedState { return { ...this.state, items: [...this.state.items] }; } dispatch(action: FeedAction): void { switch (action.type) { case 'SET_ITEMS': this.state.items = action.payload; this.state.cursor = action.cursor; this.state.hasMore = action.cursor !== null; this.state.error = null; break; case 'APPEND_ITEMS': // 去重:如果新数据中有本地已存在的数据,跳过 const existingIds = new Set(this.state.items.map((i) => i.id)); const newItems = action.payload.filter( (item) => !existingIds.has(item.id) ); this.state.items = [...this.state.items, ...newItems]; this.state.cursor = action.cursor; this.state.hasMore = action.cursor !== null; break; case 'PREPEND_ITEM': this.state.items = [action.payload, ...this.state.items]; break; case 'UPDATE_ITEM': { this.state.items = this.state.items.map((item) => item.id === action.payload.id ? { ...item, ...action.payload.changes } : item ); break; } case 'REMOVE_ITEM': this.state.items = this.state.items.filter( (item) => item.id !== action.payload ); break; case 'SET_LOADING': this.state.loading = action.payload; break; case 'SET_ERROR': this.state.error = action.payload; break; } this.notify(); } /** * 乐观更新:先改 UI,失败再回滚 */ async optimisticUpdate( action: FeedAction, apiCall: () => Promise<void> ): Promise<void> { // 保存更新前的状态用于回滚 const snapshot = this.state.items.map((item) => ({ ...item, // 深拷贝可变字段 })); // 先执行乐观更新 this.dispatch(action); try { await apiCall(); // 成功:无需操作,UI 已是最新状态 } catch (err) { // 失败:回滚到更新前的状态 this.state.items = snapshot; this.notify(); console.error('[FeedState] 乐观更新失败,已回滚:', err); } } subscribe(listener: (state: FeedState) => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } private notify(): void { const state = this.getState(); for (const listener of this.listeners) { try { listener(state); } catch (err) { console.error('[FeedState] 订阅回调错误:', err); } } } }

三、缓存策略:内存缓存 + IndexedDB + Service Worker 的三级缓存

3.1 缓存层级设计

Feed 流的缓存设计采用三级策略:

  • L1 内存缓存(毫秒级):在 SPA 生命周期内,Feed 数据保存在状态管理器中。Tab 切换时直接读取内存数据,不需要网络请求。
  • L2 IndexedDB 持久化缓存(秒级):关闭 Tab 或刷新页面后,Feed 数据从 IndexedDB 恢复。用户重新打开页面时,先展示缓存数据(秒开),再请求最新数据做增量更新。
  • L3 Service Worker 缓存(网络层):利用 Service Worker 拦截 API 请求。离线环境下,如果 IndexedDB 中有数据,直接返回;在线环境下,先返回缓存再更新缓存。
/** * Feed 流三级缓存管理器 * L1:内存 → L2:IndexedDB → L3:Service Worker */ interface CacheEntry { feedKey: string; // Feed 流的唯一标识(如 'home'/'following'/'topic_123') items: FeedItem[]; cursor: string | null; timestamp: number; ttl: number; // 缓存有效期(ms) } class FeedCacheManager { private memoryCache = new Map<string, CacheEntry>(); private dbName = 'feed_cache_db'; private storeName = 'feed_pages'; private db: IDBDatabase | null = null; private readonly DEFAULT_TTL = 5 * 60 * 1000; // 5 分钟 /** * 初始化 IndexedDB */ async init(): Promise<void> { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName, { keyPath: 'feedKey' }); } }; request.onsuccess = (event) => { this.db = (event.target as IDBOpenDBRequest).result; resolve(); }; request.onerror = () => { reject(new Error('IndexedDB 初始化失败')); }; }); } /** * 读取缓存(优先内存 → IndexedDB) */ async get(feedKey: string): Promise<FeedItem[] | null> { // L1:内存缓存 const memEntry = this.memoryCache.get(feedKey); if (memEntry && Date.now() - memEntry.timestamp < memEntry.ttl) { return memEntry.items; } // L2:IndexedDB try { const dbEntry = await this.readFromDB(feedKey); if (dbEntry && Date.now() - dbEntry.timestamp < dbEntry.ttl) { // 回填内存缓存 this.memoryCache.set(feedKey, dbEntry); return dbEntry.items; } } catch (err) { console.warn('[FeedCache] IndexedDB 读取失败:', err); } return null; } /** * 写入缓存(同时写内存和 IndexedDB) */ async set( feedKey: string, items: FeedItem[], cursor: string | null, ttl = this.DEFAULT_TTL ): Promise<void> { const entry: CacheEntry = { feedKey, items, cursor, timestamp: Date.now(), ttl, }; // L1:写内存 this.memoryCache.set(feedKey, entry); // L2:写 IndexedDB(异步,不阻塞主流程) this.writeToDB(entry).catch((err) => { console.warn('[FeedCache] IndexedDB 写入失败:', err); }); } /** * 清除指定 Feed 的缓存 */ async invalidate(feedKey: string): Promise<void> { this.memoryCache.delete(feedKey); try { await this.deleteFromDB(feedKey); } catch { // 清理失败不阻塞 } } private readFromDB(feedKey: string): Promise<CacheEntry | null> { return new Promise((resolve, reject) => { if (!this.db) return reject(new Error('DB 未初始化')); const tx = this.db.transaction(this.storeName, 'readonly'); const store = tx.objectStore(this.storeName); const request = store.get(feedKey); request.onsuccess = () => resolve(request.result ?? null); request.onerror = () => reject(request.error); }); } private writeToDB(entry: CacheEntry): Promise<void> { return new Promise((resolve, reject) => { if (!this.db) return reject(new Error('DB 未初始化')); const tx = this.db.transaction(this.storeName, 'readwrite'); const store = tx.objectStore(this.storeName); const request = store.put(entry); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } private deleteFromDB(feedKey: string): Promise<void> { return new Promise((resolve, reject) => { if (!this.db) return reject(new Error('DB 未初始化')); const tx = this.db.transaction(this.storeName, 'readwrite'); const store = tx.objectStore(this.storeName); const request = store.delete(feedKey); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } }

四、离线体验:Service Worker 兜底与离线标识

4.1 Service Worker 的缓存优先策略

Service Worker 在 Feed 流场景下的策略是"缓存优先 + 网络更新"(Stale-While-Revalidate):

  • 有网 + 有缓存:先返回缓存数据(秒开),同时在后台发起网络请求更新缓存。
  • 有网 + 无缓存:直接走网络请求,结果写入缓存。
  • 无网 + 有缓存:返回缓存数据,并在 UI 上显示"当前为离线数据"的提示。
  • 无网 + 无缓存:显示离线兜底页面("暂无网络,请检查连接")。

4.2 离线状态 UI 的三个级别

离线状态的处理需要在 UI 上给用户明确的反馈,而不是静默失败:

  • 弱提示(Toast):网络恢复后 3 秒内消失。用于短暂网络波动。
  • 状态条(顶栏横幅):黄色背景,提示"当前为离线数据,最后更新于 3 分钟前"。持续显示直到网络恢复。
  • 全局离线页(全屏):在没有任何缓存数据且网络断开时显示。提供"重试"按钮和 WiFi 设置快捷跳转。

五、总结

Feed 流架构的核心是状态管理,而非列表渲染。关键设计包括:

游标分页替代偏移分页,解决数据插入导致的重复和遗漏。每次请求携带上一页最后一条数据的 ID/时间戳作为起点。

乐观更新 + 回滚保证交互即时性。用户的点赞、评论操作先更新 UI 再发请求,失败后回滚到操作前的状态快照。

三级缓存保证加载速度和离线可用。L1 内存缓存(Tab 切换可用)、L2 IndexedDB(页面刷新可用)、L3 Service Worker(离线可用)。

离线状态的阶梯式反馈:弱提示(Toast)→ 状态条(顶栏横幅)→ 全屏离线页。用户在任何网络状态下都清楚当前发生了什么。

落地路线:先实现游标分页(替换现有偏移分页),然后加入 IndexedDB 缓存(实现秒开),最后接入 Service Worker(离线体验)。乐观更新可以渐进式添加——先加点赞的乐观更新,验证稳定后再加评论和发布。