超越包大小限制:探寻真正导致网页应用变慢的原因
为何删除几千字节的数据几乎无法解决应用运行缓慢的问题,以及如何追踪贯穿服务器、工作流、数据加载过程、第三方脚本和图片的真正等待时间。
前端团队中常见这样的场景:花费数周时间只为从 JavaScript 打包文件中削减 40 KB,而关键请求路径上那条耗时 900 毫秒的数据库查询却依旧未被优化。图标库被更换,依赖项被替换,另一个打包插件也被配置好,人们还会为某个包的体积是 18 KB 还是 12 KB 而争论不休。然而当有人通过真实网络在真手机上加载该应用时,它依然显得很慢。
本文将解释为何打包文件大小常常成为错误的目标、真正的延迟源自何处,以及如何通过优化循环优先解决最大的性能瓶颈。较小的打包文件确实有帮助,有时甚至作用显著。但如果页面缓慢是因为在服务器端等待、阻碍渲染、执行不必要的操作、发送过多请求或加载了庞大的组件树,那么再削减 20 KB 也无法改善其性能。
为何打包大小会成为默认的性能目标
打包大小之所以具有吸引力,是因为它是一个数字。你的构建过程会输出类似这样的内容:
main.js 842 KB
vendor.js 611 KB
styles.css 94 KB
有人提议将JavaScript文件大小控制在500 KB以内,于是团队立刻有了一个目标。你可以在持续集成中强制执行这一标准,在拉取请求中跟踪进度,并为每一点的减少而庆祝。这看起来像是工程上的进步,有时确实也是。
问题在于,当这个数字不再只是现象而变成目标时。团队往往只优化那些最容易看到的性能方面,而非那些让用户花费最多时间的部分。要了解这为何重要,请比较两个假设的应用程序。
应用程序A:打包体积小,但其他功能都很慢
第一个应用程序发布的打包文件体积很小:
JavaScript: 250 KB
然而其周围的各项功能却都非常耗资源:
Server response: 1.2s
Database query: 700ms
API calls before rendering: 5
Main-thread work: 900ms
应用B:体积较大,但内容加载速度更快
第二个应用程序所包含的JavaScript代码量几乎是前者的三倍:
JavaScript: 700 KB
但在页面真正可用之前,其服务器和客户端所需处理的任务要少得多:
Server response: 150ms
Database query: 40ms
API calls before rendering: 1
Main-thread work: 180ms
应用A并不一定就更优。B类应用往往给人的感觉更快,因为它在服务器处理、数据库操作、连续请求以及主线程工作上所花费的时间大约少一秒,对于大多数拥有稳定网络连接的用户来说,这一时间节省远远超过了额外的下载量。所有性能讨论都应遵循一个简单原则:用户感知的不是字节数,而是等待时间。
页面加载是一个漫长的流程,而非三个步骤
许多开发者脑海中都存在着简化的加载模型:
Download JavaScript
↓
Execute JavaScript
↓
Page appears
实际请求要经过更多阶段,而每个阶段都可能造成延迟:
DNS
↓
Connection
↓
TLS
↓
Request
↓
Server processing
↓
Database
↓
Response
↓
HTML parsing
↓
CSS processing
↓
JavaScript download
↓
JavaScript parsing
↓
JavaScript execution
↓
Hydration
↓
API requests
↓
Rendering
↓
Layout
↓
Paint
在服务器接收到任何内容之前,就已经完成了DNS查询、连接建立以及TLS协商。随后才是服务器处理和数据库操作。浏览器接着会解析HTML、处理CSS、下载并解析执行JavaScript、进行数据填充、发起API调用,最后才进行渲染、布局和绘制。而当用户点击时,这一流程的大部分步骤会再次重复。
由于存在如此多的处理阶段,代码包恰恰是时间可能被浪费的环节之一。所谓“缩小代码包大小”其实并非明智之举,因为它在尚未明确时间消耗点的情况下就试图找到解决方案。
服务器可能是前端中最慢的部分
前端工程师通常将性能问题视为浏览器层面的问题。他们会打开开发者工具,查看网络标签页和JavaScript代码块,并运行Lighthouse测试。但实际上,很大一部分影响前端速度的因素在浏览器接收到任何有用数据之前就已经决定了。
以仪表板请求为例:
GET /dashboard
在服务器端,它可能在响应之前完成所有这些操作:
→ authenticate user
→ fetch organization
→ fetch permissions
→ query projects
→ query project statistics
→ query notifications
→ calculate recommendations
→ render response
如果整个处理流程需要1.4秒,即便将代码包的大小从600 KB减少到500 KB,用户体验也几乎不会改变,因为第一个有意义的字节依然会在1.4秒时到达。浏览器无法渲染它尚未接收到的数据。
常见的罪魁祸首是后端代码中依次等待各种独立操作完成的情况。这一切都是从获取用户信息开始的:
const user = await getUser();
随后会继续依次等待组织、项目及通知的相关处理结果,每个操作都在等待前一个完成:
const organization = await getOrganization(user.orgId);const projects = await getProjects(organization.id);const notifications = await getNotifications(user.id);
这些步骤中确实存在依赖关系:查询组织信息需要user.orgId,而项目又需要该组织的ID。但那些不依赖先前结果的操作却毫无必要地依次等待,从而增加了延迟。对于相互独立的操作,同时启动并一起等待可以显著缩短响应时间:
const [user, notifications] = await Promise.all([
getUser(),
getNotifications()
]);
请注意,并行版本在调用getNotifications()时并未传入用户ID。只有当通知信息能够从现有数据(如会话信息)中获取时,这种方式才有效;否则该调用仍需等待用户响应。通用原则是映射真实的依赖关系图,并并行执行其中的每一层。关于如何在这几种模式间进行选择的更多信息,请参阅我们的指南:Promise.all、Promise.race与顺序await。这样的改动往往能显著提升性能,远超常规的代码打包处理。
瀑布式流程的成本远不止字节数
进行问题排查时,最有效的起点是“网络”标签页,而非代码打包分析工具。一种非常常见的模式如下所示:
HTML
↓
JavaScript
↓
API A
↓
API B
↓
API C
↓
API D
每一步都需要等待前一步完成,而每个数据传输都会增加一次往返延迟。与之相比,如果首条响应就已经包含页面所需的所有内容,情况会好很多:
HTML
↓
API response containing everything required
第二种方案虽然传输的字节数可能更多,但速度却会快得多。字节数与延迟是两个不同的问题——100 KB的响应一旦到达即可立即使用,而20 KB的响应则需要四次连续的往返传输才能让页面开始发挥作用,尤其是在每次往返成本都很高的移动网络环境中。
因此,当发现某个接口返回300 KB的数据时,人们的本能反应往往是试图减少数据量。虽然这或许有意义,但更好的第一步应是先思考用户为何在能够与页面交互之前就需要那部分响应。答案往往能揭示出那些可以推迟处理或完全删除的内容。
JavaScript的功能重要性高于其文件大小
另一个常见的误区是将JavaScript的文件大小与其实际功能混为一谈。500 KB的代码包并不一定就是灾难。关键在于浏览器需要为它执行哪些操作:
- 下载该代码。
- 解析它。
- 编译它。
- 执行它。
- 构建应用程序状态。
- 生成组件树结构。
- 绑定事件处理程序。
- 将服务器渲染的标记内容激活。
- 重新计算布局。
- 绘制最终结果。
两个打包体积相近的应用,在运行成本上可能存在巨大差异。以一个包含5,000行的表格为例,问题往往不在于数据本身,而在于渲染这5,000行交互式DOM节点所带来的负担。解决之道并非削减50 KB的脚本代码,而是仅渲染当前可见的30行左右内容。这种虚拟化技术能够在保持原有应用和数据不变的前提下,大幅减少浏览器的处理工作量。
当水合过程成为瓶颈时
在React及其他在服务器端渲染的组件框架中,这种差异尤为明显。服务器端渲染能让HTML快速显示在屏幕上,但浏览器仍需先对庞大的组件树进行水合处理,才能响应用户的操作:
HTML arrives quickly
↓
User sees content
↓
Browser starts hydration
↓
Large amount of JavaScript executes
↓
Page becomes interactive
页面在实际准备好之前就显得已经准备好了。正因如此,仅在内容首次出现时进行测量可能会误导判断。一个包含200个交互组件的控制面板虽然能生成完全正常的HTML,但在数据加载过程中仍会消耗大量CPU资源,导致点击操作无法响应。
这里关键的问题不在于代码包是否过大,而在于为何这么多代码必须立即具备交互功能。有些组件根本不需要客户端JavaScript;某些交互操作可以被隔离在独立的小模块中;有些控件可以稍后加载,还有一些服务器端渲染的组件可能根本不需要数据加载过程。正如我们在部分预渲染与并发渲染一文中所探讨的那些技术,它们带来的好处远超过为30KB的依赖文件而争论。
第三方脚本往往比你自己的代码更重要
在启动大型营销活动之前,先检查你所使用的代码中有多少是由他人编写的。常见的包括:
- 分析工具
- 聊天插件
- 热图功能
- A/B测试工具
- 广告组件
- 客户支持工具
- 会话录制功能
- 社交平台嵌入代码
- 营销追踪像素
- 用户同意管理功能
这些脚本都会增加请求处理、脚本执行、布局调整以及网络活动。具有讽刺意味的是,工程师花费数小时优化应用程序代码时,这些脚本却几乎不会经过任何审查。一个页面可能会加载这样的组件:
app.js
analytics.js
chat.js
tracking.js
experimentation.js
heatmap.js
团队为app.js的体积减少了70 KB而庆祝,但页面仍需加载数百千字节的第三方代码。正因如此,性能预算的范畴需要更广泛。不要只关注打包文件的大小,而应考虑在页面真正发挥作用之前,用户的设备需要处理多少代码——这两个问题的答案截然不同。
图片可能占据你全部JavaScript预算的很大比例
过大的图片是另一个容易被忽视的问题。一张主图的大小甚至可能超过整个优化后的JavaScript代码块:
main.js 180 KB
hero.webp 1.4 MB
product.jpg 900 KB
background.png 2.1 MB
如果有一个移除12 KB依赖项的拉取请求被提交,而一张2.1 MB的背景图片却未被修改就继续使用,那这支团队其实只是在做表面上的优先级排序,而非真正的优化。图片应该与代码一样受到严格处理:
- 在适用且支持的情况下,优先选择WebP或AVIF等现代格式。
如果手机仍要下载2MB的用户几乎注意不到的图片,即便节省了15KB的JavaScript代码也意义不大。
许多性能问题其实源于架构问题
深入探究后会发现,很多性能问题根本与代码优化无关,而是源于应用程序的架构设计。想象一下一个需要包含所有这些内容的商品页面:
Product
Reviews
Recommendations
Inventory
Shipping estimate
User preferences
Related products
如果在页面加载后逐个获取各个组件,虽然可以优化每个请求,但页面速度依然可能很慢。更好的做法是先确定用户首先需要看到什么。初始响应中可以仅包含:
Product
Price
Availability
Primary image
评论、推荐及相关产品可以在之后逐步加载。此时你不再是在优化实现方式,而是在重新定义页面中“准备就绪”的含义,而这往往能带来最大的提升。
衡量用户实际注意到的关键节点
真正的性能优化会将关注点从“数据包有多大?”转变为“用户何时能使用到有用的内容?”,这样的问题才能带来更准确的评估指标。
显示首个有用内容的时间
用户何时能看到他们想要的内容?这通常因产品而异:账户余额、搜索结果或产品图片。
交互时间
用户何时能够可靠地进行交互,而不会因后台任务占用导致点击无效?最新版本的 Lighthouse 已不再将 TTI 包含在评分中,但针对自身页面而言,这一指标依然值得关注。
最大内容绘制时间
主要可见内容何时完成渲染?
交互到下一次绘制的时间
用户在进行交互后,界面在视觉上多久能做出响应?
累积布局偏移量
用户在尝试阅读或点击时,页面布局是否会频繁变动?
总阻塞时间
主线程因某些任务而被占用,导致浏览器无法响应用户输入的时间有多长?
单独来看,这些信息都无法完整呈现整个情况,但结合起来却能比这样一句话更准确地描述相关体验:
bundle.js = 487 KB
资源包信息用于描述某个资产,而性能指标则说明用户实际体验到的情况。
值得信赖的优化流程
当应用程序运行缓慢需要修复时,删除依赖项不应是第一步,遵循规范化的流程效果更好。
1. 在真实环境中复现问题
应在具有代表性的设备与网络环境下进行测试,而不仅仅是使用连接办公室Wi-Fi的快速笔记本电脑——很多用户并不具备这样的条件。
2. 通过测量找出性能瓶颈
确定时间消耗在哪些环节。主要问题是否出在这些方面?
server response?
network?
rendering?
JavaScript execution?
layout?
images?
third-party scripts?
3. 确定最主要的性能损耗点
避免同时尝试修复多个问题,应找出造成性能问题的最大因素。
4. 修改一个要素
做出最小的架构或实现层面的改动来解决那个瓶颈,这样你才能将改进效果归因于该改动。
5. 再次测量
如果数据上没有显示出改进,就不要认为改动有效。
6>通过回归测试巩固成果
改进效果很容易消失——有人添加了依赖项,产品团队加入了某个组件,某个组件开始加载更多数据,查询方式也变成了顺序处理,三个月后你就又回到了起点。性能优化需要通过持续集成和监控系统来实现自动化保障,而非偶尔的临时性清理。
包大小依然重要,只是作用不同
尽管如此,包大小依然至关重要。过大的包会增加下载、解析、编译和运行的成本,而在性能较弱的设备或网络环境下,这种代价更为显著。代码拆分、树摇动、懒加载以及移除未使用的依赖项都是非常有用的方法。关键在于在有证据表明这些方法是最大问题时再加以应用。
一次合理的性能评估应按影响程度依次进行,可能如下所示。首先是服务器响应缓慢:
Problem:
900ms server response
通过并行执行后端请求来解决:
Action:
parallelize backend requestsResult:
-420ms
其次是耗时的仪表板初始化过程:
Problem:
large dashboard hydration
通过延迟加载那些不需要立即交互的组件来解决:
Action:
defer non-critical interactive componentsResult:
-280ms main-thread work
再则是过大的主图尺寸:
Problem:
hero image is 1.8 MB
通过使用现代格式实现响应式传输来解决:
Action:
responsive WebP/AVIF deliveryResult:
-1.2 MB transferred
经过所有这些步骤后,一个占用大量资源的 JavaScript 依赖项才出现在列表的最顶端:
Problem:
large JavaScript dependency
即便替换它,依然能节省相当可观的资源:
Action:
replace dependencyResult:
-60 KB
这样的修复依然很有效,只是应该排在第四位,而非第一位。
关键要点
- 应优化等待时间,而非追求最小的文件大小。打包体积只是众多指标之一。
- 在使用打包分析工具之前,先查看服务器及请求流程;延迟和往返次数往往比字节数更影响性能。
- 评估 JavaScript 应以其带来的工作量来衡量:解析、执行、渲染和初始化过程,而不仅仅是文件大小。
- 应以与自身代码相同的严格标准来审查第三方脚本和图片。
- 为每个页面重新定义“就绪”标准,确保关键内容首先加载,其他内容再依次呈现。
相关阅读
- AI时代前端开发者的真正价值所在 —— 阐述了为何在AI逐渐接管常规前端编码工作的背景下,理解能力、判断力以及系统级思维比熟练掌握框架更为重要。
- 超越P95:衡量用户实际体验的延迟 —— 说明为何良好的P95数值仍可能与性能低下的产品共存,队列时间和数据扩散现象为何会隐藏在监控面板之后,以及如何通过逐步骤计时来结束对延迟责任的推诿。