C++ 11 后,移动语义、资源管理、函数式编程等特性彻底改变了 C++ “带类的 C” 的偏见。人们称其为“Modern C++”。 其实,近年来 React 范式也开始走向成熟,我认为 React 18 之后的 React 也可以被称为“Modern React”。 这些年来 React 的更新,并非只是多了几个 Hook,而是范式的继续改革。从命令式地手动管理生命周期和状态同步,转向声明式地定义资源与行为。我们离真正的声明式可以更进一步。
传统 React#
过去,我们习惯用 useEffect 处理所有逻辑。为了获取“peek”状态(读取状态但不希望这是响应式的),我们可能在依赖数组中刻意忽略一些依赖,然而 linter 又会阻止我们这样做。eslint-ignore 之类的东西充斥在代码中。
这无疑会导致代码变得难以维护,同时更隐性的影响是,他让编码者的大脑混乱,或者说对框架的逻辑本身有着混乱的认知,这样的状态下,写出来的代码必然不可能是高质量、具有高可读性、现代化的。
而且,useEffect 已经偏离了其初衷:用于同步内部状态与外部系统。甚至,如果要同步外部的 store,useEffect 也完全称不上是一个好的方案。
为了保护重渲染机制下的性能,useCallback 和 useMemo 也被大量使用,这种语法冗余的同时也加剧了依赖数组依赖 linter 的问题。
思维模型转变:以 useEffectEvent 为例#
在 Modern React 中,useEffect 必须是响应式的。如果需要读取某个状态,而该状态变更时又不应该重新触发 useEffect,你应该使用 useEffectEvent ↗。
官方文档的示例如下:
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme)
})
useEffect(() => {
const connection = createConnection(serverUrl, roomId)
connection.on('connected', () => {
onConnected()
})
connection.connect()
return () => connection.disconnect()
}, [roomId])
// ...
}jsshowNotification 是一个行为,而非数据的一部分,因此它不应破坏 Effect 的稳定性。
可以用 useEffectEvent 来调用 showNotification,并在 useEffect 中使用这个 event 而不需要将其加入依赖数组。
不过需要注意,useEffectEvent 返回的 Effect Event 函数只能用在 Effects 中(如 useEffect)。
上述例子反映了 Modern React 最底层的思想:让一切回归本源,依赖数组就应该诚实地声明响应式的依赖,开发者不应该为实现本来常见的目的而使用奇技淫巧;
同样地,useEffect 也只应该被用作同步 React 状态与外部系统,而不应该承担其他的职责,尽管这是 React 自始至终都声明地原则,但只有 Modern React 时代,我们才有足够的便利实现这一原则。让我们继续往下看。
现代化的数据获取与同步#
如果使用 useEffect 来获取数据,我们需要手动设置 data, isLoading, error 等状态,手动处理竞态条件。这样做应该被视为一种反模式。
他造成了许多模板代码,还要求业务开发时也要熟练地掌握一些底层问题,这不仅违背了声明式的范式,在实践中要求每处处理都完美得当也是不可能的。更何况这样做还难以实现缓存、去抖动等现代应用所必须的特性。
依照 React 哲学,数据理应是一种资源,而不是副作用。考虑以下两点:
- 使用 SWR ↗,TanStack Query ↗ 等库。在 Safe & Consistent SWR 中,我试图说明使用
useSWR等 Query 库获取与同步数据的最佳方式,不妨看看。 - 使用
useSyncExternalStore↗。这是 React 18 引入的底层原语。对于浏览器 API 或使用全局状态库时,不再需要用 useEffect 去监听和设置状态。- 而且,它解决了并发渲染下的问题,确保 UI 在任何时间切片读取到的外部数据是一致的,从而为并发渲染这一重要特性开辟道路。在后面我们将会看到这如何改变了组件渲染的思维方式。
派生状态#
你或者身边的人是否写过这样的代码?
function User({ firstName }) {
const [fullname, setFullname] = useState(null)
useEffect(() => {
setFullname(`Kumo ${firstName}`)
}, [firstName])
// ...
}jsReact 教导我们,这里不应该用 useEffect,直接派生状态。
function User({ firstName }) {
const fullname = useMemo(() => `Kumo ${firstName}`, [firstName])
// ...
}js等等……还是不对劲!所有开发者都应该了解 React Compiler ↗,对于仅仅为了保证引用稳定性的场景,不用再浪费时间手动 memo 了。
function User({ firstName }) {
const fullname = `Kumo ${firstName}`
// ...
}js如果不需要明确的对 memorization 行为的控制,请改用 React Compiler 并直接自然地处理派生状态。
并发渲染#
假设我们有一个耗时操作。设置一个 pending 状态,然后显示一个 spinner!
这的确是很自然的想法,然而滥用会导致界面频繁闪烁,从而要求更高级的处理技巧,这又提高了写出好代码的门槛,而且让开发者的注意力集中在本不应该的事情上。
在并发渲染的模式中,UI 的更新是可以分优先级的,且可以被“挂起”的。
组件可以用 use ↗ 解包 Promise,在 Promise resolved 前,渲染会回退到最近的 Suspense ↗ 边界。
这意味着我们可以像写同步代码一样处理异步数据,不过,这是从底层层面而言的;对于实际应用,还是建议通过 useSWR 等机制来处理。
什么是 Suspense?我记得 Suspense 以前不是干这个的吧?
你说得没错。Suspense 在 Modern React 中承担起了新的职责,正如上面所述。一旦开始使用 Suspense 而不是在组件内处理 pending 状态,我们就可以回头看看这些场景:
- 用户在输入框打字,然而 Select 组件的搜索和渲染带有一定延迟
- 打开 dropdown menu 时需要抓取数据,但显示一个 spinner 显然观感不好;让 router 或 context 传递数据又是反模式
这意味着 UI 渲染是可以延后的。useTransition ↗ 展示了如何做到这一点。
例如,在 dropdown menu 的数据获取中,为 useSWR 使用 { suspense: true } 选项,然后用 startTransition 包裹打开 dropdown menu 的逻辑。
你可以用 startTransition 来渲染需要 suspense 的 UI,而这不会真的回到 Suspense 边界;React 会标记这些更新为非紧急,只有在完成后才会显示界面,从而避免了屏幕闪烁的问题。
此外,transitions 可以广泛地应用于需要不阻塞地更新的场景:
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY))
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}js把 setScrollY 这种操作 UI 状态的行为封装进 startTransition,就能确保更新不会 block UI 了。
Action 同样可用于客户端应用#
很多人说这些 API 是 RSC 带来的,我并不认可。即使在纯客户端应用中,React 19 的 Action API 依然极具革命性。
<form onSubmit={handleSubmit}>- 在
handleSubmit里preventDefault setPending(true),或许看了前面的内容会考虑startTransition…?try/catchPOSTsetPending(false)/setError(...)- 啊……对!还有乐观更新该怎么办 🤔🤯
相信你已经对这些模板代码感到反感。submit 作为数据的 mutation,不应该这么复杂。
useActionState↗ 自动管理异步操作的结果和状态,只需要提供一个处理函数就行!useFormStatus↗ 在 button 等 form 内组件访问 form 状态。useOptimistic↗ 让我们拥抱现代化 UX!立即在 UI 上显示更新;如果失败,自动回滚。
最终的对比#
现代 React 应该能够这样构建应用:
const useMessages = (roomId) => useSWR(..., { suspense: true })
function ChatRoom({ roomId }) {
// messages 不会是 undefined,可以把 isLoading 扔了
const { data: messages } = useMessages(roomId)
const [state, formAction, isPending] = useActionState(sendMessageAPI, null)
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [...state, newMessage]
)
const activeCount = optimisticMessages.filter(m => !m.archived).length
return (
<div>
<MessageList messages={optimisticMessages} />
<div className="status">Active: {activeCount}</div>
<form action={async (formData) => {
const msg = formData.get('message')
addOptimisticMessage({ text: msg, sending: true })
await formAction(formData)
}}>
<input name="message" />
{/* 内部使用 useFormStatus,无需传递状态 */}
<SubmitButton />
</form>
</div>
)
}js你可能已经忘了在这一切以前,宇宙是一片混沌,造物主因为 messy code 放弃了人类。
// 继承 isSubmitting 状态 :(
function SubmitButtonLegacy({ isSubmitting }) {
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
)
}
function ChatRoomLegacy({ roomId }) {
// 经典三件套!
const [messages, setMessages] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [fetchError, setFetchError] = useState(null)
useEffect(() => {
let ignore = false // 处理竞态条件
async function fetchData() {
setIsLoading(true)
setFetchError(null)
try {
const data = await fetchMessagesAPI(roomId)
if (!ignore) {
setMessages(data)
}
} catch (err) {
if (!ignore) setFetchError(err)
} finally {
if (!ignore) setIsLoading(false)
}
}
fetchData() // 嗯。说实话我还不如用 .then.catch.finally
return () => {
ignore = true
}
}, [roomId]) // 我最爱的依赖数组 🥰
// 又来。
const [isSending, setIsSending] = useState(false)
const [sendError, setSendError] = useState(null)
// 我们需要一个 ref 来防止闭包陷阱,或者使用函数式更新
// 我们需要手动修改 messages 并在失败时回滚
const handleSendMessage = async (e) => {
e.preventDefault() // 必须阻止默认行为
const formData = new FormData(e.target)
const msgText = formData.get('message')
if (!msgText) return
// 第一步 乐观更新
const tempId = Date.now()
const optimisticMsg = { id: tempId, text: msgText, sending: true }
setMessages((prev) => [...prev, optimisticMsg])
setIsSending(true)
setSendError(null)
e.target.reset() // 手动清空表单
try {
// 第二步 发送请求
const newServerMsg = await sendMessageAPI(msgText)
// 第三步 成功:用服务器返回的真实数据替换临时数据
setMessages((prev) =>
prev.map(m => m.id === tempId ? newServerMsg : m)
)
} catch (err) {
// 第四步 失败:手动回滚(非常容易写错!)
setSendError(err)
setMessages((prev) => prev.filter(m => m.id !== tempId))
// 甚至还需要把输入框的内容恢复回去,这里省略了,太麻烦
} finally {
setIsSending(false)
}
}
const activeCount = useMemo(() => {
return messages.filter(m => !m.archived).length
}, [messages]) // 小心翼翼写依赖。
// 骨架屏又忘记做了。算了写个 Loading 以后再说
if (isLoading) return <div>Loading...</div>
if (fetchError) return <div>Error: {fetchError.message}</div>
// 终于!吟唱了那么久,终于……不对!这么简单的 UI,为什么需要如此冗长的逻辑?我不理解 😭
return (
<div>
<MessageList messages={messages} />
<div className="status">Active: {activeCount}</div>
<form onSubmit={handleSendMessage}>
<input name="message" disabled={isSending} />
<SubmitButtonLegacy isSubmitting={isSending} />
{sendError && <div className="error">Failed to send</div>}
</form>
</div>
)
}js总而言之,在传统 React 中,为了实现同样的功能,需要开发者手动编写大量的“胶水代码”来维护状态的一致性。 代码量不仅大,而且每一个手动维护的状态都是潜在的 Bug 来源。
在这个框架之争愈演愈烈的时代,请用更少的代码,更强的鲁棒性,easy 地做正确的事,让我们为 React 正名。