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。