React useDeferredValue 详解:让你的应用丝滑流畅的秘密

49 阅读5分钟

一、从一个常见的卡顿场景说起

做前端的同学,肯定都遇到过这样的场景:一个搜索框,下面跟着一个长长的列表。用户在搜索框里每敲一个字,列表就根据输入的内容进行过滤。如果列表数据量不大,那还好说。但如果列表有成百上千条数据,那场面就有点尴尬了——用户每输入一个字,页面就卡一下,感觉就像在放慢动作的幻灯片。

这种体验,用户不爽,我们开发者看着也难受。问题出在哪儿呢?

很简单,因为每一次输入,都触发了 React 的重新渲染。而每一次重新渲染,都要遍历整个长列表,进行过滤和展示。浏览器扛不住这么高频率的计算,自然就卡了。

二、一个不那么完美的解决方案

遇到这种问题,很多同学的第一反应是:用 debounce(防抖)或 throttle(节流)啊!

确实,这是一个很常见的优化手段。我们可以设置一个延迟,比如 200 毫秒,等用户停止输入 200 毫秒后,再进行列表的过滤和渲染。这样确实能减少渲染次数,缓解卡顿。

但这个方案并不完美。为什么呢?

  1. 延迟时间的设置很玄学:200 毫秒对于高性能的电脑来说,可能太长了,用户会感觉有明显的延迟;对于低性能的手机来说,可能又太短了,依然会卡顿。你很难找到一个适合所有设备的完美延迟时间。
  2. 体验上还是有点怪:用户会发现,输入框里的文字已经变了,但下面的列表却没反应,要等一下才会更新。这种“脱节”的感觉,总让人觉得有点不自然。

那么,有没有更好的办法呢?

三、主角登场:useDeferredValue

React 18 给我们带来了一个新式武器:useDeferredValue。这个 Hook 可以说是专门为了解决这类问题而生的。

它的核心思想很简单:允许我们把一个值的更新推迟(defer)到不那么紧急的时候再进行

换句话说,它会给我们一个“延迟”版本的值。这个延迟版本的值,会在浏览器空闲的时候,才更新到最新状态。

让我们来看代码:

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

function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input 
        type="text" 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
      />
      <SlowList text={deferredQuery} />
    </div>
  );
}

看到没,我们把 query 这个 state 传给了 useDeferredValue,得到了一个新的变量 deferredQuery。然后,我们把 deferredQuery 传给了那个很慢的列表组件 <SlowList>

这里发生了什么神奇的事情呢?

  1. 当用户在输入框里输入时,query 的值会立刻更新。
  2. App 组件会立刻重新渲染。
  3. 但是,deferredQuery 的值并不会立刻变成最新的 query。它会保持上一次的值。
  4. React 会先用旧的 deferredQuery 渲染一次 <SlowList>。因为 props 没有变,如果 <SlowList>React.memo 包裹了,它就不会重新渲染,UI 也就不会卡顿。
  5. 然后,React 会在后台启动一个低优先级的渲染,把 deferredQuery 更新到最新的 query
  6. 如果在这个低优先级渲染完成之前,用户又输入了新的内容,React 就会中断这次渲染,重新开始一个新的高优先级渲染(只更新输入框),然后再安排一个新的低优先级渲染(更新列表)。

这样一来,用户的输入操作永远是最高优先级的,输入框的响应会非常快,非常丝滑。而那个耗时的列表渲染,则被推迟到了浏览器不忙的时候再进行,不会阻塞用户的操作。

四、两个需要注意的地方

React.memouseMemo

useDeferredValue 的威力,需要和 React.memouseMemo 配合才能完全发挥出来。

如果你的慢组件没有用 React.memo 包裹,那即使 deferredQuery 还是旧的值,组件也还是会重新渲染,那就白搭了。

方案一:使用 React.memo

const SlowList = React.memo(function SlowList({ text }) {
  // ... 耗时的列表渲染逻辑
});

方案二:使用 useMemo

如果你不想单独抽离一个组件,也可以用 useMemo 来缓存渲染结果:

function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const slowList = useMemo(() => {
    return <SlowList text={deferredQuery} />;
  }, [deferredQuery]);

  return (
    <div>
      <input 
        type="text" 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
      />
      {slowList}
    </div>
  );
}

这样,只有当 deferredQuery 变化时,<SlowList> 才会重新渲染。

五、给用户一个加载提示

useDeferredValue 还有一个很贴心的功能:我们可以通过比较原始值和延迟值是否相等,来判断那个慢的更新是否还在“路上”。

const isStale = query !== deferredQuery;

return (
  <div>
    <input 
      type="text" 
      value={query} 
      onChange={e => setQuery(e.target.value)} 
    />
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
      <SlowList text={deferredQuery} />
    </div>
  </div>
);

querydeferredQuery 不相等时,就说明列表的更新正在被延迟。这时候,我们可以给列表一个半透明的样式,或者显示一个 loading 图标,告诉用户:“别急,马上就来!”

六、总结一下

useDeferredValue 是一个非常强大的性能优化工具,它让我们能够优雅地处理那些“高优先级”和“低优先级”的渲染任务,让应用在各种设备上都能保持流畅的交互体验。

下次再遇到类似的卡顿问题时,不妨试试 useDeferredValue。它可能会给你带来意想不到的惊喜。