Menu

React useDeferredValue 教程:让慢的部分延后更新

useDeferredValue 给你一个值的副本,在繁重的渲染期间它允许滞后,这样输入框能保持流畅,而缓慢的列表随后再追上来。了解它的工作原理、React 19 新增的初始值,以及它与 useTransition 和防抖的对比。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

useDeferredValue 给你一个允许滞后的值的副本。当值变化时,React 会先用旧的副本重绘屏幕,让输入保持即时响应,然后在后台用新值渲染缓慢的部分。

快速输入一个单词。输入框跟得上你的速度,而列表落后时会变暗。Console 会为每个字母打印一行 SlowList rendering,因为 React 一开始渲染 SlowList,它就会打印,但中间那些值对应的列表从未显示到屏幕上:每输入一个新字母都会中断后台渲染,React 再用最新的文本重新开始。把 query={deferredQuery} 改成 query={query} 再输入一次:现在每次按键都要等列表渲染四分之一秒。

语法

const deferredValue = useDeferredValue(value);
const deferredValue = useDeferredValue(value, initialValue); // React 19
  • value 是你要渲染的任何东西:来自输入框的字符串、数字、对象。原始类型的值效果最好,因为每次渲染都新建的对象总会被视为发生了变化。
  • 返回值大多数时候等于 value。在 value 刚刚变化后的一次渲染中,它仍然保留着之前的值,随后在一次后台渲染中追上来。

工作原理

每次变化都会产生两次渲染:

  1. 一次紧急渲染,使用新的 value 和旧的 deferredValue。输入框显示新的文本。只接收 deferredValue 的组件拿到的 props 和之前相同。
  2. 一次后台渲染,使用新的 deferredValue。这是慢的那次。React 会把它分片进行渲染,如果在它完成之前 value 又变了,就放弃这次渲染,再用最新的值重新开始。

把两个值都打印出来,就能看到这个过程:

输入一个字母,比如 a,Console 会再打印两行:先是 text = "a" 加上 deferred = "",然后两者都是 "a"。第一次渲染正是让输入保持流畅的关键,但前提是缓慢的部分能跳过它。这就是第一个示例中 SlowList 要用 memo 包裹的原因:在紧急渲染期间,它的 query prop 没有变化,所以 React 会跳过它。没有 memo,紧急渲染仍然会把整个缓慢的列表跑一遍,只不过用的是旧文本,你什么也得不到。

显示过期的内容

后台渲染进行期间,value 和延迟值是不同的。比较两者,就能在不隐藏内容的情况下告诉用户内容已经过期:

const isStale = query !== deferredQuery;

<div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' }}>
    <SlowList query={deferredQuery} />
</div>

这通常比加载动画更友好:旧的结果依然可读,新结果准备好后就地替换。同样的思路也适用于 Suspense:如果延迟值让某个组件在加载数据时挂起,React 会继续显示旧的内容而不是后备内容,直到新内容准备好(见 Suspense 和 lazy)。

初始值(React 19)

默认情况下,第一次渲染没有可以滞后的对象,所以延迟值从一开始就是真实值。从 React 19 开始,你可以传入第二个参数。第一次渲染时,Hook 返回 initialValue,随后 React 在后台用真实值再渲染一次。这样你就能先立即显示一些开销小的内容,紧接着再补上开销大的部分。

Console 显示了两次渲染:第一次是 "",然后是完整的文本。去掉第二个参数,就只会打印一次渲染,直接就是完整文本。

useDeferredValue 与 useTransition

两者都把工作标记为非紧急,并使用同一套渲染机制。区别在于你把它们放在哪里:

  • useTransition 包裹的是更新。你调用 startTransition(() => setQuery(text)),所以需要拿到 setter。isPending 会直接提供给你。
  • useDeferredValue 包裹的是值。你在使用这个值的地方调用它,所以当值作为 prop 传进来,或来自一个你无法控制的 Hook 时,它也能用。“等待中”的状态需要你自己用 value !== deferredValue 计算。
// You own the state: either works, a transition is direct
startTransition(() => setQuery(nextQuery));

// You only receive the value
function SearchResults({ query }) {
    const deferredQuery = useDeferredValue(query);
    return <SlowList query={deferredQuery} />;
}

useDeferredValue 与防抖和节流

防抖会在最后一次变化后等待一段固定的时间;节流则在每个时间间隔内最多更新一次。两者都要预先选定一个延迟,所以在速度快的笔记本上,它们会让页面比实际需要的更慢,而在速度慢的手机上,这个延迟又可能仍然太短。

useDeferredValue 没有延迟。后台渲染会立即开始,所以在速度快的设备上,列表几乎立刻就会更新;在速度慢的设备上,React 会保持输入流畅,并尽可能频繁地渲染列表。后台工作还是可以中断的,而防抖后的渲染则不行:防抖后的渲染一旦开始,就会一直阻塞直到完成。

它做不到的,是减少网络请求。如果每个新值都会发起一次请求,那么延迟值仍会随着每次完成的渲染而变化,所以当请求数量很重要时,要对请求本身做防抖。

常见错误

  • 忘了 memo。 正如上面所示,缓慢的组件必须能够跳过紧急渲染。如果它没有用 memo 包裹,或者它的其他某个 prop 每次渲染都是新对象,那么每次按键它都会渲染两次,而不是一次。
  • 传入一个新对象。 useDeferredValue({ query }) 每次渲染都会创建一个新对象,所以延迟值看起来总是变了,React 每次都会多启动一次后台渲染。应该延迟原始类型的值(useDeferredValue(query)),再基于它构造对象。
  • 指望请求变少。 根据延迟值请求数据的组件,仍然会为每个完成渲染的值发起请求。

什么时候该用它

当一个输入驱动着页面上渲染缓慢的一部分、这部分可以 memo 化、而且你能接受短暂显示稍旧的结果时,就用 useDeferredValue:搜索结果、根据筛选条件重绘的图表、格式化文本的预览。如果缓慢的部分之所以慢,是因为某个你能修复的原因,比如只有 50 行可见却渲染了 5,000 行,就先修复那个问题。如果你能拿到 setter,这种模式的另一种写法请看 useTransition。

常见问题

useDeferredValue 有什么作用?

它返回一个可能落后于真实值的版本。当值变化时,React 会先用旧的延迟值渲染,再在后台用新值渲染一次,而这次后台渲染是可以被中断的。

为什么 useDeferredValue 没有让我的列表变快?

缓慢的组件必须用 memo 包裹,并且接收的是延迟值。否则紧急渲染仍然会渲染这个缓慢的组件,只不过用的是旧值,输入依然会卡。

useDeferredValue 和 useTransition 有什么区别?

useTransition 包裹的是 state 更新,所以你需要拿到 setter。useDeferredValue 包裹的是值,所以当你只能拿到这个值时它也能用,比如值作为 prop 传进来,或者来自一个你无法控制的 Hook。

useDeferredValue 是一种防抖吗?

不是。它没有固定的延迟。React 会在紧急渲染之后立即开始延迟渲染,在速度快的设备上几乎看不出滞后。它本身也不会减少网络请求。

useDeferredValue 的第二个参数是什么?

从 React 19 开始,你可以传入一个 initialValue。第一次渲染时 Hook 返回它,随后 React 会在后台用真实值再渲染一次。

Coddy 编程语言插图

和 Coddy 一起学编程

开始