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

副作用

想象一个组件需要在渲染后保持一个定时器继续运行,就像下面的 Timer 组件所做的那样。组件做的大部分工作都发生在渲染时:它读取 props 和 state,然后返回 JSX。让定时器运行是不同的。打开一个订阅、同步文档标题或从服务器获取数据也是这样。每一个都伸向那个世界之外,触及 React 不管理的东西。useEffect 就是为此而生的 hook。它让组件在渲染后运行一些代码,这样它就可以与那个外部的东西同步。

这里是一个只启动一次定时器,然后每秒计数加一的副作用:

jsx
import { useState, useEffect } from 'react'

function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000)
    return () => clearInterval(id)
  }, [])

  return <p>{count}</p>
}

这里有三个部分在工作,它们对应了每个副作用处理的三件事:做什么、怎样清理,以及何时运行。

你传给 useEffect 的函数就是副作用本身。这个例子调用 setInterval 来启动一个重复的定时器,每秒让 count 加一。

依赖数组

第二个参数,最后的 [],是**依赖数组**。它控制副作用何时运行。

  • 一个空数组 [] 意味着副作用在组件第一次出现在屏幕上后运行一次。上面的定时器使用这个:启动定时器一次,然后让它继续运行。
  • 一个包含值的数组,比如 [a, b],意味着副作用在第一次渲染后运行,然后在这些值中任何一个在渲染之间改变时再运行。这是你在副作用依赖的某个东西改变时重新同步的方式。
  • 完全不提供数组意味着副作用在每次渲染后都运行。这很少是你想要的,也是导致状态更新形成死循环的常见原因。

所以这个数组是你回答"这段代码应该何时再次运行?"这个问题的方式。列出副作用读取并依赖的值,React 会在它们中任何一个改变时重新运行。

清理函数

上面的副作用返回一个函数:

jsx
return () => clearInterval(id)

这个返回的函数是**清理**函数。React 在副作用再次运行之前运行它,以及在组件从屏幕上移除时再运行一次。它的工作是撤销副作用设置的任何东西。

清理函数很重要,因为副作用经常启动一些会独立继续运行的东西:一个定时器、一个事件监听器、一个打开的连接。如果组件被移除,没有什么停止那个定时器,它会永远继续运行,并保持对已经消失的组件的引用。做几次这样的事,你就会有一个缓慢的泄漏,以及更新降落在不再存在的东西上。经验法则:如果一个副作用启动了什么东西、订阅了什么东西或打开了什么东西,它的清理函数应该停止、取消订阅或关闭它。

你可能不需要副作用

副作用是为了与外部世界同步的,所以很多看起来像 useEffect 的候选代码实际上应该放在别的地方。有两种情况经常出现。

第一种是衍生值。如果你能从已有的 props 和 state 中计算出什么,就在渲染时直接计算它:

jsx
function Cart({ items }) {
  const total = items.reduce((sum, item) => sum + item.price, 0)
  return <p>Total: {total}</p>
}

total 是从 items 衍生出来的,所以它在每次渲染时计算,总是保持同步的。把它存储在自己的 state 中并从副作用更新它会是更多代码和一次额外的渲染,得不到任何好处。

第二种是响应用户操作。当某个事情应该因为用户点击或输入而发生时,那段代码应该放在那个操作的事件处理器中:

jsx
function BuyButton({ product }) {
  function handleClick() {
    buyProduct(product)
  }
  return <button onClick={handleClick}>Buy</button>
}

购买发生在 handleClick 中,就在点击被处理的地方。通过副作用传递它会增加一层间接性,使流程更难跟踪。一个好的测试:如果代码因为特定交互而运行,使用事件处理器;如果它运行是为了让组件与外部系统保持同步,使用副作用。

任何副作用的实际问题是什么属于依赖数组。答案是机械的:副作用读取的每个反应值,即出现在副作用函数内的每个 prop、state 变量或从它们衍生的值,都属于数组中。漏掉一个,副作用就会一直使用它的旧副本,而不是接收改变。

jsx
function SearchResults({ query }) {
  const [results, setResults] = useState([])

  useEffect(() => {
    let active = true
    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(data => { if (active) setResults(data) })
    return () => { active = false }
  }, [query])

  return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
}

这个副作用读取 query,所以 query 在数组中。作用域内没有其他东西改变副作用做什么,所以不需要在那里有其他东西。你不必凭记忆工作这个。eslint-plugin-react-hooks lint 规则 exhaustive-deps 读取副作用函数并标出它使用的任何数组中缺失的值。把它的警告当作真实的 bug 来修复,而不是噪音来消除,因为一个被压制的警告正是陈旧值的 bug 如何潜入生产环境的。

当一个副作用开始做两个不相关的工作时,把它分成两个副作用,而不是一个有更长依赖列表的副作用。一个既获取用户数据又根据那个用户设置文档标题的组件,作为两个单独的 useEffect 调用更容易理解,每个有自己关注的依赖数组,而不是一个副作用同时处理两个关注点。每个副作用应该与外部世界同步一件事。

一个快速测试,看代码是否根本属于副作用:它是否获取某个东西、订阅某个东西,或伸出去直接触及 DOM 或某些 React 之外的系统?那是副作用领地。它是否从你已有的 props 或 state 中计算一个值?那属于渲染体,不是副作用。使用 useEffect 来衍生值是副作用最常见的过度使用方式。

副作用时序值得精确。你的副作用不在渲染期间运行。React 渲染组件,提交 DOM 更新,浏览器绘制,只有那时副作用才运行。这是有意的:副作用看到一个已经反映当前渲染的屏幕,它永远不会阻止浏览器绘制。这也意味着你不能依赖副作用在第一次绘制前生成用户看到的东西。对于需要在绘制前测量或改变 DOM 的罕见情况,有 useLayoutEffect,它在提交后、浏览器绘制前同步运行。

你会注意到在开发中副作用在挂载时运行两次。React 的 Strict Mode 有意挂载每个组件、卸载它,然后再挂载一次。它运行你的副作用,运行清理,然后再运行一次副作用。这是对清理的压力测试。如果你的副作用设置了什么东西但从不拆除它,双重运行会立即表露 bug,因为你会看到两个定时器或两个订阅,而不是一个。这只发生在开发中。在生产环境中副作用运行一次。

两个依赖数组的陷阱值得记住。第一个是陈旧闭包。一个副作用捕获它被创建时所在渲染的值。如果你漏掉依赖数组中的一个值,副作用会一直读取那个渲染中的旧值,即使它已经改变,它也永远不会重新运行来接收新值。修复方法是列出副作用读取的每个值,或使用更新器形式如 setCount(c => c + 1) 这样你根本不需要读取当前值。第二个是身份。在渲染期间创建的对象、数组和函数每次都是新值,所以把一个作为依赖列出会导致副作用在每次渲染上重新运行,因为引用永远不等于上次的。当你需要一个作为依赖时,在副作用内定义它,或用 useMemouseCallback 包装它(在渲染之间缓存值或函数的 hook),这样它的身份在渲染间保持稳定。

版本说明

类组件中,这个行为被分散在三个生命周期方法中:componentDidMount 用于第一次渲染后的设置,componentDidUpdate 用于数据改变时的重新运行,以及 componentWillUnmount 用于清理。一个带依赖数组和清理函数的单一 useEffect 覆盖了全部三个,这是为什么相关的设置和拆除代码现在住在一起,而不是散布在单独的方法中。

Juno副作用与外部世界同步useEffect 看作伸向 React 之外的代码的地方:一个定时器、文档标题、对服务器的请求。最后的数组说何时运行,[] 意味着一次,就在组件出现后。如果你的副作用启动了什么东西,返回一个停止它的小函数。大多数时候你根本不需要副作用,所以停下来问问是否一个普通计算或点击处理器会做这个工作是个好习惯。
Juno副作用与外部世界同步useEffect 在渲染后运行来与 React 不拥有的东西同步。依赖数组是控制:[] 运行一次,[a, b] 在那些改变时重新运行,没有数组则每次渲染都运行。对你启动、订阅或打开的任何东西都返回一个清理函数。在你写一个之前,检查该值是否能在渲染时衍生或工作是否属于事件处理器,因为那些覆盖了看起来像副作用领地的令人惊讶的大量东西。
Juno副作用与外部世界同步 副作用在提交后运行,所以它们看到一个已绘制的屏幕并永远不会阻止它;只有在你必须在绘制前触及 DOM 时才使用 useLayoutEffect。Strict Mode 在开发中的双重挂载是一个清理测试,所以把任何加倍的定时器或订阅当作真实的 bug。两个依赖陷阱是漏掉值导致的陈旧闭包和不稳定的对象或函数身份导致每次渲染都重新运行副作用;更新器形式、useMemouseCallback 是你如何化解它们的。

接下来:获取数据,组件从完全在 React 之外的地方拉入数据。