
🎉 今日特别福利:大年初二快乐! 值此马年新春佳节,前端达人送给大家 6000个微信红包封面 免费领取!
在深入本文之前,强烈建议先读完前几篇,知识是有递进关系的:
你有没有遇到过这种场景:
用户打开你的页面,三个组件同时向同一个接口发起请求——服务器收到了三份一模一样的请求。你们老板眼睛一瞪:"我们后端为什么这么慢?流量好像翻了几倍?"
你尴尬地打开控制台,发现 Network 面板里密密麻麻全是重复请求……
或者是这样:用户手机断网了一秒钟,结果整个页面白屏,还附赠一个无情的报错弹窗——这不是应用在"保护"用户,这是在"抛弃"用户。
大多数 React 应用在"能跑"的状态下发布了,但从来没有被认真"保护"过。 本篇,我们就来聊聊让应用真正健壮的两件事:性能优化 和 规模化的错误处理。
先打个比方。你在公司群里问了一个问题,结果有三个同事同时在私聊里找你要相同的答案。聪明的做法不是回答三次,而是在群里统一回一次,让所有人看到。
请求去重(Request Deduplication)就是这个思路。当你的页面顶部导航、侧边栏、主内容区同时需要用户信息时,不应该发出三个 /api/user/profile 请求——只发一个,三处共享结果。
组件A ──┐
组件B ──┼──→ [去重管理器] ──→ 只发一次 HTTP 请求 ──→ 服务器
组件C ──┘ ↑
返回同一个 Promise,三个组件都拿到数据
手动实现版本:
// 用 Map 存储正在进行中的请求
const pendingRequests = newMap();
asyncfunction deduplicatedFetch(url) {
// 如果这个请求已经在飞行中,直接返回那个 Promise
if (pendingRequests.has(url)) {
return pendingRequests.get(url); // 插队共享,不重新发请求
}
// 第一次请求,正式发出去
const promise = fetch(url).then(r => r.json());
pendingRequests.set(url, promise);
try {
const data = await promise;
return data;
} finally {
// 请求结束后清理,下次还能正常发
pendingRequests.delete(url);
}
}
// 10 个组件同时调用,只有 1 个真实网络请求
const data = await deduplicatedFetch('/api/users');
💡 好消息:React Query 自动帮你做了这件事。但理解原理,遇到问题你才不会抓瞎。
想象你去一家很懂你的餐厅。你刚坐下,服务员已经把你最常点的菜提前备好了——你看菜单的时候,厨房已经开始准备了。
这就是 预取(Prefetching)。
用户的操作是有规律的:他们在列表页鼠标悬停某个用户头像,很大概率要点进去看详情。这0.3秒的悬停时间,就是我们偷偷加载数据的黄金窗口。
function UserListItem({ user }) {
const queryClient = useQueryClient();
return (
<li
onMouseEnter={() => {
// 用户悬停的瞬间,悄悄预加载详情页数据
queryClient.prefetchQuery({
queryKey: ['users', user.id],
queryFn: () => fetchUserDetails(user.id),
});
}}
>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
);
}
用户点击的时候,数据已经在缓存里了。页面瞬间加载,用户以为你的服务器很快,其实是你比他更了解他。
你公司有50个员工。老板改了一下自己的头像,结果整个公司所有人的工卡都重新打印了一遍——这荒唐吗?
但很多 React 应用就是这么干的。一个深层状态变了,整棵组件树从头渲染到脚。
React.memo 就是给组件装了个"门卫":如果你的 props 没变,就不让重新渲染进门。
import { memo } from 'react';
// 加了 memo 的 UserCard,只有 user 这个 prop 真正变化时才重新渲染
const UserCard = memo(({ user }) => {
console.log('渲染用户:', user.id); // 没变化?这行不会打印
return <div>{user.name}</div>;
});
⚠️ 新手常见误区:不是所有组件都要加
memo,频繁变化的组件加了反而更慢(因为每次都要做 props 比较)。用在渲染开销大、但 props 变化少的组件上才值。
再打个比方。你去图书馆找书,管理员不会把10万本书全摆在你面前——他只把你当前视野范围内的书架展示给你,你往前走,前面的书架才出现,后面的收起来。
这就是 虚拟滚动(Virtual Scrolling)。
┌─────────────────────────────────┐
│ 可见区域 (浏览器实际渲染) │
│ ┌─────────────────────────────┐ │
│ │ Item 45 │ │
│ │ Item 46 │ │ ← DOM 中只有这几个节点
│ │ Item 47 │ │
│ │ Item 48 │ │
│ └─────────────────────────────┘ │
│ ... 下方 9952 条数据存在内存里 │
│ 但 DOM 里根本没有渲染它们 │
└─────────────────────────────────┘
用 @tanstack/react-virtual 实现:
import { useVirtualizer } from'@tanstack/react-virtual';
import { useRef } from'react';
function VirtualUserList({ users }) {
const parentRef = useRef();
const virtualizer = useVirtualizer({
count: users.length, // 总数据量:哪怕1万条
getScrollElement: () => parentRef.current,
estimateSize: () =>50, // 每行大约50px高
});
return (
// 固定高度的滚动容器
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
{/* 占位高度:让滚动条看起来是"完整"的 */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{/* 只渲染可视区域内的 item */}
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{users[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
渲染10条还是10000条,DOM 节点数量基本相同。 这是数据密集型后台系统的必备武器。
高楼大厦为什么每层都有防火门?不是为了防止所有火灾,而是把火势控制在一个区域内,不蔓延到整栋楼。
ErrorBoundary 就是 React 的防火门。没有它,一个组件的报错会导致整页白屏;有了它,只有出错的那个区域崩掉,其他部分继续正常运行。
┌─────────────────────────────────────────┐
│ App │
│ ┌───────────────────────────────────┐ │
│ │ ErrorBoundary (防火门) │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 用户模块 ✅ │ │ 订单模块 💥│ │ │
│ │ └─────────────┘ └──────┬──────┘ │ │
│ │ ↓ │ │
│ │ 显示友好错误提示 │ │
│ │ [重试按钮] │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
用户模块完好,只有订单模块提示错误
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h2>😅 这里出了点小问题</h2>
<p style={{ color: '#666' }}>{error.message}</p>
<button onClick={resetErrorBoundary}>重新试试</button>
</div>
)}
>
<YourApp />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
你打电话给朋友,对方没接。你会怎么做?
❌ 蠢的做法:每隔1秒疯狂重拨,连续打100次 ✅ 聪明的做法:等1秒打一次,再等2秒,再等4秒……对方可能只是在忙
这就是 指数退避(Exponential Backoff)。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// 404 是"真的没有",不要傻乎乎地重试
if (error.status === 404) returnfalse;
// 其他错误,最多重试3次
return failureCount < 3;
},
retryDelay: (attemptIndex) => {
// 第1次失败等1秒,第2次等2秒,第3次等4秒
// 最长不超过30秒
returnMath.min(1000 * 2 ** attemptIndex, 30000);
},
},
},
});
请求失败
↓
等待 1 秒 → 第1次重试 → 失败
↓
等待 2 秒 → 第2次重试 → 失败
↓
等待 4 秒 → 第3次重试 → 成功 ✅ (或彻底报错 ❌)
你去一家餐厅,服务员告诉你厨房着火了。你会怎么做?
❌ 傻的做法:每隔5秒问一次"好了吗好了吗好了吗",然后把服务员烦死 ✅ 聪明的做法:知道没戏了,等30分钟再来问,或者换一家餐厅
熔断器(Circuit Breaker)就是这个道理:连续失败5次后,自动"断路",停止请求一分钟,既保护你的用户体验,也不给已经在喘气的服务器再施压。
┌──────────┐
│ CLOSED │ ← 正常状态,放行请求
│ (闭合) │
└────┬─────┘
│ 连续失败 ≥ 5 次
▼
┌──────────┐
│ OPEN │ ← 断路状态,直接拒绝请求
│ (断开) │ 不发网络请求,立即报错
└────┬─────┘
│ 等待 60 秒
▼
┌───────────┐
│ HALF-OPEN │ ← 探测状态,放行一个请求试试
│ (半开) │
└────┬──────┘
成功 ↓ ↑ 失败,重新 OPEN
┌──────────┐
│ CLOSED │ ← 恢复正常
└──────────┘
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold; // 失败几次触发断路
this.timeout = timeout; // 断路持续多久(毫秒)
this.state = 'CLOSED'; // 初始状态:正常
this.nextAttempt = Date.now();
}
async call(fn) {
if (this.state === 'OPEN') {
// 断路中:检查是否可以进入半开状态
if (Date.now() < this.nextAttempt) {
thrownewError('服务暂时不可用,请稍后再试');
}
this.state = 'HALF_OPEN'; // 尝试探测一次
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED'; // 恢复正常
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
// 记录下次可以重试的时间
this.nextAttempt = Date.now() + this.timeout;
console.warn(`熔断器触发!将在 ${this.timeout/1000} 秒后重试`);
}
}
}
// 使用方式
const breaker = new CircuitBreaker(5, 60000); // 5次失败触发,断路1分钟
async function fetchWithBreaker(url) {
return breaker.call(() => fetch(url).then(r => r.json()));
}
回顾一下我们这篇学了什么:
技术 | 解决的问题 | 一句话类比 |
|---|---|---|
请求去重 | 重复请求浪费资源 | 群里回一次,不私聊三次 |
预取 | 用户等待数据加载 | 比用户先一步准备好饭菜 |
选择性渲染 | 无关组件也跟着重渲染 | 局部修路,不用封全城 |
虚拟滚动 | 大列表卡顿 | 图书馆只展示你能看到的书 |
错误边界 | 单点故障导致白屏 | 防火门隔离火势 |
指数退避 | 网络抖动导致假失败 | 智能重拨,而不是疯狂拨号 |
熔断器 | 服务器挂了还继续轰炸 | 厨房着火了,先别催菜 |
下一篇,我们会聊 请求/响应拦截器(Interceptors) 以及如何对这些代码进行 单元测试和集成测试——这是很多同学学了一堆理论之后缺失的最后一块拼图。
如果这篇文章对你有帮助,点个赞 是对阿森最大的支持!
你的一次分享,可能帮助到正在踩同样坑的同事——转发给他,他可能会请你吃饭 🍜
关注公众号 《前端达人》,我们持续更新:
我们下一层见! 🚀