TOP

React Suspense

JND 优化

JND(Just Noticeable Difference)如果有用过 spin 组件就能理解闪烁这个概念,即在数据快速更新的时候,反复切换 loading 状态反而用户的体验不好。

闪烁问题

场景:请求数据->立即触发 loading->实时获取数据->隐藏loading,此时页面会闪烁一个loading。

JND 是一个阶梯式的更新策略

主要利用人对短时间差异不敏感的特点,在数据可能很快到达时,延迟显示 loading,避免闪烁。通过阶梯式阈值,等待越久,可容忍的额外等待时间越长。以下是使用 JND 优化后的场景:

function jnd(timeElapsed) {
  return timeElapsed < 120
    ? 120
    : timeElapsed < 480
    ? 480
    : timeElapsed < 1080
    ? 1080
    : timeElapsed < 1920
    ? 1920
    : timeElapsed < 3000
    ? 3000
    : timeElapsed < 4320
    ? 4320
    : ceil(timeElapsed / 1960) * 1960;
}

场景一:用户点击,50ms 后数据未到,先继续等待 70ms,如果 70ms 内已经等到,则直接展示数据。

场景二:已等待 500ms,继续等待 580ms。

心理学依据

权衡

为什么会有多个阈值呢?而不是统一命中底第一个 case

timeElapsed 表示用户已经为当前调度任务等待的真实时间,它由 Scheduler 在任务创建时记录并持续累积,用于通过 JND 模型动态决定调度超时与反馈时机,而非衡量 Promise 或 render 本身的耗时。

const now = ()=>performance.now();
task.startTime = now();
//	在后续的调度判断中
timeElapsed = now() - task.startTime;

timeElapsed = 用户从”发起这个更新”到”现在还没看到结果”的时间,这是一个用户感知时间,不是技术耗时,再结合心理学的认识,「等待越久,对额外等待的容忍度越高」,所以才会在 JND 函数中等待更多的时间。

RootSuspendedRootSuspendedWithDelay 的差异

RootSuspended 的核心概念

Suspense 内部抛出了 Promise,且 React 认为当下已经可以展示 fallback

  1. 已经超过了 suspense delay 时间
  2. 这是一个非过渡的更新,不使用 transition
  3. 用户主动触发的更新,需要立即反馈

因此,这次 render 会被中断,Root 标记为 RootSuspended,首次渲染会提交 fallback,或者等待 resolve 后重新调度 render。

RootSuspendedWithDelay 的核心概念

给 fallback 一段时间,等待这段时间后,再渲染 fallback:

  1. 必须是并发渲染的模式下
  2. 更新是过渡的,即 startTransition
    • 输入框 onChange 是普通更新,不触发
    • 点击按钮 setState 是普通更新
    • 非 transition 的路由跳转也是普通更新
  3. Suspense 必须抛出 Promise
  4. 没有必要立刻展示 fallback 的场景

因此,先保留旧 UI,监听 promise resolve 或者等待 delay 时间,如果在等待期已经收到了 promise resolve,那就直接渲染无需 fallback 了。

非必须立即展示的含义

如果不更新,屏幕上是否有一个可接受的旧 UI:

在 delay 场景下,再会考虑 JND 优化,即本来已经等待了这些时间了,就再等一会再展示 fallback 了。

T = 0ms
用户触发 transition 更新

T = 5ms
Suspense 抛 Promise
→ RootSuspendedWithDelay
→ 保留旧 UI

T = 80ms
Promise resolve
→ 直接恢复 render
→ 无 fallback
→ 用户无感

-------------------------

另一个分支:

T = 0ms
transition 更新

T = 5ms
RootSuspendedWithDelay

T = 200ms
Promise 仍未 resolve
→ 超过 JND 安全区
→ 升级为 RootSuspended

T = 210ms
fallback commit
→ 用户感知 loading(但合理)