一、从一个常见的卡顿场景说起
做前端的同学,肯定都遇到过这样的场景:一个搜索框,下面跟着一个长长的列表。用户在搜索框里每敲一个字,列表就根据输入的内容进行过滤。如果列表数据量不大,那还好说。但如果列表有成百上千条数据,那场面就有点尴尬了——用户每输入一个字,页面就卡一下,感觉就像在放慢动作的幻灯片。
这种体验,用户不爽,我们开发者看着也难受。问题出在哪儿呢?
很简单,因为每一次输入,都触发了 React 的重新渲染。而每一次重新渲染,都要遍历整个长列表,进行过滤和展示。浏览器扛不住这么高频率的计算,自然就卡了。
二、一个不那么完美的解决方案
遇到这种问题,很多同学的第一反应是:用 debounce(防抖)或 throttle(节流)啊!
确实,这是一个很常见的优化手段。我们可以设置一个延迟,比如 200 毫秒,等用户停止输入 200 毫秒后,再进行列表的过滤和渲染。这样确实能减少渲染次数,缓解卡顿。
但这个方案并不完美。为什么呢?
- 延迟时间的设置很玄学:200 毫秒对于高性能的电脑来说,可能太长了,用户会感觉有明显的延迟;对于低性能的手机来说,可能又太短了,依然会卡顿。你很难找到一个适合所有设备的完美延迟时间。
- 体验上还是有点怪:用户会发现,输入框里的文字已经变了,但下面的列表却没反应,要等一下才会更新。这种“脱节”的感觉,总让人觉得有点不自然。
那么,有没有更好的办法呢?
三、主角登场: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>。
这里发生了什么神奇的事情呢?
- 当用户在输入框里输入时,
query的值会立刻更新。 App组件会立刻重新渲染。- 但是,
deferredQuery的值并不会立刻变成最新的query。它会保持上一次的值。 - React 会先用旧的
deferredQuery渲染一次<SlowList>。因为 props 没有变,如果<SlowList>被React.memo包裹了,它就不会重新渲染,UI 也就不会卡顿。 - 然后,React 会在后台启动一个低优先级的渲染,把
deferredQuery更新到最新的query。 - 如果在这个低优先级渲染完成之前,用户又输入了新的内容,React 就会中断这次渲染,重新开始一个新的高优先级渲染(只更新输入框),然后再安排一个新的低优先级渲染(更新列表)。
这样一来,用户的输入操作永远是最高优先级的,输入框的响应会非常快,非常丝滑。而那个耗时的列表渲染,则被推迟到了浏览器不忙的时候再进行,不会阻塞用户的操作。
四、两个需要注意的地方
React.memo 或 useMemo
useDeferredValue 的威力,需要和 React.memo 或 useMemo 配合才能完全发挥出来。
如果你的慢组件没有用 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>
);
当 query 和 deferredQuery 不相等时,就说明列表的更新正在被延迟。这时候,我们可以给列表一个半透明的样式,或者显示一个 loading 图标,告诉用户:“别急,马上就来!”
六、总结一下
useDeferredValue 是一个非常强大的性能优化工具,它让我们能够优雅地处理那些“高优先级”和“低优先级”的渲染任务,让应用在各种设备上都能保持流畅的交互体验。
下次再遇到类似的卡顿问题时,不妨试试 useDeferredValue。它可能会给你带来意想不到的惊喜。