likes
comments
collection
share

React 八大常见错误及其解决方案

作者站长头像
站长
· 阅读数 3

大家好,这里是大家的林语冰。

本期共享的是 —— 通过回顾某些最常见的 React 错误消息并解释它们的含义、后果、以及如何修复它们,辅助大家理解这些技术细节。

我们会深度学习这 8 大错误消息,包括但不限于:

  • 警告:列表中的每个子元素都应该有一个唯一的 key 属性
  • 防止在 key 中使用数组索引
  • React Hook useXXX 的条件调用。React Hooks 必须在每个组件渲染中以完全相同的顺序调用
  • React Hook 缺少依赖:“XXX”。包含它或删除依赖数组
  • 无法对已卸载的组件执行 React 状态更新
  • 重新渲染次数过多。React 限制渲染次数,防止无限循环
  • 对象作为 React 子元素无效/函数作为 React 子元素无效
  • 相邻的 JSX 元素必须包含在封闭标签中

React 八大常见错误及其解决方案

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 8 common React error messages and how to address them

警告:列表中的每个子元素都应该有一个唯一的 key 属性

假设我们有一个卡片列表,如下所示:

import { Card } from './Card'

const data = [
  { id: 1, text: '关注' },
  { id: 2, text: '点赞' },
  { id: 3, text: '收藏' }
]

export default function App() {
  return (
    <div className="container">
      {data.map(content => (
        <div className="card">
          <Card text={content.text} />
        </div>
      ))}
    </div>
  )
}

React 开发中最常见的事情之一就是,获取数组元素,并使用组件根据元素内容渲染它们。得益于 JSX,我们可以使用 Array.map() 函数轻松将循环逻辑嵌入到组件中,并从回调中返回所需的组件。

虽然但是,在浏览器控制台中收到 React 警告也司空见惯,提示列表中的每个子元素都应该有一个唯一的 key 属性。在养成给每个子元素一个独特的 key 属性的习惯之前,我们可能会多次遭遇此警告,尤其是如果我们对 React 经验较少。但在养成习惯之前该如何解决呢?

正如警告所示,我们必须将 key 属性添加到从 map 回调返回的 JSX 的最外层元素中。虽然但是,我们要使用的 key 有若干要求。key 应该是:

  1. 字符串或数字
  2. 对于列表中的特定元素而言是唯一的
  3. 跨渲染列表中该元素的代表
export default function App() {
  return (
    <div className="container">
      {data.map(content => (
        <div key={content.id} className="card">
          <Card text={content.text} />
        </div>
      ))}
    </div>
  )
}

虽然如果不遵守这些要求,我们的 App 也不会崩溃,但它可能会导致某些意外且通常不需要的行为。React 使用这些 key 来确定列表中的哪些子元素已更改,并使用该信息来确定可以重用先前 DOM 的哪些部分,以及重新渲染组件时应该重新计算哪些部分。因此,我们始终建议添加这些 key

防止在 key 中使用数组索引

在上文警告的基础上,我们将深入学习有关 key 的同样常见的 ESLint 警告。当我们养成了在列表中生成的 JSX 中包含 key 属性的习惯后,通常会出现下列警告。

import { Card } from './Card'

// 粉丝请注意,data 中没有预生成的唯一 id
const data = [{ text: '关注' }, { text: '点赞' }, { text: '收藏' }]

export default function App() {
  return (
    <div className="container">
      {data.map((content, index) => (
        <div key={index} className="card">
          <Card text={content.text} />
        </div>
      ))}
    </div>
  )
}

有时,我们的数据不会附加唯一标识符。一个简单的解决方案就是,使用列表中当前元素的索引。虽然但是,使用数组中元素的索引作为 key 的问题在于,它不能代表跨渲染的特定元素。

假设我们有一个包含多个元素的列表,并且用户通过删除第二个元素与它们进行交互。对于第一个元素,其底层 DOM 结构没有任何改变;这反映在它的 key 上,它保持不变,第一个元素的 key 还是索引 0

对于第三个元素及之后的项目,它们的内容没有改变,因此它们的底层结构也不应该改变。虽然但是,因为第二个元素被删除了,所有其他元素的 key 属性都会发生变化,因为这里的 key 是基于数组索引生成的。React 会假设它们已经改变并重新计算它们的结构 —— 但这是不必要的。这会对性能产生负面影响,并且还可能导致不一致和不正确的状态。

为了搞定这个问题,最重要的是要记住,key 不一定是 id。只要它们是唯一的,并且能代表生成的 DOM 结构,我们想使用的任何 key 都问题不大。

export default function App() {
  return (
    <div className="container">
      {data.map(content => (
        <div key={content.text} className="card">
          {/* 直接使用内容作为 key 也能奏效 */}
          <Card text={content.text} />
        </div>
      ))}
    </div>
  )
}

React Hook useXXX 有条件地调用。React Hooks 必须在每个组件渲染中以完全相同的顺序调用

我们可以在开发过程中以不同的方式优化我们的代码。我们力所能及的一件事就是,确保某些代码只在需要该代码的代码分支中按需执行。尤其是在处理时间或资源密集型的代码时,这可能会在性能方面产生巨大的差异。

const Toggle = () => {
  const [isOpen, setIsOpen] = useState(false)

  if (isOpen) {
    return <div>{/* ... */}</div>
  }
  const openToggle = useCallback(() => setIsOpen(true), [])
  return <button onClick={openToggle}>{/* ... */}</button>
}

不幸的是,将这种优化技术应用于 Hooks,会向我们提示不要条件调用 React Hooks 的警告,因为我们必须在每个组件渲染中以相同的顺序调用它们。

这是必要的,因为在内部,React 使用 Hook 的调用顺序来跟踪其底层状态,并在渲染之间保留它们。如果我们打乱了这个顺序,React 内部将不再知道哪个状态与 Hook 匹配。这会给 React 带来重大问题,甚至可能导致错误。

React Hooks 必须始终在组件的顶层无条件地调用。在实践中,这通常可以归结为保留组件的第一部分用于 React Hook 初始化。

const Toggle = () => {
  const [isOpen, setIsOpen] = useState(false)
  const openToggle = useCallback(() => setIsOpen(true), [])

  if (isOpen) {
    return <div>{/* ... */}</div>
  }
  return <button onClick={openToggle}>{/* ... */}</button>
}

React Hook 缺少依赖:“XXX”。包含它或删除依赖数组

React Hooks 一个有趣的地方在于依赖数组。几乎每个 React Hook 都接受数组形式的第二个参数,我们可以在其中定义 Hook 的依赖。当任何依赖发生变化时,React 会检测到它,并重新触发 Hook。

在官方文档中,如果变量在 Hook 中使用,React 建议开发者始终将所有变量包含在依赖数组中,并在更改时影响组件的渲染。

为了搞定这个问题,建议在 react-hooks ESLint plugin 中使用 exhaustive-deps rule。当任何 React Hook 未定义所有依赖时,激活它会向我们自动发出警告。

const Component = ({ value, onChange }) => {
  useEffect(() => {
    if (value) {
      onChange(value)
    }
  }, [value])
  // onChange 没有作为依赖包含进来
}

我们应该深刻认识到,依赖数组问题的原因与 JS 中闭包和作用域的概念有关。如果 React Hook 的主回调使用了其自身作用域之外的变量,那么它只能记住这些变量在执行时的版本。

但是,当这些变量发生更改时,回调的闭包无法自动获取这些更改的版本。这可能会导致使用过时的依赖引用来执行 React Hook 代码,并导致与预期不同的行为。

因此,始终建议对依赖项数组进行抽丝剥茧。这样做可以搞定以这种方式调用 React Hooks 时可能出现的所有问题,因为它将 React 指向要跟踪的变量。当 React 检测到任何变量的更改时,它将重新运行回调,从而允许它获取依赖的更改版本,并按预期运行。

无法对已卸载的组件执行 React 状态更新

在处理组件中的异步数据或逻辑流时,我们可能会在浏览器控制台中遭遇运行时错误,告诉我们无法对已卸载的组件执行状态更新。问题在于,在组件树中的某个位置,已卸载的组件会触发状态更新。

const Component = () => {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetchAsyncData().then(data => setData(data))
  }, [])
}

这是由依赖于异步请求的状态更新引起的。异步请求在组件生命周期的某个位置开始,比如在 useEffect Hook 内,但需要一段时间才能完成。

有多种方案可以搞定此问题,所有这些方案都可以归类为两个不同的概念。首先,可以跟踪组件是否已安装,我们可以据此执行操作。

虽然这能奏效,但不建议这样做。此方案的问题在于,它不必要地保留未安装组件的引用,这会导致内存泄漏和性能问题。

const Component = () => {
  const [data, setData] = useState(null)
  const isMounted = useRef(true)

  useEffect(() => {
    fetchAsyncData().then(data => {
      if (isMounted.current) {
        setData(data)
      }
    })

    return () => {
      isMounted.current = false
    }
  }, [])
}

第二种方案是首选方案,是在组件卸载时取消异步请求。某些异步请求库已经有一个机制来取消此类请求。如果是这样,就像在 useEffect Hook 的清理回调期间取消请求一样简单。

如果我们没有使用这样的库,我们可以使用 AbortController 实现同款需求。这些取消方法的唯一缺陷在于,它们完全依赖于库的实现或浏览器支持。

const Component = () => {
  const [data, setData] = useState(null)

  useEffect(() => {
    const controller = new AbortController()
    fetch(url, { signal: controller.signal }).then(data => setData(data))
    return () => {
      controller.abort()
    }
  }, [])
}

重新渲染次数过多。React 限制渲染次数,防止无限循环

无限循环是每个开发者的大坑,React 开发者也不例外。幸运的是,React 可以很好地检测它们,并在整个设备变得无响应之前向我们发出警告。

正如警告所示,问题在于我们的组件触发了太多的重新渲染。当组件在很短的时间内排队太多状态更新时,就会发生这种情况。导致无限循环的最常见原因在于:

  • 直接在渲染中执行状态更新
  • 未向事件处理程序提供正确的回调

如果我们遭遇同款警告,请务必检查组件的这两个方面。

const Component = () => {
  const [count, setCount] = useState(0)

  setCount(count + 1) // 在渲染中更新状态

  return (
    <div className="App">
      {/* onClick 没有接受一个妥当的回调 */}
      <button onClick={setCount(prevCount => prevCount + 1)}>
        Increment that counter
      </button>
    </div>
  )
}

对象作为 React 子元素无效/函数作为 React 子元素无效

在 React 中,我们可以在组件中渲染很多东西到 DOM。选择几乎是无穷无尽的:所有 HTML 标签、任何 JSX 元素、任意原始 JS 值、先前值的数组,甚至 JS 表达式,只要它们被评估为任何先前值。

尽管如此,不幸的是,React 仍然不接受所有可能作为 React 子元素存在的东西。具体而言,我们无法将对象和函数渲染到 DOM,因为这两个数据值不会评估为 React 可以渲染到 DOM 中的任何有意义的内容。因此,任何这样做的尝试都会导致 React 以上述错误的形式发出警告。

如果我们遭遇这些错误之一,建议验证我们正在渲染的变量是否是预期的类型。大多数情况下,这个问题是由于在 JSX 中渲染子元素或变量而引起的,假设它是一个原始值,但实际上,它是一个对象或函数。作为一种预防方法,拥有适当的类型系统可以提供很大帮助。

const Component = ({ body }) => (
  <div>
    <h1>{/* */}</h1>
    {/* 必须确保 body prop 是有效的 React 子元素 */}
    <div className="body">{body}</div>
  </div>
)

相邻的 JSX 元素必须包含在封闭标签中

React 最大的优势之一在于,能够通过组合许多较小的组件来构建整个 App。每个组件都可以以 JSX 的形式定义它应该渲染的 UI 部分,这最终有助于 App 的整个 DOM 结构。

const Component = () => (
  <div><NiceComponent /></div>
  <div><GoodComponent /></div>
);

由于 React 的复合性质,一个常见的尝试是在一个组件的根中返回两个仅在另一个组件中使用的 JSX 元素。虽然但是,这样做会令人惊讶地向 React 开发者发出警告,告诉我们必须将相邻的 JSX 元素包装在封闭标签中。

从普通 React 开发者的角度来看,该组件只会在另一个组件内部使用。因此,在我们的心智模型中,从组件返回两个元素是完全有意义的,因为无论外部元素是在该组件还是父组件中定义,生成的 DOM 结构都是相同的。

虽然但是,React 无法做出这个假设。该组件有可能在根中使用并破坏 App,因为它将导致无效的 DOM 结构。

React 开发者应该始终将从组件返回的多个 JSX 元素包装在封闭标记中。这可以是一个元素、一个组件或 React 的 Fragment,如果你确定该组件不需要外部元素。

const Component = () => (
  <React.Fragment>
    <div>
      <NiceComponent />
    </div>
    <div>
      <GoodComponent />
    </div>
  </React.Fragment>
)

高潮总结

在开发过程中邂逅 bug 是不可避免的一部分。虽然但是,我们处理这些错误消息的方案也表明了作为 React 开发者的技术修养。为了正确地做到这一点,有必要了解这些错误并知道它们发生的原因。

本文科普了我们在 React 开发过程中会遭遇的八大最常见的 React 错误消息。我们介绍了错误消息背后的含义、潜在错误、如何解决错误,以及如果不修复错误会发生什么。

有了这些知识储备,我们现在可以更彻底地理解这些错误,并感到有能力编写更少的包含这些错误的代码,从而产生更高质量的代码。

本期话题是 —— 你遭遇的其他常见 React 开发警告或 bug 是什么?

欢迎在本文下方自由言论,文明共享。谢谢大家的点赞,掰掰~

《前端猫猫教》每日 9 点半更新,坚持阅读,自律打卡,每天一次,进步一点

React 八大常见错误及其解决方案