前端Loading状态设计与用户体验优化实践
项目初期的技术选型
上个月刚收尾一个数据看板项目,前端用的 React + TypeScript,后端接口响应时间不太稳定,尤其在高峰期经常卡个 1-2 秒。用户一点击就干等着,体验特别差。所以一开始我就决定加 Loading 状态——不是那种花里胡哨的动画,而是简单、明确、能传递“正在处理”的反馈。
技术选型没想太多,直接用 Ant Design 的 Spin 组件,因为项目里已经引入了 AntD,开箱即用。但后来发现,这种“全局一把梭”的做法埋了不少雷。
最开始的 naive 实现
一开始图快,直接在每个数据请求的地方包一层 Spin:
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch('https://jztheme.com/api/data');
const result = await res.json();
setData(result);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
然后在 JSX 里这么写:
{loading ? <Spin /> : <DataComponent data={data} />}
看起来没问题,对吧?上线前测试也 OK。但一到真实用户场景,问题就来了。
最大的坑:Loading 抖动和闪烁
用户反馈说“页面一直在闪”,我一开始还不信,直到自己用弱网模拟器试了下——好家伙,接口有时候 200ms 就返回,有时候 800ms。结果就是:点一下,Spin 刚出来,数据就回来了,立马消失。视觉上就是“闪一下”,特别难受,甚至比没 Loading 还糟。
更麻烦的是,有些页面有多个并行请求(比如同时拉用户信息、配置、统计数据),只要其中一个快,其他还在 loading,整个区域就反复切换状态,抖得眼睛疼。
折腾了半天,发现核心问题在于:**Loading 状态太敏感,没有做防抖或最小展示时间**。
加个最小展示时间,稳住
我参考了 Google 和 GitHub 的做法——它们的 Loading 都会至少显示 300ms,哪怕数据秒回。这样避免了闪烁,用户感知也更一致。
于是改了下逻辑:设置一个最小展示时间(比如 300ms),即使数据提前回来,也要等够时间再隐藏。
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const fetchData = async () => {
setLoading(true);
const startTime = Date.now();
try {
const res = await fetch('https://jztheme.com/api/data');
const result = await res.json();
setData(result);
// 计算已过去的时间
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, 300 - elapsed); // 至少显示 300ms
// 延迟关闭 loading
timeoutId = setTimeout(() => {
setLoading(false);
}, remaining);
} catch (err) {
setLoading(false); // 错误时立即关闭
}
};
fetchData();
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, []);
这个改动亲测有效!再也不闪了。但注意:**错误情况要立刻关掉 loading**,不然用户会以为还在加载,其实已经失败了——这点我踩过坑,千万别忘。
多个请求的 Loading 合并
另一个头疼的问题是:一个页面有 3 个接口,怎么统一管理 loading?最初我是给每个接口单独设 loading 状态,结果 UI 要判断 loading1 || loading2 || loading3,代码又臭又长。
后来改成用计数器:
const [pendingCount, setPendingCount] = useState(0);
const startLoading = () => setPendingCount(c => c + 1);
const endLoading = () => setPendingCount(c => Math.max(0, c - 1));
// 封装请求函数
const request = async (url: string) => {
startLoading();
try {
const res = await fetch(url);
return await res.json();
} finally {
endLoading();
}
};
// 使用
useEffect(() => {
Promise.all([
request('https://jztheme.com/api/user'),
request('https://jztheme.com/api/config'),
request('https://jztheme.com/api/stats')
]).then(([user, config, stats]) => {
// 处理数据
});
}, []);
然后 UI 层只看 pendingCount > 0 就行。清爽多了!不过要注意:**必须用 finally 确保计数器能减回去**,否则一次网络错误就会让 loading 永远不消失。
骨架屏 vs Spin:我们选了折中方案
其实团队里有人提议上骨架屏(Skeleton),说更高级。我也试了,但发现两个问题:一是维护成本高,每个组件都要写 skeleton 版本;二是数据结构一变,骨架就得跟着改,容易遗漏。
最后我们折中:**关键区域用骨架屏,次要区域用 Spin**。比如主表格用骨架,侧边栏小卡片用 Spin。这样既保证核心体验,又不至于被维护拖垮。
骨架屏代码大概是这样(用 AntD 的 Skeleton):
{loading ? (
<TableSkeleton row={5} />
) : (
<Table dataSource={data} />
)}
其中 TableSkeleton 是我们封装的,内部用 Skeleton.Input 拼出来的。虽然还是得写,但只针对高频核心组件,能接受。
回顾与反思
这次 Loading 改造整体效果不错,用户反馈“感觉快了”(其实接口没变快,只是反馈更友好)。但也有没完全解决的问题:
- 某些极端弱网下,300ms 最小展示时间反而让用户觉得“卡”——但权衡后觉得比闪烁好
- 计数器方案在并发极高时可能有 race condition,但我们业务量不大,暂时没出问题
另外,其实可以进一步抽象成自定义 Hook,比如 useLoadingState(),把最小展示时间、计数逻辑都封装进去。但项目赶时间,就先这样了——技术债嘛,谁没有呢。
总的来说,Loading 看似简单,但要做好细节很多。核心就两点:别让 loading 闪,别让 loading 撒谎(比如失败了还转圈)。
结尾碎碎念
以上是我在这个项目里关于 Loading 状态的实战总结,从踩坑到修坑,基本覆盖了常见问题。如果你也在搞类似的东西,希望这些经验能帮你少走点弯路。
这个方案肯定不是最优解,比如用 Suspense + Relay 那套会更优雅,但我们技术栈不支持。所以——在约束条件下做最务实的选择,才是前端日常。
有更优的实现方式?欢迎评论区交流!后续我还会分享更多这类“不完美但能跑”的实战技巧。

暂无评论