React 并发特性深入:Suspense、Transitions 与 useDeferredValue
React 18+ 的并发渲染不是"多线程",是"可中断渲染"。本文讲清三个核心并发特性:Suspense(声明式加载状态)、useTransition(把重更新标为低优先级不阻塞输入)、useDeferredValue(延迟昂贵计算),以及它们怎么让 React 应用在不牺牲响应性的前提下做更多事。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
写过几年 React 的人,大概都经历过这种场景:一个搜索框,用户输入的时候页面卡半秒;一个表格切了几万条数据,点击按钮后整个界面"冻住";一个路由切过去,白屏转圈,连返回按钮都点不动。
这些问题的根因是同一个——React 的渲染曾经是同步的、不可中断的。一旦开始 reconcile 一棵大树,主线程就被霸占到结束,用户的输入、点击、动画全部排队等候。
React 18 把"并发特性(Concurrent Features)"扶正,就是要解决这个问题。但社区里对它的误解一直不少:有人以为 React 变成多线程了,有人把 useTransition 当万金油到处套,有人把 <Suspense> 包到根节点上然后奇怪为什么瀑布流跑不出来。
这篇文章把这些一次讲透:并发渲染到底改了什么、三个核心 API(Suspense / useTransition / useDeferredValue)各自的边界、它们怎么配合、什么时候不该用。
一、并发渲染的本质:不是多线程,是"可中断的渲染"
先把最根上的误解澄清掉。
React 并发渲染 ≠ 多线程渲染。 React 的渲染依然跑在 JS 主线程里,依然单线程。它没有派 worker 去并行画组件,也没有把 reconcile 过程丢到别的线程。
那"并发"到底并发在哪?并发在调度。具体来说是三件事:
- 可中断(Interruptible):渲染一棵树的过程中,如果有更高优先级的任务进来(比如用户输入),React 可以暂停当前渲染。
- 可恢复(Resumable):暂停之后,处理完高优先级任务,React 可以从断点继续,而不是从头再来。
- 优先级调度(Priority Scheduling):不同的更新有不同的优先级。用户输入是高优先级(
ContinuousEvent/UserBlocking),数据获取、setState的批量结果是低优先级(Default/Idle)。
这一切的底层是 Fiber 架构在 16 就埋下的种子。Fiber 把组件树拆成可中断的工作单元,每个 fiber 节点都可以 pause / resume。但 16/17 时代,这套机制只在 shouldComponentUpdate / unstable_batchedValues 这种局部露出水面,普通开发者摸不到。
到了 18,并发模式从 opt-in 的 unstable_ConcurrentMode 变成默认开启,配套的 API(createRoot、Suspense 数据获取、useTransition、useDeferredValue、useSyncExternalStore)才真正可用。
一个直观的对比
同步渲染时代,下面这段代码会卡:
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
// 假设 filterResults 是一个 200ms 的昂贵计算
setResults(filterResults(e.target.value));
}
return (
<>
<input value={query} onChange={onChange} />
<ResultList results={results} />
</>
);
}
每次按键,React 都要同步完成"更新 input → 计算 results → 渲染 ResultList"这一整套。200ms 期间用户什么都做不了。打字快一点就会感到明显的"粘滞感"。
并发渲染时代,React 可以说:"输入框的更新(受控组件)是高优先级,先做;结果列表的更新是低优先级,可以被打断、可以推迟。"于是输入框永远跟手,结果列表在后台慢慢追。
但这个能力不会自动生效——你得用下面这三个 API 把意图告诉 React。
二、Suspense:声明式的加载状态
<Suspense> 是并发特性里最早出现、也最好理解的一个。它解决的问题是:"这块子树还没准备好,先显示个占位"。
核心机制:throw promise
Suspense 不是魔法。它的本质是一个约定:子组件在数据没就绪时,抛出一个 promise(技术上叫"throw a promise")。最近的 <Suspense> 边界接住这个 promise,渲染 fallback,等 promise resolve 后再重新渲染子树。
import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<Skeleton />}>
<Profile userId={1} />
</Suspense>
);
}
这种"声明式加载状态"的好处是:组件本身只关心"数据有就有、没有就没有",加载状态从组件内部抽离到边界上,逻辑更干净。
2.1 配合 React.lazy 做代码分割
这是最早、最稳的用法。
import { lazy, Suspense } from 'react';
const SettingsPage = lazy(() => import('./SettingsPage'));
function App() {
return (
<Suspense fallback={<PageLoader />}>
<SettingsPage />
</Suspense>
);
}
React.lazy 加载 chunk 时 throw 一个 promise,Suspense 显示 fallback,chunk 下载完后重新渲染。从代码角度看,组件依然写成一个同步函数,没有 if (loading) return ... 这种条件分支。
2.2 配合数据获取
这才是 Suspense 真正的设计意图。React 团队一直想让"数据获取"也能 throw promise,从而把加载状态彻底从业务组件里挤出去。
社区里 React Query、SWR、Relay 都支持这个模式。以 React Query 为例(启用 suspense: true):
function Profile({ userId }: { userId: number }) {
// 这个 hook 内部在数据未就绪时 throw promise
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <div>{data.name}</div>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<Profile userId={1} />
</Suspense>
);
}
组件代码里没有任何 loading 分支,业务逻辑非常干净。但这有代价:你必须把加载状态交给外层的 Suspense,如果边界放得不对,会出现"整个页面白屏转圈"的体验。下面会专门讲边界放哪。
2.3 嵌套 Suspense:避免瀑布,允许并行
这是实战里最容易踩坑的地方。看下面两段代码的差别:
瀑布式(差):
<Suspense fallback={<PageSpinner />}>
<Header />
<Suspense fallback={<BodySpinner />}>
<Body />
<Suspense fallback={<SidebarSpinner />}>
<Sidebar />
</Suspense>
</Suspense>
</Suspense>
如果你把所有 Suspense 都写在一起,但数据请求是在子组件 useEffect 里发的——典型的"组件挂载才发请求"——那就会形成瀑布:父组件数据回来 → 挂载子组件 → 子组件发请求 → 等待 → 孙组件挂载……每一层都串行等待。
并行式(好):
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Header />
<Suspense fallback={<BodySpinner />}>
<Body />
</Suspense>
<Suspense fallback={<SidebarSpinner />}>
<Sidebar />
</Suspense>
</Suspense>
);
}
如果 Body 和 Sidebar 的请求是同时触发的(比如用了 React Query 的 prefetch、或者数据获取框架的并行声明),那这两个 Suspense 边界会并行 resolve,谁先回来谁先显示,互不阻塞。
这是 Suspense 最大的价值:它让"并行加载"成为一种默认的、声明式的结构,而你不需要手写 Promise.all。
2.4 Streaming SSR 中的 Suspense
如果你用 Next.js(App Router)或者 Remix,Suspense 还承担了 SSR 流式渲染的职责。服务端不需要等所有数据都拿到才返回 HTML——遇到 <Suspense> 就先吐出 fallback,等数据 ready 再通过同一个 HTTP 流把真实内容以 <script> 形式注入。
效果:首屏 TTFB 极快,慢组件后续逐步"补全",用户不需要盯着白屏。
// Next.js App Router 里,每个 page.tsx 本身就在一个 Suspense 边界里
export default function Page() {
return (
<>
<Hero /> {/* 立即可用 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* 数据慢,流式补全 */}
</Suspense>
</>
);
}
Suspense 小结
- 价值:把加载状态从组件内部抽到边界,让业务组件回归"纯渲染"。
- 用法:
React.lazy(最稳)、数据获取框架(React Query/SWR/Relay)、SSR 流式渲染。 - 关键决策:边界放哪决定体验。放得太外,整个页面白屏;放得太内,瀑布流。
- 别忘了:Suspense 不是性能优化,它是加载状态的语义化抽象。
三、useTransition:把"重的更新"标为低优先级
useTransition 是并发特性里最实用的一个。它的定位是:让某个状态更新可以被打断,不阻塞高优先级交互。
API 长这样
const [isPending, startTransition] = useTransition();
function onChange(e) {
setQuery(e.target.value); // 高优先级:受控输入必须立即生效
startTransition(() => {
setResults(filterEverything(e.target.value)); // 低优先级:结果可以延迟
});
}
startTransition 里包的 setState 会被标记成 transition,优先级降到 Default 以下。在渲染过程中,如果有更高优先级的更新进来(用户继续打字、点击按钮),React 会扔掉这次 transition 渲染,等高优先级处理完再重新开始。
注意"扔掉"——这是关键。同步渲染时代,一旦开始渲染就要渲染完,即使中间用户已经输入了 5 个字符,React 也得把第 1 个字符的结果算完。transition 模式下,那个被作废的中间结果根本不会被提交。
完整的搜索框实战
这是 useTransition 最经典的场景:
import { useState, useTransition, useMemo } from 'react';
interface Item {
id: string;
name: string;
}
export function SearchBox({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>(items);
const [isPending, startTransition] = useTransition();
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // 高优先级:输入框立即更新
startTransition(() => {
// 低优先级:把昂贵的过滤标记为 transition
setResults(items.filter((i) => i.name.includes(value)));
});
}
return (
<div>
<input value={query} onChange={onChange} />
{isPending && <span className="text-gray-400">过滤中…</span>}
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</div>
);
}
对比最开头那个卡顿的版本,输入框永远跟手,结果列表在后台慢慢追。isPending 还能用来显示一个微弱的 loading 提示,告诉用户"结果还在算"。
3.1 它和 debounce 的本质区别
这是面试和实战都常被问的问题。
debounce:用定时器延迟触发。不管主线程忙不忙,它都等到 N ms 后才执行。期间用户停手了才更新。
useTransition:不延迟,立即开始渲染,但允许被打断。如果主线程空闲,它会立刻执行;如果用户在持续输入,它会"追赶"——每个按键都触发一次新的 transition,旧的被丢弃。
实战上的取舍:
- 数据获取这种网络请求,用 debounce——你不想每个字符都打一次 API。
- 本地昂贵计算这种纯 CPU 工作,用 useTransition——它能在主线程空闲时立即完成,体验比 debounce 的固定延迟更跟手。
两者也能组合:debounce API 请求,useTransition 处理本地过滤。这才是成熟方案。
3.2 useTransition 的边界
startTransition 里只能放状态更新(setState)。直接在里面做昂贵的同步计算是没用的——因为计算本身是同步阻塞的,React 管不到。正确的做法是把计算结果用 setState 提交,让 React 调度这次提交。
// 错:昂贵计算直接放在 transition 里,仍然阻塞主线程
startTransition(() => {
const r = heavyCompute(value); // 这一步同步执行,不受 transition 影响
setResults(r);
});
// 对:把计算挪到 setState 里,或者用 useMemo + useDeferredValue 配合
这是社区里最常见的误用。startTransition 不是"让这段代码异步化",它只是"让这次 setState 低优先级"。代码本身的执行依然是同步的。
useTransition 小结
- 价值:让重的状态更新可被打断,保护高优先级交互(输入、点击、动画)。
- 适用:本地昂贵计算驱动的更新(搜索过滤、列表排序、tab 切换大数据视图)。
- 不适用:网络请求(用 debounce/缓存框架)、纯同步计算(先把结果存 state)。
- 配合
isPending给用户反馈。
四、useDeferredValue:延迟一个值的传播
useDeferredValue 是 useTransition 的"镜像"。它从值的视角解决同一个问题。
API
const deferredQuery = useDeferredValue(query);
query 变化时,deferredQuery 不一定立即跟着变。React 会判断:当前渲染是不是太忙?如果是,就先保留旧值,把新值的传播推迟到下次空闲渲染。
听起来玄,看例子就懂。
大数据表格实战
import { useState, useDeferredValue, useMemo } from 'react';
interface Row {
id: string;
/* ...很多字段 */
}
export function BigTable({ rows }: { rows: Row[] }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
// 昂贵的计算依赖的是 deferredFilter,而不是 filter
const filtered = useMemo(
() => rows.filter((r) => matches(r, deferredFilter)),
[rows, deferredFilter]
);
const isStale = filter !== deferredFilter;
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<table className={isStale ? 'opacity-60' : ''}>
<tbody>
{filtered.map((r) => (
<Row key={r.id} data={r} />
))}
</tbody>
</table>
</div>
);
}
这里的精髓是:input 用的 filter 是"新"的,表格用的 deferredFilter 是"旧"的。输入框永远立即响应,表格的过滤计算可以延迟、可以被打断。isStale 标记让 UI 表达"表格正在追"。
4.1 useTransition vs useDeferredValue:什么时候选哪个
它们解决的是同一类问题,但视角不同:
| 维度 | useTransition | useDeferredValue |
|---|---|---|
| 视角 | "这次 setState 是低优先级" | "这个值的传播可以延迟" |
| 触发点 | setState 的生产端 | value 的消费端 |
| 何时使用 | 你能控制 setState 的调用位置 | 你只能拿到一个值(比如来自 props、来自第三方 store) |
| 配套 | 自带 isPending | 需要自己对比 value !== deferredValue 得到 stale 状态 |
| 适用组件 | 当前组件 setState | 子组件 / 接收 prop 的组件 |
实战经验法则:
- 能改 setState 那行代码 → 用
useTransition。 - 值是从 props 或外部传进来的,你改不了源头 → 用
useDeferredValue。
举个例子:你封装了一个 DataTable,外部传 query 进来。你没法去外层包 startTransition,这时候在 DataTable 内部 useDeferredValue(query) 是最干净的解法。
4.2 和 debounce/throttle 的区别
跟 useTransition 类似:
- debounce/throttle 基于时间,固定延迟,不管主线程状态。
- useDeferredValue 基于调度,主线程闲就立即更新,忙就推迟。
对本地计算来说,useDeferredValue 通常体验更好——不需要人为引入延迟,用户在停下来时立刻看到结果。
useDeferredValue 小结
- 价值:从"值消费端"延迟昂贵计算的触发,保护输入响应。
- 适用:表格过滤、图表重绘、依赖 prop 的复杂派生计算。
- 与 useTransition 互补:生产端可控用 transition,消费端可控用 deferred。
- 别忘了配合
useMemo,否则延迟了值的传播但没缓存计算,等于白干。
五、三者怎么配合,以及什么时候不要用
讲到这里你应该能感觉到:这三个 API 不是孤立的,它们都在围绕"让昂贵的渲染可被打断"这一件事做不同的切面。
5.1 典型组合
Suspense + useTransition 是黄金搭档。看这个路由切换的例子(Next.js App Router 或者自研路由都适用):
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
function switchTab(next: string) {
startTransition(() => {
setTab(next);
});
}
return (
<div>
<Nav active={tab} onSwitch={switchTab} isPending={isPending} />
<Suspense fallback={<RouteSpinner />}>
{isPending ? <RouteSpinner /> : renderTab(tab)}
</Suspense>
</div>
);
}
切换 tab 时,新的 tab 组件可能在请求数据(throw promise)。如果只用了 Suspense,切 tab 的瞬间整个区域会立刻 fallback 成 spinner——视觉上很跳。把 setTab 包在 startTransition 里之后,旧 tab 会保持显示直到新 tab 数据就绪,过渡平滑得多。isPending 还能让 Nav 显示"加载中"的微弱状态。
这是 React 官方推荐的路由切换姿势,也是 Next.js App Router router.push 默认就是 transition 的原因。
useDeferredValue + useMemo 是大数据组件的标准结构,上面已经展示过。
5.2 一个完整的实战:实时搜索 + 路由切换
把三者合到一个真实组件里:
function ProductSearch({ query }: { query: string }) {
// query 来自 props(比如 URL search param),源头不可控 → deferred
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// useMemo 锁住昂贵计算
const results = useMemo(
() => expensiveFilter(deferredQuery),
[deferredQuery]
);
return (
<div className={isStale ? 'opacity-70' : ''}>
<Suspense fallback={<GridSkeleton />}>
<ProductGrid products={results} />
</Suspense>
</div>
);
}
这里 useDeferredValue 保护输入响应,useMemo 缓存计算,Suspense 兜住可能 throw promise 的子组件(比如 ProductGrid 里某些图片懒加载)。各司其职。
5.3 取舍:不要到处用
这三个 API 都有成本——不是性能成本,是认知成本。每多一个 startTransition / useDeferredValue,读者就得多想一层"这个值是新的还是旧的、这次更新是不是被延迟了"。
判断要不要用的标准:
- 真的卡了吗? 用 React DevTools 的 Profiler 录一下,看看渲染时长。100ms 以内的更新,绝大多数时候用不上这些 API。React 本身的 batch 已经够用。
- 卡的根因是渲染吗? 如果卡的是网络请求,请用 debounce + 缓存;如果卡的是巨大的同步计算(比如 100 万次的纯 CPU),React 的并发调度救不了你,得用 Web Worker 或 OffscreenCanvas。
- 加完之后更难读了吗? 如果团队里 80% 的人看不懂这段 transition 的意图,那它的代价就超过了收益。
我的经验:这两个 hook 在 5% 的组件里用得到,剩下 95% 该用 debounce 用 debounce、该用缓存用缓存、该拆组件拆组件。把并发 API 当银弹是新人最常见的过度工程。
六、三个典型实战场景拆解
场景 1:实时搜索(useTransition)
需求:用户在输入框打字,下方结果列表实时过滤。数据 1 万条,本地过滤。
关键点:
- 输入框的
value用query(高优先级),保证跟手。 - 过滤结果用
startTransition包裹,标记为低优先级。 isPending给一个微弱的 loading 提示,不要让用户以为界面卡了。
代码见第三章。如果数据再大一点(10 万条),建议同时上 useMemo + Web Worker,把过滤挪出主线程,transition 只负责让"提交结果"这一步可被打断。
场景 2:大数据表格(useDeferredValue + useMemo)
需求:一个表格展示几千行数据,用户输入过滤条件,表格重排重渲染很慢。
关键点:
filter是受控 input 的 state。deferredFilter = useDeferredValue(filter),表格内部用的是 deferred 版本。useMemo依赖deferredFilter,否则延迟没意义。isStale判断做视觉降级(透明度、骨架纹),告诉用户"表格在追"。
代码见第四章。如果行渲染本身也很重(每行有复杂组件),考虑虚拟列表(react-virtual / TanStack Virtual)一起上——并发特性优化的是"渲染可中断",虚拟列表优化的是"渲染总量",两者不冲突,叠加用。
场景 3:路由级 Suspense(Next.js App Router)
需求:App Router 切到数据密集的页面,希望首屏快、过渡顺。
关键点:
- Next.js App Router 默认把每个
page.tsx包在 Suspense 边界里,loading.tsx就是 fallback。这个不用你写。 - 页面内部把"慢的子组件"再单独包
<Suspense>,让快的内容先出来。 - 主动路由跳转用
useRouter().push包在startTransition里(或者直接用useTransition配合 Link 的 prefetch),让旧页面保持显示直到新页面就绪。
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<>
<Header /> {/* 立即可用 */}
<Suspense fallback={<ChartSkeleton />}>
<SlowChart /> {/* 数据获取慢,流式补全 */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable /> {/* 与图表并行加载 */}
</Suspense>
</>
);
}
App Router + Streaming SSR 的组合下,这个页面的 HTML 会分块返回:Header 先到,Chart 和 Table 各自的数据好了之后再补。用户首屏看到内容的速度远快于"等所有数据"的传统 SSR。
七、常见误用和陷阱
最后总结几个我反复在 code review 里挑出来的问题。
7.1 把 startTransition 当成"异步化"
// 错
startTransition(async () => {
const data = await fetch('/api/x');
setData(data);
});
startTransition 的回调里不应该有 await。它只关心同步执行的 setState 调用。异步代码里再 setState,那已经是微任务之后的事,transition 标记丢了。正确做法:
// 对
fetch('/api/x').then((data) => {
startTransition(() => {
setData(data);
});
});
把 transition 包在 setState 外面,而不是包在整个异步操作外面。
7.2 把 Suspense 边界放在根节点
// 错
<Root>
<Suspense fallback={<FullPageSpinner />}>
<Header />
<Nav />
<Main />
<Footer />
</Suspense>
</Root>
任何一个子组件 throw promise,整个页面变成 spinner。哪怕 Header / Nav 这些本来就立即可用的部分也被拖下水。
Suspense 边界应该放在**"加载状态有独立语义"的层级**——一个 widget、一个 panel、一个 route section。粒度越细,并行度越高,体验越好。
7.3 在 transition 里又混入同步 setState
startTransition(() => {
setQuery(value); // 这个也会变成低优先级!
setResults(filter(value));
});
如果你把受控 input 的 state 也包进了 transition,输入框就会变得"粘滞"——React 会认为它也是低优先级,可以延迟提交。这正好抵消了你想优化的东西。
记住:受控组件的 state 永远不要进 transition。
7.4 useDeferredValue 不配 useMemo
const deferred = useDeferredValue(query);
// 每次都重算,deferred 没有任何意义
const results = heavyFilter(deferred);
useDeferredValue 推迟的是"值的传播",但如果你不缓存派生计算,React 还是会算——只是晚一点点算而已。必须配合 useMemo(或者把计算挪进 setState),延迟才有实际收益。
7.5 过度使用
最隐蔽的问题。有些团队学了并发特性之后,所有 useState 都包 startTransition,所有 prop 都过一遍 useDeferredValue。结果代码到处是"这个值可能是旧的"的隐式假设,调试的时候自己都搞不清哪次渲染用的是新值。
并发 API 是手术刀,不是撒盐。只在 Profiler 真的显示了渲染瓶颈的地方用。其它地方老老实实写同步代码,可读性远比"理论上更优"重要。
7.6 忘了 useSyncExternalStore
如果你用了非 React 的状态管理(zustand、Redux、Jotai 等),在并发模式下可能会出现"tearing"——同一次渲染中,不同组件读到了不同时刻的 store 值。这是并发渲染可中断带来的副作用。
解法不是不用这些库,而是确认它们用了 useSyncExternalStore 来订阅外部 store(主流库从 2022 年起都已迁移)。自己在写外部 store 集成时也要用 useSyncExternalStore,不要手撸 useEffect + setState 的订阅模式。
小结
把全文压缩成几条可以直接带走的:
- 并发渲染不是多线程,是"可中断 + 可恢复 + 优先级调度"的渲染调度模型,跑在单线程 JS 上,背后是 Fiber 架构。
- Suspense 是声明式加载状态的抽象。它解决的不是"加载快不快",而是"加载状态写得干不干净"。边界位置决定并行度。
- useTransition 标记低优先级更新,用于保护受控输入这类高优先级交互。只对
setState起作用,不是异步化魔法。 - useDeferredValue 从消费端延迟值的传播,用于你改不了源头 setState 的场景(比如 prop 进来的值)。一定要配
useMemo。 - 三者配合 > 单打独斗。Suspense + transition 做路由切换、deferred + useMemo 做大表格,都是经过验证的组合拳。
- 不要到处用。Profiler 测出来真的卡的才上;网络瓶颈用缓存、CPU 瓶颈用 Worker,并发 API 只解决"渲染可中断"这一件事。
金句:并发特性的本质,是把"什么时候渲染、渲染到哪"的决定权从代码里搬到 React 调度器里——你告诉它意图(这个更新可以慢、这个值可以旧),它来安排时机。它不会让代码变快,它让代码在不快的时候不被卡住。这才是 React 18 给你的真正礼物。
写完这篇文章,再去看任何一份 React 项目的并发相关代码,你应该能一眼分辨出:哪些是真优化,哪些是"为了显得高级而包了一层 transition"。后者比前者多得多。能克制地用工具,比能用工具更接近资深。
Related