Skip to content

useTransition 和 useDeferredValue 是如何优化用户体验的?它们之间有什么区别?

在日常的前端开发中,我们常常追求极致的交互体验。一个常见的挑战是:当一个操作既需要即时响应用户输入,又需要执行一个耗时的渲染任务时,如何避免界面卡顿?

设想一个场景:我们有一个搜索框,用户每输入一个字符,下方列表就需要根据输入内容进行筛选和渲染。如果列表数据量巨大,每次按键都可能触发一次昂贵的渲染,导致输入框响应迟钝,用户会感到明显的卡顿。

在 React 18 之前,我们可能会使用 debounce (防抖) 或 throttle (节流) 来处理这类问题。但这些方案本质上是“延迟”执行,并非最优解。React 18 引入的并发(Concurrency)特性,以及与之配套的 useTransitionuseDeferredValue 两个 Hooks,为我们提供了更优雅的解决方案。

问题的根源:阻塞渲染

要理解这两个 Hooks 的作用,我们首先需要明确问题所在。JavaScript 是单线程的,这意味着浏览器在同一时间内只能做一件事。当 React 进行一次大规模的更新时,主线程会被占用。在此期间,浏览器无法响应用户的其他交互,如点击、输入等,这就是所谓的“阻塞渲染”。

对于用户而言,最影响体验的是那些需要即时反馈的交互。输入框的回显、按钮的点击状态变化,这些都属于高优先级任务。而搜索结果列表的更新,虽然也很重要,但允许有轻微的延迟,属于低优先级任务。

并发模式的核心思想,就是允许 React 将渲染任务拆分成多个小块,并且可以根据任务的优先级来调度。它能够在渲染低优先级任务的过程中,“暂停”下来,优先处理用户输入等高优先级任务,从而保证界面的响应性。

useTransition:分离高低优先级更新

useTransition 是一个能让我们主动将某些更新标记为“非紧急”的 Hook。通过它,我们可以告诉 React:“接下来这个状态更新可能会导致一次大规模渲染,你可以先处理更重要的事情,稍后再来渲染它。”

useTransition 返回一个包含两个元素的数组:

  1. isPending:一个布尔值,当被标记的低优先级更新正在进行时,它为 true。我们可以利用这个状态来展示加载提示。
  2. startTransition:一个函数。所有包裹在 startTransition 回调中的状态更新,都会被标记为“过渡”(Transition),即低优先级任务。

让我们用代码来改造前面的搜索场景:

javascript
import { useState, useTransition } from 'react';

function SearchPage() {
  const [isPending, startTransition] = useTransition();
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');

  const handleInputChange = (e) => {
    // 1. 高优先级更新:立即更新输入框的值
    setInputValue(e.target.value);

    // 2. 低优先级更新:用 startTransition 包裹
    startTransition(() => {
      setSearchQuery(e.target.value);
    });
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleInputChange} />
      {isPending && <div>Loading...</div>}
      <SearchResultList query={searchQuery} />
    </div>
  );
}

在这个例子中,我们做了以下区分:

  • 高优先级更新:setInputValue 直接执行。这保证了用户在输入时,输入框内的文本能够立即得到反馈,体验流畅。

  • 低优先级更新:setSearchQuery 被包裹在 startTransition 中。React 会在空闲时处理这次更新。在 searchQuery 更新并触发 SearchResultList 重新渲染的过程中,isPending 会变为 true,我们可以借此展示一个加载状态,给予用户明确的反馈。

useDeferredValue:延迟更新一个值

useDeferredValue 提供了另一种实现类似效果的思路。它不是包裹更新状态的函数,而是包裹一个值。这个 Hook 会返回该值的一个“延迟”版本。

当组件重新渲染时,useDeferredValue 会首先返回旧的值,同时在后台用新的值进行一次低优先级的渲染。当后台渲染完成后,再将新的值显示出来。

它的语法非常简洁:

javascript
const deferredValue = useDeferredValue(value, { timeoutMs: 500 });

我们继续改造搜索示例:

javascript
import { useState, useDeferredValue } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  // 将 query 包装成一个延迟更新的值
  const deferredQuery = useDeferredValue(query);

  const handleInputChange = (e) => {
    setQuery(e.target.value);
  };

  // 通过比较原始值和延迟值,判断是否正在后台渲染
  const isStale = query !== deferredQuery;

  return (
    <div>
      <input type="text" value={query} onChange={handleInputChange} />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <SearchResultList query={deferredQuery} />
      </div>
    </div>
  );
}

在这个实现中:

  1. 用户输入时,setQuery 会立即触发一次重新渲染。

  2. 在这次渲染中,deferredQuery 的值仍然是旧的 query 值,因此列表不会立即更新。

  3. 同时,React 会在后台启动一次使用新 query 值的低优先级渲染。

  4. 当后台渲染完成后,deferredQuery 更新为新值,组件再次渲染,显示最新的搜索结果。

我们可以通过 query !== deferredQuery 的判断来得知列表当前是否处于“过时”(Stale)状态,并以此给旧列表一个半透明的样式,作为一种视觉反馈。

如何选择:useTransition vs. useDeferredValue

那么,我们该如何在两者之间做出选择呢?

  • 使用 useTransition**:当我们可以直接控制并修改触发更新的状态设置函数时,useTransition 是首选。它意图明确,并且直接提供了 isPending 状态,非常适合处理由当前组件内部事件(如 onClickonChange)触发的更新。

  • 使用 useDeferredValue**:当我们无法直接访问状态更新的逻辑时,useDeferredValue 就派上了用场。例如,当一个值作为 props 从父组件传递而来,或者来自一个我们无法修改的第三方 Hook 时,我们可以用 useDeferredValue 来“延迟”这个值,从而优化使用该值的慢渲染组件。

简单来说,useTransition 作用于“因”(更新操作),而 useDeferredValue 作用于“果”(产生的值)。

总结

useTransitionuseDeferredValue 并非为了让渲染速度变得更快,而是通过智能的调度,提升应用的“响应速度”,从而优化了用户的感知体验。它们的核心价值在于:

  1. 区分优先级:允许我们将紧急的 UI 反馈和非紧急的渲染任务分离开。
  2. 可中断渲染:确保高优先级任务(如用户输入)可以随时中断正在进行的低优先级渲染,避免界面冻结。
  3. 提供即时反馈:无论是通过 isPending 状态还是展示旧的 UI,都能让用户感知到系统正在响应。

理解并掌握 React 的并发特性,是构建现代化、高性能 Web 应用的关键一步。希望通过本文的梳理,我们能对 useTransitionuseDeferredValue 的应用场景和内在逻辑有一个更清晰的认识,并在未来的项目中,用它们来打造更流畅、更具响应性的用户界面。

不知道说啥了很无语了