资讯详情

H5游戏红点系统设计:树形结构、脏标记与批量刷新实战

📅 2026/10/14 23:26:33 | 华诺云谱 👁 阅读
H5游戏红点系统设计:树形结构、脏标记与批量刷新实战
1. 红点系统到底在解决什么问题做过H5游戏或者带社交属性的前端项目的人大概率都遇到过这样的场景主界面底部有五个页签其中任务页签上冒出一个红点玩家点进去发现日常任务里有个红点再点进去发现每日签到里还有个红点签到完退出来任务页签的红点还在因为成就任务里也有未领取的奖励。这一层套一层的逻辑如果每个页面各写各的代码会迅速变成一团乱麻。红点系统的本质是一棵状态树。每个红点对应树上的一个节点节点的状态由它自身的数据条件比如是否有未领取奖励和它所有子节点的状态共同决定。叶子节点的状态是算出来的父节点的状态是汇总出来的。这个模型听起来简单但真正落地时会遇到几个很实际的问题数据什么时候刷新、刷新时怎么避免全树重算、多个模块同时修改状态时怎么保证一致性、以及最关键的——怎么让策划配的红点规则不用改代码就能生效。我见过不少项目把红点逻辑直接写在UI组件里比如在任务按钮的mounted里判断if (hasUnclaimedReward) showRedDot()。这种做法在只有三五个红点的时候没问题一旦红点数量超过二十个就会出现点了子页面父页面不更新两个模块互相触发导致死循环某个红点该亮的时候不亮排查半天发现是刷新时机不对这类问题。所以红点管理器要解决的核心诉求就三个字可维护。这篇文章会从零搭一个能直接用在H5游戏里的红点管理器不依赖任何框架纯TypeScript实现但思路对Vue、React、甚至原生JS都通用。我会把设计决策背后的取舍讲清楚包括为什么用树而不是扁平列表、为什么状态计算要分脏标记和实时计算两种模式、以及怎么处理游戏里常见的红点延迟刷新问题。如果你正在做带任务系统、邮件系统、商城系统的H5项目这套东西可以直接抄作业。2. 红点树的节点设计从扁平列表到层级结构2.1 为什么不能用扁平Map存红点最直觉的做法是用一个Mapstring, boolean存所有红点的状态key是红点路径比如task.daily.signvalue是是否显示。读取的时候直接查表更新的时候直接改值。这个方案在红点之间没有关联时完全够用但游戏里的红点几乎必然有关联——任务页签的红点等于它下面所有子红点的逻辑或。如果用扁平Map每次子红点变化时你得手动去更新所有祖先节点的状态。比如task.daily.sign变成false了你得检查task.daily下其他子节点是否还有true再检查task下其他子节点是否还有true。这个向上冒泡的逻辑如果每个修改点都写一遍代码重复不说还特别容易漏。更麻烦的是当红点数量多了之后你根本记不住谁是谁的父节点。所以正确的做法是显式地建一棵树。每个节点存自己的key、parent引用、children列表、自身的原始状态我叫它selfState、以及汇总后的最终状态finalState。树结构一旦建好向上冒泡和向下查找都是天然支持的。2.2 节点数据结构的字段取舍先看节点的定义。我用TypeScript写但字段设计对任何语言都适用interface RedDotNode { key: string; // 全局唯一路径如 task.daily.sign parent: RedDotNode | null; children: RedDotNode[]; selfState: boolean; // 自身条件是否满足 finalState: boolean; // 自身或任一子孙满足 dirty: boolean; // 是否需要重算 calculator?: () boolean; // 可选动态计算自身状态 listeners: Set(state: boolean) void; // 状态变化回调 }这里有几个设计点值得展开。selfState和finalState分开存是因为父节点的最终状态是自身条件 OR 所有子节点最终状态而自身条件可能来自一个异步接口比如是否有未读邮件需要请求服务器。分开之后刷新逻辑就清晰了先更新selfState再向上冒泡重算finalState。dirty标记是为了做惰性计算。游戏里红点刷新往往集中在某个时刻比如登录后拉取所有数据如果每次改一个叶子节点就立刻重算整条祖先链在红点数量上百时会有明显的性能开销。用dirty标记把重算推迟到真正读取状态的时候能省掉大量无效计算。calculator字段是给动态红点用的。有些红点的条件不是简单的布尔值而是需要根据当前游戏状态实时算出来的比如背包里是否有可升级的装备。这种红点不适合在数据变化时主动推送而是每次读取时现算。把计算函数挂在节点上读取finalState时如果发现是动态节点就调一下逻辑内聚。listeners是给UI层用的。红点最终要反映到界面上UI组件订阅自己关心的红点key状态变化时收到回调去更新显隐。用Set而不是数组是为了自动去重避免同一个组件重复订阅导致回调触发多次。2.3 树的构建与路径约定树的构建我推荐用路径注册的方式而不是手动new节点再appendChild。调用方只需要声明我要一个key为task.daily.sign的红点管理器自动把路径上缺失的中间节点补全。这样调用方不用关心树的结构只关心自己的红点路径。路径分隔符用.因为红点key通常来自业务模块的命名用.比用/更符合前端习惯也不会和URL路径混淆。注册逻辑大概是这样register(key: string, calculator?: () boolean): RedDotNode { if (this.nodes.has(key)) { return this.nodes.get(key)!; } const segments key.split(.); let parent: RedDotNode | null null; let currentPath ; for (let i 0; i segments.length; i) { currentPath currentPath ? ${currentPath}.${segments[i]} : segments[i]; let node this.nodes.get(currentPath); if (!node) { node this.createNode(currentPath, parent); this.nodes.set(currentPath, node); if (parent) parent.children.push(node); } parent node; } const leaf this.nodes.get(key)!; if (calculator) leaf.calculator calculator; return leaf; }这段代码有个细节中间节点也会被注册进nodesMap。这意味着你可以直接查询task.daily的状态即使它没有对应的UI。这在调试时特别有用你可以打印整棵树的状态快照一眼看出哪个节点导致父节点亮红点。注意路径注册时如果中间节点已经存在但被当成了叶子节点比如先注册了task.daily后来又注册task.daily.signtask.daily会自动从叶子变成中间节点。这个转换是透明的但要注意如果之前给task.daily设过calculator它仍然会生效最终状态是自身计算结果 OR 子节点状态。3. 状态计算的核心机制冒泡、脏标记与批量刷新3.1 向上冒泡的正确实现当某个节点的selfState变化时需要沿着parent链一路向上更新finalState。这里有个容易写错的地方很多人会写成递归更新所有祖先但没考虑如果父节点的finalState没变就不用继续往上这个优化。实际上这个优化是必须的否则在深层树里每次叶子变化都会触发O(depth)次无意义的重算。正确的冒泡逻辑是private bubbleUp(node: RedDotNode): void { let current node.parent; while (current) { const newFinal current.selfState || current.children.some(c c.finalState); if (newFinal current.finalState) { // 状态没变祖先也不会变直接终止 break; } current.finalState newFinal; this.notifyListeners(current); current current.parent; } }这个break是关键。假设task.daily.sign从true变false但task.daily下还有task.daily.achievement是true那么task.daily的finalState仍然是truetask的finalState也不会变。此时冒泡在task.daily这一层就终止了省掉了对task及其祖先的重算。但这里有个陷阱如果task.daily的finalState没变但它的dirty标记需要清理吗答案是需要的不过清理时机不在冒泡里而在读取时。冒泡只负责更新finalState和通知监听者dirty的清理放在getState里做。3.2 脏标记与惰性计算的配合dirty标记的语义是这个节点的finalState可能过期了下次读取时需要重算。什么时候会置脏两种情况一是子节点状态变化但冒泡被break截断了父节点finalState没变但子节点变了严格说父节点不需要重算但为了保险可以置脏二是动态节点有calculator的在外部数据变化时主动置脏。读取状态的逻辑getState(key: string): boolean { const node this.nodes.get(key); if (!node) return false; if (node.dirty) { this.recalculate(node); } return node.finalState; } private recalculate(node: RedDotNode): void { if (node.calculator) { node.selfState node.calculator(); } node.finalState node.selfState || node.children.some(c { if (c.dirty) this.recalculate(c); return c.finalState; }); node.dirty false; }recalculate是递归的会先确保所有子节点都是最新的再算自己的finalState。这样即使中间有多个节点被置脏一次读取就能把整条路径刷新到最新。这里有个性能上的考量如果树很深且大量节点被置脏一次getState可能触发大量递归。实际项目中我建议对动态节点做缓存即calculator的结果缓存一帧同一帧内多次读取不重复计算。可以用一个frameId标记每帧递增calculator结果带上frameId读取时如果frameId匹配就直接返回缓存。3.3 批量刷新与帧同步游戏里红点数据往往来自多个异步接口比如登录后同时请求任务列表、邮件列表、商城折扣信息。如果每个接口回来都触发一次冒泡会有多次无效的UI更新。更好的做法是提供一个batchUpdate方法把多个更新攒到一起在下一帧统一冒泡。batchUpdate(updates: Array{key: string, state: boolean}): void { updates.forEach(({key, state}) { const node this.nodes.get(key); if (node node.selfState ! state) { node.selfState state; node.dirty true; } }); // 统一冒泡从所有被修改的节点向上 const affectedRoots new SetRedDotNode(); updates.forEach(({key}) { const node this.nodes.get(key); if (node) affectedRoots.add(node); }); affectedRoots.forEach(node this.bubbleUp(node)); }这个批量更新在H5游戏里特别有用因为游戏的红点数据通常是在场景切换或弹窗关闭时集中刷新的。用requestAnimationFrame把冒泡推迟到下一帧还能避免在同一个事件循环里反复触发UI重排。实操心得我一般会在管理器里维护一个pendingUpdates队列setState只是把更新推进队列真正的冒泡在flush时执行。flush的触发时机有两个一是显式调用二是requestAnimationFrame回调。这样既保证了实时性又避免了频繁重算。4. 与UI层的对接订阅、解绑与常见坑4.1 订阅模式的选择UI组件要感知红点状态变化有两种模式推和拉。推是组件订阅某个key状态变化时管理器回调通知拉是组件在需要的时候主动调getState。实际项目中两者要结合初始化时用拉获取当前状态之后用推接收变化。订阅的API设计成subscribe(key: string, callback: (state: boolean) void): () void { const node this.nodes.get(key); if (!node) { console.warn([RedDot] 订阅了未注册的红点: ${key}); return () {}; } node.listeners.add(callback); // 立即回调一次当前状态避免组件初始化时状态不同步 callback(this.getState(key)); return () { node.listeners.delete(callback); }; }返回一个取消订阅的函数这是ReactuseEffect和VueonUnmounted里最顺手的模式。注意订阅时立即回调一次当前状态这个细节能避免组件挂载时红点不显示要等下次状态变化才显示的bug。4.2 组件封装与生命周期管理在Vue里可以封装一个useRedDot的composablefunction useRedDot(key: string) { const visible ref(false); let unsubscribe: (() void) | null null; onMounted(() { unsubscribe redDotManager.subscribe(key, (state) { visible.value state; }); }); onUnmounted(() { unsubscribe?.(); }); return { visible }; }React里对应的是自定义Hook用useState和useEffect。这里的关键是必须在组件卸载时取消订阅否则组件销毁后回调还在触发轻则内存泄漏重则报setState on unmounted component的警告。我踩过的一个坑是在列表项里订阅红点列表滚动时项被复用比如虚拟列表如果订阅没跟着项的生命周期走会出现红点显示在错误的项上。解决办法是订阅的key必须和项的数据绑定项复用时先取消旧订阅再订阅新key。4.3 红点动画与性能红点通常有个缩放或呼吸动画如果每个红点组件都跑一个CSS动画在红点数量多的时候会有性能问题。我的做法是用一个全局的CSS类控制动画所有红点共用同一个keyframes浏览器会做合成层优化。另外红点的显隐用visibility而不是display因为display: none会触发重排而visibility只触发重绘。还有一个细节红点上的数字比如未读邮件数更新时如果直接改textContent在快速连续更新时会有闪烁。可以用requestAnimationFrame节流或者用CSS的will-change: contents提示浏览器优化。5. 游戏场景下的特殊处理延迟刷新与跨模块通信5.1 为什么红点需要延迟刷新游戏里有个很常见的需求玩家领取奖励后红点不应该立刻消失而是等奖励飞入背包的动画播完再消失。如果红点立刻消失玩家会觉得我还没看到奖励飞过去红点就没了体验很割裂。这种延迟刷新不能简单地用setTimeout硬编码因为动画时长可能因设备性能而异。更好的做法是让红点管理器支持延迟队列调用setState时带一个delay参数管理器把更新推入延迟队列在指定时间后或某个事件触发时才真正执行。setState(key: string, state: boolean, delayMs: number 0): void { if (delayMs 0) { setTimeout(() this.applyState(key, state), delayMs); } else { this.applyState(key, state); } }但setTimeout有个问题如果玩家在延迟期间又触发了新的更新两个更新会竞争。比如先setState(key, false, 500)200ms后又setState(key, true)最终状态应该是true但延迟的false会在500ms时覆盖掉。解决办法是给每个key维护一个版本号延迟执行时检查版本号是否还是最新的。5.2 跨模块通信与事件总线红点数据往往分散在不同模块任务模块管任务红点邮件模块管邮件红点商城模块管折扣红点。这些模块之间不应该直接互相引用而是通过红点管理器这个中介来通信。我通常会在管理器里加一个简单的事件总线模块可以发布某类数据变了的事件管理器根据注册的规则决定哪些红点需要重算。比如邮件模块领取邮件后发布mail:read事件管理器监听到后把mail相关的红点全部置脏。emit(event: string): void { const affectedKeys this.eventMap.get(event) || []; affectedKeys.forEach(key { const node this.nodes.get(key); if (node) { node.dirty true; this.bubbleUp(node); } }); }eventMap在注册红点时通过配置建立比如register(mail.unread, { events: [mail:read, mail:new] })。这样模块之间完全解耦新增红点只需要改配置不用动业务代码。5.3 红点配置的持久化与热更新游戏上线后策划经常要调整红点规则比如把每日任务的签到红点改成只在有奖励可领时才显示。如果规则硬编码在代码里每次调整都要发版。我的做法是把红点的触发条件做成配置表运行时从服务器拉取。配置表的结构大概是字段类型说明keystring红点路径typeenumself / children / dynamicconditionstring条件表达式或calculator的IDeventsstring[]触发重算的事件列表prioritynumber同层多个红点时的显示优先级condition用简单的表达式引擎解析比如hasUnclaimedReward level 10。这样策划改条件不用改代码前端只需要保证表达式里用到的变量在计算时是可获取的。注意配置热更新要处理好版本兼容。如果新配置里删除了某个红点key但旧代码还在订阅它订阅会失败。我的做法是保留已删除key的节点但标记为deprecated订阅时返回false等下一个大版本再彻底清理。6. 调试与测试怎么确认红点逻辑是对的6.1 可视化调试面板红点逻辑的bug往往很难复现因为涉及多个模块的数据状态。我强烈建议在开发环境做一个红点树可视化面板把整棵树的结构、每个节点的selfState、finalState、dirty状态都打印出来。实现上可以用一个简单的递归遍历把树转成嵌套的JSON再用浏览器的console.group折叠展示。更高级一点可以做一个悬浮面板实时刷新点击节点能看到它的calculator源码和最近一次计算时间。debugDump(): void { const dump (node: RedDotNode, indent: number) { const prefix .repeat(indent); const mark node.finalState ? ● : ○; console.log(${prefix}${mark} ${node.key} (self${node.selfState}, dirty${node.dirty})); node.children.forEach(c dump(c, indent 1)); }; this.roots.forEach(r dump(r, 0)); }这个面板在排查为什么这个红点不亮时特别有用一眼就能看出是selfState不对还是子节点没冒泡上来。6.2 单元测试的写法红点管理器的核心逻辑是纯函数式的非常适合单元测试。测试用例要覆盖叶子节点状态变化后祖先是否正确冒泡、冒泡在状态不变时是否正确终止、动态节点的calculator是否被正确调用、批量更新是否只触发一次UI通知、延迟更新是否被新更新覆盖。我用Jest写测试时会mock掉requestAnimationFrame和setTimeout用jest.useFakeTimers()控制时间。一个典型的测试test(子节点变化时父节点正确冒泡, () { manager.register(a.b.c); manager.register(a.b.d); manager.setState(a.b.c, true); expect(manager.getState(a.b)).toBe(true); expect(manager.getState(a)).toBe(true); manager.setState(a.b.c, false); expect(manager.getState(a.b)).toBe(false); // d还是false expect(manager.getState(a)).toBe(false); });6.3 线上问题的排查思路线上红点出问题时第一件事是确认数据源是否正确。很多时候红点不亮不是管理器的问题而是上游接口返回的数据不对。我会在管理器里加一个trace模式记录每个红点最近N次的状态变化和触发来源出问题时把trace日志上报。trace日志的格式大概是[时间戳] key: oldState - newState, trigger: setState/calculator/event。有了这个排查红点为什么亮了或为什么没亮就很快了。7. 我在实际项目中踩过的几个坑第一个坑是订阅泄漏。早期版本里订阅返回的取消函数没有被组件正确调用导致页面切换多次后同一个红点有几十个监听者每次状态变化触发几十次回调UI卡顿。后来我在管理器里加了监听者数量统计超过阈值就打印警告帮我们定位到了几个忘记取消订阅的组件。第二个坑是动态节点的循环依赖。有个红点的calculator里调用了getState去读另一个红点的状态而那个红点的calculator又反过来读它导致栈溢出。解决办法是在recalculate里加一个calculating标记检测到循环时直接返回false并打印错误。第三个坑是批量更新的顺序问题。批量更新时如果先冒泡了父节点再更新子节点父节点的finalState会算错。正确的顺序是先把所有selfState更新完再从最深的节点开始冒泡。我后来改成先把所有受影响节点按深度排序从深到浅冒泡问题就解决了。第四个坑是红点与UI的时序。游戏切场景时旧场景的UI销毁和新场景的UI创建之间有短暂的空档如果此时红点状态变化新UI还没订阅就会错过这次变化。解决办法是在管理器里缓存每个key的最近状态订阅时立即回调当前状态前面提到的callback(this.getState(key))这样即使错过了变化订阅时也能拿到最新值。这套红点管理器从最初的两百行代码经过几个项目的迭代现在稳定在六百行左右支撑了上百个红点的管理。核心思路就是树结构 脏标记 批量冒泡 订阅通知没有特别复杂的算法但每个细节都经过实际项目的打磨。如果你正在做H5游戏的前端架构建议把这套东西尽早搭起来后期加红点就是注册一个key的事不用再改UI代码。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑