最近给一个老项目做性能体检,打开Chrome DevTools的Network面板一看,35个CSS和JS文件排得整整齐齐,光是资源请求就有47个。这个项目还是传统的服务端渲染,模板里散落着各种插件、样式库的引用,每次改版都得手动画一张“页面到底引用了哪些文件”的图。想直接上webpack做工程化重构吧,模板引擎、静态资源体系、部署方式全部要动,短期根本排不出这个人力。
所以我就做了个决定:不折腾构建框架,自己写一个HTML资源合并工具,专门干一件事——自动合并CSS/JS,减少请求数。这个工具不依赖任何第三方库,用Node.js原生能力手搓,能解析HTML模板、提取外链样式和脚本、压缩合并成一个文件、自动修复CSS里的相对路径、给文件名加内容Hash做缓存更新,最后把模板里的引用替换掉。整个过程跑一遍只要几百毫秒,项目里所有页面的请求数从47个降到2个。
这篇文章就把这个工具从思路到代码完整拆开讲一遍,包括我自己踩过的坑:@charset位置导致的乱码、CSS变量覆盖顺序出错、JS文件合并后的作用域污染、字体文件404等等。适合正在做传统多页面项目、又暂时没预算上重型构建工具的前端同学参考。
1. 为什么要合并CSS/JS:先算清请求数这笔账
1.1 一次资源请求到底贵在哪
前端性能优化里有个非常经典的说法:浏览器加载一个页面,请求数越少,首屏越快。这句话在HTTP/1.1时代几乎就是铁律,因为HTTP/1.1对同一域名有并发连接数限制,标准是6个,也就是浏览器最多同时用6个TCP连接去取资源。如果一个页面有40个CSS/JS文件,这些文件要在6个通道里排着队下载,每一个请求都要经历DNS解析、TCP握手、TLS协商(HTTPS还要多两个RTT)、发送请求头、服务器处理、响应返回这一整条链路。
咱们简单地算笔账。假设网络RTT(往返延迟)是50ms,服务器处理请求平均50ms,那一个请求从发出到拿到数据大约就是100ms。40个文件分到6个连接上,平均每个连接要处理约7个文件,串行排队的总耗时就是 7 × 100ms = 700ms,这还没算浏览器解析、渲染、执行脚本的时间。如果把40个文件合并成2个,下载时间基本可以控制在200~300ms以内,光这一项就能省下400~500ms。在移动端弱网环境下,RTT涨到200ms甚至更高,这个差距会进一步拉大到几秒钟,危害非常直接。
有人会说,现在都HTTP/2了,多路复用不是能同时传多个文件吗?这话没错,HTTP/2确实打破了同域名6个连接的限制,一个TCP连接里可以并发传输所有文件。但HTTP/2的推广远没有想象中那么彻底,很多内网系统、老旧服务器、第三方CDN仍然跑在HTTP/1.1上,而且就算用了HTTP/2,几十个文件的请求头开销、服务器IO压力、移动端网络波动也依然存在。所以“合并资源、减少请求数”这个手段,在今天仍然是性价比很高的优化方案,尤其对于中小型站点和传统服务端渲染项目来说。
1.2 三种资源合并方案,为什么我选择手搓脚本
资源合并这件事,业界已经有了不少成熟工具。最典型的是构建工具链方案,用webpack或者Vite做打包,把所有CSS通过import引入,JS按模块拆分,构建后自动产出合并文件。gulp生态里也有gulp-concat、gulp-clean-css这类插件,配一条流水线任务就能做文件合并压缩。
还有一种方案是用现成的在线压缩合并服务,把文件贴上去,一顿操作拿到一个合并后的文件下载回来。这种方式最省事,但问题也很明显:不可复现、不能接入自动化流程、不好处理相对路径和缓存更新,整个流程是黑盒的,出了问题你根本不知道它在哪一步动了手脚。
我最终选择自己用Node.js写脚本,核心原因有三个。第一是可控,整个工具逻辑就几个函数,每行代码都看得懂,改起来非常灵活,比如我想针对某个目录例外处理,加一个if判断就行,不用去翻webpack文档找loader配置。第二是零依赖,不需要npm install任何东西,Node.js自带的fs、path模块就够了,脚本拷到任何一台服务器上都能直接跑,连node_modules都不用装。第三是和项目现状匹配,传统多页面项目的资源引用方式基本就是和
