大型揭秘!为什么在JSX数组map时必须传入key!

75 阅读6分钟

大型揭秘!为什么在JSX数组map时必须传入key!

在 JSX 中,当你创建一个元素列表时,React 要求每个元素都应该有一个唯一的 key 属性。这是为了帮助 React 识别哪些元素改变了、添加了或者移除了,从而提高渲染效率和维护组件状态的稳定性。

问题引入

咱们先来看看一段代码,和它的报错信息

import { useState,useEffect } from 'react'

import './App.css'

function App() {
  const [todos, setTodos] = useState([
    {
      id:1,
      title:'标题一'
    },
    {
      id:2,
      title:'标题二'
    },
    {
      id:3,
      title:'标题三'
    }
  ])

  return (
    <ul>
      {
        todos.map((todo)=>(
          <li>{todo.title}</li>
        ))
      }
    </ul>
  )
}

export default App

image.png 为什么报错,这个错误是什么意思?

  • 咱们先直译一下:

直译:

  • 错误信息:  每个列表中的子元素都应该有一个唯一的 "key" 属性。
  • 位置:  报错出现在 hook.js 文件的第608行。
  • 建议:  检查 App 组件的渲染方法。更多详情请参阅 react.dev/link/warnin…

相信各位大佬看直译就能看懂了,所以错误的原因就在于我们没有给列表设置唯一的key属性,解决这个问题简单,咱们直接上代码!

todos.map((todo)=>(
          <li key={todo.id}>{todo.title}</li>
        ))
        

id是我们主动给todos设置的独一无二的属性,刚刚好符合key的要求

但是,但是,在座的各位,就不想知道为什么吗,为什么我就一定要设置这个key,这个key有那么重要吗?那现在就让我们去探索一下,到底为什么!

探索谜题

废话不多说,上代码!

useEffect(()=>{
    setTodos(prev=>prev.map(todo => {
      if(todo.id ===1) return{
        ...todo,
        title:'标题一更新'
      }
      return todo
    }))
  },[])

来个小插曲

提问:为什么要使用useEffect?直接setTodos不可以吗?

使用 useEffect 并结合空依赖数组 [] 是一种明确告诉 React 我们希望这段代码只在组件挂载时运行一次的方式。这样做可以确保我们的副作用(这里是更新 todos)不会被无谓地反复执行,从而优化了组件的行为和性能。而直接调用 setTodos 则不具备这样的控制能力,容易造成不必要的状态更新和渲染。因此,在需要针对特定生命周期事件执行副作用时,推荐使用 useEffect。 其他代码不变,在return之前加上上面这段代码,只改变了第一项其他的没有变

回到正题

我们都知道setTodos必然会触发组件渲染,生成虚拟DOM,如果DOM发生结构或者样式的改变,将会触发重绘重排,重绘重排非常耗能

而reat这样一个强大的前端开发框架,将性能优化做到了极致,根据我的直觉,想必谜底就跟虚拟DOM的改变有关系

接下来就让我们专注于看在我运行代码时,到底哪些部分发生了改变!

image.png

当然还是报错,但是通过看后台元素的高光,我们会发现只有我们改变了值的那个li进行了改变,其他li元素没有发生变化!

奇怪,我们并没有加上独一无二的key,但是似乎已经做到了性能优化,真的是这样吗?

不要着急,我们继续往下看!


  useEffect(()=>{
   
  setTimeout(()=>{
    
    setTodos(prev=>[
      {
        id:4,
        title:'标题四'
      },
      ...prev
    ])

  },5000)

  },[])



提问:prev是什么,在这里有什么用?

prev 表示 当前的状态值,也就是 todos 的当前值

这是因为:

  • React 的状态更新可能是异步的。
  • 多个状态更新可能会被批量处理
  • 如果你直接使用 todos(比如 setTodos([newItem, ...todos])),可能读取的是旧的、过时的值

使用函数式更新(传入 prev)可以确保你拿到的是最新的状态值

我们在return之前加入这段代码,实现了在我们所有li之前插入第四个li

e3dc67b62b0c724f9fc4cd85984998cb.png

诶呀,全部都改变了,按道理来说,应该是只需要改变我们添加的那个li,但是这里全部改变了,耗费了大量性能,看来谜底已经浮出水面了

揭秘时刻

首先我们要理解react底层更新DOM的原理

1.React 的虚拟 DOM Diff 算法原理

我们的内存中存储着新旧两个状态,形成新旧两棵虚拟 DOM 树,React 在更新界面之前,会进行一个 diff 过程:将新旧两棵虚拟 DOM 树进行比较, diff 差值,让界面更新,每一项每一项进行比较找出最小的差异,从而决定如何高效地更新真实 DOM。

  • 当你在渲染一个列表(如 map(todos => <TodoItem />))时,React 会为每个元素创建一个虚拟节点。
  • 如果没有设置 key,React 默认使用索引 index 作为 key

这下我们就可以知道了,我们上面代码出现大量li更新的原因是因为我们将第四个li插入到了数组的最前端,导致整个数组所有li的索引都发生了改变,而diff的比较是每一项每一项进行比较,对于没有设置key的li,通过索引来判断是否为同一项,进行比较,不同则改变,此时索引发生了大幅度改变,发生了错位比较,对于v8引擎来说,li的每一项都发生了改变,因此,发生了大量不需要的改变

2. 不设置 key 或使用 index 作为 key 的问题

  • 问题一:无法准确识别元素身份

    • 如果你使用 index 作为 key,当数组中的元素顺序发生变化时(比如插入、删除或排序),每个元素的 index 都会变化。
    • React 会认为这些元素是全新的被修改的,从而触发不必要的重新渲染或重新创建 DOM 节点,造成性能浪费。
  • 问题二:大量不必要的更新

    • 比如你在数组开头插入一个新元素,所有后续元素的 index 都会变化。
    • React 会误以为这些元素都发生了变化,于是重新创建或更新这些元素,即使它们的内容没有变。
3.为什么 key 必须唯一?
  • 每个 key 应该是一个稳定、唯一且可预测的标识符,比如数据库中的 id

  • 这样 React 才能准确地识别每个元素的身份,即使它们的顺序发生变化。

  • 使用唯一 key 可以确保:

    • 相同内容的元素不会被错误地重新创建。
    • 状态(如输入框内容、组件内部状态)不会因为顺序变化而丢失。

结尾

我们来看看在学习过程中常写的代码

todos.map((todos,index)=>(
          <li key={todos.index}>{todos.title}</li>
        ))
        

再回过头来看,还真是写了跟没写一样,因为系统默认会将数组的index作为key值,我们没有必要再写一次

好了,就这样吧