React中key的那些事儿 - 一篇让你彻底搞懂diff算法的文章

62 阅读4分钟

面试官:为什么 React 的 map 渲染需要 key?为什么不能用 index 作为 key?

我:emmm...因为...性能优化?

面试官:具体怎么优化的?

我:...(内心OS:完蛋,又要挂了)

相信很多小伙伴都遇到过类似的灵魂拷问。今天咱们就用一个简单的 Todo 应用来彻底搞懂 React 中 key 的奥秘!

从一个简单的例子说起

先来看看这个经典的 Todo 列表:

import { useState, useEffect } from 'react'

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

  useEffect(() => {
    setTimeout(() => {
      // 场景一:修改某一项内容
      // setTodos(prev => prev.map(todo => {
      //   if (todo.id === 1) return {
      //     ...todo,
      //     title: '标题-改-'
      //   }
      //   return todo
      // }))
      
      // 场景二:在数组开头插入新元素
      setTodos(prev => [
        { id: 4, title: '标题四' },
        ...prev
      ])
    }, 1000)
  }, [])

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

看起来很简单对吧?但这里面藏着 React 性能优化的大学问!

React 的 Diff 算法是怎么工作的?

todos 状态发生变化时,React 并不会傻乎乎地把整个列表重新渲染一遍。它会:

  1. 生成新的虚拟 DOM 树:基于新的 todos 数组
  2. 与旧的虚拟 DOM 进行对比:这就是传说中的 diff 算法
  3. 计算出最小的变更:只更新真正需要变化的部分
  4. 应用到真实 DOM:减少重绘重排的开销

这个过程就像是找茬游戏,React 需要快速找出新旧两张图片的差异。

为什么 key 这么重要?

没有 key 的情况

如果我们不提供 key,React 只能按照索引顺序来比较元素:

// 旧状态
[
  <li>标题一</li>,  // index: 0
  <li>标题二</li>,  // index: 1
  <li>标题三</li>   // index: 2
]

// 在开头插入新元素后
[
  <li>标题四</li>,  // index: 0  ❌ React以为这是"标题一"变成了"标题四"
  <li>标题一</li>,  // index: 1  ❌ React以为这是"标题二"变成了"标题一"
  <li>标题二</li>,  // index: 2  ❌ React以为这是"标题三"变成了"标题二"
  <li>标题三</li>   // index: 3  ✅ 新增的元素
]

结果就是 React 以为前面三个元素都变了,需要更新 3 个 DOM 节点!

有了唯一 key 的情况

当我们使用唯一的 id 作为 key 时:

// 旧状态
[
  <li key="1">标题一</li>,
  <li key="2">标题二</li>,
  <li key="3">标题三</li>
]

// 在开头插入新元素后
[
  <li key="4">标题四</li>,  // ✅ 新元素,需要创建
  <li key="1">标题一</li>,  // ✅ 找到了!位置变了但内容没变
  <li key="2">标题二</li>,  // ✅ same here
  <li key="3">标题三</li>   // ✅ same here
]

React 通过 key 就能准确识别:

  • key="4" 是新元素,需要创建并插入
  • key="1", key="2", key="3" 只是位置变了,直接移动就行

为什么不能用 index 作为 key?

很多新手会这样写:

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

这在什么情况下会出问题呢?

问题场景:在数组开头插入元素

// 原始数组
[
  { id: 1, title: '标题一' },  // key: 0
  { id: 2, title: '标题二' },  // key: 1
  { id: 3, title: '标题三' }   // key: 2
]

// 在开头插入新元素后
[
  { id: 4, title: '标题四' },  // key: 0  ❌ 
  { id: 1, title: '标题一' },  // key: 1  ❌
  { id: 2, title: '标题二' },  // key: 2  ❌
  { id: 3, title: '标题三' }   // key: 3  新增
]

看到问题了吗?所有元素的 key 都变了!React 会认为:

  • key=0 的元素从"标题一"变成了"标题四"
  • key=1 的元素从"标题二"变成了"标题一"
  • key=2 的元素从"标题三"变成了"标题二"
  • 新增了一个 key=3 的元素

结果就是触发了 3 次不必要的 DOM 更新!

性能优化的本质

使用正确的 key 能带来什么好处?

  1. 减少 DOM 操作:只操作真正变化的元素
  2. 避免重绘重排:不必要的样式计算和布局计算
  3. 保持组件状态:如果列表项是复杂组件,还能保持其内部状态
  4. 提升用户体验:动画效果更流畅,响应更快

实践总结

  1. 总是使用唯一且稳定的 key

    // ✅ 好的做法
    {todos.map(todo => (
      <li key={todo.id}>{todo.title}</li>
    ))}
    
  2. 避免使用 index 作为 key(除非列表完全静态)

    // ❌ 避免这样做
    {todos.map((todo, index) => (
      <li key={index}>{todo.title}</li>
    ))}
    
  3. 如果数据没有唯一标识,可以使用一些技巧

    // 权宜之计,但不推荐
    {todos.map((todo, index) => (
      <li key={`${todo.title}-${index}`}>{todo.title}</li>
    ))}
    

写在最后

React 的 diff 算法虽然已经很智能了,但它不是魔法,需要我们给它一些"提示"。key 就是这样一个重要的提示,它帮助 React 更准确地识别元素的变化,从而做出最优的更新策略。

下次面试官再问你 key 的问题,你就可以自信地说:"key 是 React diff 算法的重要依据,它帮助 React 准确识别列表元素的变化,避免不必要的 DOM 操作,提升应用性能!"

性能优化不是玄学,而是对原理的深度理解!