社交前端 Feed 流架构复盘无限滚动、缓存策略与离线体验一、Feed 流的前端挑战不是渲染而是状态的一致性维护Feed 流是社交产品最重要也最容易被低估的前端模块。表面上看它是一个列表请求数据 → 渲染列表 → 用户滚动到底部 → 请求下一页 → 追加渲染。但实际场景远比这个复杂。真正的挑战来自以下场景用户在 Feed 流中间点赞列表第 37 条动态用户点了赞。UI 需要立即更新点赞数 1、图标亮起但服务端的数据可能还没同步。5 分钟后用户刷新页面这条动态的点赞数需要与 UI 一致。用户发布了一条新动态发布后希望立即出现在 Feed 流顶部。但如果直接插入顶部与下一页的数据加载后会产生重复。用户在 WiFi 下打开了 Feed 流切换到 4G 后未读完的 Feed 应该能继续浏览。用户在 A Tab 点赞切换到 B Tab 后再切回来点赞状态需要保持。Feed 流中有 2000 条数据DOM 节点过多导致滚动卡顿需要虚拟滚动。这五个场景指向同一个核心——Feed 流的状态管理远比渲染复杂。二、无限滚动的三层架构数据层、状态层、渲染层2.1 数据层的分页与游标设计Feed 流的分页不能使用page1pageSize20的偏移分页原因是如果你在浏览第 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(): PromiseFeedPage { 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(): PromiseFeedPage | 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: PartialFeedItem } } | { 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: Mapstring, () 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: () Promisevoid ): Promisevoid { // 保存更新前的状态用于回滚 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内存 → L2IndexedDB → L3Service 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 Mapstring, 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(): Promisevoid { 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): PromiseFeedItem[] | null { // L1内存缓存 const memEntry this.memoryCache.get(feedKey); if (memEntry Date.now() - memEntry.timestamp memEntry.ttl) { return memEntry.items; } // L2IndexedDB 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 ): Promisevoid { 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): Promisevoid { this.memoryCache.delete(feedKey); try { await this.deleteFromDB(feedKey); } catch { // 清理失败不阻塞 } } private readFromDB(feedKey: string): PromiseCacheEntry | 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): Promisevoid { 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): Promisevoid { 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离线体验。乐观更新可以渐进式添加——先加点赞的乐观更新验证稳定后再加评论和发布。