React 18 的并发特性改变了我们对"渲染过程"的认知。传统 Hooks 的使用方式在并发模式下可能暴露新的问题。掌握这些最佳实践,才能真正享受到 React 18 的性能红利。
useEffect 是同步的,但外部世界不是
并发模式下,一个 Effect 可能在其清理函数执行后,异步回调才姗姗来迟。代码示例中使用 AbortController + mountedRef 的组合就是标准解法:
import { useState, useEffect, useTransition, useDeferredValue, useMemo, useCallback, useRef } from 'react'
interface User {
id: number
name: string
email: string
bio: string
}
function useDebounce<T>(value: T, delay: number = 300): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
function UserSearch() {
const [query, setQuery] = useState('')
const [users, setUsers] = useState<User[]>([])
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const mountedRef = useRef(true)
const debouncedQuery = useDebounce(query, 250)
const deferredQuery = useDeferredValue(debouncedQuery)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
abortRef.current?.abort()
}
}, [])
useEffect(() => {
if (!deferredQuery.trim()) {
setUsers([])
return
}
const controller = new AbortController()
abortRef.current?.abort()
abortRef.current = controller
;(async () => {
try {
setError(null)
const res = await fetch(`/api/users?q=${encodeURIComponent(deferredQuery)}`, {
signal: controller.signal
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (mountedRef.current) {
startTransition(() => setUsers(data))
}
} catch (e: any) {
if (e.name !== 'AbortError' && mountedRef.current) {
setError(e.message)
}
}
})()
return () => controller.abort()
}, [deferredQuery])
const highlightText = useCallback((text: string, keyword: string) => {
if (!keyword) return text
const parts = text.split(new RegExp(`(${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'))
return parts.map((p, i) =>
p.toLowerCase() === keyword.toLowerCase() ? <mark key={i}>{p}</mark> : <span key={i}>{p}</span>
)
}, [])
const filteredList = useMemo(() => users.slice(0, 50), [users])
return (
<div className="search-box">
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索用户..."
className="input"
/>
{error && <div className="error">{error}</div>}
<div className={isPending ? 'results pending' : 'results'}>
{filteredList.map(user => (
<div key={user.id} className="user-item">
<strong>{highlightText(user.name, deferredQuery)}</strong>
<span className="email">{user.email}</span>
</div>
))}
{users.length === 0 && deferredQuery && !isPending && !error && (
<div className="empty">无匹配结果</div>
)}
</div>
</div>
)
}
useMemo 与 useCallback 优化边界
过度使用 useMemo/useCallback 是初学者的典型特征。下面的决策表帮你判断什么时候该用:
| 场景 | 是否应该使用? | 理由 |
|---|---|---|
| 子组件被 React.memo 包裹且 props 是引用类型 | ✅ 需要 | 避免不必要重渲染 |
| 给 useEffect 作依赖且是数组/对象 | ✅ 需要 | 防止 Effect 意外触发 |
| 计算量大(排序/遍历 >1000 项/正则 heavy) | ✅ 需要 | 纯 CPU 性能收益远大于 memo 开销 |
简单计算如 a + b 或对象只有 3 个字段 |
❌ 不要 | memo 比较本身更贵 |
| 传递给原生 DOM 元素的函数 | ❌ 不要 | React 不 memo DOM 回调 |
| 传递给自定义 Hook 的函数但 Hook 不依赖它 | ❌ 不要 | 无实质好处 |
并发模式的三大武器
React 18 提供了三把钥匙来解锁并发体验:
// 1. startTransition:标记非紧急更新
startTransition(() => setFilteredItems(heavyCompute()))
// 2. useDeferredValue:延迟某个值的下游计算
const deferredList = useDeferredValue(hugeList)
// 3. Suspense + use:等待异步资源(React 18.2+)
const user = use(fetchUserPromise)
关键区别:useTransition 由你主动包裹更新动作;useDeferredValue 是被动延迟某个 prop/state 的传播。
避免陈旧闭包的三个技巧
- Ref 保存最新值:用
useRef+useEffect同步值给异步回调读取 - 函数式更新:setState 传
(prev) => next而非直接依赖外部值 - 把 effect 当"订阅"看:严格填依赖数组,信不过的工具就用 eslint-plugin-react-hooks
最佳实践
- Effect 的依赖永远诚实写,实在想模拟"只执行一次"的语义,先反思是不是设计问题
- 一个 Hook 只做一件事,自定义 Hook 超过 100 行就考虑拆分
- 数据获取用成熟库(SWR / React Query / TanStack Query)而不是手写 useEffect
- 状态提升到合适的层级:能用 Lifting State 解决就不要急着上 Context/Redux
- Context 拆分细粒度:一个全局 UserContext 会让所有消费组件无脑重渲染
总结
React 18 不是"加了几个 Hook"的增量升级,而是底层调度模型的重构。拥抱并发模式的关键,在于放弃"Effect 是 componentDidMount"的旧思维,改用"声明副作用的同步与清理关系"的新范式。你的代码越纯、越声明式,React 18 就能帮你做得越快。