
好了,休假回来继续肝技术文 😄 大家都知道,我时不时会写一些类似Stop Installing Libraries: 10 Browser APIs That Already Solve Your Problems这种风格的帖子——说实话我挺喜欢写这种的,读者反馈也不错 🙂
今天我想换个角度来聊这个话题。给大家看几个有意思的东西,它们可能在悄悄让你的应用变慢很多,但乍一看完全人畜无害。更爽的是?其中一些问题修复起来快得惊人。还有,这些问题你在问 Claude Code 或者 Codex "为什么我的应用这么慢"的时候,它们大概率不会主动指出来 😅
通常我们在开发应用时,用的都是高性能机器——CPU 飞快、内存管够、网速炸裂。可惜真实用户完全是另一个世界。有些用户确实有现代设备,但总会有那么一些人还在用老旧笔记本、低端安卓机、烂成渣的 WiFi,或者地狱级别的移动网络 😅
然后突然发现你的应用对 10%-30% 的用户来说慢得令人发指。
于是毫秒争夺战开始了 😀
这篇文章里的每个例子,都是我自己在真实项目中遇到的,或者从其他开发者那里听来的,所以绝对不是"假设场景"。看看你自己的应用里有没有偷偷中招的 👀
顺便说一句,我正在慢慢准备 JSNation 大会的演讲。如果你想支持我(或者想看我尴尬地在院子里叨叨 😄),可以在这里给我点个赞here。如果你想完全免费看我的完整演讲,可以在这里领免费门票here。是不是很香 😄
好了不整活儿了,进入正题!
这就是为什么要去参加技术大会的原因。有时候你会在那里听到一些靠自己根本不会主动去搜的问题 😀
我的一个同事在演讲中提到了一个这样的问题。他们的团队一直在排查为什么应用对部分用户来说感觉很慢。自然,后端先被拉出来背锅。可怜的后端,每次都是 😅
但后来他们注意到 Network 面板里有个有意思的现象:几乎每次 API 调用之前都会出现一个 OPTIONS 请求。有的甚至耗时几百毫秒。
这到底是怎么回事?
这跟 CORS 有关。浏览器有时候会在真正的 API 调用之前发送一个额外的 OPTIONS 请求,这就是所谓的预检请求(preflight request),通常发生在"非简单"请求的场景——比如使用了 PUT 或 DELETE 之类的方法,但加上自定义请求头也会触发。
没错,就算是一个完全无辜的 GET 请求,只要有人三年前加了个 X-Feature-Whatever 请求头,瞬间就能变成两次网络调用 😅
最搞笑的是,在他们的例子里,那个自定义请求头根本没人用了。就是好几年前留下来的历史遗留产物。没人知道它为什么存在。没人质疑它。就像一个不死的遗留系统化石一样活过了每一次重构 😀
如果你感兴趣,我实际上用 Claude Code 一起搞了个小项目来演示这个行为:
https://github.com/sylwia-lask/preflight-options
来一起看截图(请为我的高超绘图技术鼓掌!):
不带自定义请求头的 GET 请求:
带自定义请求头的 GET 请求:
说实话,在大型项目里这种事随时都在发生。有人为了功能开关、调试、本地化、分析或者"临时的"元数据加了个自定义请求头……然后这个头就存活了接下来的四年。
当然,有些场景下自定义请求头是完全合理的。但如果只是前端逻辑用的,有很多更好的替代方案,比如 query 参数、cookie、本地状态,或者在启动时只获取一次配置接口。
单个额外请求看起来可能没那么灾难。但如果说你的应用在启动时要调用几十个接口,尤其是在慢速移动网络下,这突然就变得非常明显了。
有时候问题不在网络本身,而是启动时加载的那个巨大的 JavaScript 打包文件。这时候大家通常会说:
"但是怎么回事?我们已经在做代码分割了!到处都是懒加载!"
嗯……关于这个 😄
我曾经审计过一个 Angular 应用,乍一看结构非常好。到处都是模块、懒加载、良好的架构,所有的"最佳实践"。
但这个应用加载起来就是慢得离谱。
还好我们有 webpack-bundle-analyzer、source-map-explorer、rollup-plugin-visualizer 或者 @next/bundle-analyzer 这些工具,可以看到打包文件里到底发生了什么。
然后我们发现了什么?
是的,应用确实按模块分割了……
……只不过每个模块只有 2 KB 😅
因为几乎所有重要的东西都住在一个巨大的共享模块里,而这个模块被到处引用,结果应用的大部分代码还是塞进了主包里 😀
恭喜,你的应用现在被分割成了 400 个看起来很漂亮的独立文件,然而它们全在启动时加载。
这也不是我见过的唯一一种奇葩情况。我还遇到过应用"懒加载"了模块,但实际上每次都还在下载几乎整个应用 😄
比如说,下面这种写法看起来完全没问题:
{ path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
}
看起来很干净。很现代。很优化。
直到你发现 AdminModule 引用了一个巨大的共享模块,里面装着半个应用 😅
所以——用了 import() 或者懒加载模块并不自动意味着你的打包文件就是健康的。一定要检查浏览器实际下载了什么。
这是另一个极其常见的问题,尤其在没人真正管着团队装了哪些 npm 包的项目里 😅
在我现在的项目里,引入一个新依赖几乎就像需要王国最智慧架构师们审批的神圣仪式(基本上就是我和两三个同事点头 😀)。但在很多项目里,大家装起库来完全不过脑子。
然后你的应用突然就包含了:
我见过一个应用同时加载三个不同的日期库。最搞笑的是?那个应用其实基本不怎么处理日期 😅 看来每个开发者都有自己的"信仰"。
另一个经典例子是像这样引入 Lodash:
import _ from 'lodash';
而不是:
import debounce from 'lodash/debounce';
差别看起来很小,但时间长了这些东西会累积非常多。尤其是在企业应用里,一搞就是好几年。
可惜的是,树摇(tree shaking)不是魔法 😅
这个听起来简直太显而易见了对吧?
谁不知道大图片不好。
……问题是大家还是在不断上线大图片 😄
最近在做 WeAreDevelopers 播客的时候,我们聊到哪些政府网站加载最快。出乎意料的是,英国完全碾压了所有对手。为什么?我可能会另外写篇文章讲这个,但总的来说,那个网站就是极其简洁。视觉噪音很少,很多信息文字,布局简单,SSR,最小化的不必要资源。
第二快的是美国政府网站。
它几乎遵循了完全相同的原则……除了在启动时加载了一张花哨的大图 😅
然后 Largest Contentful Paint(LCP,最大内容绘制)就明显变差了。
大背景图的有意思之处在于,在开发者的高配机器和快速网络下,它们看起来人畜无害。但在慢速设备上,它们绝对能把"感知性能"给炸了。
好在这有很多改进方法:用 AVIF 或 WebP,激进压缩,避免首屏上方有大尺寸 Hero 图,懒加载非关键视觉资源,只预加载真正关键的资源。
说实话?
有时候最快的图片就是……没图片 😀
应用优化显然是个无底洞,这篇文章只是蜻蜓点水。但我觉得最重要的一点是:性能问题通常是千里之堤溃于蚁穴。
一个多余的请求头。
一个超大的依赖。
一个"临时的"共享模块。
一张没人质疑的背景图。
单独看,没一个看起来有多灾难。但加在一起,就造出了一个在老设备或慢速移动网络下感觉卡顿的应用。
而真正可怕的部分是?
当初做这些决定的时候,看起来都完全合理啊 😅
原文链接:https://dev.to/sylwia-lask/4-tiny-mistakes-that-secretly-destroy-app-performance-5a67