React并发渲染:useDeferredValue与useTransition的本质区别与场景选择
React 并发渲染的面试题里useDeferredValue和useTransition这对兄弟出现的频率越来越高。很多朋友学完这两个 API 之后的第一反应是这俩不都是让界面别那么卡的吗到底有啥区别我在实际项目里试过之后发现它们背后的设计思路完全不同用错的代价也很大。这篇文章我就结合自己的踩坑经历把这两个 API 的底层逻辑、使用场景、以及它们之间真正的区别讲透。先说一句结论useTransition是让你把“一个状态更新”标记为低优先级useDeferredValue是让你把“一个值”在渲染时延迟派生。一个是主动让更新让路一个是被动让值慢半拍。看起来差不多但底层机制和适用场景差得远。1. 这俩 API 到底在解决什么问题1.1 React 18 之前的“卡顿”是怎么来的在 React 18 之前所有状态更新都是同步渲染的。什么意思呢就是一旦你调用了setStateReact 会立刻开始协调reconcile虚拟 DOM然后提交到真实 DOM这个过程中主线程是被占用的。如果组件树很庞大、渲染很耗时用户在这段时间里点按钮、敲键盘、滚动页面全部会被卡住因为浏览器的主线程没空处理这些交互事件。我举个直观的例子一个搜索框input 每敲一个字符就触发一次过滤过滤一个两万条数据的列表。每次敲键都要同步跑完整个列表的过滤和渲染低端手机上妥妥的卡成幻灯片。React 官方的说法叫“渲染阻塞交互”本质就是同步渲染占用了主线程太久。1.2 并发渲染和 Fiber 的作用React 18 引入并发特性Concurrent Features之后局面才发生了转变。核心变化是渲染不再是“一口气到底”的同步任务而是分片执行的。这背后就是 Fiber 架构在起作用——每个组件对应的 Fiber 节点可以被拆成一个个小单元渲染过程可以在这些单元之间暂停、让出主线程等浏览器处理完紧急的输入事件之后再回来继续渲染。打个比方以前同步渲染是一条单行道所有车渲染任务必须依次通过后面的车用户输入只能等着。并发渲染变成了一条多车道紧急的车用户输入、点击可以插队先走不急的车列表过滤、大图表更新稍微绕一下路也没关系。这个“让路”和“插队”的机制就是useTransition和useDeferredValue共同的底层基础。没有并发渲染这两个 API 就是空中楼阁。1.3 紧急更新 vs 过渡更新这两个 API 的核心思想可以归结为一句话区分紧急更新和过渡更新。什么是紧急更新输入框里打字、点击按钮、切换 Tab、拖拽滑块——这些用户能直接感知到的操作需要立刻反馈这是紧急更新。什么是过渡更新根据输入内容过滤列表、根据选择生成图表、切换页面后加载和渲染大组件——这些内容需要的结果但用户能接受稍微晚几十毫秒甚至一两百毫秒出现这是过渡更新。问题来了在一个 setState 触发的复杂渲染过程中React 怎么知道哪些部分是紧急的哪些部分是过渡的单纯靠 React 自动判断是不现实的所以 React 把选择权交给你了。useTransition和useDeferredValue就是你用来告诉 React“这个不急可以先放一放”的两把工具。2. useTransition主动让更新让路2.1 基本用法把状态更新放进 startTransitionuseTransition的用法非常直观。它返回两个东西isPending和startTransition。isPending是一个布尔值告诉我们当前是否有过渡更新正在进行startTransition是一个函数你把需要降级的那个状态更新放进它的回调里就行。来看一个最常见的搜索列表场景import { useState, useTransition } from react; function SearchPage() { const [query, setQuery] useState(); const [searchResults, setSearchResults] useState([]); const [isPending, startTransition] useTransition(); const handleChange (e) { const value e.target.value; // 紧急更新输入框立即显示用户敲的内容 setQuery(value); // 过渡更新大列表的过滤结果可以慢一点 startTransition(() { setSearchResults(filterLargeList(value)); }); }; return ( div input value{query} onChange{handleChange} / {isPending ? div加载中.../div : ResultList list{searchResults} /} /div ); }注意这段代码里的一个关键细节输入框本身的更新setQuery(value)没有被包进startTransition只有setSearchResults被包进去了。这就是useTransition的典型使用模式——把紧急更新和过渡更新拆开保输入框的即时响应让结果列表慢慢来。2.2 原理startTransition 标记的是更新本身那么startTransition内部到底做了什么从源码层面讲它做了一件很简单的事把回调里的更新标记为 Transition 优先级在 React 的调度器里对应一个比普通更新更低的优先级。被标记为 Transition 的更新在调度时会被排在紧急更新之后执行而且可以被更高优先级的更新打断。这就是我前面说的“主动让路”的含义——你通过startTransition这个 API明确告诉 React这个更新不着急你可以先处理别的紧急事情有空了再来完成它。如果用户在这期间又输入了新的内容之前的过渡更新会被直接放弃React 只会处理最新的一次。这里有一个重要的认知startTransition作用于“更新”这个动作本身。它包裹的是setState的调用而不是某个值。在 concurrent 模式下低优先级的更新在其渲染过程中会读到最新的 state所以用户不会看到陈旧的内容——你输入“abc”的时候如果过滤计算正在处理“ab”阶段的结果React 不会渲染出“ab”的旧结果这个低优先级更新会被后续的高优先级更新打断和覆盖最终只会渲染出基于“abc”的最新结果。这也为什么过渡更新不会导致界面显示过时数据。2.3 注意别把输入框更新也包进去很多新手在刚用useTransition的时候很容易把所有 setState 一股脑包进去比如这样// ❌ 错误示例 const [isPending, startTransition] useTransition(); const handleChange (e) { const value e.target.value; startTransition(() { setQuery(value); // 连输入框的值也变“慢”了 setSearchResults(filterLargeList(value)); }); };这个写法会导致什么结果输入框的响应也变迟钝了。因为setQuery也被标记成了低优先级更新用户在键盘上敲下一个字符输入框里可能要等几十毫秒才会显示出来这种体验比卡顿还糟糕——用户会以为键盘坏了。记住一个原则用户直接操作的那个东西永远是紧急更新不能包进startTransition。startTransition只用来包住那些“跟随用户操作而更新、但不需要立刻反映在界面上”的状态。3. useDeferredValue被动让值慢半拍3.1 基本用法把值延迟派生useDeferredValue的用法和useTransition完全不一样。它不需要你包裹任何 setState它接收一个值返回一个延迟版本的值import { useState, useDeferredValue, useMemo } from react; function SearchPage() { const [query, setQuery] useState(); const deferredQuery useDeferredValue(query); const searchResults useMemo(() { return filterLargeList(deferredQuery); }, [deferredQuery]); return ( div input value{query} onChange{(e) setQuery(e.target.value)} / ResultList list{searchResults} / /div ); }这段代码和前面useTransition的例子达到了类似的效果输入框立刻显示用户敲入的query而下面的列表渲染的是deferredQuery这个延迟版本的值。用户敲击键盘时input 保持流畅列表在后台慢慢更新。这里的注意点是useDeferredValue(query)不是一个同步拷贝。在紧急更新触发重新渲染时React 先用旧值渲染一次为了快速响应用户操作然后会在后台用新值再渲染一次。也就是说你可能看到列表中的结果短暂地比输入的内容“慢一版”但这是刻意为之的。3.2 原理延迟的是渲染读取到的值useDeferredValue的实现机制和useTransition不同。它并不是把某个 setState 标记为低优先级而是利用了 React 的 concurrent 渲染能力——在 render 阶段React 读到的 deferred value 是旧值于是这次渲染会以低优先级进行。当 React 有空闲时再用新值触发一次后台渲染。这就是“被动延迟”的含义你没有主动指定哪个更新是低优先级的你只是在渲染时读取了一个被延迟后传递给子组件的值React 内部为这个值相关的渲染安排了较低的优先级。我自己在使用时的感觉是useDeferredValue更像是一个“渲染层的防抖”但它是基于并发渲染的不是基于定时器的。它不会像防抖那样固定延迟几百毫秒而是优先把浏览器空闲时间让给用户输入在空闲时段来执行低优先级渲染。还有一个关键点useDeferredValue是原始值的一个快照。当原始值在一帧内多次变化时React 只会在最终状态确定后再去更新 deferred value 对应的渲染。这样可以显著减少因为连续输入而触发的多余渲染次数。3.3 别把 useDeferredValue 当 useMemo 用useDeferredValue和useMemo用途不同但有些人会混在一起。举一个常见的错误用法// ❌ 这没什么意义 const [count, setCount] useState(0); const deferredCount useDeferredValue(count);count是一个简单的数字把它 defer 之后没有任何意义因为它没有什么昂贵的渲染计算需要让路。你并不会因为延迟一个数字就变快反而会在不该延迟的地方引入额外的复杂度。再举一个容易踩坑的组合// ❌ useMemo 的依赖写反了 const results useMemo(() { return expensiveFilter(query, list); }, [query]); // 应该用 deferredQuery如果你在useDeferredValue后面跟着一个useMemo但useMemo的依赖数组里写的是原始值而不是延迟值这个useMemo每次都会立刻重新计算延迟效果就完全失效了。正确写法永远是把deferredQuery作为useMemo的依赖。4. 核心区别一张表看清 useDeferredValue 和 useTransition4.1 本质差异更新 vs 值前面铺垫了那么多现在可以正面回答题目问题了。useTransition和useDeferredValue的本质区别在于操作对象对比维度useTransitionuseDeferredValue操作对象状态更新setState值本身state/props核心 APIstartTransition(fn) 包裹状态更新逻辑useDeferredValue(value) 接收值返回延迟值使用方式你可以控制哪些 setState 是低优先级的React 根据渲染流程自动延迟该值相关的更新判断更新状态isPending 可以标记过渡任务是否进行中没有等价的 isPending但可以通过渲染值是否变化推断心智模型主动让更新让路被动让值慢半拍适用场景多个 setState 联动时部分紧急、部分过渡单个值驱动开销很大的渲染时灵活性高可以精确控制每一处更新的优先级低只能针对某个值做延迟把这张表展开来说useTransition最核心的一句话是“主动把更新标记为低优先级”。这种主动标记可以在一个事件回调里做出精确的判断——例如输入框的值紧急更新列表结果过渡更新。你甚至可以一个回调里有多个 setState有的紧急、有的过渡。反过来useDeferredValue是“值级别的延迟”。它没有startTransition这种 API而是把状态值先延迟一层任何依赖这个延迟值的渲染都会变成过渡更新。这其实更适合你只关心某个值、不想去区分多个 setState 的场景。4.2 在同一个场景中的代码对比我再扩展一下这段代码对比让可读性更直观。假设你有一个表格数据量很大需要根据输入框筛选// useTransition 写法 function TablePage() { const [keyword, setKeyword] useState(); const [tableData, setTableData] useState(fullData); const [isPending, startTransition] useTransition(); const handleKeywordChange (e) { const value e.target.value; setKeyword(value); // 紧急 startTransition(() { setTableData(fullData.filter(row row.name.includes(value))); // 过渡 }); }; }// useDeferredValue 写法 function TablePage() { const [keyword, setKeyword] useState(); const deferredKeyword useDeferredValue(keyword); const tableData useMemo(() { return fullData.filter(row row.name.includes(deferredKeyword)); }, [deferredKeyword]); }注意两种写法的核心差异第一种写法setTableData本身是一个状态它存储了过滤后的数据。React 知道这个更新是低优先级的。第二种写法tableData不是 useState 保存的值而是每次渲染时通过useMemo根据deferredKeyword现场计算出来的。React 并不知道具体哪个状态更新是低优先级的它只负责延迟渲染那个依赖了deferredKeyword的部分。4.3 关于 isPending 和 isStale 的取舍useTransition的isPending是它很实用的一个特性在过渡更新进行中你可以给用户一个视觉反馈比如转圈或者“加载中”的提示。这在实际项目里很好用——用户点击 Tab 切换时新 Tab 内容较大你可以用isPending显示一个 loading 占位。useDeferredValue没有直接暴露这个状态。在 React 18 的版本里你要判断当前渲染的是旧值还是新值得自己去对比// 手动判断 deferredQuery 是否落后于 query const isStale query ! deferredQuery;React 19 之后useDeferredValue会在返回的数组形式中支持isStale字段但这个能力在 18 里还是需要自己解决的。如果你确实需要 pending 状态useTransition用起来会更顺手。4.4 它们可以叠加使用这两者可以是协作关系。一个需求里你可能先用startTransition把某个 setState 标记为过渡更新而在渲染这个过渡状态时又用useDeferredValue延迟某个具体的值。我很早之前做的数据大屏项目里就遇到过这种场景切换城市 Tab 时用startTransition标记城市切换这个更新为过渡让 Tab 先亮起来、图表数据慢慢换图表内部又把时间维度参数useDeferredValue延迟防止拖动时间轴时图表频繁重新绘制。两个 API 各管一摊互不冲突。5. 实际开发中怎么选场景与踩坑指南5.1 优先选 useTransition 的场景我自己在实际开发中遇到下面这些情况会优先考虑useTransition一是你在一个事件回调里要更新多个状态这些状态里只有部分是需要立即反馈的。比如点击“下一页”时页码和列表数据可以一起更新但想要页码立刻变、列表数据慢一点刷新就可以在startTransition里只包住列表数据那个 setState。二是你依赖isPending来给出过渡提示。这种体验上的细腻差异很重要用户知道系统在加载而不是干等着。三是你的更新来自事件处理函数内部你可以在回调里清楚地控制哪些更新要优先级高、哪些更新优先级低。这种“手动可控”的感觉在复杂交互中是很有价值的。5.2 优先选 useDeferredValue 的场景反过来遇到下面这些场景我会选useDeferredValue第一种是“值驱动渲染”的典型模式——数据请求已经通过 props 传进来了你没有机会在一个事件回调里去标记 setState 优先级。这时候直接在渲染层把值延迟一下效果就来了改造成本极低。第二种是你有一个高频变化的状态比如搜索框的受控值而这个值被多个昂贵渲染的组件使用。你不想去逐个梳理到底哪个 setState 该包裹、哪个不该包裹用useDeferredValue把所有基于这个值的渲染统一降级最省事。第三种是你在组合式组件里父组件已经把 onQueryChange 之类的回调传下去了子组件不能随便改事件逻辑。这时候在子组件渲染层用useDeferredValue包装一下收到的新值伪装成“延迟渲染版本”非常优雅。5.3 生产环境里的几个高频坑这里分享几个我从实际项目里踩出来的坑希望能帮你少走弯路。坑一useDeferredValue 搭配 useMemo 时dependency 写错前面提过依赖必须写deferredQuery而不是query。写错之后延迟机制完全失效每次输入都全量计算页面照样卡。这是新手最容易犯的错。坑二startTransition 包裹了非状态更新逻辑很多人以为startTransition可以包裹任意耗时逻辑比如在里面对大数组做排序、做 localStorage 写入等。但startTransition只约束 React 的状态更新优先级它不能把一段同步的 CPU 密集计算变成异步。如果你在startTransition回调里同步跑一段 200ms 的循环主线程照样会被阻塞。正确的做法是耗时的数据计算放在useMemo或useDeferredValue的渲染链路里让 React 调度器有机会中断渲染如果计算本身在事件回调里无法避免建议配合requestIdleCallback或 Web Worker 来处理。坑三把 useDeferredValue 用在受控组件的 value 上如果在 input 的 value 上直接使用useDeferredValue那么输入框会“慢半拍”用户体验会非常奇怪——你敲字符过一会才出现。受控组件的 value 必须是紧急更新或者是同步的最新值。useDeferredValue应该用在派生数据的渲染上而不是用户交互的源头值上。坑四过度使用导致两帧渲染合并useDeferredValue会带来额外的渲染先渲染旧值再渲染新值这可能意味着你的列表组件被渲染两次。如果你在列表组件里有useEffect做昂贵的副作用容易造成副作用执行两次。建议把副作用改成基于数据状态变化才触发的方式或者保证副作用执行是幂等的。React 17 时代很多人习惯在useEffect里打点统计或者发请求到了 18 的并发模式里这些逻辑如果不加判断很容易出现重复请求。5.4 简单选择决策表为了让决策更清晰我总结一个固定流程帮你快速判断你的需求推荐选择在事件回调里有多个 setState需要精细控制优先级useTransition某个值驱动了大量耗时的渲染计算useDeferredValue需要 isPending 做 UI 过渡提示useTransition不能轻易修改事件回调逻辑比如第三方组件useDeferredValue数据已经通过 props 进入组件想在渲染层降级useDeferredValue受控输入框 大列表过滤想保留输入框流畅都可以看你是想控制 setState 还是控制值需要同步知道当前渲染是否是旧值useTransitionisPending或用 query ! deferredQuery 判断5.5 简单聊下 React 19 之后的走向React 19 里useTransition的使用方式有了一些增强比如可以直接配合 action 使用返回的 pending 状态以及useActionState的引入在处理表单和 server action 时的体验更顺滑。useDeferredValue也开始支持返回isStale状态位的数组形式。这些变化让两者的分工更清晰了useTransition越来越偏向“动作级别的过渡”useDeferredValue则保持“值级别的延迟”。我把话放这里只要你能理解它们是两个不同层面的优化工具在 React 19 里这套心智模型依然适用不需要推倒重来。真到项目里写的时候我自己的判断流程其实很简单先问自己一个问题——“我想让什么东西变慢”如果答案是“某个 setState 触发的更新”用useTransition如果答案是“某个值驱动的渲染派生”用useDeferredValue。把这个问题想清楚代码自然就不会写错了。最后还是那句话React 18 的并发特性并不是让你把所有更新都标记成低优先级而是让你根据用户感知主动设计哪些操作值得等待、哪些操作不值得等待。这才是useDeferredValue和useTransition背后真正想让你学会的东西。