前端Loading状态设计与性能优化实战经验分享
先看效果,再看代码
最近在优化一个老项目,用户反馈“点完按钮没反应”,其实不是没反应,是数据加载太慢,又没给 loading 状态。我赶紧加了个 loading 动画,结果发现坑不少。今天就聊聊我在实际项目中怎么处理 loading 状态的,亲测有效,尤其适合中小型项目。
最简单的做法,就是在组件里加个状态:
const [loading, setLoading] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch('https://jztheme.com/api/data');
// 处理数据
} finally {
setLoading(false);
}
};
然后在 UI 上根据 loading 显示 spinner 或遮罩。但问题来了:如果请求特别快(比如本地缓存),loading 闪一下反而干扰用户。所以我现在基本都加个最小显示时间,比如 300ms:
const fetchData = async () => {
setLoading(true);
const start = Date.now();
try {
const res = await fetch('https://jztheme.com/api/data');
// 处理数据
} finally {
const elapsed = Date.now() - start;
if (elapsed < 300) {
await new Promise(resolve => setTimeout(resolve, 300 - elapsed));
}
setLoading(false);
}
};
别小看这 300ms,体验提升很明显。我试过 200ms,还是有点闪;400ms 又觉得卡。300ms 是反复调出来的平衡点。
这个场景最好用:局部 loading vs 全局 loading
很多人一上来就搞全局 loading 遮罩,结果页面一动不能动,连返回按钮都点不了。我建议:优先用局部 loading。
比如表单提交,只在提交按钮上加 loading 状态:
<button disabled={loading}>
{loading ? '提交中...' : '提交'}
</button>
或者列表加载,只在列表容器里加 spinner:
<div className="list-container">
{items.length > 0 ? (
items.map(item => <Item key={item.id} />)
) : loading ? (
<div className="spinner">加载中...</div>
) : (
<div>暂无数据</div>
)}
</div>
全局 loading 只在两种情况用:1)页面首次加载关键数据;2)全站级操作(比如登出)。其他时候,局部 loading 更友好。
踩坑提醒:这三点一定注意
第一,**别忘了 finally**。我见过太多人只在 then 里设 setLoading(false),一旦接口报错,loading 就永远停不下来。一定要用 try...catch...finally,或者 .finally():
fetchData()
.then(handleSuccess)
.catch(handleError)
.finally(() => setLoading(false));
第二,**多个请求共享 loading 状态要小心**。比如同时发两个请求,你不能等第一个结束就关 loading,得等两个都结束。这时候用计数器更稳:
let loadingCount = 0;
const startLoading = () => {
loadingCount++;
if (loadingCount === 1) {
setLoading(true);
}
};
const endLoading = () => {
loadingCount--;
if (loadingCount === 0) {
setLoading(false);
}
};
第三,**防抖和 loading 别混用**。比如搜索框输入即搜,很多人加了防抖,但没处理 loading。结果用户快速输入时,旧请求还在 loading,新请求已经发出,UI 状态就乱了。我的做法是:每次新请求前,取消上一个请求(用 AbortController),并重置 loading 状态。
let abortController;
const handleSearch = (query) => {
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
setLoading(true);
fetch(https://jztheme.com/api/search?q=${query}, {
signal: abortController.signal
})
.then(res => res.json())
.then(data => setResults(data))
.catch(e => {
if (e.name !== 'AbortError') {
console.error(e);
}
})
.finally(() => {
if (!abortController.signal.aborted) {
setLoading(false);
}
});
};
这段代码折腾了我半天,主要是 finally 里要判断是否被 abort,否则会把新请求的 loading 关掉。
高级技巧:骨架屏 + 智能 loading
对于内容型页面(比如文章详情、商品页),我越来越倾向用骨架屏(Skeleton Screen)代替 spinner。用户看到的是“即将出现的内容结构”,心理预期更稳。
实现起来也不难,用 CSS 或组件库的 Skeleton 组件就行。关键是要和真实数据结构一致:
{loading ? (
<div className="skeleton-card">
<div className="skeleton-avatar"></div>
<div className="skeleton-line short"></div>
<div className="skeleton-line long"></div>
</div>
) : (
<div className="real-card">
<img src={user.avatar} />
<h3>{user.name}</h3>
<p>{user.bio}</p>
</div>
)}
配合前面说的最小显示时间,效果更好。另外,如果数据来自缓存(比如 SWR、React Query),可以做到“几乎无 loading”——首次加载显示骨架屏,后续刷新直接用缓存数据,loading 瞬间消失,体验丝滑。
说到 React Query,如果你项目里用了这类数据管理库,强烈建议用它的 isLoading 状态,它内部已经处理了竞态、重复请求等问题,比手写省心多了。
结尾碎碎念
loading 看似简单,但细节决定体验。我现在的项目里,基本遵循这几个原则:局部优先、加最小显示时间、用骨架屏替代 spinner、关键路径用全局 loading。虽然不是 100% 完美(比如极端网络下仍有闪烁),但用户反馈明显变好了。
以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多,比如结合 Web Worker 做复杂计算时的 loading、SSR 下的 loading 状态同步等,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论