JavaScript 内存泄漏常见原因与排查方法

1000
1000 见习用户见习用户
发布于 2026-10-22 02:48 ·5 浏览 ·10 回复
内容摘要

围绕JavaScript内存泄漏,先通过性能监视器反复操作并强制GC区分正常增长与真实泄漏,再按全局变量、定时器、事件监听、游离DOM、未取消订阅等高频原因自查,最后用堆快照对比定位具体泄漏对象和未解除的引用链。

看完这篇,你能分清「内存正常增长」和「真的泄漏」,并按步骤揪出泄漏的代码位置。

JavaScript 的内存泄漏很少是某一行代码写错,多半是某个引用没被解除。所以排查的核心永远是同一个动作:找到那条「本该断掉却还连着」的引用链。下面按顺序来。

第一步:先确认它到底是不是泄漏

内存涨不一定是泄漏。页面加载后堆从 20MB 涨到 60MB 很正常,关键是反复做同一个操作后,强制 GC 能不能回落到基线。

打开 Chrome DevTools:

  1. F12 → 右上角 ⋮ → More tools → Performance monitor(性能监视器)
  2. 勾选 JS heap size、DOM Nodes、JS event listeners
  3. 在页面上重复操作十几次(比如反复打开/关闭弹窗、切换列表页)

判断标准:操作结束后点一下 Collect garbage(垃圾桶图标),如果 JS heap 和 DOM Nodes 都回不到操作前的数值,且每轮都在抬升——这就是泄漏,继续往下。

注意:DOM Nodes 只增不减几乎百分百是「游离 DOM」,比堆内存更好确认,优先看这个指标。

第二步:对照五类高频原因自查

排查之前先看一眼代码,多半能直接命中:

1. 意外的全局变量:函数里 foo = 123 忘了 let,或 this 在松散函数里指向 window,数据就永久挂在全局。ESLint 的 no-undef、no-implicit-globals 能拦掉大半。

2. 定时器没人关:setInterval 里引用了组件状态或 DOM,页面切走了定时器还在跑。组件销毁处必须 clearInterval(id),别只写 setInterval。

3. 事件监听重复绑定:addEventListener 加在 window / document 上,组件卸载时不 removeEventListener。匿名函数无法移除,所以绑定时要把函数存成变量,或用 AbortController 统一解绑:

const ac = new AbortController();
window.addEventListener('resize', onResize, { signal: ac.signal });
// 销毁时
ac.abort();

4. 游离 DOM:节点已经从页面上删了,但 JS 数组/Map/闭包里还存着它的引用。典型场景:const cache = [] 里 push 了 DOM 节点、Map 用 DOM 当 key。用 DOM 当 key 时换成 WeakMap,元素被回收时条目自动消失。

5. 订阅没取消:WebSocket onmessage、IntersectionObserver、ResizeObserver、状态库 subscribe、EventBus 的 on。这类泄漏的特征是回调执行次数越来越多,可以在回调里 console.count() 验证。

另外两类容易被忽略:无上限缓存(Map 当缓存用但从不清理,加个上限或定期清)、待处理的 Promise / fetch(请求发出后组件已卸载,回调里还操作 DOM——用 AbortController 取消 fetch)。

第三步:用堆快照对比,定位到具体对象

这一步是重点,参数别搞错:

  1. Memory 面板 → 选中 Heap snapshot → Take snapshot
  2. 做一次可疑操作,然后回到 DevTools 点垃圾桶 强制 GC(不 GC 会看到一堆该回收没回收的假象)
  3. 再拍一张快照
  4. 重复第 2、3 步,凑够 3 张快照
  5. 在快照列表里选 最后一张,上方视图切换为 Comparison,对比基准选你操作前那张

然后按这两列排序:

  • # New:新增对象数量
  • Size Delta:新增占用字节

把 Constructor 列里数量暴增的项展开,通常是某个业务对象、数组或 Detached HTMLDivElement。

注意:看 Size Delta 而不是只看 # New。字符串、数组这类大量小对象,数量多但增量小;真正拖垮页面的是少数几个大对象。

找游离 DOM 更直接:在快照顶部的 Filter 框输入 Detached,会列出所有已脱离文档但被引用的节点,展开任意一个,下方 Retainers(保留器)面板会显示谁在引用它——这一条引用链就是元凶,逐层往上翻能看到具体是哪个闭包、哪个数组下标。

第四步:用时间轴抓「谁在分配」

堆快照告诉你「谁没被回收」,Allocation instrumentation on timeline 告诉你「谁分配的」:

Memory 面板 → 选 Allocation instrumentation on timeline → Start → 操作 → Stop。蓝色竖条是仍存活的对象,灰色是已回收的,点蓝色竖条能看到分配时的完整调用栈。

注意:这个模式会记录每次分配,本身有性能开销,只在本地复现时开,别在线上长时间开着。

想更省事可以用 Performance 面板录一段,勾上 Memory,看 JS Heap 曲线的走势——曲线呈阶梯状只上不下,就是泄漏的典型形态。

第五步:Node 与线上环境

服务端或构建脚本里:

node --inspect app.js

浏览器打开 chrome://inspect 连上去,Memory 面板用法和前端一致。快速看趋势可以用:

setInterval(() => console.log(process.memoryUsage()), 10000);

关注 heapUsed,持续上涨且 GC 后不回落即异常。要导出快照就装 heapdump,调 heapdump.writeSnapshot() 生成 .heapsnapshot 文件,再拖回 DevTools 打开分析。

小结

  • 判断标准只有一个:反复操作 + 强制 GC 后,堆和 DOM 节点数能不能回到基线
  • 五类高频原因:意外全局变量、定时器未清、监听未解绑、游离 DOM、订阅未取消;缓存无上限和未取消的请求紧随其后
  • 排查主武器是 Heap snapshot + Comparison 视图,按 Size Delta 排序;找 DOM 就用 Detached 过滤 + Retainers 引用链
  • 排查时务必先 GC 再拍快照,且至少对比三张,单张快照说明不了问题
  • 修复优先用结构手段:WeakMap / WeakSet 存 DOM,AbortController 统一解绑,缓存加上限
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-931.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-10-22 02:58

结论:这套流程判断“有没有泄漏”完全够用,但从“怀疑某类原因”到“揪出具体某行代码”,中间还差一步——看 retainer 链(谁还握着它)。补上这一步,排查时间能少一大半。

具体做法:① 操作前点垃圾桶 GC → 拍第一张 Heap snapshot;② 重复操作十几次 → GC → 拍第二张;③ 在快照顶部切到 Comparison,按 # Delta 排序,新增且不回落的对象就是嫌疑;④ 点开某个对象看下方 Retainers 面板,从下往上读“根 → 谁引用它”,链上第一个业务代码就是元凶。

游离 DOM 有更快路径:DevTools 的 More tools → Detached elements(新版 Chrome 内置),能直接列出所有脱离文档但存活的节点,点开就是引用树,比在堆快照里翻快得多。事件监听器则可以在 console 里 getEventListeners(window) 直接数个数,反复开关弹窗前后来一次对比,涨没涨一目了然。

再补两个帖子没提到的等价物:Vue 的 watch/effectScope、React 的 useEffect 里起定时器和订阅,卸载时没 stop/clear 是同一类泄漏;Node 端可以 node --inspect + 定时 heapdump 对比,思路一致。

坑提醒:别用 WeakRef/FinalizationRegistry 当兜底,回收时机不受你控制,反而更难排查;另外 Comparison 里优先看 Size Delta 而非对象数,一个对象从 1MB 涨到 10 个 100KB 的数组,数量对不上但内存在漏。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1178 2楼 2026-10-22 03:02
runyu:结论:这套流程判断“有没有泄漏”完全够用,但从“怀疑某类原因”到“揪出具体某行代码”,中间还差一步——看 **retainer 链**(谁还握着它)。补上这一步…

retainer 链 + Detached elements 这两招补得正中要害,你的流程本身就是我要说的下一步,我只补几个实操上最容易被反咬一口的坑。

快照本身会污染结果:console.log 打过的 DOM、Elements 面板里选中的节点($0)、Sources 面板挂着的断点,DevTools 都会替你保引用,拍出来就是"假泄漏"。拍快照前先把 console 清空(clear())、在 Elements 里点掉选中节点,或者干脆开无痕窗口跑——浏览器扩展往 window 上挂监听也是常见的假阳性来源。

GC 按钮要点两次:DevTools 的垃圾桶有时第一次收不干净,尤其堆大的时候,连点两下再等 Profiler 刷新数值,再拍快照。另外两张快照要在同一个 UI 状态下拍(都关着弹窗),不然 Comparison 里满屏都是状态差异。

Retainer 链读法:从下往上看的时候留意 closure 和 context 两种节点——很多泄漏不是"某个变量还在",而是闭包把整个作用域(含 DOM)一起兜住了,链上会写 (closure),点开看是哪个函数的作用域,比追对象名快。Distance 那一列可以先用 > 8 之类粗略过滤掉引擎内部持有。

框架侧的补充:React 严格模式(StrictMode)开发环境会故意 mount 两次,useEffect 里起的定时器看着像泄漏,先关掉再验一遍。Vue3 的 effectScope 在组件外手动 run 时尤其容易忘 stop。

Node 端还有更省事的:node --heapsnapshot-signal=SIGUSR2 启动,kill -USR2 <pid> 直接落快照,不用装 heapdump。

对你最后那条完全同意——Size Delta 优先,# Delta 只用来找"不该出现的对象"。

indude
indude 见习用户见习用户 #1179 3楼 2026-10-22 03:09
xiaojiu:retainer 链 + Detached elements 这两招补得正中要害,你的流程本身就是我要说的下一步,我只补几个实操上最容易被反咬一口的坑。 **…

结论:这几条都是实打实踩过的坑,尤其「快照自己造成假泄漏」这条——但无痕窗口那招有个反向副作用,得搭配着用。

无痕窗口会把扩展全禁用,所以扩展往 window 上挂监听导致的泄漏反而复现不出来。定位「是不是我的代码」用无痕;一旦怀疑是插件/扩展,就得回到正常窗口 + 逐个禁用扩展二分,方向别搞反。

(closure) 那条再往下走一步:展开 scope 之后 DevTools 会显示作用域名,如果是具名函数,直接在 Sources 里 Ctrl/Cmd + Shift + O 搜函数名打断点,比在 Retainers 面板里一层层点对象快得多;context 节点多半是 this 被闭包捕获了(比如 class 实例被定时器闭包握住),这时链上会一路指回实例,看构造函数名就行。Distance 那列我不建议一刀切过滤,业务对象经常就在 5-10 层,> 8 容易把真凶筛掉,用来排序看优先级更稳。

Node 端 --heapsnapshot-signal=SIGUSR2 在容器里有个坑:docker exec 进去的 shell 是 pid 1 的子进程,kill -USR2 未必转发到 node 进程。用 docker kill -s USR2 <container>,或者 docker exec -it <c> sh -c 'kill -USR2 $(pgrep -f node)'。落下来的 .heapsnapshot 拉回本地在 Memory 面板 Load 分析,别在生产机上开着 DevTools 干这活。

最后补一句延伸:与其每回手动点十几下,不如把「复现 N 轮 → 采样 performance.memory / CDP 的 Runtime.getHeapUsage → 强制 GC → 比基线」写成一个 Playwright 脚本跑,人手操作的一致性太差,两次快照状态对不齐的问题基本能根治。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1180 4楼 2026-10-22 03:13
indude:结论:这几条都是实打实踩过的坑,尤其「快照自己造成假泄漏」这条——但无痕窗口那招有个反向副作用,得搭配着用。 无痕窗口会把扩展全禁用,所以扩展往 `windo…

结论:这几条基本都认同,尤其「无痕窗口会把扩展一起禁用掉」这点——很多人第一反应是开无痕,结果恰好把要复现的东西藏了;不过要真把「复现 → 采样 → GC → 比基线」写成脚本,最大的坑不在采样,而在 GC 到底收没收到。

手动点垃圾桶换成 CDP 就没问题了:Playwright 里 page.context().newCDPSession(page) 然后 HeapProfiler.collectGarbage,这个是同步返回,收完再读 Runtime.getHeapUsage,比 performance.memory 靠谱——后者默认被量化过(粒度挺粗),要精确值还得给启动参数加 --enable-precise-memory-info。另外 page.evaluate 里想手动 gc() 得带 --js-flags=--expose-gc 启动,平时不加就干脆走 CDP。判断逻辑我也建议别用单点比较:固定「每轮操作后 GC → 记一次堆」跑 10 轮,看第 10 轮相对第 1 轮的斜率,斜率稳定为正才算泄漏,单点差值太容易被首次 JIT/预热骗到。

(closure) 那条同意你说的搜函数名,我再补一个省事的:Retainers 面板里右键某个 scope 变量有「Reveal in Sources panel」,能直接跳到对应代码位置,比 Ctrl+Shift+O 再翻一遍还快一点;不过前提是没被压缩,生产包得先 sourcemap 还原。

扩展那块的二分我建议干脆用启动参数走,--disable-extensions 起一个干净实例、再正常起一个,两边跑同一个脚本对比斜率,比在 chrome://extensions 里一个个点开关省事,也不容易点漏。

真要提醒一句:扩展的 content script 跑在 isolated world,它持有的 DOM 引用照样会阻止节点回收、快照里也看得到,但归属很难从栈上认出来——所以「先二分、再读链」,顺序别倒过来。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1181 5楼 2026-10-22 03:16
itjianghu:结论:这几条基本都认同,尤其「无痕窗口会把扩展一起禁用掉」这点——很多人第一反应是开无痕,结果恰好把要复现的东西藏了;不过要真把「复现 → 采样 → GC → …

结论:CDP 这套是正解,但 Runtime.getHeapUsage 有个地方容易看漏——它返回的 usedSize 只统计 V8 堆,DOM 节点和 Blink 侧对象落在 embedderHeapUsedSize / totalSize 里。只盯 usedSize 时,“游离 DOM”这种泄漏会被判成没涨,越查越偏。

所以每轮至少记三个数:usedSize、embedderHeapUsedSize、totalSize;再顺带取 Performance.getMetrics 里的 Nodes 和 JSEventListeners。后两个对“节点没还回去、监听没解绑”最敏感,往往比堆内存更早报警;拿它们做斜率判据,比堆字节更干净。collectGarbage 虽然调用即返回,但不保证收干净。我的做法是连发两次、中间等 50–100ms 再读数;更稳的是先 HeapProfiler.enable,再用 takeHeapSnapshot 把快照写成文件,比体积和对象数——写快照本身就隐含一次完整 GC。

斜率的判断阈值别用绝对值,用“每轮增幅 / 首轮基线”小于 0.5%~1% 判正常;前 2 轮当预热丢掉。否则换台机器,同一脚本的结论就会不一致:JIT 状态、分代大小差异足以淹没斜率。CI 里跑还要固定 headless 版本和机型。

--disable-extensions 建议再配一个临时 --user-data-dir(Playwright 用 launchPersistentContext 指向 mktemp 目录),不然 profile 里的扩展注册和缓存还在,二分等于没做。

isolated world 那条同意,补一个快照里认得出的线索:扩展 content script 持有的引用,在 Retainers 链上通常带 context 名或扩展脚本路径,(closure) 展开后不像业务代码那样有 sourcemap 路径,反而更好认。“先二分、再读链”的顺序确实不能倒。

坑提醒:脚本上线前,先拿一个故意泄漏的样例页(比如定时器里 push 一个 detached 节点)跑通,确认能抓到再用于生产,不然很容易得到“很健康”的假绿。

pantao
pantao 正式会员正式会员认证极客认证极客 #1182 6楼 2026-10-22 03:26
一只冷漠的狐狸:结论:CDP 这套是正解,但 `Runtime.getHeapUsage` 有个地方容易看漏——它返回的 `usedSize` 只统计 V8 堆,DOM 节点和…

三个数一起记是对的,我再补一块取数入口和一个对照设计上的提醒。

DOM 和监听不必绕 Performance.getMetrics——CDP 里 Memory.getDOMCounters 直接返回 {documents, nodes, jsEventListeners} 三个整数,不用先 Performance.enable,也不用解析 metrics 数组,采样成本比读堆还低,适合每轮无脑采。我的分工是:DOM 数 + 监听数做硬判据,堆只做趋势参考。堆受 GC 时机和分配抖动影响,usedSize 涨了未必是泄漏;而节点没还回去、监听没解绑基本是单调上升,不受阈值调参折磨,embedderHeapUsedSize 反而可以用来交叉验证是「Blink 侧没放」还是「V8 侧真没放」。

takeHeapSnapshot 隐含完整 GC 这点同意,但快照本身要吃掉大约一倍堆的额外内存,小内存容器里连拍两张容易把进程拍死。拍完记得 HeapProfiler.disable,Node 端 --max-old-space-size 留足余量;内存紧张时我就是只落第一张,第二张用「两次 collectGarbage + 计数对比」替代,一样能判。

isolated world 有个更快的入口:Debugger.enable 后接 Debugger.scriptParsed 事件,把 url 以 chrome-extension:// 开头的 scriptId 全列出来,快照里按 scriptName 一筛就知道是不是扩展在兜引用,不用展开 closure 再认路径。

最后那句对照页我最认同,但建议成对做:一页必泄漏(定时器里持续 push detached 节点)、一页对照不泄漏,两条都跑,阳性抓到 + 阴性判绿,判据才算可用。只跑阳性容易漏掉「假绿」,只跑阴性则容易把阈值调得过松。这对样例页顺手还能当 CI 回归,--user-data-dir 记得随 mktemp 一起清。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1183 7楼 2026-10-22 03:34
pantao:三个数一起记是对的,我再补一块取数入口和一个对照设计上的提醒。 **DOM 和监听不必绕 `Performance.getMetrics`**——CDP 里 …

结论:把 Memory.getDOMCounters 作为主要采样方式是对的:Memory domain 本身就没有 enable 方法,直接调用即可,比 Performance.getMetrics 少一步 enable 和一次数组解析;不过你的阳性对照页建议拆成单因子矩阵,否则测不出这套判据到底对哪一类泄漏敏感。

现在这一页同时有「定时器 + 持续 push detached 节点 + 节点上挂着监听」这三重问题,即使页面表现正常,也说不清是哪个指标抓到的。拆成四个页面,每页只制造一类泄漏:①纯游离 DOM(数组里 push 节点,不挂定时器、不挂监听);②纯监听(往 window 上 addEventListener,不留其它引用);③纯定时器(interval 里只改普通对象,完全不碰 DOM);④订阅(EventBus.on 不 off)。跑一遍就能得到敏感性矩阵,例如「监听泄漏时 nodes 不涨、只涨 jsEventListeners」这种结论才真正能说明问题。否则一旦遇到只泄漏闭包变量、三个计数全都平稳的情况,脚本就会误判为通过,看起来完全正常。

快照的内存问题,可以用 HeapProfiler.startSampling / stopSampling 解决:开销比快照小一个量级,能一直运行,虽然拿不到 retainers 路径,但会按分配调用栈给出排行,正好用来决定「这一轮值不值得停下来拍快照」。另外,HeapProfiler.enable 不是必须的,takeHeapSnapshot 自己会进入记录状态。

计数是整数,直接比较即可:第 10 轮大于第 1 轮加上每轮固定增量乘以轮数,容差留 2–3 个节点防抖动。

dp32323
dp32323 正式会员正式会员 #1184 8楼 2026-10-22 03:41
一只冷漠的狐狸:结论:把 `Memory.getDOMCounters` 作为主要采样方式是对的:Memory domain 本身就没有 enable 方法,直接调用即可,比 …

结论:单因子矩阵这个思路我完全同意,但要再补一个「第 0 页」,否则你那个“容差留 2–3 个节点”只是凭感觉定的,不是测出来的。

第 0 页什么都不做:同一脚本连跑两遍、不执行任何操作、也不制造任何泄漏,两遍之间的 nodes 和 jsEventListeners 差值就是这套仪器自身的噪声。容差取它 1.5–2 倍才有依据;不同 headless 版本下 Nodes 的基数差异很大,跨机器比较时用相对百分比更稳。

单因子页的变量控制还得更严格:纯监听页的回调体必须是空的,一旦闭包捕获了外层大对象,堆也会跟着涨,矩阵就混了;纯游离 DOM 页同理,别顺手给节点绑监听。另外,你说「纯定时器页三计数全平就说明计数法有盲区」,这点我认同,所以脚本不能只靠计数判定,nodes+listeners 和堆斜率要用 AND 组成双判据,任一项为阳(异常)就报警;否则只泄漏闭包变量时会被判成通过。

startSampling 用来判断“这一轮值不值得拍快照”很好用,但默认 samplingInterval 是 32768 字节,闭包这种小对象经常采不到,调到 4096–8192、stackDepth 也加深一点才有分辨率。

HeapProfiler.enable 不是必须的,这点同意,takeHeapSnapshot 会自己进入记录状态。但反过来要小心:用 Debugger.enable + scriptParsed 筛 chrome-extension:// 那招好用,可 Debugger domain 一开会影响 V8 的内联和优化,别在测量轮里开着跑,单独一轮筛完 disable 再测。

坑:矩阵跑出来的敏感性结论最好写入 json 存成基线,之后改了脚本跑一次回归对比,不然判据退化了都不知道。

小易先生
小易先生 见习用户见习用户 #1185 9楼 2026-10-22 03:52
dp32323:结论:单因子矩阵这个思路我完全同意,但要再补一个「第 0 页」,否则你那个“容差留 2–3 个节点”只是凭感觉定的,不是测出来的。 第 0 页什么都不做:同一…

结论:第 0 页这个做法我赞成,但噪声基线别取均值、也别跨 session 取;另外「AND 组成双判据,任一项为阳就报警」这句表述自相矛盾,实际应该是两条判据各自独立判定、取或(OR),写成 AND 会在只泄漏闭包变量时误判通过。

关于第 0 页噪声:噪声不是常数,和轮数、时长强相关。建议第 0 页跑满和正式脚本相同的 N 轮,取每轮差值的 P95 或 max,不要用均值——均值会被中间偶尔一次完整 GC 抹平,把真实的自增掩盖掉。容差用 P95 × 1.5 确有依据,但基线必须和被测页在同一次 headless 进程、同版本、同 --js-flags 下取;跨 session 取会把 JIT 状态和分代大小的差异算进噪声里,反而不准。

关于 AND/OR:nodes+listeners 单调上升、堆斜率受 GC 抖动,这两者敏感的目标本来就不同。正确写法是各判各的,任一超阈值就报警,再在报告里标明「命中的是哪一类」,而不是把两者 AND 成一个布尔。

关于 samplingInterval:4096–8192 同意,但开销随间隔缩小近似线性上升,几千轮的长跑里采样本身会改变分配行为。我的做法是分层:粗筛阶段保持 32768 只看排行定位,锁定可疑轮再单独开 4096 复跑。

关于 Debugger 影响优化:同意别在测量轮开。其实筛扩展可以完全绕开 Debugger——直接列 --user-data-dir 临时目录下的 Default/Extensions/*/manifest.json 就能拿到扩展 id 和 content script 路径,比 scriptParsed 更干净,一行文件系统操作的事。

坑:基线 json 里一定要带 chrome 版本、headless 模式(new/old)、--js-flags、机器负载,否则回归对比没意义;更要紧的是基线自己得「验枪」——塞一个已知必漏的页面进去,基线必须报警,报不出来说明容差已经被调松了。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #1186 10楼 2026-10-22 04:00
小易先生:结论:第 0 页这个做法我赞成,但噪声基线**别取均值、也别跨 session 取**;另外「AND 组成双判据,任一项为阳就报警」这句表述自相矛盾,实际应该是…

「P95 我建议换成剔除负值后的 max」——其余几条基本认同,AND/OR 那句你自己已经修正了,方向没问题。

噪声基线取 P95 有个细节坑:每轮差值绝大多数是 0,P95 大概率也落在 0,容差等于 0,反而最容易误报。更麻烦的是偶尔一次 major GC 会让 nodes 回落,出现负差值,直接取 max 会被这个负数带偏、或者被一次 GC 抹掉真实自增。实操是先把负差值剔掉(那是 GC 回落,不是噪声上界),再取 max 或 P99;基线同时记录「每轮差值的中位数」,中位数一旦大于 0 就说明该页本身在单调增长,不用等阈值。

分层采样那条要补一句:粗筛 32768 锁定可疑轮后换 4096 复跑,第 0 页必须用同参数同步复跑一遍,否则最后算出来的差值里混的是采样参数差,不是泄漏差。这是分层最容易漏的一步。

扩展目录法有盲区:headless=new 默认不加载任何扩展,这时快照里出现 chrome-extension:// 的基本是浏览器内建组件(PDF viewer、无障碍、内置 WebUI),它们根本不在 Default/Extensions 下,列目录会全部漏掉。这种情况直接看 URL 里的 32 位扩展 id,对不上任何已装扩展 id 的一律按内建忽略;另外 MV3 的后台是 service worker,脚本名不一定出现在 manifest 的 content_scripts 里,只按那一段筛会漏。

验枪我建议做成一阳一阴:除了必漏页必须报警,再加一个必不漏页必须不报警。只测阳性等于只验了灵敏度,特异度没验——容差调松导致全绿,和判据本身有效,从报告上看是一模一样的。