资讯动态

REA:前端资源效率分析CLI工具,从依赖到体积全面体检

发布时间:2026/10/21 4:53:33 来源:尧图企业网站定制
做前端项目维护这几年我最大的感受不是功能难写而是不知道项目什么时候悄悄变重——页面越来越多依赖越装越顺手组件库、工具函数、图表库一层层叠上去等到线上出现明显的加载卡顿再回头查才发现已经攒了一堆说不清来路的体积和请求。REA 这个工具就是在这种背景下被逼出来的它的全称是 Resource Efficiency Analyzer一个专门盯着前端资源效率和加载链路做体检的命令行工具。它能做的事其实很朴素扫一遍你的项目把依赖关系、打包体积、运行时的资源消耗梳理成一份可读的报告然后告诉你哪些地方是真正的优化突破口。如果你也维护着不算小的前端工程或者刚接手一个别人留下的项目想快速摸清家底这篇文章里的思路和代码可以直接拿来用。其实市面上并不缺分析工具webpack 有自带的 stats也有不少在线分析站点可以贴 JSON 上去看图。但它们的姿势都比较重有的要你改造构建配置有的得单独起一个本地服务有的只盯着打包体积对运行时和真实用户路径完全没概念。REA 的出发点很简单——我想要的是一把瑞士军刀不要你改任何构建设置装好就能跑跑完直接把结论扔给你哪些依赖是虚胖哪些请求是重复的哪个组件在用户最常走的路径上拖后腿。1. 为什么我会动手写 REA而不是继续用现成方案先说动机。我维护的那个项目是典型的中型后台系统十几个业务模块几百个路由页面打包产物从最初的几百 KB 一路涨到将近 4MB。每次发版前都能听到这版好像更慢了但没有人能说清楚慢在哪。我试过几种常规操作手动翻 webpack bundle 分析报告太费眼给关键页面做 Lighthouse只能反映当前这台机器的情况用浏览器 Performance 面板录一段录完还得自己对着时间线猜。这些都太依赖经验而且每次都得重复劳动。真正让我下决心写工具的是一个很具体的场景某天排查一个列表页的卡顿发现它明明只用了表格和两个按钮首屏却加载了完整的图表库和富文本编辑器代码。原因也简单——该项目有一个公共模块图表的、富文本的、权限的全都挂在里面任何页面引到这个公共模块就会把一大堆永远用不上的代码拖进来。这种问题用体积面板能看出来吗能但你要一个一个页面去翻依赖图效率极低。我需要的是反着查的能力给定一个页面入口顺着 import 链往下走列出它到底把哪些包带进了主包每个包贡献了多少体积哪些是显式依赖、哪些是间接传递。再加一层运行时采样用户在真实环境里打开这个页面哪些资源是闲置的哪些请求是可以合并的。REA 就按这三个维度来设计依赖关系扫描、包体积归因、运行时资源采样。它不替代已有的专业工具而是把散在各处的信息汇总成一份统一报告让我在几分钟内做出这个模块该不该拆的判断。1.1 一个CLI工具的定位与边界动手之前我给自己划了几条线避免做成一个收不住的怪物不依赖具体构建工具。项目用的是 webpack但我不想去读它的 stats 文件因为那样换构建工具就废了。REA 直接解析源码里的 import/require 语句和构建产物里的文件结构跟构建链解耦。不做自动优化。它只负责分析、归因、出报告永远不自动改代码。自动改代码的风险太高尤其在一个多人协作的项目里谁知道哪个模块被谁悄悄引用着。支持增量分析。整个项目几万个文件全量扫描很慢。我加了缓存和按目录过滤只对修改过的文件重新解析。划完边界实现难度其实不大真正花时间的反而是后面的各种边角情况。2. 核心设计REA 的三个分析维度和底层原理这一节我把每个维度背后的原理拆开讲因为如果你也想写类似的工具最难的部分不是写代码而是搞清楚数据从哪来、怎么算才靠谱。2.1 依赖体检用 AST 解析 import而不是正则匹配第一种最粗暴的做法是用正则去匹配import xxx from和require(xxx)。我一开始也这么干很快就被各种写法打脸动态 import 的参数是个变量require被包在 try/catch 里还有路径别名和export * from这种转发写法。正则要么漏要么误报。正确姿势是走 AST。Node.js 生态里有现成的解析器我用的组合是用某知名 JavaScript 解析器把每个源文件变成语法树再遍历ImportDeclaration、ImportExpression、CallExpression里 callee 是require的节点。这样不管是静态导入还是动态导入都能稳定拿到来路。这里有一个关键决策要不要跟着别名配置走。项目里几乎都有/指向src/之类的别名。我在解析阶段直接把路径别名替换成真实路径否则后面做依赖图的时候会出现一堆查无此包的悬挂节点。别名映射从项目的jsconfig.json或tsconfig.json里读读不到就让用户手传一份。const parser require(babel/parser); const traverse require(babel/traverse).default; function extractImports(source) { const ast parser.parse(source, { sourceType: unambiguous, plugins: [jsx, typescript, dynamicImport, topLevelAwait] }); const deps new Set(); traverse(ast, { ImportDeclaration(path) { deps.add(path.node.source.value); }, ImportExpression(path) { deps.add(path.node.source.value); }, CallExpression(path) { const callee path.node.callee; if (callee callee.name require path.node.arguments.length) { const arg path.node.arguments[0]; if (arg.type StringLiteral) deps.add(arg.value); } } }); return [...deps]; }拿到每个文件的依赖列表之后下一步就是构建整张依赖图文件是节点import 关系是边。这一步做完才能回答从页面 A 出发最终会波及哪些文件。2.2 包体积归因从构建产物反推而不是看 node_modules 的原始大小分析依赖关系只是第一步我还想知道这个依赖到底给线上包贡献了多少 KB。直接统计 node_modules 里对应包的目录大小是不准的——npm 包里包含源码、文档、示例真正打进构建产物的往往只是一小部分。准确的做法是从构建产物反推。我的实现方式分成三层先拿 webpack 这类打包工具产出的 chunk 文件列表把每个 chunk 里的模块 ID 和模块内容读出来然后用构建工具的 source map如果有把模块 ID 映射回源码路径最后把源码路径归属到对应的顶层包名上。这样算出来的体积就是这个包在最终产物里实际占的体积。有些项目不开 source map也不保留 module 注释。这种情况我退而求其次从 chunk 内容里抽取node_modules路径片段做关键词匹配命中率大概七成够用来定位明显的问题依赖。我在报告里会标注这份体积数据是精确归因还是模糊估算避免误导。2.3 运行时资源采样在真实路径上埋点统计闲置体积是很重要但它只说了一半。另一半是这些资源真的被用了吗。我见过一个项目引了全套图表库线上却只用折线图一种富文本组件更是从头到尾没人点开过。这些靠静态分析看不出来必须看运行时的真实使用情况。REA 的运行时采样做得比较朴素提供一段可选的探针脚本你把它挂到页面入口它会监听两个指标——模块执行时间和元素曝光/点击事件。模块执行时间用 PerformanceObserver 里的longtask加自定义计时元素曝光用 IntersectionObserver。数据汇总后上报一个本地端点REA 聚合成一张实际使用率表。说实话这段功能我不常开因为需要业务方配合而且会稍微影响性能。但它有个不可替代的价值当你在纠结要不要删掉这个看起来没用的大依赖时它能给你数据背书而不是凭感觉拍板。团队里如果有人犹豫一张这个包上线后 30 天内零使用的报告比嘴仗管用得多。3. 具体实现从零搭一个能用的 CLI下面进入写代码环节。我不会贴完整源码那太长只把关键模块的结构和核心逻辑讲清楚你照着拼就能拼出一个可用的工具。3.1 项目结构和技术选型工具本体我选了 Node.js TypeScript原因很直接这个生态里文件解析、图表生成、模板渲染的库最齐而且作为命令行工具分发也简单打成 npm 包后用户一条命令就能装。目录结构大致是这样rea/ ├── src/ │ ├── cli.ts # 入口处理参数 │ ├── scanner/ │ │ ├── files.ts # 文件遍历与过滤 │ │ ├── imports.ts # AST 解析 │ │ └── graph.ts # 依赖图构建 │ ├── analyzer/ │ │ ├── bundle.ts # 产物体积归因 │ │ ├── runtime.ts # 运行时探针数据处理 │ │ └── report.ts # 生成报告 │ └── config.ts # 配置加载 ├── templates/ │ └── report.ejs # HTML 报告模板 └── bin/ └── rea.js # CLI 入口这里解释一个选型细节为什么遍历文件不用现成的 glob 库而是自己写。因为项目里经常有node_modules、dist、.git这些需要排除的目录而且源文件可能分布在不同目录层级自己写一个带排除规则的文件遍历器可控性更好速度也不慢。async function collectFiles(root: string): Promisestring[] { const exts new Set([.ts, .tsx, .js, .jsx, .vue, .svelte]); const ignored new Set([node_modules, dist, .git, .cache]); const result: string[] []; async function walk(dir: string) { for (const item of await fs.promises.readdir(dir, { withFileTypes: true })) { if (ignored.has(item.name)) continue; const full path.join(dir, item.name); if (item.isDirectory()) await walk(full); else if (exts.has(path.extname(item.name))) result.push(full); } } await walk(root); return result; }3.2 核心代码依赖图构建与循环依赖检测依赖图的存储结构就是邻接表MapfilePath, Setstring。构建过程是遍历所有源文件逐个解析 import把相对路径和别名都解析成绝对路径再入图。interface DepGraph { nodes: Mapstring, FileNode; } interface FileNode { path: string; imports: Setstring; // 绝对路径或包名 size: number; // 源码体积 } function buildGraph(fileList: string[]): DepGraph { const nodes new Mapstring, FileNode(); for (const file of fileList) { const source fs.readFileSync(file, utf-8); const rawImports extractImports(source); nodes.set(file, { path: file, imports: new Set(rawImports.map(resolvePath)), size: Buffer.byteLength(source) }); } return { nodes }; }构建完图之后有个很少人提但很重要的副产品循环依赖检测。很多项目跑着跑着出现怪异的 undefined十有八九是某个文件被循环引用了。REA 顺手实现了环检测深度优先搜索 状态标记碰到环会打印出完整的引用链。这个功能上线后被同事夸得最多因为以前只能靠肉眼在编译器报错里猜。function findCycles(graph: DepGraph): string[][] { const visited new Mapstring, number(); // 0未访问 1访问中 2已结束 const stack: string[] []; const cycles: string[][] []; function dfs(node: string) { visited.set(node, 1); stack.push(node); const fileNode graph.nodes.get(node); if (fileNode) { for (const dep of fileNode.imports) { if (!graph.nodes.has(dep)) continue; // 外部包跳过 const state visited.get(dep); if (state 1) { const start stack.indexOf(dep); cycles.push(stack.slice(start).concat(dep)); } else if (state 0) { dfs(dep); } } } stack.pop(); visited.set(node, 2); } for (const node of graph.nodes.keys()) { if (!visited.get(node)) dfs(node); } return cycles; }3.3 结果输出终端表格、JSON 和一份自解释的 HTML 报告分析做完了怎么呈现决定了工具好不好用。REA 三种输出都支持。终端表格适合快速浏览用第三方表格库渲染列分别是依赖名、体积、占总体积比例、是否显式声明、使用率有运行时数据时。默认按体积降序排一眼就能看到最大的几个元凶。JSON 输出是为了能接进 CI 流程。我在 CI 里配置了一个阈值比如单包体积超过 500KB 或者未使用依赖超过 20 个就报警把仓库的健康度变化纳入发布卡口。HTML 报告则是给团队其他人看的。它包含一个可交互的依赖往来图——注意不是用内置流程图组件而是用前端图表库画的力导向图鼠标悬停能看到某个包被哪些文件引用了。报告顶部放健康度评分和环比变化底部才是明细表。做成自包含的单文件可以直接丢给群里的同学打开看不需要起服务。4. 实测中的意外情况和排查过程工具写出来之后第一件事当然是拿自己项目开刀。结果没跑几步就暴露了三个问题每一个都值得单独说道说道。4.1 CSS 引用导致体积归因错乱第一次跑完报告看到某个 UI 组件库的体积数据高得离谱。我以为是它确实大后来核对才发现问题出在 CSS 上——项目里很多页面import xxx/dist/xxx.css而 RA 在解析 import 时把 CSS 文件也算成了包的一部分。这种归因本身没错问题在于 CSS 是全局共享的把它算到某个页面头上会让那个页面的体积虚高。我的修法是在体积归因阶段加一个开关默认忽略样式文件单独列出一份全局样式体积汇总。如果你想看某个页面实际承担的样式成本再打开细粒度归因。这个教训让我意识到分析工具的输出一定要能解释否则数据反而会误导决策。4.2 monorepo 下的依赖重复计数后来我把 REA 拿到一个 monorepo 结构的项目上试又出问题了同一个工具库被 A 包和 B 包分别以不同版本引入于是报告里出现了一个依赖两行数据一个 1.2.1 一个 1.3.0体积各行其是。这种情况单独看没问题但你要评估升级这个库能省多少体积时就得把这两个版本合并成一行。修起来不复杂归因时按包名版本分桶出报告时再按包名聚合聚合后的体积取两者绝对路径的并集去重。真正麻烦的不是代码而是向团队解释清楚为什么同一行数据在两种视图里不一样——最后我索性在报告里同时展示这两种口径标注清楚每列用的是哪个口径。4.3 缓存导致的幽灵包第三个问题最隐蔽。项目里开了持久化缓存某次构建用了半小时前上一个分支的产物缓存导致 REA 分析出来的 chunk 内容和当前源码完全对不上。我对着报告调了半天最后发现是缓存的锅。解决方案分两层工具层面我在扫描产物前检查产物文件和源码文件的修改时间如果发现产物的时间早于源码就提示可能存在缓存过期建议先清缓存再分析流程层面我在本地开发脚本里把 REA 分析挂到了prebuild阶段保证每次分析都基于刚构建完的新产物。自此之后幽灵包再没出现过。5. 一些经验把 REA 接入日常之后我对前端优化有了新的理解工具本身的功能说完了最后聊点工具之外的东西。如果你也打算写一个内部小工具下面几条也许能让你少走弯路。第一分析工具最大的敌人是不可解释。REA 改到现在我每次加指标之前都会先问当这个数字异常时我能不能顺着它定位到具体文件、具体代码、具体构建行为如果答案是不能那这个指标就不要加。数据必须能变成行动否则就是噪音。第二体积数字要跟业务价值挂钩说。我后来在报告里加了一个业务贡献评估实验字段——统计某些大依赖对应的业务组件是否被至少 N 个页面使用。虽然这个评估很粗糙但它让非技术的同事也能理解这个大组件不是在浪费而是只服务了几个页面建议拆出去按需加载。对比单纯甩一个900KB的数字这种说法更容易推动决策落地。第三工具的边界意识和遗忘曲线。REA 刚写完的时候我很兴奋加了很多功能后来发现大多数功能三个月根本用不了一次。现在我养成了按季度精简的习惯每次会看日志统计哪些子命令的调用次数接近零该删就删。内部工具不是功能越多越好而是越贴合当前工作流越好。我自己的使用习惯是这样的每周一上午跑一次完整分析把报告发到团队群里每次涉及依赖增删的代码评审前用 REA 对比一下改动前后的体积变化让它成为评审意见的一部分。这样坚持了大概三个月几个顽固的体积上涨点被逐一处理掉整体包体积从接近 4MB 降到了 2.6MB 左右首屏时间也实打实地快了一拍。最后再分享一个小技巧给 REA 加一个简单的历史对比存储每次分析结果的 JSON 都按日期归档。这样当有人问上个月至今为什么又涨了 300KB时你能直接切开每天的差异列表精确到是哪个 PR、哪个依赖引入的。这个功能花不了多少代码量但它把一个一次性分析工具真正变成了可持续观察项目健康度的仪表盘。如果你也想写一个类似的项目分析工具建议先从最小闭环开始文件遍历加 AST 解析加体积归因三个功能跑通就已经能解决很多问题了。剩下的运行时探针、历史对比、CI 集成都是锦上添花等真需要了再加也不迟。工具是死的你对项目的理解才是活的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑