问题
前端项目中,几乎每个页面都有 loading 状态:发请求时显示骨架屏或转圈,请求结束后关掉。
async function loadOrders() {
loading.value = true
orderList.value = await fetchOrders()
loading.value = false
}
这看起来理所当然。但实际体验中有一个被忽视的问题——当请求太快时,loading 反而是一种负体验。
设想以下场景:
- 用户点开订单列表
- 屏幕先是正常内容
- 瞬间闪出一个全屏转圈
- 不到 200ms 又消失了
- 正文出现
这种"闪一下"比什么都不显示更糟糕。人眼捕捉到了状态切换——尽管内容早就加载完了。loading 没有被感知为"等待中",而是被感知为"页面抖了一下"。
这在小程序中尤其明显,因为很多接口在良好网络下延迟只有 200-400ms。
思路
核心策略:只有请求确实慢的时候,才值得显示 loading。
请求耗时 ≤ 300ms → 不显示 loading,直接出内容
请求耗时 > 300ms → 正常显示 loading
实现方式:不立即显示 loading,而是设一个定时器,300ms 后如果请求还没完成,才把 loading 亮出来。
如果请求在 300ms 内完成了,定时器被清除,loading 永远不会出现。
实现
useDelayedLoading 用 Vue 的 computed({ get, set }) 实现,对调用方完全透明——现有代码不需要改任何调用方式:
import { ref, computed } from 'vue'
export function useDelayedLoading(delay = 300) {
const show = ref(false)
let timer = null
const loading = computed({
get: () => show.value,
set: (val) => {
if (timer) {
clearTimeout(timer)
timer = null
}
if (val) {
timer = setTimeout(() => {
show.value = true
}, delay)
} else {
show.value = false
}
},
})
return { loading }
}
使用
对现有代码的侵入性为零。只需要两处修改:
修改前:
const loading = ref(false)
修改后:
import { useDelayedLoading } from '@/utils/useDelayedLoading'
const { loading } = useDelayedLoading()
其余一切照旧——模板绑定、赋值都无需变动:
<nut-loading-page :loading="loading" />
loading.value = true
const data = await fetchData()
loading.value = false
哪些场合不该延迟
并非所有 loading 都适合延迟:
| 场景 | 是否延迟 | 原因 |
|---|---|---|
| 页面初次加载、列表刷新 | 是 | 避免闪烁 |
| 详情/封面/媒体加载 | 是 | 同上 |
| 提交按钮的 loading 状态 | 否 | 防止重复点击,需立即响应 |
| 分页加载更多的 loading | 否 | 防止重复触发翻页 |
| 上传进度条 | 否 | 需要即时反馈 |
参数
const { loading } = useDelayedLoading(400) // 自定义 400ms
const { loading } = useDelayedLoading() // 默认 300ms
300ms 是一个经验值——大致等于人眼感知到"卡顿"的临界点,也略短于良好网络下大多数 API 请求的响应时间。
效果
优化前,快速加载页面会经历:内容 → 空 → loading → 内容,页面"抖"一次。
优化后,快请求直接走:内容,一次渲染到底。只有真正慢的请求才会看到 loading,而此时 loading 的出现是合理的、符合预期的。
