☰
轻量级前端事件聚合器:从请求合并到批量提交的工程实践
2026/10/21 13:23:02 网站建设 项目流程

说个事儿。最近我在整理一个自己攒了大半年的前端工具模块,代号叫 rea。全称是 Realtime Event Aggregator,实时事件聚合模块。它不依赖任何第三方库,整体体积压缩后不到 3KB,却解决了我这边业务代码里最头疼的一类问题:各种事件满天飞、重复上报、顺序错乱、接口被刷。

说起来挺有意思,当时我负责的一个运营后台改版,用户点击一次“加入购物车”,结果同一个操作被三个模块各自监听了一遍,后端日志里同一条 payload 出现了三次。更魔幻的是,因为测试环境还残留着旧版埋点代码,同一条点击事件在服务端产生了七条近似重复的记录。从那天起我意识到,业务代码里的“事件出口”如果不做统一收敛,迟早会出大事。rea 就是在这样的背景下被我一点点磨出来的。

这篇文章我会把它拆开讲:为什么设计成现在这样、核心数据结构怎么组织、怎样用最小成本接入、以及我踩过的几个坑。如果你手头也有一个事件特别碎、请求特别频繁的业务项目,这份经验应该能帮你省不少时间。

1. rea 解决了什么问题:一次真实的事件处理事故

1.1 一场由三次监听到的重灾区

先还原一下事故发生的过程。我们当时有个商品详情页,页面里同时存在三个模块:

  • 购物车模块需要监听“加入购物车”按钮,跳转购物车。
  • 埋点模块需要监听同一个按钮,上报用户行为。
  • 推荐位模块也需要监听这个按钮,用来刷新推荐位数据。

三个模块互不知情,各自写了一段addEventListener/onClick。点击一次按钮,三个模块各发一次请求,后端收到 3 份几乎一模一样的请求体。排查那天我做了三件事:

  1. 在后端接入层加了临时访问日志,确认来源是同一个 IP、同一时刻。
  2. 前端打开浏览器开发者工具的 Network 面板,看到 3 个独立请求,payload 完全相同。
  3. 在代码里全局搜索“add-cart”关键字,定位到三个监听者。

问题的本质不是这三个模块写错了,而是没有统一的事件出口。高频滚动场景更夸张:列表滚动时,滚动事件每秒触发约 60 次,业务代码里虽然写了 300ms 的节流,但节流函数散落在十几个组件里,有的没生效,有的重复生效,最后后端每分钟要抗上万次无效请求。

1.2 rea 的定位:只做事件汇聚与出口收敛

所以我写 rea 时给自己定了个原则:它不处理业务逻辑,不做事件分发,不抢框架该干的活。它只负责一件事——把业务代码里散落的事件统一推入一个汇聚层,按窗口合并、去重、排序,最后批量提交给后端或其他消费者。

你可以把 rea 理解成小区楼下的快递收发站。以前每个快递员都直接上楼敲门,每天敲几十次,你烦不胜烦。现在所有快递先放到收发站,整理合并后再一批送到你手上。rea 就是这个收发站,抗住高并发事件的冲击,同时让接收方(接口)保持清静。

rea 我平时在项目里这样用:

  • 统一上报用户行为埋点。
  • 合并多个模块对同一个实例的状态刷新请求。
  • 批量提交日志、监控数据和消息推送事件。
  • 在接口网关前充当一层“流量整形缓冲”,降低下游压力。

适合参考这套方案的人,主要是被“事件散乱、请求频繁、重复提交”折磨的中大型前端项目,以及想自己写一套轻量级事件治理层的开发者。如果你只是想快速给单个按钮加个防抖,那用不上 rea,直接写一行debounce就够了。

1.3 为什么没有直接选择现成方案

在决定自己写 rea 之前,我认真评估过几类现成方案,也踩过一些坑:

方案优点实际遇到的问题
RxJS功能强大,能处理复杂的响应式事件流团队大部分同事不熟悉响应式编程,代码 review 阻力大;引入体积偏大
各类埋点 SDK开箱即用,自带上报能力和具体的数据平台强绑定,后端接口不是同一套协议时很难适配
各组件里自己写防抖/节流上手简单没有统一出口,逻辑重复、参数不一致,出问题后排查成本极高
rea轻量、无依赖、行为透明,业务代码几乎零改动接入需要自己维护,极端复杂场景的编排能力比 RxJS 弱

我选择 self-build 的核心原因是:我希望业务代码的“推送姿势”不变,只把“出口路径”收拢。RxJS 很强,但对一个以传统业务代码为主的项目来说,引入响应式范式等于改变所有人的心智模型,这是巨大的隐性成本。rea 只需要约定一个事件长什么样,业务方照常写逻辑,剩下的合并和提交全部交给我这套聚合层。

2. rea 的核心设计拆解:聚合、去重、排序、批量提交

2.1 核心数据结构:buffer 队列与合并表

rea 的内部结构不复杂,核心是几个对象各司其职。我把它简化成一个类来理解,关键字段就四块:一个待提交的事件缓冲队列、一个合并用的 Map、一个定时器句柄、一个飞行中(inFlight)标志位。

type ReaEvent = { type: string; // 事件类型,同类语义事件共用同一个 type key?: string; // 聚合去重用的 key,缺省时用 type 代替 payload: unknown; // 实际数据 timestamp: number; // 事件发生时间 priority?: 'low' | 'normal' | 'high'; // 优先级,默认 normal }; type ReaOptions = { windowMs?: number; // 聚合窗口,默认 500ms batchSize?: number; // 单批最大条数,默认 20 dedupKey?: 'id' | 'type' | 'key' | 'none'; // 去重维度 retry?: number; // 失败重试次数,默认 0 maxPending?: number; // 缓冲上限,默认 1000 debug?: boolean; // 是否打印合并明细 };

buffer 队列是事件进入汇聚层后的临时停放区,所有 push 进来的事件先落在这个数组里。合并用 Map 是因为 Map 在按 key 覆盖时特别方便,时间复杂度可以压到 O(1)。我全程没有引入任何第三方集合库,一个原生 Map 就把去重合并搞定了。

2.2 聚合策略:时间窗口、按 key 合并、优先级排序

聚合的核心是时间窗口。默认 500ms 内,相同 key 的事件只保留一条,保留最后传入的那一条,窗口结束统一触发提交。这个设计有个生活化类比:好比洗衣机,你不会一件衣服洗一次,而是攒到一定量,一锅洗。对接口来说,一次批量请求远比几十次单条请求友好。

优先级排序的逻辑是这样的:

  • low事件参与正常窗口聚合,能省请求就省。
  • normal事件作为默认值,进窗口。
  • high事件不等待窗口,立即触发一次 flush。

但 high 事件一多,频繁 flush 会让窗口形同虚设,这是我在实际使用中踩过的坑,后面会详细讲。

合并阶段,我会遍历 buffer,把 key 相同的事件用后一条覆盖前一条,然后按照优先级排序。下面是我在 rea 里用到的核心合并逻辑片段:

private merge(events: InternalEvent[]): InternalEvent[] { const map = new Map<string, InternalEvent>(); const priorityMap = { high: 2, normal: 1, low: 0 } as const; for (const event of events) { const mergeKey = event.key ?? event.type; map.set(mergeKey, event); } return [...map.values()].sort((a, b) => { return priorityMap[b.priority ?? 'normal'] - priorityMap[a.priority ?? 'normal']; }); }

2.3 异步批量提交机制:串行 flush 与失败重试

事件合并完,最终要通过一个发送器(sender)交出去。我设计的发送器是一个返回 Promise 的函数,调用方可以自由决定是发 fetch 请求、调用本地接口封装还是写入 IndexedDB。

最关键的机制是串行化。如果前一个 flush 还没结束,下一个 flush 已经开始,就会导致前后两批事件顺序错乱。所以我在 rea 里加了一个inFlight标志位:

private async flush(): Promise<void> { if (this.inFlight) return; const events = this.buffer.splice(0, this.options.batchSize); if (events.length === 0) return; this.inFlight = true; try { await this.sending(events); } catch (err) { if (this.options.retry && this.options.retry > 0) { await this.retrySend(events, this.options.retry); } } finally { this.inFlight = false; if (this.buffer.length > 0) { this.flush(); } } }

为什么这里用splice而不是清空整个数组?因为缓冲区可能超过 batchSize,一次只取出一批,剩下的下一轮再处理,避免一次性发送太多导致接口超时。另外,finally块里递归调用 flush 保证了不会因为一次失败就把后面的事件堵死。retry 则采用了简单的指数退避,第一次失败等 200ms,第二次等 400ms,第三次等 800ms。

3. rea 的实操过程:从一个业务接入到完整配置

3.1 环境准备与最小接入步骤

rea 的依赖条件非常宽松,只需要一个原生支持 Promise 和 Map 的 JavaScript 运行环境,浏览器端、Node.js 16 以上都可以直接跑,无需安装任何 npm 包。如果你想把这份逻辑放到项目里,直接复制核心源码,或者封装成一个共享模块就行。

我建议的最小接入分四步走:

  1. 创建一个 rea 实例,配置聚合窗口和去重维度。
  2. 调用onSend注册一个发送器,告诉 rea 合并好的事件往哪发。
  3. 在业务代码里用push把事件推进聚合层。
  4. 启动时打开 debug 模式观察事件合并情况,平稳后再关闭。

第一步到第三步用代码写出来是这样的:

import { createRea } from './rea'; const rea = createRea({ windowMs: 500, batchSize: 20, dedupKey: 'key', retry: 3, debug: true, }); rea.onSend(async (batch) => { const resp = await fetch('/api/event-batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(batch.map(e => e.payload)), }); if (!resp.ok) { throw new Error('event batch send failed'); } }); // 业务原代码不变,只把上报动作改成 push rea.push({ type: 'click', key: 'add-cart', payload: { skuId: 'sku-123', userId: 'u-9527' }, });

第一次跑通你会发现一个立竿见影的效果:就算业务代码里同一个“点击加入购物车”事件被 push 了三次,只要 key 都是add-cart,在 500ms 窗口内最终只会提交一条。

3.2 关键参数与配置项对照

下面是 rea 的配置项和官方文档里不会写的“为什么这样设”,我按实测经验整理成表格:

参数默认值建议范围说明
windowMs500200 - 2000窗口太小,聚合效果不明显;太大,实时性变差
batchSize2010 - 50超过 50 后接口响应时间可能明显增加
dedupKey'none''key'/'id'/'none'按 key 去重适合业务类事件,按 id 适合需精确去重的场景
retry00 - 3超过 3 次容易造成下游重复数据
maxPending1000500 - 5000队列积压超过上限时要主动丢弃旧事件,保护内存
debugfalse生产环境必须关闭开启后每个 flush 都会打印合并明细,有助于排查

你可能会问,dedupKey什么时候用'none'?我举个例子:用户在一个页面里连续点击 5 次“领取优惠券”,每次点击都需要精确记录,这个时候就不能用 key 去重,否则会丢事件。正确的做法是dedupKey: 'none',每条事件都会被保留。还有一种折中方案,用 payload 里的业务流水号作为 key,这样同一个流水号重复提交时会被截住,但不同流水号的事件都能正常通过。

3.3 接入后的完整配置示例

下面是一个更贴近真实项目的配置。我们当时有一个活动页,既有高频的滚动行为埋点,也有低频的领券、分享、支付成功事件,它们应该走不同优先级:

const rea = createRea({ windowMs: 300, batchSize: 30, dedupKey: 'key', retry: 2, maxPending: 2000, debug: process.env.NODE_ENV === 'development', }); rea.push({ type: 'scroll', key: 'feed-scroll', priority: 'low', payload: { scrollTop: 1200, feedId: 'f-001' }, }); rea.push({ type: 'coupon_claim', key: 'claim-coupon', priority: 'normal', payload: { couponId: 'c-998' }, }); rea.push({ type: 'pay_success', key: 'pay-success', priority: 'high', payload: { orderId: 'o-10086' }, });

3.4 性能实测:聚合前后的差异

我们当时在低端安卓机上做了个简单测试,模拟用户连续滑动商品列表 10 秒:

指标未使用 rea使用 rea(300ms 窗口,按 key 去重)
期间发出的请求数43819
后端收到的重复 payload1110
接口平均响应时间420ms260ms
前端页面掉帧次数明显明显减少

请求数降到原来的约 1/23,主要原因有两点:一是窗口聚合把相同 key 的事件合并了,二是批量提交让原本十几条的小请求合并成一条大请求。接口 RT 降下来了,一是因为请求量少了,二是因为后端对单条小请求做频繁解析的开销没了。

4. 常见问题与排查技巧实录

rea 用久了之后,我总结出了几个高频问题。这些坑不是文档里会写的,都是实际业务中暴露出来的,建议直接对照排查。

4.1 事件重复导致接口被刷

现象:点击一次按钮,接口在前后端日志里能看到多次请求。

原因:业务代码里有多个模块同时监听了同一个操作,或者旧版本代码未下线导致重复埋点。

排查思路:把 debug 打开一次,看 rea 的合并明细。如果merge count一直是 1,说明事件只进来一次,问题出在 rea 之外的发送器没有去重;如果merge count大于 1,说明上游 push 次数过多。

解决办法:建议从入口收敛开始。业务代码里所有“加入购物车”按钮的行为都走同一个rea.push调用,别让每个模块独立监听。同时后端对带有幂等键的请求做唯一索引,作为兜底。

提示:不要只依赖 rea 去重就认为万事大吉。前端聚合层只能降低重复发生的概率,真正的高可靠去重必须放在后端做,要处理好幂等字段。

4.2 批次顺序错乱导致后发先至

现象:先发生的事件反而后写入数据库,最后统计的数据顺序颠三倒四。

原因:多个 flush 并发执行,没有串行排队。

排查思路:在 send 函数里打印batch[0].timestamp,观察时间戳是否逆序。如果确实逆序,多半是inFlight没有生效,或者发送器本身支持并发执行。

解决办法:确保flush中inFlight的判断足够严格。如果你扩展了发送逻辑,不要在一个 flush 未完成时手动调用另一个 flush。另一个容易被忽略的点是服务器时间戳和客户端时间戳不一致,这时不要盲目用服务端时间排序,建议在每条事件里带客户端时间戳,并在服务器端按这个字段排序。

4.3 内存持续增长

现象:页面运行两三个小时后,内存占用居高不下,甚至卡顿。

原因:debug 模式下每个事件都保存了一份完整 payload,没有定期清理;或者 buffer 积压过多但没有触发释放策略。

排查思路:使用浏览器 Performance 面板抓一次堆快照,看看是不是InternalEvent实例数量异常。如果内存里堆积了大量事件对象,说明maxPending没有生效,或者发送器长期处于 pending 状态。

解决办法:强制给缓冲队列设置上限,maxPending超出后直接丢弃最旧的事件。严格限制 debug 模式下日志的长度,只保留最近 50 条合并明细,不要无限追加。

4.4 问题排查速查表

问题可能原因怎么查解决办法
重复请求多处监听同一个操作debug 看 merge 次数统一入口,后端幂等
顺序错乱flush 并发检查 inFlight 逻辑串行化,带客户端时间戳
内存上涨debug 日志未清理堆快照分析生产关 debug,限制日志长度
同一个 key 吞掉独立事件dedupKey 配置太激进查看合并明细改 dedupKey 为 'none' 或按 id
高优先级事件太多导致频繁 flushpriority 使用不当统计 high 事件数量重新评估哪些事件真的需要 high
窗口期内同类型事件只保留一条用户连续操作确认业务是否需要每条控制 key 粒度,把唯一业务 ID 拼进 key

5. rea 的扩展空间与二次开发方向

rea 现在这套核心逻辑已经稳定使用了大半年,我还在持续扩展。跟大家分享几个我验证过可行的方向,如果你也想做自己的事件聚合层,可以沿着这些思路继续深化。

第一个方向是本地持久化。当网络断连或接口异常时,会把合并好的事件暂存到 IndexedDB 或 localStorage 里,等网络恢复后重新提交。这个能力对弱网场景特别实用,类似于快递收发站晚上关门后,快递还能暂存在柜子里,第二天再派送。

第二个方向是接入异常监控。我后来给 rea 扩展了一个中间件机制,在事件 push、merge、flush 三个阶段分别设置钩子,只要某个阶段出现异常,就能把当时的上下文、错误堆栈、事件内容一起串上,发给外部监控系统。这个思路很像给快递包裹加了一个定位芯片,不管哪个环节丢了都能溯洄。

第三个方向是跨端复用。rea 核心逻辑并不依赖浏览器 API,所以能打包成通用模块供小程序或 Node.js 服务端复用。我后来把同一份 rea 代码同时跑在浏览器和云函数里,服务端收到的数据协议完全一致,省掉了两套实现。

如果你也想动手写一个类似的东西,我建议从最小版本开始,不要一上来就搭脚手架。核心三个功能——队列缓存、窗口合并、批量提交——写成 200 行左右的类就够了。等真实业务跑起来再逐步添加强大特性,而不是一开始就背着一个重包袱。

注意:任何事件聚合层都只是服务侧的第一道屏障。真正要保证数据可靠到达,还得靠服务端消费方对重复事件做幂等处理,否则聚合层再完美,依然可能在链路末端出现脏数据。

我个人实际操作下来的体会是,这种小工具的核心价值不是“省了多少次请求”,而是它逼着我把“事件流”当作一等公民去设计。以前我代码里的上报、刷新、通知都是平铺直叙的,出了事只能靠肉眼搜监听器。现在所有事件都经过一个明确的收口点,看一眼 debug 日志就能知道整个流程经历了什么。这个改变比减少百分之多少的请求量,更让我觉得踏实。

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

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

立即咨询