用 useDeferredValue 治好搜索框卡顿:大列表实时过滤实战

📅 2026/7/18 9:14:32
用 useDeferredValue 治好搜索框卡顿:大列表实时过滤实战
用 useDeferredValue 治好搜索框卡顿:大列表实时过滤实战做过带实时搜索的列表页的人都遇到过这个场景:输入框绑了一个 state,每次敲键盘都触发过滤,列表一大(几千条以上)就开始明显掉帧——你打字,字要过半秒才出现在输入框里,整个界面像卡住了一样。这篇讲清楚这种卡顿的根因,以及 React 18 的useDeferredValue为什么能一行解决,顺便说说它和防抖(debounce)的区别,别再无脑上 lodash.debounce 了。先复现卡顿:朴素写法哪里错了一个很常见的实时搜索列表,输入框过滤一个五千条的数组:import { useState } from react; // 造 5000 条假数据 const ALL Array.from({ length: 5000 }, (_, i) Item ${i} - 用户数据行); function SearchList() { const [query, setQuery] useState(); // 每次渲染都重新过滤 渲染整个列表 const filtered ALL.filter((item) item.includes(query)); return ( div input value{query} onChange{(e) setQuery(e.target.value)} / ul {filtered.map((item) ( li key{item}{item}/li ))} /ul /div ); }这段代码逻辑没问题,但体验很差。问题出在:输入框的值和列表渲染绑在了同一次更新里。你每敲一个字符,React 就要:①更新query,②过滤 5000 条,③重新渲染几千个li。这三步是同步的、不可打断的,全部做完屏幕才刷新。渲染几千个节点要几十上百毫秒,于是你会感觉到输入框「跟不上手」。关键洞察:输入框应该立刻响应,列表可以稍微慢一点。用户不在乎搜索结果晚 100ms 出现,但非常在乎自己打的字卡在那儿。朴素写法把这两件优先级不同的事捆死了。useDeferredValue:让列表用「旧值」先顶着useDeferredValue的作用是:给它一个值,它返回这个值的一个「延迟版本」。当值变化时,延迟版本不会马上跟上,而是让 React 先把高优先级的更新(输入框)渲染完,再在空闲时用新值重渲染列表。import { useState, useDeferredValue } from react; const ALL Array.from({ length: 5000 }, (_, i) Item ${i} - 用户数据行); function SearchList() { const [query, setQuery] useState(); const deferredQuery useDeferredValue(query); // 延迟版本 // 注意:过滤用 deferredQuery,不是 query const filtered ALL.filter((item) item.includes(deferredQuery)); return ( div {/* 输入框绑 query,永远即时响应 */} input value{query} onChange{(e) setQuery(e.target.value)} / ul {filtered.map((item) ( li key{item}{item}/li ))} /ul /div ); }改动就两点:多一行useDeferredValue,过滤时用deferredQuery而不是query。现在发生的事变成了:你敲字 →query立刻更新 → 输入框立刻显示新字符(高优先级,不卡);与此同时deferredQuery还是旧值,列表暂时不变;等这一帧渲染完,React 在后台用新的deferredQuery重新过滤和渲染列表。打字体验瞬间跟手了。关键一步:列表组件必须用 memo,否则白搭上面的代码有个隐藏问题:即使deferredQuery没变,只要父组件因为query变化而重渲染,列表还是会跟着重渲染一次。要真正让「延迟」生效,得把列表拆成独立组件并用memo包起来:import { useState, useDeferredValue, memo } from react; const ALL Array.from({ length: 5000 }, (_, i) Item ${i} - 用户数据行); // memo 保证:props 没变就不重渲染 const List memo(function List({ query }) { const filtered ALL.filter((item) item.includes(query)); return ( ul {filtered.map((item) ( li key{item}{item}/li ))} /ul ); }); function SearchList() { const [query, setQuery] useState(); const deferredQuery useDeferredValue(query); return ( div input value{query} onChange{(e) setQuery(e.target.value)} / {/* List 只在 deferredQuery 变化时才重渲染 */} List query{deferredQuery} / /div ); }为什么必须memo?因为useDeferredValue的机制是:在延迟期间,deferredQuery保持旧值。如果List收到的queryprop 是旧值、且用了memo,React 发现 props 没变就跳过这次渲染——昂贵的列表渲染被真正推迟了。没有memo,List每次父组件更新都重渲染,useDeferredValue就等于没加。这一点官方文档也强调过,是新手最容易漏的:useDeferredValue的收益,建立在被延迟的那部分组件能靠memo跳过渲染的前提上。顺手加个「更新中」的视觉反馈延迟期间,列表显示的是旧结果。可以对比query和deferredQuery是否一致,给个变淡的过渡态,让用户知道结果正在更新:function SearchList() { const [query, setQuery] useState(); const deferredQuery useDeferredValue(query); // 两者不一致 正在延迟更新中 const isStale query ! deferredQuery; return ( div input value{query} onChange{(e) setQuery(e.target.value)} / div style{{ opacity: isStale ? 0.5 : 1, transition: opacity 0.2s }} List query{deferredQuery} / /div /div ); }结果更新时列表淡一下再恢复,体验比「突然一卡」好得多。和 debounce 到底有什么区别?很多人第一反应是「这不就是防抖吗」。不是,两者解决的问题不一样,搞混会用错:debounce(防抖)是「延后触发」:你停止输入 N 毫秒后才执行一次。它的典型用途是减少某种昂贵操作的调用次数——比如每次输入都发网络请求,用 debounce 把 10 次请求压成 1 次。它省的是「操作次数」。useDeferredValue是「降低优先级」:值该更新还是更新,只是让出主线程给更高优先级的渲染(输入框)。它不减少渲染次数,而是把渲染重排到不卡输入的时机。它省的是「卡顿感」,靠的是 React 18 的并发渲染(可打断)。具体怎么选:纯前端过滤/计算(数据已在本地,只是渲染贵)→ 用useDeferredValue。它能被打断、能感知最新值,不像 debounce 有固定延迟,快打字快出结果、慢打字也不多等。触发副作用(发网络请求、写数据库)→ 用 debounce/节流。你不想因为并发渲染就狂发请求,这里要的就是「减少次数」。两者甚至可以叠加:输入框过滤本地缓存用useDeferredValue,同时用 debounce 控制后台请求补全数据。小结搜索框卡顿的根因:把「即时的输入框」和「昂贵的列表渲染」捆进了同一次同步更新,渲染做完屏幕才刷新。useDeferredValue(query)返回一个延迟版本,让输入框先渲染、列表在空闲时用新值补渲染,打字立刻跟手。必须配合memo:被延迟的列表组件要能在 props(deferredQuery)没变时跳过渲染,否则延迟等于没加——这是最常被漏的一步。用query ! deferredQuery判断「更新中」,加个变淡过渡提升体验。记住区别:debounce 减少操作次数(适合发请求),useDeferredValue 降低渲染优先级(适合本地过滤)。本地数据卡顿优先想useDeferredValue。