从零搞懂 useState 与 Fragment:React 函数式组件的基石

前言

如果你刚入门 React,useState 绝对是你接触的第一个 Hook,也是用得最多的一个。但你真的把它吃透了吗?为什么有时候一连调三次 setCount 只加了一次?为什么有些写法会让页面卡成 PPT?

这篇文章我们从一个简单的计数器讲起,逐步深入 useState 的异步机制、懒加载优化,再到 Fragment 组件和它背后的 DOM 性能思想。配合大量可运行的示例代码,保证你看完就能用。


一、useState:函数式组件的"记忆芯片"

1.1 为什么叫 Hook?

在 React 16.8 之前,函数组件被称为"无状态组件"------它没有记忆,每次渲染都是全新的。Hooks 的出现让它"钩"住了状态。useState 就是第一个,也是最重要的 Hook。

jsx 复制代码
import { useState } from 'react'

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

  const addCount = () => {
    setCount(count + 1)
  }

  return (
    <>
      <p>当前计数:{count}</p>
      <button onClick={addCount}>+1</button>
    </>
  )
}

export default App

1.2 语法拆解

jsx 复制代码
const [状态值, 更新状态的函数] = useState(初始值 | 函数)
部分 说明
useState(...) Hook 函数,告诉 React 这块数据需要"被记住"
参数 初始值(简单数据),或一个返回初始值的函数(复杂计算)
返回值 一个长度为 2 的数组
count 只读的状态值,当前渲染周期内的快照
setCount 修改状态的唯一入口,调用后触发组件重新渲染

注意: count 是通过 const 解构出来的,在这一轮渲染中它就是常量,直接修改它不会生效。

1.3 状态是响应式的

React 的核心理念:UI = f(state)。界面是状态的映射,状态变了,界面自动更新。你不需要手动操作 DOM,只需要修改状态。

jsx 复制代码
const [filterText, setFilterText] = useState('')
const filteredUsers = users.filter(user =>
  user.name.includes(filterText)
)

// 输入框一敲,filterText 变化 → 组件重新渲染 → filteredUsers 重新计算 → 列表自动更新

这就是 React 的数据驱动视图 ,也是 useState 被称作"响应式数据状态"的原因。


二、setState 的异步本质:三次 +1,为什么只加了一次?

这是新手最容易踩的坑。我们来看这段代码:

jsx 复制代码
function App() {
  const [count, setCount] = useState(0)

  const addCount = () => {
    setCount(count + 1)  // count = 0,期望变成 1
    setCount(count + 1)  // count = 0,期望变成 2
    setCount(count + 1)  // count = 0,期望变成 3
    console.log(count)   // 打印的还是 0
  }

  return (
    <>
      <p>当前计数:{count}</p>
      <button onClick={addCount}>+3</button>
    </>
  )
}

点击按钮后,count 从 0 变成了------1,不是 3。

2.1 为什么会这样?

两个关键原因:

count 是常量,不会中途变

通过 const 解构出来的 count,在这个函数调用周期里就是一个常量快照。第一行 count 是 0,最后一行 count 还是 0。

② setState 是异步调度,不是立即执行

调用 setCount 不会立刻修改 count 的值------它只是把"我要更新"这个消息放进了队列,等当前代码全部跑完,React 才会批量处理这些更新并重新渲染。

scss 复制代码
addCount 执行时 count = 0

setCount(0 + 1)  →  告诉 React:"下回渲染把 count 设为 1"
console.log(0)   →  当前 count 还是 0,log 打印 0
setCount(0 + 1)  →  告诉 React:"下回渲染把 count 设为 1"(又是1,和上次一样)
setCount(0 + 1)  →  告诉 React:"下回渲染把 count 设为 1"(还是1...)

React 一看:三次都是要设为 1,没必要搞三次,合并成一次 →
最终结果:count 从 0 → 1,只加了一次

2.2 为什么要合并?------性能优化

想象一下,一个复杂的页面同时改了 x、y、z 坐标,如果每次 setState 都立刻触发重渲染,那就要渲染三次。React 的策略是:本轮代码全部执行完,把所有更新合并成一次,统一渲染

这个思想和原生 DOM 里的 DocumentFragment 如出一辙(后面会细说)。

2.3 真要想 +3 怎么办?------函数式更新

如果你确实需要"基于最新状态做连续更新",传函数而不是直接传值:

jsx 复制代码
const addCount = () => {
  setCount(prevCount => prevCount + 1)  // prevCount = 0 → 返回 1
  setCount(prevCount => prevCount + 1)  // prevCount = 1 → 返回 2
  setCount(prevCount => prevCount + 1)  // prevCount = 2 → 返回 3
}
// 三次都生效,0 → 3,完美!
写法 逻辑
setCount(count + 1) 基于闭包里的旧值(可能是过期的)
setCount(prev => prev + 1) 基于队列里的最新值(每一次都拿到最新)

原则: 当你本次更新依赖于上一次的状态结果时,用函数式更新。简单场景两者都可以。


三、useState 的参数:初始值 vs 惰性函数

jsx 复制代码
// 简单数据:直接传值
const [count, setCount] = useState(0)
const [name, setName] = useState('')
const [list, setList] = useState([])

// 复杂计算:传函数(惰性初始化)
const [users] = useState(() => heavyComputation())

3.1 为什么不能直接传 heavyComputation()

看这段代码:

jsx 复制代码
function heavyComputation() {
  console.log('开始执行 heavyComputation')
  const startTime = performance.now()
  const result = []
  for (let i = 0; i < 1000000; i++) {
    result.push({ id: i, name: `用户-${i}` })
  }
  const duration = performance.now() - startTime
  console.log('耗时:', duration)
  return result
}

// ❌ 错误写法
const [users] = useState(heavyComputation())

// ✅ 正确写法
const [users] = useState(() => heavyComputation())

3.2 两种写法的执行时机

写法 首次渲染 后续每次重渲染
useState(fn()) 执行 ✅ 又执行 ❌(React 丢弃结果,白跑)
useState(() => fn()) 执行 ✅ 跳过 ✅(React 知道这是惰性函数,不调用)

heavyComputation() 生成了 100 万个用户,假设耗时 50ms。

  • 错误写法: 首次渲染 50ms,用户敲一个字触发重渲染,又 50ms;再敲,再 50ms...输入框直接卡成 PPT。
  • 正确写法: 首次渲染 50ms,后续重渲染直接跳过,耗时 0ms。

这就是惰性初始化(Lazy Initialization)------告诉 React:"这个函数很重,只在第一次帮我跑,以后别跑了。"

3.3 什么时候用惰性初始化?

  • 初始状态需要经过昂贵的计算(大量数据、复杂逻辑、复杂随机算法)
  • localStorage 读取数据
  • 初始化 1000 个 NPC、10000 个用户等大批量数据
jsx 复制代码
// 从 localStorage 读数据(惰性写法)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos')
  return saved ? JSON.parse(saved) : []
})

四、Fragment:不给 DOM 添乱的容器

4.1 JSX 要求单一根节点

JSX 有个硬性规定:一个组件 return 的东西只能有一个顶层元素。所以你经常得包一层:

jsx 复制代码
// ❌ 这样不行(两个顶层元素)
return (
  <p>当前计数:{count}</p>
  <button onClick={addCount}>+3</button>
)

// ✅ 必须包一层
return (
  <div>
    <p>当前计数:{count}</p>
    <button onClick={addCount}>+3</button>
  </div>
)

4.2 但 <div> 是有代价的

每多一层 <div>,DOM 树就多一层节点:

less 复制代码
#root
└── <div>              ← 其实不需要的一层
    ├── <p> 计数 </p>
    └── <button> +3 </button>
  • 破坏 Flex / Grid 布局的子元素关系
  • 增加 CSS 选择器嵌套深度
  • 表格里不能随意包 <div>(会破坏 <table> 结构)

4.3 Fragment 就是"不存在的包裹层"

jsx 复制代码
return (
  <>
    <p>当前计数:{count}</p>
    <button onClick={addCount}>+3</button>
  </>
)

渲染后:

less 复制代码
#root
├── <p> 计数 </p>
└── <button> +3 </button>

<>...</><React.Fragment> 的简写。它只在 JSX 编译时充当"虚拟容器",到了 DOM 里不留任何痕迹

4.4 Fragment 和 DocumentFragment 的关系

如果你写过原生 DOM,可能见过这个:

html 复制代码
<script>
  const data = ["任务1", "任务2", "任务3"]
  const oList = document.querySelector('#list')

  // 创建一个"文档碎片"------内存里的虚拟容器
  const fragment = document.createDocumentFragment()

  for (const task of data) {
    const item = document.createElement('li')
    item.innerText = task
    fragment.appendChild(item)  // 加到内存里,浏览器不绘制
  }

  oList.appendChild(fragment)   // 一次性挂到页面上,只画一次
</script>

两者的思想一模一样:

概念 React Fragment DOM DocumentFragment
是什么 虚拟包裹容器 内存中的临时容器
在哪 JSX 编译层 内存层
DOM 里看得见吗? ❌ 不可见 ❌ 不可见(挂载后消失)
作用 少一层 div 减少重绘次数

一句话: Fragment 是让你"名正言顺地不需要 <div> 包裹"的语法糖,让你的 DOM 树更干净。


五、综合实战:用户筛选列表

把我们学到的知识点串在一起,看一个完整的例子:

jsx 复制代码
import { useState } from 'react'

// 模拟昂贵的初始计算(生成 100w 条用户数据)
function heavyComputation() {
  console.log('开始执行 heavyComputation')
  const startTime = performance.now()
  const result = []
  for (let i = 0; i < 1000000; i++) {
    result.push({ id: i, name: `用户-${i}` })
  }
  const duration = performance.now() - startTime
  console.log('耗时:', duration, 'ms')
  return result
}

function App() {
  // ✅ 惰性初始化:只在首次渲染时跑一次
  const [users] = useState(() => heavyComputation())
  const [filterText, setFilterText] = useState('')

  // 计算属性:根据 filterText 过滤用户
  const filteredUsers = users.filter(user =>
    user.name.includes(filterText)
  )

  return (
    <div style={{ padding: '20px' }}>
      <h2>用户列表</h2>
      <input
        type="text"
        placeholder="输入用户名过滤"
        value={filterText}
        onChange={(e) => setFilterText(e.target.value)}
      />
      <p>当前显示 {filteredUsers.length} 个用户</p>
      <ul style={{ maxHeight: '300px', overflowY: 'auto' }}>
        {filteredUsers.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

export default App

知识点盘点

  1. useState(() => heavyComputation()) --- 惰性初始化,100w 数据只算一次,重渲染不重算
  2. useState('') --- 简单初始值直接传
  3. onChange={(e) => setFilterText(e.target.value)} --- 受控组件,输入框的值由 React 状态接管
  4. users.filter(...) --- 这就是计算属性,依赖 filterText 状态,状态变 → 自动重新算
  5. key={user.id} --- 列表渲染必须加 key,帮助 React 识别每个元素

六、总结

useState 核心要点

要点 说明
作用 给函数组件加上"状态记忆"
初始值 简单数据直接传,复杂计算用惰性函数 () => fn()
返回值 [状态快照, 更新函数]
状态是只读的 const 解构,不可直接修改
setState 是异步的 当前作用域内 count 仍是旧值
批量合并 多次相同更新会被合并,优化性能
函数式更新 prev => prev + 1 拿到最新值,突破闭包陷阱

Fragment 核心要点

要点 说明
写法 <>...</><Fragment>...</Fragment>
作用 JSX 语法要求的包裹容器,但不产生真实 DOM 节点
优点 DOM 更干净,不破坏布局结构
思想来源 类似原生 DOM 的 DocumentFragment------内存容器,一次挂载

希望这篇指南能帮你在 React 的入门路上少踩几个坑。如果你正在跟着教程练习,建议把文中的每个例子都亲手敲一遍------写代码的感觉和看代码完全不同。

Happy coding! 🚀

相关推荐
Android研究员5 小时前
Android进阶之事件分发机制深度剖析
android·前端·面试
触底反弹5 小时前
🔥 AI 写代码总翻车?这套实战方法论救了我
人工智能·面试·程序员
风栖柳白杨5 小时前
【面试】AI算法工程师_空白自测版本
人工智能·算法·面试
风栖柳白杨8 小时前
【面试】职位要求_准备
面试·职场和发展
Assby9 小时前
为什么我不建议你在 MySQL 里写 `IN (超过1000个ID)`?从 AST 解析到存储引擎的深度拆解
后端·面试
触底反弹1 天前
🚀 从 DOM0 级到 React 合成事件:前端事件监听的 20 年演进史
前端·react.js·面试
kyriewen1 天前
我给前端项目的接口请求套了6层保护——才发现以前一直在裸奔
前端·javascript·面试
wdfk_prog1 天前
嵌入式面试真题第 15 题:不可恢复异常后的通用崩溃快照、调用栈保存与离线分析架构
linux·开发语言·面试·架构
LayZhangStrive1 天前
JUC相关的函数、注解、变量杂记
java·面试·多线程·juc