Skip to content

Effect 生命周期、清理与竞态 ​

问题:最后发出的查询不一定最后显示 ​

网络完成顺序不等于发起顺序。查询 r 的慢响应可能晚于 react 的快响应到达;若两个回调都无条件写 state,旧查询会覆盖新界面。

每个 Effect 描述一个同步过程:setup 让外部系统匹配当前依赖,cleanup 撤销上一次 setup。依赖变化时顺序是:

text
提交 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 函数,将这两个返回责任分开。

完整实验:可控制乱序的搜索器 ​

jsx
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')。

  1. 快速从 alice 切到 bob,最终只能显示 Bob。
  2. fail 显示指定错误;切回 alice 能恢复且不保留旧错误。
  3. 空 id 不发请求,显示“请选择用户”。
  4. 清理既调用 abort(),又阻止不支持取消的数据源提交旧结果。

完成后对照答案 04-02。

下一篇 ​

下一篇用 Ref 在提交后安全地访问 DOM。

用心记录每一步成长