☰
从零搭建H5游戏红点系统:树形状态聚合与自动冒泡机制
2026/10/14 6:23:36 网站建设 项目流程

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-id="nav_task" />,在“每日签到”条目旁边放<RedDot node-id="task_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 = '<h3>RedDot 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里加分支,测试用例跟着补就行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询