Ajax玉兔Ajax玉兔AJAX.BLOG
返回文章列表
2026 年 9 月22 日5 分钟阅读

哈希表的业务实战:从图片加载失败到相关笔记,小红书的三个处理范式

哈希表不只是 O(1) 查找。以小红书为例,看它如何在图片加载失败兜底、笔记相关推荐、Feed 流去重这些具体业务里被使用。

数据结构哈希表前端业务

为什么业务里到处是哈希表

面试背过一百遍「哈希表查找 O(1)」,但真到业务代码里,很多人还是满屏 array.find()。这篇文章换个角度:不画原理图,只看业务现场。以小红书的图文社区场景为例,三个最高频的处理范式,每一个背后都是哈希表。

先给结论——业务代码选择哈希表的三个理由:

  1. 「按 ID 找东西」是最高频的操作:找一篇笔记、找一个用户状态、查一张图加载过没有
  2. 「去重」是第二高频的操作:Feed 不推重复内容、已读不再红
  3. 前两者的组合——「ID → 状态」的映射,几乎就是所有客户端缓存的本体

范式一:图片加载失败的处理 —— Map<url, failCount>

信息流里图片加载失败是家常便饭:弱网、CDN 抽风、图被删。天真的处理是 onerror 时换占位图,但有两个真实问题:

问题 1:无限重试闪屏。 某些环境下 onerror 后组件重渲染又触发加载,再次失败再次换图,用户看到的是疯狂闪烁。

问题 2:重复犯错。 用户滑走再滑回,同一张失败 URL 重新加载一遍,重新失败一遍——浪费流量、浪费心情。

解法是一张「失败记录表」:

ts
// 模块级缓存:URL -> 失败次数(跨组件、跨页面生命周期) const failCounts = new Map<string, number>(); const FAIL_THRESHOLD = 2; // 同一 URL 失败 2 次后,下次进入页面直接渲染占位、不再请求 export function handleImageError(url: string, img: HTMLImageElement) { const count = (failCounts.get(url) ?? 0) + 1; failCounts.set(url, count); // 先换占位:保证卡片不塌、下方内容不跳,与是否重试无关 img.src = "/placeholder-failed.svg"; // count >= FAIL_THRESHOLD 时,列表渲染逻辑读到 failCounts 已超限, // 会跳过原 URL 直接渲染占位——「不再重试」由这里保证,不靠本函数分支 }

三个设计点:

  • 键是 URL 字符串,因为图片的身份就是 URL;值是失败次数,因为业务需要区分「抖一次」和「真的死了」
  • 阈值而不是一刀切:CDN 瞬时抖动占失败的相当比例,无脑重试和永不重试都是极端
  • 缓存是模块级的:组件卸载不能丢——用户滑回来时,「这张图已经死过两次」的事实必须还在

进阶做法是把 failCounts 从 Map 升级成带淘汰的 LRU:信息流滑过几千张图,Map 无限增长会漏 memory。浏览器端 Map 没有容量上限,需要业务自己控制(比如只保留最近 500 条,或者只缓存「确认死亡」的 URL,成功的随手删除)。

范式二:每篇笔记的相关处理 —— Map<noteId, NoteMeta>

一篇笔记在客户端涉及的状态比想象中多:点赞没、收藏没、评论数、是否已读、相关推荐列表。这些状态的共同结构是 noteId → 状态对象,天然哈希表。

已读集合:Set<noteId>

「看过」标记最省事的实现不是给笔记对象加字段,而是一个独立 Set:

ts
const readNotes = new Set<string>(); // 进入详情页时 readNotes.add(noteId); // 列表渲染时 const isRead = readNotes.has(note.id); // 标题置灰的依据

用 Set 而不是数组,因为「这篇看过吗」会被每一条卡片询问一次——O(n) 的 includes 在千条信息流里就是千次扫描。

互动状态本地缓存:Map<noteId, Interaction>

点赞/收藏的乐观更新需要一份本地真相:

ts
interface Interaction { liked: boolean; likeCount: number; collected: boolean; } const interactions = new Map<string, Interaction>(); function toggleLike(noteId: string) { const prev = interactions.get(noteId) ?? { liked: false, likeCount: 0, collected: false }; const next = { ...prev, liked: !prev.liked, likeCount: prev.likeCount + (prev.liked ? -1 : 1) }; interactions.set(noteId, next); // 先改本地,UI 立即响应 render(noteId, next); api.like(noteId, next.liked).catch(() => { interactions.set(noteId, prev); // 失败回滚——本地真相显身手 render(noteId, prev); }); }

没有这份缓存,乐观更新失败后的回滚要满世界找 UI 状态;有了它,回滚就是一次 set。

相关推荐:标签反查索引

「相关笔记」的推荐可以很重(协同过滤),也可以很轻(同标签)。轻量做法是一张倒排索引:

ts
// 构建:标签 -> 笔记 ID 列表(浏览详情页时按需构建,或首屏预构建) const tagIndex = new Map<string, string[]>(); function buildIndex(notes: Note[]) { for (const note of notes) { for (const tag of note.tags) { const list = tagIndex.get(tag) ?? []; list.push(note.id); tagIndex.set(tag, list); } } } // 使用:取当前笔记权重最高的标签,反查同标签笔记 function relatedNotes(current: Note, limit = 10): Note[] { const seen = new Set<string>([current.id]); // 去重:自己不能推荐自己 const result: Note[] = []; for (const tag of current.tags) { for (const id of tagIndex.get(tag) ?? []) { if (seen.has(id)) continue; seen.add(id); result.push(id); if (result.length >= limit) return result; } } return result; }

注意里面嵌套的 Set——哈希表在业务里从来不是单独出现的,它和别的数据结构组队:Map 管索引,Set 管去重,这是标准拍档。

范式三:Feed 流去重 —— 曝光 Set<noteId>

信息流有个隐性 KPI:同一段时间内不推重复内容。分页加载、下拉刷新、分类切换,三个入口都可能把同一篇笔记带回屏幕。

解法是会话级曝光表:

ts
const exposed = new Set<string>(); function mergeFeed(fresh: Note[]): Note[] { return fresh.filter((note) => { if (exposed.has(note.id)) return false; // 已曝光过,过滤 exposed.add(note.id); // 注意:过滤的同时就要登记 return true; }); }

关键细节:登记时机是「进入列表」而不是「被用户看到」。如果用曝光监听(IntersectionObserver)来登记,快速滑过的笔记没被观测到就漏登记了;进入列表即登记,语义是「这条我有机会展示」,简单且不漏。

曝光表的边界要想清楚:刷新页面要不要清空?切分类要不要清空?通常「本次会话 + 当前分类」是合理粒度——全清会导致刷新后首页重复,永不清会导致用户抱怨「怎么老是这几条」。

为什么不用对象字面量 / 数组

三个常见疑问,一次说清:

Q:{} 不也是键值对吗,为什么用 Map?

  • {} 的键只能是字符串/Symbol,noteId 是数字时要隐式转换;Map 的键可以是任意类型
  • {} 原型链上有 toString 等继承键,用户数据里有 "constructor" 这种键时会踩原型污染({}["constructor"] 不报错但结果离谱)
  • Map 有 size,{} 要 Object.keys().length
  • 频繁增删时 Map 性能更稳({} 删除键可能触发隐藏类退化)

Q:Set 和 Map 怎么选? 只关心「在不在」用 Set(已读、曝光);关心「在,而且带着数据」用 Map(互动状态、失败次数)。没有灰色地带。

Q:数组 find 不是挺直观? 数据量上三位数后就是灾难。业务列表动辄几百上千条,每次渲染每张卡片都 find 一次,是典型的「写着舒服、跑着卡」。查找频率 × 数据规模超过约 50,就该换成哈希结构。

工程细节:别让缓存变成漏内存

哈希表的业务风险从来不是「不会用」,而是「忘了大小」:

  1. 能淘汰的才进缓存:失败 URL、互动状态设上限(LRU 思想:超容量先淘汰最久未用)
  2. 会话级状态随会话销毁:曝光 Set 在页面卸载/切分类时按策略清理,别指望它自己消失
  3. 倒排索引按需构建:全量构建所有标签索引不如进入详情页时按当前标签现查——索引也有构建成本

小结

回看这三个范式,共同点惊人一致:

业务问题结构键值
图片加载失败兜底Map图片 URL失败次数
笔记互动状态Map笔记 ID互动状态对象
已读 / 曝光去重Set笔记 ID——
相关推荐索引Map + Set标签笔记 ID 列表

哈希表在业务里的角色,本质是给「身份」一个 O(1) 的答案:这篇笔记点赞没、这张图死没死、这条内容推过没。下次写业务代码想 find 的时候,停一秒——你要找的东西,是不是早就该有一张表了。