从零搭建H5游戏红点系统:树形状态聚合与自动冒泡机制

发布时间:2026/10/14 6:23:37
从零搭建H5游戏红点系统:树形状态聚合与自动冒泡机制 1. 红点系统到底在解决什么问题做过H5游戏或者带社交属性的前端项目的人大概率都碰过这个场景底部的“任务”按钮上冒出一个红点玩家点进去发现是“每日签到”亮了签完到退出来红点还在——因为“成就系统”里也有新东西。再点进去领完成就奖励红点终于消失了。整个过程玩家点了三次骂了两次。这就是红点系统要解决的核心问题把散落在各个业务模块里的“有新内容”状态收敛成一套统一的、可递归计算的、能自动冒泡的显示逻辑。它不是一个UI组件而是一套状态管理机制。很多团队一开始觉得红点就是v-if一个红点图片的事等到功能多起来运营活动、好友申请、邮件、排行榜奖励全都要挂红点的时候代码里就会出现几十个hasNewTask、hasNewMail、hasNewFriendRequest这样的布尔变量互相之间还有依赖关系改一个功能崩三个入口。我见过一个上线半年的H5项目光红点相关的判断逻辑就散落在17个文件里每次加新功能策划都要专门提一句“记得把红点加上”开发就得去翻一遍所有入口的代码。这种维护成本在项目初期是看不出来的等到功能数量超过20个就是灾难。所以这篇文章要聊的是从零搭一套红点管理器核心目标有三个第一业务模块只管自己有没有新内容不关心红点怎么显示第二父节点的红点状态由子节点自动汇总不需要手动维护第三新增业务模块时接入成本控制在几行代码以内。适合正在做H5游戏、小程序或者任何带多层级入口的前端开发者参考不管你是用Vue、React还是原生JS思路是通用的。2. 整体设计思路与核心数据结构选型2.1 为什么用树结构而不是扁平列表红点系统的本质是一个状态聚合问题。底部导航栏有5个入口每个入口下面可能挂着若干子功能子功能下面还有更细的条目。比如“社交”入口下有“好友申请”“私聊消息”“公会通知”其中“好友申请”又分“新的好友申请”和“推荐好友”。玩家期望看到的是只要任何一个子条目有新内容从最底层到最顶层的入口整条路径上的红点都要亮起来。这种“子节点有状态父节点自动响应”的需求天然就是树结构。用扁平列表也能做但每次计算父节点状态时都要遍历所有子节点而且层级关系要靠额外的字段维护代码会越写越绕。树结构的好处是每个节点只需要关心自己的直接子节点递归计算逻辑可以复用新增层级不需要改核心代码。具体到数据结构我选的是邻接表式的树每个节点存自己的id、parentId和children数组。不用嵌套对象的原因是实际项目中经常需要根据id快速定位节点比如收到服务器推送说“邮件模块有新内容”邻接表配合一个Map索引查找是O(1)的。嵌套对象虽然结构直观但查找要递归遍历节点多了性能会掉。2.2 红点状态的三种类型不是所有红点都一样。实际项目里至少会遇到三种布尔型有或没有比如“有新公告”。数字型显示具体数量比如“3条未读消息”超过99显示“99”。点型只显示一个小圆点不显示数字比如“有新功能上线”。这三种在数据结构上可以统一成一个count字段count 0表示有红点count -1表示点型红点count 0表示无红点。显示层根据count的值决定渲染成数字还是圆点。这样核心逻辑只需要维护一个数字不用为每种类型写一套判断。注意数字型红点的上限处理99建议放在显示层做不要污染数据层。数据层存真实数量显示层根据UI规范决定截断阈值这样以后策划想把上限改成999只改一个配置就行。2.3 自动冒泡的计算策略核心计算逻辑是叶子节点的状态由业务模块直接设置非叶子节点的状态由其所有子节点聚合得出。聚合规则很简单所有子节点的count求和如果和大于0父节点count就是和或者按需截断否则为0。点型红点可以特殊处理只要有一个子节点是点型父节点也显示点型。这里有个容易踩的坑不要用事件驱动的方式逐级向上通知。我一开始就是每个节点状态变化时手动调用parent.updateCount()结果出现了循环触发和重复计算的问题。后来改成“标记脏节点统一重算”的模式任何节点状态变化时只标记自己和所有祖先节点为“脏”然后在下一个微任务或者帧渲染前从根节点向下重新计算一遍。这样逻辑简单不会漏算也不会重复触发。3. 核心模块拆解与代码实现3.1 节点注册与树构建先定义节点类。每个节点需要存id、parentId、children、count、dirty这几个字段。注册节点时如果父节点还不存在就先创建一个占位父节点等父节点真正注册时再填充信息。这种“懒创建”的方式可以让业务模块按任意顺序注册不用关心初始化顺序。class RedDotNode { constructor(id, parentId null) { this.id id; this.parentId parentId; this.children []; this.count 0; this.dirty false; } } class RedDotManager { constructor() { this.nodes new Map(); this.rootIds []; this.pendingRecalc false; } register(id, parentId null) { if (this.nodes.has(id)) return this.nodes.get(id); const node new RedDotNode(id, parentId); this.nodes.set(id, node); if (parentId) { let parent this.nodes.get(parentId); if (!parent) { parent new RedDotNode(parentId, null); this.nodes.set(parentId, parent); this.rootIds.push(parentId); } parent.children.push(id); } else { this.rootIds.push(id); } return node; } }这段代码里有个细节parentId对应的父节点如果不存在会创建一个临时的根节点。等真正的父节点注册时需要把临时节点的children迁移过去。这个迁移逻辑我放在register里判断如果当前注册的id已经作为临时父节点存在就复用它的children数组。3.2 状态设置与脏标记传播业务模块调用setCount(id, count)来更新状态。这个方法不直接计算父节点而是标记脏节点然后调度一次重算。setCount(id, count) { const node this.nodes.get(id); if (!node || node.count count) return; node.count count; this.markDirty(id); this.scheduleRecalc(); } markDirty(id) { let current this.nodes.get(id); while (current) { current.dirty true; current current.parentId ? this.nodes.get(current.parentId) : null; } } scheduleRecalc() { if (this.pendingRecalc) return; this.pendingRecalc true; Promise.resolve().then(() { this.pendingRecalc false; this.recalcAll(); }); }用Promise.resolve().then()做微任务调度是为了把同一帧内的多次setCount合并成一次重算。比如服务器一次性推了5条消息业务模块连续调了5次setCount如果没有合并就会触发5次全树重算。合并之后只算一次性能差别在节点多的时候非常明显。实操心得如果你的项目不支持Promise比如某些老旧的H5游戏引擎环境可以用setTimeout(fn, 0)替代但要注意setTimeout是宏任务合并效果不如微任务。另一个方案是手动维护一个requestAnimationFrame的调度适合和渲染帧对齐的场景。3.3 递归重算与显示层对接重算逻辑从根节点开始深度优先遍历先算子节点再算父节点。recalcAll() { const recalc (nodeId) { const node this.nodes.get(nodeId); if (!node) return 0; if (node.children.length 0) { node.dirty false; return node.count; } let total 0; let hasDot false; for (const childId of node.children) { const childCount recalc(childId); if (childCount -1) hasDot true; else total childCount; } if (hasDot total 0) { node.count -1; } else { node.count total; } node.dirty false; return node.count; }; for (const rootId of this.rootIds) { recalc(rootId); } this.notifyListeners(); }显示层通过订阅notifyListeners来更新UI。每个UI组件注册自己关心的节点id重算完成后管理器遍历监听器把对应节点的最新count推给组件。组件拿到count后根据值决定显示数字、圆点还是隐藏。这里有个优化点只通知count发生变化的节点。在recalc过程中记录哪些节点的count变了notifyListeners只遍历这些节点对应的监听器。节点数量上百的时候这个优化能减少大量无意义的UI更新。4. 实操接入流程与业务模块改造4.1 初始化与节点声明在项目启动阶段把所有红点节点注册进去。我习惯用一个独立的配置文件来声明树结构而不是散落在各个业务模块里。const redDotConfig [ { id: root, parentId: null }, { id: nav_task, parentId: root }, { id: nav_social, parentId: root }, { id: task_daily, parentId: nav_task }, { id: task_achievement, parentId: nav_task }, { id: social_friend, parentId: nav_social }, { id: social_guild, parentId: nav_social }, { id: friend_request, parentId: social_friend }, { id: friend_recommend, parentId: social_friend }, ]; const redDot new RedDotManager(); redDotConfig.forEach(item redDot.register(item.id, item.parentId));这样声明的好处是整个红点树的结构一目了然策划要调整层级关系改配置就行不用动业务代码。新增功能时在配置里加一行然后在业务模块里调setCount。4.2 业务模块的状态上报以“好友申请”模块为例。当收到新的好友申请时业务模块只需要做一件事// 收到服务器推送的新好友申请 function onNewFriendRequest(count) { redDot.setCount(friend_request, count); }不需要关心social_friend和nav_social的红点怎么亮管理器会自动冒泡上去。同样当玩家处理完所有申请count变成0整条路径上的红点自动消失。对于“每日签到”这种布尔型红点业务模块在初始化时判断今天是否已签到没签到就setCount(task_daily, 1)签到完成后setCount(task_daily, 0)。注意事项业务模块不要在setCount之后手动去改父节点的状态也不要在UI层直接读父节点的count来做判断。所有状态流转都走管理器否则会出现状态不一致。我踩过的坑是有个模块为了“优化性能”直接改了父节点的count结果下次重算时被覆盖红点闪了一下又消失排查了半天。4.3 UI组件的订阅与渲染UI组件这边以Vue为例封装一个通用的红点组件// RedDot.vue export default { props: [nodeId], data() { return { count: 0 }; }, mounted() { this.unsubscribe redDot.subscribe(this.nodeId, (count) { this.count count; }); }, beforeDestroy() { this.unsubscribe(); }, render(h) { if (this.count 0) return null; if (this.count -1) { return h(span, { class: red-dot }); } const text this.count 99 ? 99 : String(this.count); return h(span, { class: red-dot-num }, text); } };用的时候在导航栏的“任务”按钮旁边放一个RedDot node-idnav_task /在“每日签到”条目旁边放RedDot node-idtask_daily /。组件不关心数据从哪来只负责渲染。订阅机制这里subscribe方法返回一个取消订阅的函数组件销毁时调用避免内存泄漏。在H5游戏里场景切换频繁如果忘记取消订阅旧场景的组件会一直持有引用不仅浪费内存还可能在重算时触发已销毁组件的更新导致报错。5. 常见问题排查与性能优化实录5.1 红点不消失或异常闪烁这是最常见的问题通常有三个原因。第一业务模块的count没有归零。比如玩家领完奖励业务代码只更新了UI忘了调setCount(id, 0)。排查方法是加一个调试面板把所有节点的count实时打印出来看哪个节点该是0却不是0。第二脏标记传播断了。如果某个节点的parentId指向了一个不存在的节点markDirty在向上遍历时会提前终止导致祖先节点没被标记重算时跳过。解决办法是在register时校验parentId的有效性或者在markDirty里加一个兜底如果找不到父节点就把所有根节点都标记为脏。第三重算调度被阻塞。如果项目里用了长任务比如大量的同步计算微任务可能被延迟执行红点更新就会滞后。这种情况可以把调度改成requestAnimationFrame和渲染帧对齐视觉上更流畅。5.2 节点数量多时的性能问题我实测过一个极端场景500个红点节点其中200个是叶子节点每次全树重算大约耗时0.8ms。单次看起来不多但如果服务器高频推送比如聊天消息每秒好几条累积起来就会掉帧。优化手段有三个。第一合并调度前面已经讲了同一帧内的多次setCount合并成一次重算。第二增量重算只重算脏节点及其祖先而不是全树。实现方式是在markDirty时把脏节点加入一个集合重算时只遍历这个集合里的节点从最深的脏节点开始向上算。第三缓存聚合结果如果某个节点的所有子节点都没变直接复用上次的count跳过递归。对于大多数H5游戏节点数量在50以内全树重算完全够用。但如果你做的是大型MMO或者社交产品节点可能上百建议直接上增量重算。5.3 与服务器数据的同步时机红点的初始状态通常来自服务器。我的做法是登录时拉一次全量红点数据之后靠推送增量更新。全量数据是一个{ nodeId: count }的对象遍历调用setCount即可。这里有个时序问题如果推送比全量数据先到后到的全量数据会覆盖推送的新状态。解决办法是给全量数据加一个版本号或者时间戳管理器记录每个节点的最后更新时间全量数据只更新那些没有更新过的节点。或者更简单粗暴等全量数据应用完再建立推送连接。实操心得服务器推送的红点数据建议带上一个seq序列号管理器按序应用乱序的丢弃。我在一个项目里遇到过推送乱序导致红点数量跳变的问题加了序列号之后就没再出现过。问题现象可能原因排查方法解决方案红点不消失业务count未归零调试面板查看节点count检查业务代码是否调setCount红点闪烁重复触发重算打印重算日志合并调度加防抖父节点不亮脏标记传播断检查parentId链校验注册顺序或兜底标记数量不对推送乱序检查seq序列号按序应用丢弃乱序性能掉帧全树重算频繁性能面板看耗时增量重算或缓存结果6. 扩展玩法与进阶思路6.1 红点优先级与互斥实际运营中不同红点的优先级不一样。比如“充值返利”的红点运营希望它永远显示哪怕玩家已经看过“新功能引导”的红点玩家点过一次就永久消失。这种需求可以在节点上加一个priority字段和permanent标记聚合时按优先级决定显示哪个。互斥的场景也有比如“每日任务”和“主线任务”共用一个入口但策划要求同时只显示一个红点。这种可以在配置里加exclusiveGroup同一组的节点聚合时只取优先级最高的那个。6.2 红点已读状态的本地持久化有些红点不需要服务器记录比如“新功能上线”的提示玩家点过之后本地记一下就行。可以用localStorage存一个已读节点列表初始化时把这些节点的count设为0。注意要加过期时间否则玩家换了设备或者清了缓存红点又会冒出来。6.3 调试工具的开发红点系统上线后策划和测试经常需要验证“这个红点为什么亮/不亮”。我建议花半天时间做一个调试面板把所有节点的树结构、当前count、脏标记状态可视化出来支持手动设置count来模拟各种场景。这个工具在联调阶段能省下大量沟通成本。// 简易调试面板 function openRedDotDebug() { const panel document.createElement(div); panel.style.cssText position:fixed;top:0;right:0;width:300px;height:100%;overflow:auto;background:#fff;z-index:9999;font-size:12px;; const render () { let html h3RedDot Debug/h3; redDot.nodes.forEach((node, id) { html div${id}: count${node.count} dirty${node.dirty}/div; }); panel.innerHTML html; }; render(); setInterval(render, 500); document.body.appendChild(panel); }这个面板虽然简陋但排查问题时非常直观。你可以看到每个节点的实时状态哪个节点该亮没亮哪个节点该灭没灭一眼就能定位。最后分享一个小技巧红点系统的单元测试很容易写因为核心逻辑是纯函数式的。把recalc逻辑抽出来输入一棵树和一组setCount操作断言输出树的count分布。我一般会覆盖这几个用例单叶子节点、多叶子节点求和、点型红点冒泡、脏标记传播、合并调度。这几个用例过了基本逻辑就不会有大问题。后续如果要加优先级、互斥这些扩展也是在recalc里加分支测试用例跟着补就行。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询