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 重新运行的值,这里只有 urlProfile 从两个状态开始: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 中,像处理其他状态一样检查 errordata:错误渲染一条消息,还没有数据时渲染加载消息,有数据时渲染真实的 UI。

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

effect 中的 active 变量是一个清理标志useEffect 可以返回一个函数,React 会在 effect 再次运行时和组件卸载时调用这个函数。这里,返回的函数把 active 设为 false.then.catch 在调用 setDatasetError 之前都会检查它。没有这个检查的话,当 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 的同样两个修复:在运行开头重置 dataerror,这样重试才能真正清除之前的失败或陈旧的个人资料,以及在解析响应体之前检查 r.ok,因为 fetch 把 404 或 500 当作完成的请求而不是拒绝。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,把结果和任何错误存在状态中,直接根据那些渲染,如果不需要的话就不要添加单独的 loading 布尔值。

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

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

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

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

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