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 手册在 异步 JavaScript 这一章从基础讲起;这里的所有内容都是建立在那些基础之上的。

useEffect 是一个 hook,它在 React 渲染完组件后运行代码,而不是在渲染过程中运行。你给它提供一个函数和一个依赖数组,依赖数组里是那些值改变时应该让 effect 重新运行的值,这里只有 url。Profile 从两个状态开始:data 用来保存结果,error 用来保存任何出错信息。effect 开头,setData(null) 和 setError(null) 清除上一次运行留下的任何东西。这很重要,有两个原因:没有它的话,当 url 改变时,组件会继续显示旧的个人资料而不是"Loading...",因为 data 还保存着上一次成功的结果;而且如果一个请求在早前的请求失败后成功了,旧的错误永远不会被清除,因为没有东西会把 error 设回 null。然后 fetch 返回一个响应 Promise。fetch 只在网络失败时拒绝这个 Promise,所以 404 或 500 仍然会作为响应对象正常 resolve,这就是为什么第一个 .then 会检查 r.ok 并在响应到达 .json() 之前抛出错误。跳过这个检查的话,失败的请求会直接流入 data,好像它成功了一样。一旦检查通过,.json() 读取响应体,结果通过 setData 放入 data。如果这些中的任何一个被拒绝,.catch 会把问题放入 error。在 JSX 中,像处理其他状态一样检查 error 和 data:错误渲染一条消息,还没有数据时渲染加载消息,有数据时渲染真实的 UI。

这里没有单独的 loading 变量。只要 data 和 error 都仍然是 null,组件就处于请求已发出但还没收到响应的状态,所以 if (!data) return <p>Loading...</p> 自己就能处理这种情况。有些组件会添加一个独立的 isLoading 布尔值来获得更细致的控制,但从你已有的状态推导出 loading 状态通常更简单,而且少了一个需要保持同步的值。

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

像这样手工获取数据值得理解,因为它是几乎所有其他东西都建立在其上的模式。在真实的应用中,你通常会用 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 的同样两个修复:在运行开头重置 data 和 error,这样重试才能真正清除之前的失败或陈旧的个人资料,以及在解析响应体之前检查 r.ok,因为 fetch 把 404 或 500 当作完成的请求而不是拒绝。AbortController 只是替代了清理标志原来做的工作,取消一个不再需要的请求,所以它并不能解决状态在运行之间如何进行以及 fetch 自己如何对待失败的 HTTP 响应的问题。两种方法都解决了顺序问题,但中止请求还能省去网络完成一个没人想要的请求的麻烦。

从 React 19 开始,新的方向是用包裹在 <Suspense> 中的组件内的 use hook 读取异步数据,这样 React 就能在框架层处理挂起和加载状态,而不是在每个组件中手工处理。use 通常不单独使用。它通常与管理缓存和底层请求生命周期的框架或数据库配对。Beyond the basics 沿着更广泛的数据获取生态系统的其余部分继续这个话题。

Juno在 effect 中 fetch,观察各种状态 要记住的模式:把 fetch 放在 useEffect 中,把 data 和 error 放在状态中,在 JSX 中检查它们来决定显示什么。加载就是这两个都还是空的那一刻。

active 标志阻止迟到的响应在不再显示那些数据的组件上设置状态。

Juno在 effect 中 fetch,观察各种状态 在 useEffect 中 fetch,把结果和任何错误存在状态中,直接根据那些渲染,如果不需要的话就不要添加单独的 loading 布尔值。

返回一个清理函数来翻转 active 标志,这样前一个 url 的陈旧响应或卸载的组件就无法覆盖当前状态。

超过一次性请求的情况,就用 React Query 或你框架的数据加载,而不是到处手工编写这个。

Juno在 effect 中 fetch,观察各种状态 每次 effect 运行都拥有自己的 active 闭包,这就是它在 url 快速改变时对乱序响应保持安全的原因;AbortController 做同样的工作并且还取消了浪费的请求。

把原始 useEffect 获取当作一次值得理解的机制。真实的应用依靠数据库或带 Suspense 的 use hook 来处理实际请求,两者都恰好建立在这个模式之上。

下一章:Refs 和 DOM,处理那些需要 DOM 节点本身的工作的逃生舱。