Effect 生命周期、清理与竞态
问题:最后发出的查询不一定最后显示
网络完成顺序不等于发起顺序。查询 r 的慢响应可能晚于 react 的快响应到达;若两个回调都无条件写 state,旧查询会覆盖新界面。
每个 Effect 描述一个同步过程:setup 让外部系统匹配当前依赖,cleanup 撤销上一次 setup。依赖变化时顺序是:
提交 query="r" → setup("r")
提交 query="react" → cleanup("r") → setup("react")
组件移除 → cleanup("react")开发环境的 Strict Mode 还会执行额外的 setup → cleanup → setup 探测。正确清理应让这次探测没有用户可见差异。
useEffect(setup, [query]) 的第二参不是“想什么时候运行”的任意开关。React 在提交后用 Object.is 比较本次与上次的各个依赖,query 改变才结束旧同步并启动新同步;省略数组会在每次提交后重同步,空数组表示此同步没有响应式依赖。数组要写在组件中且长度固定。依赖相同不保证组件不渲染,只决定这一项 Effect 是否需要重同步。
每次渲染新建的 { roomId } 对象,即使字段相同,Object.is 也不相同。如果 Effect 依赖这个对象,就会不断重连。应在 Effect 内构造连接所需选项,让依赖表达真正决定连接的 roomId;不能删掉依赖来隐藏变化。Effect 也不能声明为 async:React 需要拿到清理函数,而 async 返回的是 Promise。下面在 setup 内声明并调用 async 函数,将这两个返回责任分开。
完整实验:可控制乱序的搜索器
import { useEffect, useState } from 'react'
const catalog = ['React', 'Redux', 'Relay', 'Vue', 'Svelte']
function searchCatalog(query, { signal }) {
const delay = query.length <= 1 ? 1200 : 250
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(signal.reason); return }
function abort() {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', abort)
resolve(catalog.filter((item) =>
item.toLowerCase().includes(query.toLowerCase()),
))
}, delay)
signal.addEventListener('abort', abort, { once: true })
})
}
export default function App() {
const [query, setQuery] = useState('')
const [status, setStatus] = useState({
phase: 'idle', query: '', items: [], message: '',
})
useEffect(() => {
const normalized = query.trim()
if (!normalized) {
setStatus({ phase: 'idle', query: '', items: [], message: '' })
return
}
const controller = new AbortController()
let current = true
setStatus({ phase: 'loading', query: normalized, items: [], message: '' })
async function run() {
try {
const items = await searchCatalog(normalized, {
signal: controller.signal,
})
if (current) {
setStatus({ phase: 'success', query: normalized, items, message: '' })
}
} catch (error) {
if (current && error.name !== 'AbortError') {
setStatus({
phase: 'error', query: normalized, items: [], message: error.message,
})
}
}
}
run()
return () => {
current = false
controller.abort()
}
}, [query])
return (
<main>
<h1>组件搜索</h1>
<input
aria-label="查询"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{status.phase === 'idle' && <p>请输入关键词</p>}
{status.phase === 'loading' && <p>正在查询 {status.query}…</p>}
{status.phase === 'error' && <p role="alert">{status.message}</p>}
{status.phase === 'success' && (
<p>结果 {status.query}: {status.items.join(', ') || '无'}</p>
)}
</main>
)
}一秒内先输入 r,再输入 re。第一个计时器较慢,但依赖变化会先执行旧清理:abort() 停止可取消的工作,current = false 保证即使某数据源不理会取消,旧闭包也不能提交状态。
要单独验证“忽略旧结果”,临时删除 searchCatalog 中的 abort 监听注册,保留其他代码。先输入 r,再输入 re;旧 r 仍会完成,但画面不得回到它。再临时去掉成功分支的 if (current),就会看到旧结果覆盖。分别恢复后可以判断:取消减少工作,提交资格保护正确性,两者不是同一次测试。
searchCatalog 正常结束也移除监听器,避免完成的工作继续被 signal 引用。传入已取消信号时立即拒绝,因为以后不会补发一次过去的 abort 事件。示例控制器使用默认取消原因;若接口允许任意 reason,应依自己持有的 signal 判断取消,不应只根据错误名称猜测。
取消与忽略解决不同问题
AbortController给外部操作取消信号,可节省工作;前提是数据源支持signal。current是本组件的提交资格。它不取消工作,只拒绝过期结果写回。- 清理闭包捕获那次 setup 的变量,不会突然变成新查询的数据。
缺失依赖不是优化。Effect 读取的每个响应式值都应进入依赖数组;若函数依赖导致重连,应把函数移到 Effect 内、把常量移到组件外,或重设边界。
关键变体
- 订阅的 cleanup 必须用同一个 handler 取消订阅。
- 连接应先断旧房间,再连新房间;不同同步过程拆成不同 Effect。
- 无法取消的 Promise 仍可用提交资格保证 UI 正确,但后台工作会继续。
- 真实
fetch还要检查response.ok;AbortError 是预期清理,不应显示为错误。
边界
客户端 Effect 取数缺少路由级缓存、服务端首屏数据和避免瀑布的统一策略。它适合教学、简单客户端同步或自定义 Hook;生产框架有 loader/缓存层时优先使用框架机制。清理也不只在卸载执行:依赖每次改变前都会执行旧清理。
练习:契约任务
实现 loadProfile(id, { signal }):alice 延迟 900ms,bob 延迟 100ms,fail 在 200ms 后抛出 Error('profile unavailable')。
- 快速从
alice切到bob,最终只能显示 Bob。 fail显示指定错误;切回alice能恢复且不保留旧错误。- 空 id 不发请求,显示“请选择用户”。
- 清理既调用
abort(),又阻止不支持取消的数据源提交旧结果。
完成后对照答案 04-02。
下一篇
下一篇用 Ref 在提交后安全地访问 DOM。