先测量:为何过早优化会让 Next.js 应用更难运行
了解过早的缓存机制、过宽的客户端边界以及多层缓存是如何增加 Next.js 应用的复杂度的,同时也会看到以性能测量为首要目标的开发流程是如何保持应用快速运行的。
一个 Next.js 页面的加载时间为1.2秒,就有人认为这太慢了,于是还没人查看性能分析报告就开始了一轮优化。几个月后,代码库中到处都是记忆化机制、未经基准测试的动态导入、多个重叠的缓存系统以及自定义的渲染路径,结果这个应用不仅依旧缓慢,而且更难理解了。本指南将解释为何会出现这种模式、是哪些具体习惯导致了这一问题,以及如何用一种以数据为依据、仅在数值证明有必要时才增加复杂性的工作流程来替代它们。
真正的错误:在获得证据之前就增加复杂性
在代码审查中,每一项单独的优化措施看起来似乎都很合理。这里用个useMemo,那里加个缓存,对于那些显得较重的组件则拆分代码包。但问题在于这些优化会累积起来——每新增一种机制,就需要理解更多的缓存逻辑、调试额外的渲染路径、考虑更多的边界条件,同时还要维护更多与性能相关的代码。
因此,真正需要避免的失败并非缺乏优化,而是在尚未弄清楚什么地方运行缓慢、为何会慢以及修复后是否会影响用户体验之前就引入各种解决方案。在调整执行效率之前,首先要确保系统具有足够的可观测性,以便能够对其进行测量。
在做出任何更改之前先进行分析
这种错误最常见的表现是对“性能很重要”这一普遍观点的过度反应。开发者开始编写代码时,既不进行性能分析,也不找出性能瓶颈,更不会确认用户是否在等待特定的内容。
其症状很常见:
- 在简单的计算操作上使用
useMemo和useCallback - 为那些获取成本本来就不高的数据添加缓存层
- 在尚未检查哪些代码块体积较大的情况下就进行代码拆分
- 基于假设的未来负载情况构建抽象层
- 为了避免渲染那些未被测量的内容而增加条件判断的渲染逻辑
很多时候,根本不存在所谓的问题。真正的性能优化工作是从数据开始的:页面加载时间、代码包分析、React性能分析工具的记录结果、网络请求流程图,以及用户实际在哪些环节等待的清晰画像。优化应当针对已观察到的行为来进行,而非基于对未来可能变慢的模糊担忧。
一个实用的准则是,在修改代码之前先记下你想优化的指标及其当前数值。如果无法明确给出具体数字,那就说明你还没有准备好更改实现方式。
通常问题只是JavaScript代码过多
很多前端页面加载缓慢的情况其实并不复杂。浏览器需要下载、解析、编译并执行超出页面实际需求的脚本量,而在中端手机上,每一个步骤都会耗费大量资源。
一个简单的控制面板可能会悄悄积累一些动画库、庞大的组件套件、重量级的状态管理器、图表生成工具、实用函数集,以及本应留在服务器端的客户端逻辑。单独看这些似乎都不算什么问题,但加在一起就会在页面能够顺利响应用户输入之前产生大量工作量。到了这时,团队往往开始努力提升 Lighthouse 的评分,仿佛解决问题需要某种高明的策略似的。
Next.js 已经为此提供了强大的默认配置:服务端渲染、按路由自动进行代码分割,以及基于 React Server Components 的以服务器优先的模式。不过,当应用程序的大部分功能都被放到浏览器端时,这些默认配置的价值就会大打折扣。因此在尝试其他技术之前,先检查你要传输的代码量、每个路由中占主导地位的依赖项,以及它们是否真的有必要存在。许多应用程序并不需要更复杂的优化手段,只需要减少 JavaScript 的使用量即可。若想进一步了解除了原始代码包大小之外还可能影响页面加载速度的因素,可参阅了解究竟是什么导致了网页应用加载缓慢。
不断上移的客户端处理范围
让 App Router 的架构优势大打折扣的最快方式,就是将过多代码标记为客户端代码。当有人在组件树深处需要交互功能时,会在父组件上加上 "use client",然后在祖父组件上也加上该指令,结果那些仅负责渲染静态标记、读取服务器数据或构建布局的组件,仅仅因为位于该指令之下,也就被纳入了客户端代码包中。
这种转变影响的不仅仅是代码的运行位置,通常还会带来以下问题:
- 更多 JavaScript 代码被发送到浏览器
- 页面具备交互功能之前需要更多的初始化工作
- 需要管理更多客户端状态
- 服务器端与客户端数据更容易出现不同步的情况
这里面存在一种讽刺:许多团队选择现代的 Next.js 正是为了获得服务器优先的架构,却逐渐又重建了他们原本试图摆脱的那种依赖客户端的单页应用。
在设计过程中一个有用的问题是:这个界面中真正需要浏览器处理的最小单元是什么?点赞按钮、下拉菜单或表单输入可能需要客户端状态,而周围的卡片、列表和页面往往不需要。通过明确界定这些边界,并尽可能将服务器渲染的内容作为子元素传递给客户端组件,可以让服务器承担更多工作,同时保持交互区域的专注度。长期来看,这样做的好处是代码包更小,思维模型也更简单。关于 React Server Components 如何避免代码被打包进文件包的机制,可在该文章中了解。
过早优化如何让架构变得脆弱
当优化措施的实施速度超过人们证明其有效性的能力时,性能优化工作就会变成维护问题。这类问题往往从小处开始:某个组件被加入缓存机制,出现手动实现的缓存结构,添加钩子来跳过渲染步骤,随后又出现新的层级以确保两个状态的一致性。当时看起来并无任何风险。
数月之后,代码库中会出现难以追踪相互作用的自定义钩子、只有少数人懂的失效规则、一连串被缓存的值、基于已不再成立的假设制定的渲染条件,以及仅仅因为早期某项优化需求而存在的同步代码。
这会带来实际的代价。新功能集成需要更长时间,调试时也需要更多背景信息,而细微的功能改动最终会影响到那些最初为提升速度而设计的机制。对于任何优化而言,关键问题不在于它能否提高基准测试成绩,而在于其带来的改进是否足够大,足以抵消由此产生的架构成本。目标应是可持续的性能:即应用能够快速响应,同时其日常代码依然简洁易读,而非充斥着各种提升速度的技巧。
感知性能属于用户体验问题
工程师们更倾向于那些可以精确测量的指标,比如毫秒数、数据包大小、渲染次数以及得分。而用户关注的则更为广泛。如果导航界面混乱、加载状态没有反馈、控件毫无响应、内容加载时布局突然变动,或者某个重要操作完成后没有任何提示,即便将页面加载时间缩短100毫秒也几乎起不到作用。
以一个需要两秒才能提交的表单为例,将后端处理时间缩短到1.7秒确实是一种实质性的技术进步。不过,提供即时反馈、禁用按钮以防止重复提交,并显示清晰的进度指示,往往能带来更好的用户体验,即便请求速度仍和之前相同。
这就是实际性能与用户感知性能之间的差距。人们会根据界面是否能响应他们的操作意图、他们能否理解正在发生的事情,以及在使用过程中是否感觉稳定来评判该界面。因此,优秀的前端性能优化既需要考虑渲染机制,也需要借助交互设计。诸如 React 的过渡效果、乐观更新和骨架状态等工具,都与代码包分析属于同一类优化手段。
缓存能带来好处,直到没人能解释它为止
由于系统不再重复执行耗时的操作,缓存能够带来显著的性能提升。但问题在于,当团队无法确定某个用户应该看到数据的哪个版本时,情况就变得复杂了。
常见的情况是:某个页面加载速度变快,但随后会出现过时数据。当某条记录被修改后,一个界面会更新,而另一个界面仍显示旧值。开发环境运行正常,但生产环境却出现问题,于是人们开始调查是哪个缓存提供了响应、哪一层数据失效了,以及是哪个请求产生了当前输出。
大型 Next.js 应用程序使这一点尤为容易实现,因为可以在多个层面进行重用:自定义代码、框架的数据与路由缓存、各个 fetch 调用、CDN、浏览器以及后端服务。如果在不了解这些组件之间交互方式的情况下再增加一层缓存,虽然可能降低延迟,但也会让系统所处的状态数量呈指数级增长。需要注意的是,Next.js 的缓存默认设置在不同主要版本中会有所变化,因此应参考当前版本的官方文档确认其行为,而非依赖旧版的指南。
在采用激进的缓存策略之前,应先确保具备良好的可观测性。对于任何被缓存的响应,团队都应当能够回答以下问题:
- 该响应来自何处
- 其有效期限预计为多久
- 什么因素会导致其失效
- 当失效处理失败时会发生什么
虽然缓存能提升系统速度,但会导致实际运行行为难以预测,这并非真正的双赢。
性能问题未必出在 React 上
有时延迟只是在前端显现出来,而非起始点。当某个界面需要几秒钟才能正常使用时,团队可能会开始优化渲染过程、对组件进行记忆化处理或重构客户端状态。这些改动或许能节省几毫秒的浏览器处理时间,但页面仍需等待两秒钟的数据库查询,或是接收远超界面所需的数据量的响应。
想象一下那个请求的完整生命周期:浏览器发送请求,请求经过中间件和授权处理,服务器调用后端或数据库,数据载荷再传回浏览器,只有到这时React才会进行渲染。如果大部分时间都耗费在等待响应的过程中,那么稍微加快最后这一步的作用其实很有限。
过大的数据载荷、本可并行处理的顺序式网络调用、耗时的授权检查、负荷过重的服务以及未建立索引的查询,情况也是如此。仅仅减少组件的渲染频率并不能解决这些问题。有效的解决方法是对整个请求流程进行深入分析,找出时间消耗在何处。瓶颈问题与团队分工或代码归属无关。
简单的系统能更长久地保持高速运行
许多快速的应用程序在内部结构上其实并不复杂。它们打包的文件体积较小,渲染边界清晰,客户端状态保持最少,数据获取方式可预测,其架构让新开发者无需破解各种复杂技巧就能理解。
正是这种简洁性在应用程序发展过程中发挥了作用。当数据流一目了然时,那些耗时的操作就容易被发现;当客户端职责范围明确时,浏览器需要承担什么任务也就清晰可见;而当缓存规则简洁且明确时,生产环境中的问题也更容易诊断。
巧妙的优化之所以有吸引力,部分原因在于它展现了技术功底,但每一种机制都将成为未来开发者必须理解、调试、维护或最终移除的内容。这些因素并非反对优化,而是主张采用最简单的实现方式来满足实际的性能需求,只有当测量结果显示简单设计已无法再提升性能时,才增加复杂性。那种稍欠巧妙但更易于理解的系统通常能保持更好的长期表现。
将指标视为信号,而非目标
基准测试工具之所以重要,是因为它们能让那些不可见的特性变得可检测。问题在于,当追求更高分数比改进产品本身更为重要时,一切就都出错了。
Lighthouse 的高分并不保证良好的交互设计、可维护的架构、可靠的生产环境表现,或是能快速完成用户关心的任务。实验室测试是在受控条件下进行的;而真实访问者会使用不同的设备、网络环境、数据量、认证状态及导航路径。正因如此,从实际使用场景中收集的字段数据,如 Core Web Vitals 指标,便能起到很好的补充作用。
这并非说实验室指标不重要,只是改变了使用方式:
- 如果某项指标反映出真实问题,就需要对其进行深入调查。
- 如果某种改动提升了分数,但却增加了大量复杂性,而用户却几乎察觉不到变化,那么就需要重新审视这种权衡。
- 每项指标都应与可描述的用户行为相关联。
目标并非打造在基准测试中表现优异的应用,而是让用户能够毫无不必要的延迟或阻碍地完成工作。
以测量为先的工作流程
将这些理念整合起来,一个可持续的循环如下:
- 明确用户面临的问题以及代表该问题的指标。
- 在实验室环境中进行测量,条件允许的话也在实际使用环境中进行测量。
- 追踪整个请求及渲染路径,找出主要的成本所在。
- 首先尝试减少工作量的改进措施:减少依赖项、缩小客户端边界、减小数据量、加快查询速度。
- 只有当上述措施不够时,才考虑使用记忆化、额外缓存或自定义渲染技术。
- 再次进行测量,只有当收益足以抵消维护成本时才保留该改进。
把客户端边界放在叶子组件上
在页面上写 'use client' 会把数据请求拖进浏览器包。页面应保持为服务端组件。
// app/dashboard/page.tsx — a Server Component, no 'use client'
import { Chart } from './chart';
export default async function Dashboard() {
const points = await loadSeries();
return <Chart points={points} />;
}
只有剖析显示渲染本身很贵时,再去记忆化这个叶子组件。
'use client';
export const Chart = ({ points }: { points: number[] }) => {
return <svg data-count={points.length} />;
};
核心要点
- Next.js 中最昂贵的性能错误就是在尚未了解系统运作方式之前就进行优化,或是忘记了该进行优化。
- 大多数可维护性问题的根源在于日益增加的复杂性:过多的客户端代码、层叠式的缓存机制、本可避免的水合过程以及不必要的抽象设计。
- 通常,减少工作量比增加新机制更为有效,无论是通过减少 JavaScript 代码量、将组件保留在服务器端、简化状态管理、删除冗余请求、修复缓慢的后端调用,还是移除那些收益远不及成本的优化措施。
- 某些应用确实需要复杂的缓存或渲染策略,但这一决策应当基于数据测量和明确的诊断结果。
- 最快的应用往往是那些执行最少不必要的操作的应用。