前端Loading状态设计与用户体验优化实践

皇甫怡彤 优化 阅读 754
赞 26 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月刚收尾一个数据看板项目,前端用的 React + TypeScript,后端接口响应时间不太稳定,尤其在高峰期经常卡个 1-2 秒。用户一点击就干等着,体验特别差。所以一开始我就决定加 Loading 状态——不是那种花里胡哨的动画,而是简单、明确、能传递“正在处理”的反馈。

前端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 那套会更优雅,但我们技术栈不支持。所以——在约束条件下做最务实的选择,才是前端日常。

有更优的实现方式?欢迎评论区交流!后续我还会分享更多这类“不完美但能跑”的实战技巧。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论