1. 为什么我会写一个叫 rea 的小工具
先说结论:rea 不是一个框架,也不是一个通用库,它是我为了解决一个具体的性能排查问题,自己写的资源加载分析工具。名字没有太深含义,就是 Resource Efficiency Analyzer 的缩写,简单直接,方便项目里几个同事记。
事情的起因是这样的:团队负责的一个中后台系统,用户投诉打开页面要转好几秒。我们在监控平台上能看到一堆资源加载的数据,比如某个 JS 文件加载了 800ms,某个图片加载了 500ms,数据很准确,可问题是——这些 URL 对应的业务代码在哪里?一个 URL 看起来是很长的 hash 文件名,光看地址根本不知道它是哪个页面模块、哪个组件、哪条业务链路。我们只能对着构建产物 map 文件一个个猜,效率极低。
我当时想,能不能做一个工具,把资源加载信息自动对应到“业务模块”上?让监控平台输出的不再是“chunk-abc123.js”,而是“订单列表页的列表组件”,这样谁看一眼就知道问题出在哪。于是 rea 就诞生了。
这个工具能做的核心事情有三个:
- 自动采集页面运行时所有资源加载的耗时、大小、类型;
- 通过构建产物的模块信息,把资源 URL 还原成业务模块名;
- 按组件、页面、业务链路聚合输出,生成可读性极高的分析报告。
如果你是做前端性能优化、维护大型中后台项目,或者只是对“网页为什么慢”这件事感兴趣,这篇文章里的思路和踩坑记录应该都能帮到你。即使你不需要用 rea 本身,理解它背后的“资源-模块映射”思路,对你排查线上性能问题也会有启发。
2. rea 的核心设计:把一条加载链路拆成三段
2.1 整体架构:采集、映射、聚合
rea 的逻辑其实不复杂,核心就三步:采集、映射、聚合。这三步分别解决三个不同层面的问题:数据从哪来、数据怎么归因、数据怎么呈现。
- 采集层:在浏览器里监听所有资源加载事件,拿到每一个 JS、CSS、图片、字体、接口请求的 URL、耗时、大小、发起时间。
- 映射层:把 URL 通过构建产物的元数据(比如模块 ID、chunk 名、source 路径)映射到对应的业务模块名。
- 聚合层:按业务模块做汇总,比如“订单列表页下的 list-table 组件总共贡献了首屏 1200ms 的下载耗时”,最终输出一份 JSON 或 HTML 报告。
这样设计的好处是每一层都可以独立替换。比如采集层可以用浏览器原生 Performance API,也可以换成自己的注入代码;映射层如果构建工具变了,只需要改映射表的生成逻辑;聚合层甚至可以直接对接已有的监控系统。
我一开始想过把这三层放一起,后来发现拆开反而更好维护。因为浏览器环境、构建环境、后端分析环境是完全不同的运行时,耦合在一起只会让调试变得难上加难。
2.2 采集层:不依赖 Performance API 的降级方案
一般说到资源加载监控,大家第一时间会想到PerformanceObserver配合resource类型的条目。但实际用起来会发现一个问题:跨域资源的信息经常拿不全。
比如第三方 CDN 上的脚本,如果服务器没有返回Timing-Allow-Origin响应头,responseEnd、transferSize这些字段全是 0,根本没法分析。所以我在 rea 里做了两条采集路径:
- 优先使用
PerformanceObserver,能拿到完整时序就记录下来; - 同时用
MutationObserver监听脚本、图片、样式标签的插入,通过「标签出现时间 - 页面开始时间」估算加载耗时,作为降级数据。
这个降级方案听起来不够精确,但在实际排查中已经够用了。判断一个资源慢不慢,我们往往只需要知道“是哪个业务模块加载了它”,而不是精确到微秒。后面我会在踩坑部分细说具体问题。
采集层还有一个容易忽略的点:不是所有资源都需要分析。首屏只关心 JS、CSS、首屏图片和关键字体,而埋点上报、音视频、懒加载图片这些不应该计入首屏耗时,否则会把结论带偏。rea 里通过后缀名白名单和resourceType过滤规则来做这件事,默认只统计 JS/CSS/图片/字体,其余一律忽略。
2.3 映射层:从 URL 到业务模块的归因过程
映射层是 rea 最有价值的地方,也是最难写的一部分。做前端的人都知道,构建工具会把源代码打包成带有 hash 的文件,我们拿到的 URL 长这样:
/static/js/chunk-3f8a9b.1234.js如果不用 sourcemap,任何人看到这个 URL 都只能靠猜。rea 的解决办法是:让构建阶段生成一份映射表。
简单说,在 webpack 或 vite 打包结束后,解析构建统计文件(webpack 是stats.json,vite 是build-metadata),找出每个 chunk 包含的模块路径,然后把这些路径里和业务代码相关的部分提取出来。
举个例子:
chunk-3f8a9b.js: src/pages/order/list.vue src/components/order/status-tag.vue src/utils/order-helper.ts我只需要把这个 chunk 的主要归属识别出来,规则也很粗暴:路径中如果存在pages目录,就优先取pages下的页面名;如果不存在,再看components下的组件名。最终生成这样一张映射表:
| URL 片段 | 业务归属 |
|---|---|
| chunk-3f8a9b | 订单列表页 / status-tag 组件 |
| chunk-admin-vendor | 公共第三方库 / 后台框架相关 |
然后把这张表下发到浏览器,采集层一旦抓到 URL,就用表里的 key 做匹配,自动替换成易读的业务名。这里有个关键问题:映射表不能太大。如果直接把所有模块路径都下发,动辄几百 KB,反而拖慢首屏。我在实现时只保留每个 chunk 的页面名和核心组件名,精确到页面级别就够用了,组件级别的细分则通过页面内的代码标记来补充(后面会讲)。
2.4 聚合层:输出一份“能看懂”的报告
拿到一堆“业务模块 + 耗时 + 大小”的记录后,聚合层要做的是把碎片整理成洞察。
我的做法是输出两部分内容。第一部分是页面级汇总表,展示这个页面首屏加载了哪些 chunk、每个 chunk 的业务归属、占总耗时的百分比。第二部分是模块贡献排行榜,比如:
1. 订单列表页业务代码 812ms 40% 2. moment.js 等第三方库 436ms 21% 3. 首屏图片资源 310ms 15% ...这样一列,用户自己就知道首屏慢的锅到底该扣在谁头上。而且聚合层还支持按用户粒度抽样,可以区分“首次访问”和“二次访问”的差异,避免缓存干扰判断。
聚合层最容易被忽略的需求是可比较性。如果这次优化后报告显示订单页从 812ms 降到了 500ms,团队才有信心继续做。所以我在输出里固定了一个 schema,包含时间戳、项目版本、环境标识,方便横向对比。否则过一个月再看历史数据,都不知道是哪次发版的影响。
3. 落地过程中的 4 个坑与对应的补救办法
3.1 跨域资源拿不到详细时序
这个坑我前文提到过,实际操作起来比想象中更严重。我们的项目里字体文件和部分第三方统计脚本都放在 CDN,而这些 CDN 的响应头并没有Timing-Allow-Origin。结果就是,用PerformanceObserver拿到的条目里duration为 0,transferSize为 0,看起来像资源根本没加载过。
解决思路是分层降级:
- 第一层:完整时序可用,直接使用
duration; - 第二层:时序缺失但有响应头信息,用
fetchStart到responseEnd的差值估算; - 第三层:如果完全拿不到资源本身的数据,则用
PerformanceEntry.startTime减去页面 navigation 开始时间,得到一个“该资源从页面开始到发起加载”的累计时间,这个时间能反映出它是首屏第几批资源,结合瀑布图依旧可以判断优先级。
实际使用中我发现,降级数据虽然精度不高,但对于定位问题完全够用。比如字体文件耗时难测,但通过它在瀑布图里的位置,就能看出它是被某个页面组件里的@font-face动态触发加载的,问题归因方向没有变。
3.2 映射表体积膨胀
第一版实现时,我直接把所有 chunk 的所有模块路径写进映射表,结果一个大型中后台项目的映射表 JSON 压缩后仍然有 900 多 KB,这对低端机用户来说是不能接受的。
一开始我想用 gzip 压缩下发,但后来发现更干净的办法是只在本地调试和灰度环境启用完整映射表,生产环境只下发“页面级映射表”,并且用布隆过滤器做一些模糊匹配。具体做法是:
- 构建时先根据 chunk 名和入口文件归属,生成一份粗粒度映射表,只保留 chunk 名到业务页面的关系;
- 对组件级细节,不在运行时下发,而是把采样数据上报到后端后,直接在服务端用 sourcemap 做二次归因——这样浏览器端只承担很轻的采集工作,大量计算都放到服务端。
最终生产下发的映射表被压缩到不到 20KB。如果你是个人项目或者内部分析系统,同样建议把重计算放到后端,浏览器端保持轻量,否则很容易因为工具本身影响了性能。
3.3 异步组件动态加载的边界情况
我们在项目里用了不少import()动态加载,比如用户点击某个按钮后才去加载弹窗组件、图表组件。问题也随之而来:这些资源严格来说不属于首屏,但如果点击发生得足够早,它们就会混在首屏资源列表里,甚至被误判为“首屏慢的元凶”。
后面我在采集规则里加入了用户交互时间戳:
- 记录页面 ready 时间点之前发起的资源加载,作为“首屏资源”;
- 记录首次点击、滚屏事件发生之后发起的资源加载,标记为“交互触发资源”;
- 两类资源在报告里分开统计。
这样处理之后,报告里不会再出现“用户点了一个按钮导致首屏多 1 秒”这种荒谬结论。我也在思路层面建议团队:优化首屏时,不要一门心思只盯着所有资源的总耗时,要先分清哪些资源是首屏必经链路,哪些是交互链路,把两个问题分开看待。
3.4 本地开发和生产环境行为不一致
本地开发时,资源都是由 dev server 实时编译返回的,耗时普遍偏大,而且热更新会产生很多额外的模块请求。如果只在本地跑 rea,很容易得出“页面好慢”的错误结论。
我在使用规范上做了三条约定:
- 只分析生产构建或预发构建的结果,本地数据只用于功能调试;
- 生产环境剥离 sourcemap 后,模块路径已经不可读,所以需要依赖构建期生成的统计文件;
- 如果要在本地验证映射是否正确,必须单独跑一次带 sourcemap 的构建。
这三条约定写进 rea 的文档后,团队在使用时就没有再出现过“本地几百毫秒,线上却没问题”这种困惑。
4. 从“能用”到“好用”:rea 的后续增强
4.1 支持子应用拆分上报
中后台项目经常是几个团队共同开发不同的业务子域,比如财务模块、运营模块、数据分析模块。如果所有数据混在一份报告里,每个团队都只关心自己那块,报告价值就大打折扣。
我在后面给 rea 加了一个简单的分组机制:构建时给每个子应用打上标记,上报数据时带上appName字段,聚合层按 appName 二次分组。这样各团队可以在同一个平台上看自己子域的独立报告,同时也可以汇总看整体情况。
这个改动不难,但收益很大。团队之间的“性能甩锅”明显少了,因为每个人都能直接看到自己模块的贡献度,讨论问题也有数据依据。
4.2 自动圈复杂度与预算告警
资源大小和加载耗时本质上和代码复杂度强相关。如果一个模块在首屏贡献了 300ms,但实际业务逻辑非常简单,那大概率是依赖了某个体积巨大的第三方库。为了快速发现这类问题,我在 rea 里集成了一个简单的审计规则:
- 对每个业务模块,计算它打包后的体积;
- 如果体积超过设定阈值,自动标记为“待优化模块”;
- 如果模块体积连续两个版本上升,就在报告中显示趋势警告。
阈值怎么定?我们粗略按“目标首屏预算为 3 秒内”反推。如果一个页面首屏资源最多只能下载 900KB JS,而页面级汇总显示某模块占了 300KB,那它就被判定为高开销模块。这套审计机制不需要很精确,能提醒人去看一眼就达到目的了。
4.3 生成瀑布图和火焰图
纯文字报告虽然能看清整体,但处理“谁等谁”的问题时还是不够直观。比如一个组件的 JS 加载了 700ms,但这 700ms 里面其实包含了对另一个资源的串行等待。为了看清这类依赖关系,我利用 performance 资源条目里的initiatorType和上下文信息,生成了一份简化版的瀑布图。
瀑布图用纯 CSS 渲染成 HTML,纵向列出资源时间轴,横向按时间排列,鼠标悬停可以看到归属模块。虽然没有专业性能平台的交互那么丰富,但胜在可以直接分享给团队成员,让后端或者测试同事也能一眼看懂。
火焰图则用来展示模块之间的调用依赖:一个页面模块加载了哪些子组件,每个子组件触发了哪些资源请求。这个功能实现起来比较复杂,目前我只是做了第一版,但已经能解决 80% 的“这个资源到底是谁发起的”问题。
5. 实际使用后我的一些体会
工具做出来之后,团队已经日常用了三个月。最大的感受不是报告多好看,而是性能优化终于从“凭感觉”变成“按证据”。以前排查首屏慢,大家第一反应是“图片是不是没压缩”“有没有可能是某个接口太慢”,现在只要打开 rea 报告,就能直接看到是某个业务模块的某个 chunk 占了绝对大头,省掉了很多不必要的争论。
有一个例子特别明显:之前监控平台一直提示首页某个 JS 文件加载慢,我们试过拆包、预加载各种手段都没有明显效果。后来用 rea 才发现,这个 chunk 本来只属于一个“帮助中心弹窗”组件,但在入口文件里被同步引入了,导致首屏白白承担了这 400 多毫秒。把引入方式改成异步import()后,首屏耗时直接下降了 15%。这种问题,如果只看性能监控平台的资源耗时列表,根本发现不了——因为资源时间本身就包含了大量并行下载的时间,根本没法定位到代码逻辑。
另一个体会是:这种资源归因工具的维护成本并不低,千万别指望写完就能一劳永逸。构建工具版本一升级,统计文件格式可能就变了;代码目录一调整,映射规则又得跟着改。我在实践里摸索出来的经验是,把映射规则尽量做简单,只保留“页面-入口”这一层关系,组件层交给后端 sourcemap 二次分析,这样前端升级时只需要维护很少的代码。
最后说一个实用的小技巧。如果你们暂时没有人力去写一个像 rea 这样的完整工具,有一个最简陋的替代方案:在webpack或vite构建完成后,用脚本读取统计文件,把 chunk 名和页面名对应关系输出成一份 JSON,然后在浏览器里通过PerformanceObserver拿到资源 URL 之后,手动去查这个 JSON。虽然过程笨拙,但至少能让你在一两个小时内找到首屏最可疑的那几个 chunk。
性能优化最怕的不是复杂,而是无从下手。rea 本质上就是把“资源加载”这种底层的浏览器行为翻译成“业务代码”的人话。如果你也在为线上性能问题头疼,不妨按这个思路做一个小小的分析脚本,说不定会比买高昂的监控服务更有用。