1. rea是什么起因于一次说不清的线上卡顿大概三个月前我们某项目中台收到了一连串用户投诉都说首页打开后点按钮没反应、白屏时间长得离谱。坏就坏在说不清这三个字上——传统监控平台里的数字看起来都正常整体首屏时间中位数是1.8秒报错率不到0.3%JS异常寥寥无几。但用户就是觉得卡而且不是少数人是一个明确的小群体占比大约6%。我把那几天的性能数据翻来覆去地看发现一个以前没在意的问题所有统计都是全局平均值哪个页面慢、哪类接口耗时高仪表盘上一目了然可一旦追问是哪部分用户慢、他们跑在什么环境里、用的什么网络、什么设备、当时页面是什么版本就彻底抓瞎了。监控系统能回答发生了什么回答不了在谁身上发生的为什么单单是他们。这就是我这个rea方案诞生的直接起因。手动打了个小包全名Runtime Environment Analysis翻译过来就是运行时环境分析。它做的事很聚焦在业务代码跑起来的时候把每个性能样本附带上环境快照——设备型号、系统版本、浏览器内核、网络类型、内存水位、页面版本号和实验分组标记——再连同性能指标一起上报。服务端拿到这批数据后不再只算平均值而是按环境维度做聚类自动找出哪一群环境下的人特别慢慢在哪一段是网络、是渲染、还是脚本执行。这篇文章把rea从设计到落地中间折腾的完整过程写下来。它不是什么标准方案更接近一个偏实战的轻量思路适合被整体数据正常但局部用户很卡折磨过的前端团队也适合想给现有监控体系加一层环境归因能力、又不打算上一套重型可观测性平台的人参考。主题不大但踩坑不少后面的原理、代码和排错过程我都尽量给到细节。2. rea的核心原理与采集维度设计2.1 环境指纹把跑在哪变成可对比的数据rea最关键的第一步不是统计性能而是先解决还原现场的问题。我把它拆成两层来设计。第一层叫静态环境指纹。它在运行时通过平台自带API拿一批基本不会变的信息硬件层用navigator.hardwareConcurrency拿CPU逻辑核心数结合设备内存navigator.deviceMemory这个属性Chrome系支持个别浏览器会返回undefined需要给默认值。这两个值组合起来可以粗略划分低端机/中端机/高端机。我在项目里按cores 4 memory 2判定为低性能设备实际情况里这套阈值能圈住绝大多数中低端Android机。系统层用navigator.userAgent做解析不引第三方库就自己写了个几十行的解析函数提取出平台、系统版本、浏览器名和版本号。再用navigator.connection.effectiveType拿网络类型4g、3g、2g加上savedata开关来识别用户开了省流量模式。这里必须说明effectiveType给的是浏览器估算值并不精确反映实时带宽但在画像层面已经足够用了。第二层叫动态环境状态。它负责回答这台设备此刻健康吗。我在rea里每分钟采样一次这几项performance.now()取当前时间戳配合前置采样点计算主线程繁忙比例用performance.memory只有Chromium系支持读取已用JS堆内存采集设备电池等级和充电状态这部分用的是getBatteryAPI调用时会请求权限所以做了失败兜底拿不到就标记为unknown页面可见性状态区分用户是前台真实等待还是切后台回来看到的假慢。环境指纹组装完成后会做一次轻量哈希。哈希的目的不是拿来精确标识用户而是给服务端做环境聚类的粗分组键。同一批指纹相同或相近的样本会被归入同一个环境桶后面分析时直接按桶比较性能差异非常直观。2.2 性能消耗点从规范和浏览器API里挖数据环境信息只是侧写真正的核心还是性能数据。rea没有自研一套性能采集器而是围绕标准Performance API做封装。理由很朴素浏览器已经默默把每个资源的加载耗时、每次渲染的关键节点都记录好了与其发明轮子不如把这些规范里的审计日志读出来用。我在rea里重点采集四类指标它们正好对应一次页面从请求到交互的完整链路首次内容绘制和时间到首次交互。前者用PerformanceObserver监听paint类型拿到first-contentful-paintFCP后者是event类型下的first-input-delayFID。这两项代表用户对页面有没有反应的第一感知。最大内容绘制也就是LCP。监听largest-contentful-paint类型额外记录触发元素标签名和包含它的图片地址。这信息关键时刻救过我一次后面章节会展开说。不过LCP在rea里不是取最后一次上报而是跟踪整个生命周期里最大的那一次绘制实现上要不断对比更新。资源加载明细。监听resource类型把脚本、样式、图片、接口请求的name、duration、transferSize、protocol都收下来。这里有个坑跨域资源如果不带Timing-Allow-Origin响应头duration和transferSize会读不到我在采集层做了判别并打了标记避免脏数据混进统计。布局与长任务。监听layout-shift算CLS监听longtask记录超过50ms的脚本执行片段每个片段都带开始时间和持续时长。为了不过分影响性能本身rea的采集器只在页面进入hidden状态或者达到60秒时限时统一批量取一次数据而不是每当指标更新就立刻上报。前端只做记录者把原始条目暂存在队列里避免高频回调挤占主线程。2.3 变化集成版本、发布与A/B标记静态环境和性能数据都有了之后我意识到还缺一环同一套代码不可能在所有环境里一样快发布节奏也是变量。某个版本改坏了低端机渲染路径这种事在监控面板上特别容易被平均值掩盖。所以rea在每条上报里额外挂了三个业务元信息字段releaseVersion当前前端包版本号。我在构建时通过注入方式替换不用在代码里手写版本避免了忘记更新的尴尬。routeName是哪个路由页面产生的样本。埋点不用每个页面单独写而是在全局路由切换钩子里统一打标。bucketMarkers一个字符串数组存放当前的实验分组或功能开关标识。比如新版列表骨架屏新版首屏接口合并新版图片模糊预加载这些标记直接复用现有开关的值这样后面分析时能直观看到开了某开关的那批人是不是更慢了。有了这三个元信息rea的数据维度就齐了环境设备/网络/系统加上发布状态版本/路由/实验组再加上性能数值。三者叠加才能支撑后面服务端的归因判断。3. rea的具体实现一条从采集到归因的完整链路3.1 采集层的代码组织rea最早是我在某个临时排错脚本里东拼西凑出来的后来实验组打算长期用我才把它抽成了一个独立模块。整体做了分层的模块设计避免所有代码揉在一起。采集层分三块环境探测器、性能探测器、队列调度器。探测器只负责收集和格式化数据调度器管什么时候收、什么时候发、发不出去怎么办。我专门把调度逻辑独立出来因为真实场景里事件触发时机非常杂有页面load触发的一次性采集、有PerformanceObserver回调的持续监听、有路由切换时的上下文更新没做好调度容易出现重复上报或者漏报。下面是rea初始化入口的简化版代码这个版本已经去掉了业务内部细节只看骨架import { createEnvironmentSnapshot } from ./env; import { createPerformanceTracker } from ./perf; import { createReporter } from ./reporter; export function initREA({ appVersion, routeName, bucketMarkers [], reportEndpoint, sampleRate 0.2, }) { const env createEnvironmentSnapshot(); const perf createPerformanceTracker(); let currentRoute routeName; const reporter createReporter({ endpoint: reportEndpoint, hooks: { beforeSend: (payload) ({ ...payload, appVersion, routeName: currentRoute, bucketMarkers, }), }, }); document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { const sample { env: env.getSnapshot(), perf: perf.flush(), }; if (Math.random() sampleRate) { reporter.send(sample); } } }); }关于采样率rea默认设成0.2也就是五分之一会话上报一条。这不是拍脑袋定的性能分析并不需要全量数据只要样本量够形成统计口径就行。线上真实测试里20%采样在千万级日活的系统上每天也能汇集百万级样本做环境聚类完全够。3.2 上报协议与数据格式rea上报走的是浏览器sendBeacon这个API最大的好处是页面卸载时也能尽量把数据送出去比XMLHttpRequest在visibilitychange场景下可靠得多。如果sendBeacon不可用或载荷过大会自动降级为图片打点或者fetch的keepalive模式。每条上报是一个JSON对象核心结构我用表格列一下方便理解每个字段的用途字段类型说明appVersionstring前端包版本号构建注入routeNamestring当前路由名bucketMarkersstring[]实验分组/功能开关标记env.platformstringandroid / ios / windows / macenv.deviceClassstringlow / mid / high按核心数与内存划分env.networkstring4g / 3g / 2g / offline / unknownenv.memoryHeapstringJS堆内存水位归一化后的档位perf.fcpnumber首次内容绘制单位msperf.lcpnumber最大内容绘制单位msperf.lcpElementstringLCP对应的元素或图片标识perf.fidnumber首次输入延迟单位msperf.clsnumber累计布局偏移分数perf.longTasksArray长任务列表包含开始时间和耗时perf.resourcesArray资源加载明细截断后保留前40条资源明细整包上传体量太大我做了两个限制一是只保留耗时在前20的慢资源二是个别字段超过长度阈值就截断。一条上报压缩前大概控制在8KB到15KB之间实测对网络的影响可以忽略。服务端收到后还会再做一层压缩存储这部分放到下游处理。3.3 服务端的聚类与归因逻辑数据上报到服务端后真正的分析才开始。rea的服务端逻辑分两层聚合层按小时粒度用环境指纹字段做分组键统计每个环境桶的样本数、P50/P75/P90/P95耗时以及LCP、FCP、FID各指标的分布。分组键不是单字段而是由设备档位网络系统大版本路由名拼接而成。为什么不用完整UA做键因为完整UA粒度太细会导致每个桶样本量太小失去对比意义。系统大版本我只取到主版本号比如Android 11、iOS 16不再往下拆。归因层做的事情是自动指出异常环境桶。我一开始是想用复杂的异常检测算法后来发现基线偏移法更实用以全体样本的P90作为基准线找出那些P90显著高于基线、同时样本量超过总样本1%的环境桶标记为待排查。算法能定位到桶但桶内部到底哪种因素主导还依赖人工判断。举个例子如果标记出来的是mid等级设备3g网络Android 12我可以横向比较同一个设备档位在不同网络下的差值判断是网络主导也可以比较同一个网络在不同设备档位下的差值判断是设备算力主导。这种方法虽然朴素但在绝大多数场景里已经足够定位问题方向而且执行成本极低。4. 接入rea后踩过的坑与排查实录4.1 跨域脚本的timing清零问题rea上线第一周我就发现资源加载数据里有一批JS文件的时间全部是0。这些文件走的是独立CDN域名和主业务域名不同源。查了规范才知道Performance Resource Timing在跨域资源上默认拿不到详细的duration和transferSize除非被请求的资源响应头里带上Timing-Allow-Origin。但CDN域名不全是我们能直接改的尤其是第三方统计脚本、外部字体这类资源。rea里给跨域资源专门做了标记在initiatorType为script或css且duration为0时记录资源本身耗时未知但页面等待它的时间仍然体现在FCP和LCP里。这种数据不参与耗时排序但保留数量统计用来判断页面是否加载了大量第三方脚本这个相对粗放的问题。如果是自家CDN解决方式很直接在响应头加一行Timing-Allow-Origin: *或指定来源域名。加了之后重新上线时长数据立刻就完整了这一点值得做性能监控的团队优先排查。4.2 LCP总比用户体感慢的偏差有段时间线上反馈看到一个奇怪现象rea里的LCP数值普遍在2.5秒以上但用户自己录屏测试体感是一秒多就出内容了。两边对不上我刚开始怀疑是埋点时机问题。深挖后发现机制上的原因largest-contentful-paint记录的是整个页面生命周期里最大的一次内容绘制它不代表用户眼中的首屏内容。比如首屏本身很快就渲染完了但页面下方有一个巨大的背景图在滚动后才开始加载、最终成为最大元素LCP的数值就被这个后来者拉高了。这说明直接拿LCP标准定义做业务体验指标可能失真。rea随后调整了LCP元素的记录策略加了一个可配置的观察时间窗默认只统计前5秒内出现的最大绘制元素超过窗口的一律忽略。这样对齐用户体感之后数据终于能和实际体验对上号。做类似监控的同学如果遇到LCP偏高建议先看看LCP对应元素到底是什么别急着优化代码。4.3 低端机上的采集自耗玩过性能监控的人都会有这个顾虑监控本身会不会变成新的性能负担。rea在一批低端机上的真实情况是确实会。Android 10以下的千元机跑起来rea的采集脚本峰值CPU占用能到8%—12%尤其在页面滚动和加载同时发生时PerformanceObserver回调频繁触发对低端机主线程是实打实的挤占。这问题后来用了两个手段缓解。第一是条件采集deviceClass为low且网络为2g/3g时不再采集资源明细和长任务列表只保留FCP、LCP、CLS和FID这四个核心指标。少掉Resource Timing的数据采集和序列化CPU占用直接降了一大截。第二是采样率分级低端设备采样率从0.2降到0.1高端设备反而提到0.5。这种越有问题越少打扰的设计逻辑实际上也让rea在低端机上的数据更加真实——不再因为采集器本身拖慢页面而测出失真的性能。5. 从rea数据到性能优化的实战打法5.1 找出环境小群体而不是整体平均值rea给我最大的认知转变是让我彻底不再信任整体平均耗时这个数字。平均值最大的问题在于它掩盖了分布。拿首页首屏举例假设整体P50是1.8秒听起来还行但rea的环境桶里能看到iPhone用户P50是1.2秒Android中端机是2.5秒Android低端机配合3g网络是6.8秒。哪个数字才是产品的真实体验每个都是但它们属于不同的人群。没有环境维度的时候优化方向很容易被平均数据误导大多数人盯着整体P75去优化结果改了共享代码全部人受益却忽略了那个6.8秒的群体可能根本是另一个问题——是网络差、是低端机JS执行慢、还是接口响应本来就慢。rea的做法是先把群体细分出来再按群体逐一分析。优化目标也变成哪个环境桶改善带来的整体收益最大。这种打法的工程价值相当直观。举例来说把2g/3g网络群体的首屏优化好了虽然它们只占6%的用户但对他们而言是从基本不可用到勉强能用的质变对口碑的影响远远超过对平均数据的影响。产品团队往往会愿意为这种体验公平性让步一部分性能预算。5.2 一个从rea数据直接定位问题的案例光讲方法论可能不够具体我分享一个实际案例。某次版本发布后rea的环境桶分析发现mid档设备Android 124g路由A这一个桶的LCP P90从2.1秒直接飙到4.8秒其他所有桶都正常。传统监控面板上这个异常大概率会被稀释掉因为整体LCP P90只涨了不到0.3秒。我按rea的思路继续下钻先看这个桶的FCP正常再看LCP元素发现是一张背景大图最后看这张图的resource明细发现它的加载方式在A路由上走的是一个新加的懒加载逻辑而这个懒加载在Android 12的某内核版本上出现了竞态导致图片请求延迟到滚动后才发出。问题定位到这里方向已经非常清晰。最后修复方法是调整懒加载的判断时机把元素即将进入视口再加载改成首屏渲染完成后立即预加载。修复上线后这个桶的LCP P90第二天就回落到了2.3秒。这个案例想说明的是真正值钱的不是那个异常峰值本身而是围绕峰值展开的环境分组与元素明细。rea把展开路径做得很顺滑——从P90异常点一下就能看到环境组合和环境组合之外的LCP元素、资源明细排查成本大幅降低。5.3 后续演进把rea扩展到WebView端rea目前跑在业务前端页面上但这半年我一直想把它推到App内的WebView场景去。原因很简单许多真实业务的流量大头其实在App内嵌页而WebView环境比普通浏览器复杂得多不同宿主App的WebView内核版本不一样、有各自的缓存策略、JsBridge注入时机不同、页面通信方式也不同。把rea扩展到WebView需要多采集两类数据一类是宿主App信息比如宿主版本、WebView的UA标识、是否开启了硬件加速另一类是JsBridge调用耗时因为这往往是WebView页面里隐藏最深的性能黑洞。实现上并不复杂宿主App在初始化WebView时通过一个全局对象把宿主信息暴露给页面rea识别到后自动追加进环境指纹里。有些坑我也提前踩过Android上不同宿主的WebView设置项差异很大有的默认关闭了DOM存储有的限制了缓存大小这些都会直接影响页面性能但浏览器常规环境里根本遇不到。所以rea在WebView端的分析逻辑要额外增加宿主App作为第一分组维度不然数据混在一起又回到了平均值陷阱。这块目前还在内测后续跑稳了再单独写一篇详细记录。