
有些性能 PR 看起来很漂亮:Bundle 体积小了,特定函数快了,Lighthouse 跑分也上涨了。但合入之后,用户可能依然在反馈:页面白屏时间依然长、点击按钮后界面迟迟没有反馈、或者列表滚动时偶有卡顿。
这类问题通常不是代码算得慢,而是请求发得太早、太多,或者返回的数据远超页面需要。我们是不是很容易盯着“容易测量、一眼看得到”的代码细节,却忽略了用户实际被卡在哪一步?
性能不是单纯的“计算速度”,而是“用户等待的时间”
在生产环境里,计算性能好,不等于用户体感流畅。
假设一个数据转换函数原本耗时 8 毫秒,经过改写算法压缩到了 3 毫秒。从单点执行上看确实快了;但如果在同一个交互流程中,界面还在等待一个可以避免的网络请求,那在 CPU 上节省下来的 5 毫秒,在用户的实际等待中很难被感知到。
排查性能问题,比起直接琢磨“怎么把这段函数写得更快”,更有效的第一步通常是搞清楚:
此时此刻,用户到底在等什么?
从用户发起操作到界面完成更新,浏览器跨越了网络、解析、执行、渲染等多个阶段。不同阶段的问题,对应着不同的排查工具:
- 如果是首屏加载慢、白屏时间长、数据迟迟不出来,往往是网络加载路径被阻塞,应该优先打开 Chrome DevTools 的 Network 面板,观察请求时序、Waterfall 瀑布流和响应体积;
- 如果是点击按钮没有即时反馈、输入内容掉帧、页面滚动不跟手,说明主线程可能被 Long Task 独占,或者触发了昂贵的重排重绘,这时应优先进入 Performance 面板,排查 JavaScript 执行耗时与渲染管线。
加载与时序瓶颈:先在 Network 面板看这 3 处
在排查数据加载慢的页面时,Network 面板通常能帮我们快速定位以下 3 个常见的时序问题:
1. 串行请求瀑布(Waterfall)
有时数据请求被分散在多个组件或 Hook 中,执行时序又彼此串联,最终形成连续的串行等待:
// 串行阻塞示意
async function loadDashboard() {
const user = await fetchUser(); // 假设耗时约 80ms
const permissions = await fetchPermissions(); // 假设耗时约 60ms
const config = await fetchConfig(); // 假设耗时约 70ms
const dashboard = await fetchDashboardData(); // 假设耗时约 100ms
render(user, permissions, config, dashboard);
}
假设这 4 个接口本身耗时分别约为 80ms、60ms、70ms 和 100ms。如果采用串行调用,必须等前一个返回才能发起下一个,总网络等待时间将累加到 310ms 左右。
在这些接口互相没有参数依赖、且未触发浏览器同域名并发连接数上限排队的情况下,改用并发拉取能让等待时间接近最慢的单个接口:
// 并发拉取示意
async function loadDashboard() {
const [user, permissions, config, dashboard] = await Promise.all([
fetchUser(),
fetchPermissions(),
fetchConfig(),
fetchDashboardData(),
]);
render(user, permissions, config, dashboard);
}
工程边界提示:Promise.all 只要其中任意一个 Promise reject,整体就会立即 reject。如果业务场景允许非核心内容(如配置项或通知)降级展示,应考虑模块独立加载,或使用 Promise.allSettled() 收集各请求结果,再按业务规则处理成功和失败情况。
2. 高频输入的网络风暴与响应竞态
在搜索输入场景中,如果每敲一个字母就发一次请求,不仅会制造大量冗余的网络往返,还容易引发时序竞态——先发出的慢请求若晚于后发出的快请求返回,会导致旧数据覆盖新结果。
在用户输入时,先在入口递增请求序号,并在防抖窗口内拦截高频触发;对已发出的请求尝试中止,即使旧请求晚于新请求返回,也不会让旧结果覆盖新结果:
let latestRequestId = 0;
let activeController = null;
let debounceTimer = null;
function handleSearch(query) {
// 1. 在用户输入的一瞬间立即递增请求序号
// 确保在防抖等待期内返回的旧响应也会被判定为失效,避免界面短暂被旧数据覆盖
const currentRequestId = ++latestRequestId;
// 2. 防抖拦截高频连续触发
clearTimeout(debounceTimer);
// 3. 尝试在客户端中止前一个在途请求
// 说明:AbortController 可以让浏览器侧的 fetch 或响应体消费进入中止状态,但不等同于服务端取消了已经开始的处理
if (activeController) {
activeController.abort();
}
debounceTimer = setTimeout(async () => {
const controller = new AbortController();
activeController = controller;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
// fetch 遇到 HTTP 404/500 不会自动 reject,需显式检查 res.ok
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
// 4. 请求序号校验:确保只有与最新输入序号对齐的结果才会被渲染
if (currentRequestId === latestRequestId) {
renderSearchResults(data);
}
} catch (err) {
if (err.name !== 'AbortError') {
showError(err);
}
} finally {
// 5. 避免并发覆盖:只清理当前闭包对应的 controller
if (activeController === controller) {
activeController = null;
}
}
}, 150);
}
在 150ms 的防抖窗口内,连续键入通常只会真正向网络发起最后一次请求;对已发出的请求尝试中止,即使旧请求晚于新请求返回,也不会让旧结果覆盖新结果。
3. 响应体过大与全量传输(Oversized Payload)
有时页面只需要展示包含 3 个字段的简易下拉列表,接口却将包含完整描述、扩展配置等几十个字段的全量数据一次性返回。
除了网络传输体积的膨胀,大体积 JSON 还会带来反序列化与临时对象分配的额外开销,容易诱发主线程 GC 停顿。这时候继续研究 for、map 还是 reduce,通常解决不了主要问题;和后端一起把返回字段、分页方式定下来,收益更直接。
优化的思考层级:避免不必要的操作
排查并解决性能问题时,通常有以下先后层级:
- 先考虑是否能完全避免这项操作:不发起冗余请求、不计算用不到的数据、不提前渲染当前视口外的组件。
- 再考虑调整执行的时机与位置:通过路由懒加载和代码分割,避免在首屏主路径加载低频模块;将大批量数据过滤移至服务端或分页获取。
- 最后优化必须执行的代码:针对确定存在性能瓶颈的热点逻辑,更换更合适的数据结构,或针对长列表引入虚拟滚动。
客观认识 React 中的 useMemo 与 useCallback
在组件级优化中,常见的一个误区是看到组件重渲染就本能地套用 useMemo 或 useCallback。
根据 React 官方文档,useMemo 和 useCallback 是有针对性的优化工具,而不是无条件的前置默认写法:
- 它们在底层会保存缓存值与依赖项数组,并在每次重新渲染时逐项比对依赖(内部采用
Object.is 判断);定义内联函数本身并不会因为套用了 useCallback 就免除函数字面量的创建开销。
- 它们的核心价值在于两类场景:确实包含昂贵的计算逻辑(如大量数据的转换排序),或者需要将稳定的对象/函数引用传递给已使用
React.memo 做浅比较优化的子组件。
- 另外,如果项目已经启用 React Compiler,还需要结合编译配置重新判断手动 memo 的必要性。
脱离性能测量到处包裹 memo,既增加了代码维护成本,也未必能带来体感上的改善。
遇到卡顿时的排查路径
当遇到性能反馈时,先定位问题发生的具体环节,再选择对齐的分析工具:
- 首屏阶段(Loading):检查核心内容渲染前阻塞了哪些资源,是否存在同步外链样式或脚本,关键 HTML/CSS 的传输是否顺畅。
- 网络链路(Network):检查是否存在无依赖的串行接口瀑布,是否有高频重复调用的接口,响应体体积是否存在过度返回。
- 主线程耗时(JavaScript):利用 Performance 面板录制用户操作,检查是否存在超过 50ms 独占主线程的 Long Task,定位具体耗时的函数。
- 渲染绘制(Rendering):检查 DOM 节点规模是否过大,交互时是否存在频繁读取样式导致强制同步布局(Layout Thrashing)的情况。
- 内存与增长(Memory):排查长时间运行后页面是否持续卡顿,检查是否存在未注销的全局监听、定时器或未释放的 DOM 引用。
- 真实设备体验:开发机器通常配置较好,在 DevTools 中适当开启 CPU 降速与网络节流,有助于在开发阶段近似模拟低端设备和弱网环境;关键问题仍应在代表性真实设备上复测。
总结
所以,遇到“页面慢”,先定位用户到底在等网络、等主线程还是等渲染,再决定去优化哪一部分。
参考来源与技术文档
- 权威规范与文档支持:
- Chrome DevTools Network reference
- Chrome DevTools Performance reference
- MDN Web Docs: AbortController
- MDN Web Docs: Fetch API
- MDN Web Docs: Promise.all()
- MDN Web Docs: Promise.allSettled()
- React Documentation: useMemo
- React Documentation: useCallback