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

副作用

想象一个组件需要在渲染后保持计时器运行,就像下面的 Timer 组件一样。组件的大部分工作都发生在渲染阶段:它读取 props 和 state,然后返回 JSX。但保持计时器运行是另一回事。打开一个订阅、同步文档标题,或者从服务器获取数据也都是这样。这些操作都会伸出手去接触 React 不管理的东西。useEffect 就是为此而生的钩子。它让组件在渲染后运行一些代码,这样它就可以与外部的东西保持同步。

这是一个启动计时器一次、然后每秒让计数器增加一的副作用:

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],表示副作用在首次渲染后运行,以及任何时候这些值在两次渲染之间发生变化时再运行。这就是当副作用依赖的东西改变时重新同步的方式。
  • 完全不写数组,表示副作用在每次渲染后都运行。这很少是你想要的,当副作用也会更新 state 时,这是引发无限循环的常见原因。

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

清理函数

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

jsx
return () => clearInterval(id)

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

清理很重要,因为副作用经常会启动一些自己继续运行的东西:一个计时器、一个事件监听器、一个打开的连接。如果组件被移除了而什么都没有停止那个计时器,它会永远继续触发,并持有对一个已经消失的组件的引用。干这几次,你就会有一个缓慢的泄漏,状态更新发送到已经不存在的东西。经验法则:如果副作用启动了东西、订阅了东西,或者打开了东西,它的清理函数应该停止、取消订阅,或者关闭它。

你可能根本不需要副作用

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

第一种是衍生值。如果你可以从已有的 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 的严格模式有意地挂载每个组件,卸载它,然后再挂载一次。它运行你的副作用,运行清理,然后第二次运行副作用。这是一个清理的压力测试。如果你的副作用设置了什么但从不拆除它,双次运行会立即浮现 bug,因为你会看到两个计时器或两个订阅而不是一个。这只在开发环境发生。在生产环境副作用运行一次。

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

版本说明

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

Juno副作用与外部世界同步useEffect 看作是代码伸出手去接触 React 之外东西的地方:一个计时器、文档标题、一个服务器请求。

最后的数组说的是何时运行它,[] 表示一次,就在组件出现后。如果你的副作用启动了什么东西,返回一个小函数来停止它。

很多时候你根本不需要副作用,所以养成习惯在写副作用前停下来问问一个简单的计算或点击处理函数是否能做这个工作。

Juno副作用与外部世界同步useEffect 在渲染后运行来与 React 不管理的东西同步。

依赖数组是控制:[] 运行一次,[a, b] 在那些改变时重新运行,没有数组则每次渲染都运行。对你启动、订阅或打开的任何东西返回一个清理函数。

在你写一个之前,检查值是否可以在渲染期间衍生,或者工作是否属于事件处理函数,因为那两样覆盖了令人惊讶的大量看起来像副作用地盘的东西。

Juno副作用与外部世界同步 副作用在提交后运行,所以它们看到一个已绘制的屏幕并且从不阻止它;只有在你必须在绘制前接触 DOM 时才用 useLayoutEffect

严格模式在开发环境的双次挂载是一个清理测试,所以把任何加倍的计时器或订阅当作真实的 bug。

两个依赖陷阱是从漏掉的值的过时闭包,和不稳定的对象或函数身份导致的副作用每次渲染都重新运行;更新器形式、useMemouseCallback 是你如何化解它们。

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