前端Loading状态设计与性能优化实战经验分享

Mr.彩云 优化 阅读 2,072
赞 34 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

最近在优化一个老项目,用户反馈“点完按钮没反应”,其实不是没反应,是数据加载太慢,又没给 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 状态同步等,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

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

暂无评论