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

Mc.乙豪 优化 阅读 1,214
赞 17 收藏
二维码
手机扫码查看
反馈

又踩坑了,Loading 状态闪一下就没了

前几天改一个老项目,用户反馈说点“加载更多”按钮的时候,Loading 动画几乎看不到,一闪就没了。我一开始以为是网络太快,结果本地 mock 数据也这样——根本不是快,是压根没显示出来。

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

这问题其实挺常见的,但每次遇到都得重新理一遍逻辑。这次我决定彻底搞清楚,顺便把方案整理下来,省得下次再翻车。

折腾了半天,发现是状态更新时机不对

最开始我以为是 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 按钮,防止重复提交。不然用户狂点“加载更多”,可能发起一堆请求,最后数据乱序。这个虽然是老生常谈,但每次都有人漏掉。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合防抖、错误重试等,后续会继续分享这类博客。

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

暂无评论