React 18 useTransition 实战:过滤大列表、切 Tab 时保持输入不卡顿

📅 2026/8/1 22:14:12
React 18 useTransition 实战:过滤大列表、切 Tab 时保持输入不卡顿
React 18 useTransition 实战:过滤大列表、切 Tab 时保持输入不卡顿你做过一个带搜索框的大列表:输入框下面是几千条数据,每敲一个字符就实时过滤。用户打字时明显感觉输入框「粘手」——字母延迟半拍才出现,退格也卡。你检查过没有多余请求、没有死循环,罪魁祸首其实是:每次输入都触发一次几千条的重渲染,而这个重渲染和「更新输入框」抢占同一优先级。React 18 的useTransition就是为这种场景造的,它能告诉 React「这次更新不急,别挡着用户打字」。问题复现:输入和过滤挤在一起先看没优化的版本,一个 5000 项列表的实时过滤:import { useState } from react; const ALL Array.from({ length: 5000 }, (_, i) Item ${i}); function SearchList() { const [query, setQuery] useState(); const [list, setList] useState(ALL); function handleChange(e) { const value e.target.value; setQuery(value); // 更新输入框 setList(ALL.filter((x) x.includes(value))); // 过滤 5000 项,重渲染很重 } return ( input value{query} onChange{handleChange} / ul {list.map((x) li key{x}{x}/li)} /ul / ); }这里两个setState是同一优先级,React 会在同一次渲染里既更新输入框、又渲染 5000 个li。列表渲染慢,输入框的更新就被它拖着一起慢,于是打字卡顿。用 useTransition 把过滤标记为「非紧急」useTransition返回[isPending, startTransition]。把「重的、可以晚一点」的更新包进startTransition,React 就会优先处理紧急更新(输入框),把 transition 更新放到后面、且可被后续输入打断:import { useState, useTransition } from react; const ALL Array.from({ length: 5000 }, (_, i) Item ${i}); function SearchList() { const [query, setQuery] useState(); const [list, setList] useState(ALL); const [isPending, startTransition] useTransition(); function handleChange(e) { const value e.target.value; setQuery(value); // 紧急更新:输入框必须立刻响应 // 非紧急更新:过滤结果晚一点没关系,还能被下一次输入打断 startTransition(() { setList(ALL.filter((x) x.includes(value))); }); } return ( input value{query} onChange{handleChange} / {isPending span过滤中…/span} ul style{{ opacity: isPending ? 0.6 : 1 }} {list.map((x) li key{x}{x}/li)} /ul / ); }现在打字始终跟手:setQuery是紧急的,立刻更新;setList被标记为 transition,React 会在空隙里去算,而且如果你在它算完前又敲了一个字符,上一次的过滤会被直接丢弃,不做无用功。isPending让你在过渡期间给个「过滤中」的视觉反馈,避免界面看起来卡住。关键规则:startTransition 里只能放 set,不能放读到的值一个高频踩坑:很多人想把输入值本身也放进 transition,结果输入框反而更卡了。记住驱动输入框的那个 state 必须留在 transition 外面:// ❌ 错误:把 setQuery 也包进去,输入框更新被降级,打字更卡 startTransition(() { setQuery(value); setList(ALL.filter((x) x.includes(value))); }); // ✅ 正确:输入框紧急更新在外,重活在里面 setQuery(value); startTransition(() { setList(ALL.filter((x) x.includes(value))); });还有一点:startTransition的回调必须是同步的。如果你在里面写await fetch(...)再setState,那个setState已经脱离了 transition 上下文,不会被当作非紧急更新。异步数据场景要用use或数据请求库配合 Suspense,而不是把 await 塞进 startTransition。切 Tab 场景:同样适用useTransition不只用于搜索。任何「点一下会触发重渲染,但你希望点击本身立刻有反馈」的场景都合适,典型的是切换 Tab 加载不同的重内容:function Tabs() { const [tab, setTab] useState(home); const [isPending, startTransition] useTransition(); function selectTab(next) { // 让 Tab 的高亮切换立刻发生,重内容的渲染延后 startTransition(() setTab(next)); } return ( nav {[home, posts, charts].map((t) ( button key{t} onClick{() selectTab(t)} style{{ fontWeight: t tab ? bold : normal }} {t} /button ))} /nav {isPending p加载中…/p} TabContent tab{tab} / {/* 假设 charts 这个 tab 渲染很重 */} / ); }点击charts时,即使它的内容渲染要 200ms,isPending会立刻变true给出反馈,用户不会觉得点击「没反应」,而旧的 Tab 内容还留在屏幕上直到新内容准备好。useTransition vs useDeferredValue:怎么选两者都能解决「重更新拖累交互」,区别在于你控制的是「触发」还是「值」:useTransition:你能改动那次setState的代码时用它,直接把 set 包进startTransition。额外送你一个isPending。useDeferredValue:你拿到的是别人传进来的 prop / 已有 state,改不了它的 set 调用时用它,const deferred useDeferredValue(value),让派生渲染用延迟值。一句话:能碰 setState 就用useTransition,只能碰值就用useDeferredValue。小结卡顿的根因常是「紧急更新(输入/点击)」和「重更新(大列表渲染)」挤在同一优先级。useTransition把重更新包进startTransition,让 React 优先响应交互,过渡更新可被后续操作打断,不做无用功。驱动输入框的setState必须留在 transition外面,否则越优化越卡;startTransition回调要同步,别塞await。用isPending给过渡期一个视觉反馈,界面不会看起来「死了」。一句话记忆:急的放外面,重的进startTransition;改得了 set 用useTransition,只拿得到值用useDeferredValue。