Skip to content
This page has been auto-translated and may contain errors.View in English

获取数据

一个展示服务器数据的组件首次渲染时还没有那些数据。它需要请求数据、等待响应,然后在数据到达时展示结果,或在请求失败时处理错误。这章讲的就是 React 应用处理这个问题的模式:在 effect 中执行 fetch,用 useState 保存结果、加载状态和错误。

这是一个根据 URL 获取用户信息的 Profile 组件。组件先渲染,然后才向服务器请求数据,这正是 useEffect 存在的原因:

jsx
function Profile({ url }) {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    let active = true
    setData(null)
    setError(null)
    fetch(url)
      .then(r => {
        if (!r.ok) throw new Error(`Request failed: ${r.status}`)
        return r.json()
      })
      .then(d => { if (active) setData(d) })
      .catch(e => { if (active) setError(e) })
    return () => { active = false }
  }, [url])

  if (error) return <p>Failed to load</p>
  if (!data) return <p>Loading...</p>
  return <h1>{data.name}</h1>
}

如果 fetch、promise 或 .then 还不熟悉,可以先看 JavaScript 教程中 Async JavaScript 的部分,这里的内容都建立在那个基础之上。

useEffect 是一个 hook,它在 React 渲染完组件之后运行代码,而不是在渲染过程中运行。你给它传一个要运行的函数和一个依赖数组,这个数组列出什么值变化时应该重新运行 effect,这里只有 urlProfile 开始时有两个状态:data 存放结果,error 存放任何出错信息。在 effect 开头,setData(null)setError(null) 清空前一次运行留下的任何数据。这很重要,原因有两个:如果不这样做,当 url 改变时,会继续显示旧的个人资料而不是"Loading...",因为 data 里仍然存着上次成功的结果;如果一个请求在前面一个失败之后成功了,也永远清不掉旧的错误,因为没有什么会把 error 重新设回 null。然后 fetch 返回一个 promise 来处理响应。fetch 只在网络故障时 reject 这个 promise,所以 404 或 500 仍然会正常 resolve 为一个响应对象,这就是为什么第一个 .then 要检查 r.ok 并在响应传给 .json() 前抛出错误。跳过这个检查的话,失败的请求会流进 data 仿佛它成功了一样。一旦检查通过,.json() 读取响应体,结果通过 setData 流入 data。如果这期间任何地方 reject 了,.catch 会把问题放进 error。在 JSX 中,errordata 像任何其他状态一样被检查:有错误就渲染一条信息,还没有数据就渲染加载信息,有数据就渲染真正的 UI。

这里没有单独的加载变量。只要 dataerror 都还是 null,组件就处在请求已发出但还未收到响应的状态中,所以 if (!data) return <p>Loading...</p> 自己就能覆盖这个情况。有些组件会加一个专门的 isLoading 布尔值来做更细致的控制,但从你已有的状态推导加载状态往往更简单,而且少了一个需要保持同步的值。

effect 里的 active 变量是一个清理标志useEffect 可以返回一个函数,React 会在 effect 即将再次运行时以及组件卸载时调用那个函数。这里返回的函数把 active 设为 false.then.catch 都会在调用 setDatasetError 之前检查它。没有这个检查的话,当 url 改变或组件离开屏幕时,仍在进行中的请求最终会 resolve 并试图更新那个不再属于正在显示内容的状态。这个标志让一个迟到的响应变成无操作,而不是一个 bug。

像这样手动 fetch 值得理解,因为这是几乎所有其他东西都建立在其上的模式。在真实的应用中,你通常会用像 React Query 这样的数据获取库,或者框架内置的数据加载(Next.js 这样的框架在路由层处理),而不是为每个请求都手写 useEffect 和几个状态。那些工具会为你处理缓存、重试和下面讲的时序问题。这章讲的模式正是它们建立的基础。

重叠请求之间的竞态条件是值得直接指出的尖锐边界。如果 url 快速改变(比如有人在搜索框中输入,或在个人资料间点击),组件会在前面的请求 resolve 前发出新请求,网络响应不会可靠地按发送顺序到达。没有清理标志的话,前一个请求的慢响应可能在后一个请求的快响应之后到达,用你甚至不在看的 url 的陈旧内容覆盖 data。这正是 active 要防止的:effect 的每次运行都有自己的 active 闭包,所以前面一次运行的请求在自己的清理被触发后,永远无法写入当前渲染的状态。

AbortController 是处理这个问题的另一个工具,功能更强大:它取消正在进行的请求本身,而不仅仅是忽略迟到的响应。

jsx
useEffect(() => {
  const controller = new AbortController()
  setData(null)
  setError(null)
  fetch(url, { signal: controller.signal })
    .then(r => {
      if (!r.ok) throw new Error(`Request failed: ${r.status}`)
      return r.json()
    })
    .then(setData)
    .catch(e => { if (e.name !== 'AbortError') setError(e) })
  return () => controller.abort()
}, [url])

这个版本仍然需要和上面的 effect 一样的两个修复:在运行开头重置 dataerror,这样重试才能真正清空前面的失败或陈旧个人资料,以及在解析响应体前检查 r.ok,因为 fetch 把 404 或 500 当作完成的请求而不是 rejection。AbortController 只取代了清理标志做的工作——取消一个不再需要的请求,它对状态在运行间如何持续或 fetch 如何自己处理失败的 HTTP 响应没有作用。两种方法都解决了顺序问题,但中止还能节省网络完成一个没人要的请求的麻烦。

从 React 19 开始,更新的方向是在包裹在 <Suspense> 中的组件里用 use hook 读取异步数据,这让 React 在框架层面而不是在每个组件中手动处理待定和加载状态。use 通常不单独使用。它通常和管理缓存及请求生命周期的框架或数据库配对。Beyond the basics 会连同整个更广的数据获取生态一起讲这个。

Juno在 effect 中 fetch,监视这些状态 记住的模式是:把 fetch 放在 useEffect 里,把 dataerror 放在状态里,在 JSX 中检查它们来决定显示什么。加载就是两个都还是空的那一刻。active 标志阻止迟到的响应在组件不再显示那些数据时设置状态。
Juno在 effect 中 fetch,监视这些状态useEffect 里 fetch,把结果和任何错误存在状态里,直接基于那些状态渲染,如果不需要就不必加单独的加载布尔值。返回一个清理函数,翻转 active 标志,这样前面 url 的陈旧响应或卸载的组件就无法覆盖当前状态。除了一次性请求,用 React Query 或框架的数据加载代替到处手写这个。
Juno在 effect 中 fetch,监视这些状态 每次 effect 运行都有自己的 active 闭包,这就是当 url 快速改变时不受乱序响应影响的原因;AbortController 做的是同样工作并且也取消了浪费的请求。把原始 useEffect fetch 当作值得理解一次的机制。真实应用依靠数据库或者 Suspense 配合的 use hook 来做实际请求,都建立在这个确切的模式之上。

接下来:Refs 和 DOM,这是那些需要 DOM 节点本身的工作的逃生舱口。