History API导航埋点事件的幂等处理
直答pushState、replaceState 不触发 popstate只有前进后退才触发。导航埋点重复多半是把路由变化在多个钩子里各报一次。幂等要做三层队列去重、事件标识去重、服务端去重。先把事件模型拆开看再谈怎么去重。单页应用靠 History API 在同一个文档里改地址URL 变了文档没重载埋点系统因此失去了多页应用那种整页刷新的天然切割点。同一次导航可能被不同的钩子重复捕获事件次数就这样一点点膨胀起来。否则很容易一上来就写个时间窗结果发现重复根本不在同一时刻时间窗拦不住。MDN 的 History API 文档把会话历史、pushState、replaceState、popstate 讲得很清楚先对着它把三种导航路径理一遍。这事为什么值得单独写因为导航类事件是 PV、页面停留、路径分析的地基。地基一旦重复上面所有按页面、按路径算出来的数都会偏。History API 的三种导航分别触发什么其一代码里调用 history.pushState()。它往会话历史栈里压一条新记录地址栏变了但 popstate 事件并不会因此触发。这意味着如果你只在 popstate 里监听导航用户点客户端跳转你一条都收不到。其二history.replaceState()。它替换当前历史记录条目同样不触发 popstate。常见于改查询参数、修正栈里脏地址的场景它和 pushState 一样是静默的。其三用户点浏览器前进后退或代码调用 history.back()、history.go()。这时才真正在同一文档的两条历史记录之间切换popstate 才会触发event.state 里带着那条记录当初写入的 state 对象。把这三条摆在一起重复上报的根因就浮出来了不少团队在 pushState 的封装函数里手动报一次导航又在 popstate 回调里报一次。用户点客户端前进时popstate 触发、回调再报一次封装函数里那次和回调那次叠加同一次导航就变成了两条。更糟的是不同路由库、不同业务模块各写各的监听谁也不知道别人报了没有。pushState、replaceState 与前进后退两条路径下各自触发的事件入口层上报队列怎么在客户端去重短窗去重的正确写法入口层防线放在内存里目标是同一处逻辑被同步触发多次时只放行一次。Web SDK 普遍有一个全局上报队列以本文接入的 SDK 为例下文统一用 trackQueue 这个示意变量名真实队列名以各 SDK 接入文档为准分类、名称、属性三段结构不变。去重就插在 push 进队列之前。做法是给每次导航记一个上次放行的时间戳按真实时间差判断而不是把时间整除分桶。下面是一段示意代码同一 key 距离上次放行不足 30ms 才丢弃这才是真正按时间差滑的窗口。// 示意代码导航事件客户端短窗去重按真实时间差判断 const lastSentAt new Map(); // key - 上次放行的时间戳(ms) const WINDOW 30; // 同一导航 30ms 内只放行一次 function reportNav(category, name, props {}) { const key props.page_path || location.pathname; const now performance.now(); const last lastSentAt.get(key); if (last ! undefined now - last WINDOW) { return; // 距上次放行不足 30ms丢弃 } lastSentAt.set(key, now); // 只保留最近若干条Map 按插入序超出就删最早的一条避免无限膨胀 if (lastSentAt.size 200) { const oldest lastSentAt.keys().next().value; lastSentAt.delete(oldest); } trackQueue.push([event, category, name, props]); }这一层便宜、见效快能拦住封装函数报一次、回调又报一次的大部分同步重复。但它有两个边界一是页面一刷新内存清空跨会话的重复管不着二是它只认路径和时间差拦不住路由 A 跳到 B又被另一个模块记成 B 的打开这种跨来源重复。标识层事件标识怎么设计才算幂等标识层是给每次导航一个稳定 id随事件一起上报。有了这个 id不管客户端因为什么原因发了两遍接收方都能认出这是同一条。事件三段结构里分类和名称是固定的id 放进属性里即可属性支持中文键名也可以省略示意如下。去重层级发生位置判重依据能拦什么拦不住什么队列去重客户端内存路径短时间窗 key同一处同步多次触发刷新后、跨回调来源事件标识去重客户端生成、随事件上报导航稳定 id重试、补报导致的重复id 本身生成不一致服务端去重接收与落库环节事件 id 去重约束多端并发、迟到补报客户端逻辑缺陷本身id 怎么生成才稳别用 Date.now()那是挂钟时间戳重试时会因系统校时而跳动。要用一次导航这件事本身的特征来生成比如路由库里那一条历史记录创建时发一个 uuid之后不管 pushState 封装、popstate 回调、还是失败重试都复用这同一个 id。事件分析按属性回看时这个 id 就成了核对重复的钥匙配合事件定义维护哪些属性该带 id、哪些可省略都能沉淀成团队约定。兜底层服务端怎么兜底兜底层不是优先要动的环节但必须有。当客户端可能因为断网补报、多端并发、重试逻辑而把同一条导航发多次时服务端按事件 id 建去重约束落库重复的直接丢弃、不重复计数。它保证的是最终一致而不是省带宽。要注意的是这一层只能让重复不被重复计数它并不能反过来证明客户端逻辑是对的——客户端漏报的导航服务端再怎么去重也补不回来。这里有个节奏问题要拿捏客户端能在内存里拦掉的重复就不要都压到服务端。否则服务端每天要为大量早已被内存拦掉的请求做判重成本和延迟都上去了。三层防线是递进的——内存层省带宽标识层保语义一致服务端层兜极端情况。事件分析与事件管理适用于按事件名、属性回看和维护事件定义需要落到自有明细仓库按 id 判重时再结合导出数据另行处理。队列去重、事件标识去重、服务端去重三层防线如何逐级兜底一次真实线上事故现象某个单页页面上线后PV 比业务预估高出近一倍而实际访问量并没有这么多。根因路由库的 afterEach 钩子里报了一次导航同时团队自己又在 window 上监听 popstate 报了一次。用户点客户端前进时afterEach 与 popstate 各触发一次同一次跳转被记成两条。排查证据在456数据分析后台的事件分析里按页面路径分组发现同一路径在极短间隔内成对出现把上报前的 key 打印出来比对两条事件的导航目标完全一致。修复方式统一只在路由库的 afterEach 里报一次popstate 回调里不再重复上报并给每次导航生成稳定 id服务端按 id 去重。经验先把谁负责报导航收敛到单一入口再谈去重。入口不收敛任何时间窗都只是在打补丁。几个容易问的点pushState 本身会触发 popstate 事件吗不会。调用 history.pushState() 或 history.replaceState() 不会触发 popstatepopstate 只在点击前进后退按钮、或代码调用 history.back()、history.go() 在同一文档的历史条目间切换时才触发。导航埋点为什么会重复上报常见于把路由变化同时监听在多个钩子上pushState 封装函数里报一次popstate 回调里又报一次浏览器前进后退时两者叠加。一次真实导航因此被记成两到三条事件PV 与事件次数都会虚高。事件标识去重和队列去重有什么区别队列去重发生在客户端内存里进程一刷新就失效事件标识去重是给每次导航生成一个稳定 id随事件一起上报服务端按 id 判断是否已收过。前者省带宽后者保证跨会话、跨页面的最终一致。服务端去重什么时候才需要当客户端可能因为重试、离线补报、多端并发上报导致同一条导航多次到达时才需要服务端按事件 id 做去重落库。它是末层兜底不是优先要动的环节客户端能在内存里拦掉的就不要都压到服务端。数据来源MDN Web Docs《History API》MDN Web Docs《popstate 事件》对应工具官网Web SDK 接入与事件分析说明总结History API 导航埋点的幂等本质是先认清事件模型——pushState、replaceState 静默popstate 只在历史条目切换时触发重复往往来自多个钩子各报一次。在此之上建三层防线客户端队列按真实时间差短窗 key 拦同步重复事件用稳定 id 拦重试与补报服务端用 id 去重约束兜底极端并发。导航数据干净了PV、路径、停留这些上层指标才站得住。