前端Loading状态设计与用户体验优化实践
又踩坑了,Loading 状态闪一下就没了
前几天改一个老项目,用户反馈说点“加载更多”按钮的时候,Loading 动画几乎看不到,一闪就没了。我一开始以为是网络太快,结果本地 mock 数据也这样——根本不是快,是压根没显示出来。
这问题其实挺常见的,但每次遇到都得重新理一遍逻辑。这次我决定彻底搞清楚,顺便把方案整理下来,省得下次再翻车。
折腾了半天,发现是状态更新时机不对
最开始我以为是 CSS 动画太短,或者被覆盖了。查了下样式,没问题;加了 console.log 打印 loading 状态,发现它确实被设为 true 了,但紧接着就被设回 false,中间间隔可能不到 10ms。人眼根本反应不过来。
问题出在哪儿?我用的是 React + useEffect 拉数据:
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const res = await fetch('/api/data');
setData(res);
setLoading(false);
};
fetchData();
}, []);
看起来没问题对吧?但如果你的接口返回特别快(比如本地开发时 mock 数据),setLoading(true) 和 setLoading(false) 几乎在同一帧执行,React 的批处理机制会让这两个状态更新合并成一次渲染,结果就是 loading 状态压根没机会展示。
这里我踩了个坑:以为只要写了 setLoading(true) 就一定会触发一次 re-render,但实际上 React 在事件回调或异步函数中会批量处理状态更新,如果两个相反的状态挨得太近,UI 根本来不及响应。
试了三种方案,最后选了最稳的
我先试了最简单的办法:加个 setTimeout 延迟关闭 loading。
setLoading(true);
const res = await fetch('/api/data');
setData(res);
setTimeout(() => setLoading(false), 300);
行是行,但总觉得不优雅。万一接口真的慢,300ms 又显得多余;而且如果用户连续点击,还可能造成状态混乱。
接着我想到用 requestAnimationFrame,确保至少等一帧再关掉 loading:
setLoading(true);
const res = await fetch('/api/data');
setData(res);
requestAnimationFrame(() => {
setLoading(false);
});
这个比 setTimeout 好一点,因为和浏览器渲染同步,但实测在某些低端机上还是可能闪一下就没了,不够保险。
最后我用了个更靠谱的思路:**强制让 loading 状态至少持续 200ms**。不管接口多快,Loading 都要“撑够时间”。这样用户体验一致,也不会因为网络波动导致动画忽有忽无。
实现也不难,核心就是记录开始时间,然后根据实际耗时决定是否需要补足时间:
const [loading, setLoading] = useState(false);
const fetchData = async () => {
const startTime = Date.now();
setLoading(true);
try {
const res = await fetch('https://jztheme.com/api/data');
const data = await res.json();
setData(data);
} finally {
const elapsed = Date.now() - startTime;
const minDuration = 200; // 最少显示200ms
if (elapsed < minDuration) {
setTimeout(() => setLoading(false), minDuration - elapsed);
} else {
setLoading(false);
}
}
};
这个方案亲测有效。哪怕接口 10ms 返回,Loading 也会稳稳显示 200ms;如果接口花了 500ms,那就直接关掉,不额外等待。既保证了视觉反馈,又不会拖慢真实体验。
这里注意我踩过好几次坑:一定要用 finally 块来处理 loading 关闭,否则一旦请求出错,loading 会一直卡住。别问我怎么知道的……
封装成 Hook,以后直接复用
既然这个逻辑通用,干脆抽成自定义 Hook。以后所有需要 Loading 的地方都能一键接入,不用重复写时间控制逻辑。
// useApiLoading.js
import { useState } from 'react';
export const useApiLoading = (minDuration = 200) => {
const [loading, setLoading] = useState(false);
const withLoading = async (asyncFn) => {
const startTime = Date.now();
setLoading(true);
try {
const result = await asyncFn();
return result;
} finally {
const elapsed = Date.now() - startTime;
if (elapsed < minDuration) {
setTimeout(() => setLoading(false), minDuration - elapsed);
} else {
setLoading(false);
}
}
};
return [loading, withLoading];
};
用起来也简单:
const [loading, withLoading] = useApiLoading();
const handleLoadMore = () => {
withLoading(async () => {
const res = await fetch('https://jztheme.com/api/more');
return res.json();
}).then(data => {
setList(prev => [...prev, ...data]);
});
};
这样业务代码里完全不用关心 timing 问题,Hook 内部全包了。而且 minDuration 还可以按需调整,比如某些关键操作可以设长一点(比如支付),普通列表加载就用默认 200ms。
改完后测试了几轮,Loading 终于稳了。虽然有个小问题:如果用户在 200ms 内快速点击两次,第二次请求的 loading 会覆盖第一次的结束逻辑,但实际影响不大——因为数据最终会正确更新,loading 也只是多闪一下,无伤大雅。
核心代码就这几行,但细节真不少
回头想想,这个看似简单的问题,其实涉及了 React 的状态批处理、浏览器渲染机制、用户体验一致性等多个层面。很多人可能就加个 setTimeout 草草了事,但那样埋着雷。
另外提醒一点:如果你用的是 Suspense + lazy,那 Loading 控制是另一套逻辑,本文不适用。我这个方案主要针对手动管理 loading 状态的场景。
还有,别忘了在 loading 期间 disable 按钮,防止重复提交。不然用户狂点“加载更多”,可能发起一堆请求,最后数据乱序。这个虽然是老生常谈,但每次都有人漏掉。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合防抖、错误重试等,后续会继续分享这类博客。

暂无评论