React useState 深入浅出:从 DOM Fragment 到闭包陷阱,一次讲透

18 阅读8分钟

本文以 useState 为主线,串联 DocumentFragment、异步批量更新、函数式更新、懒初始化等核心知识点,带你真正理解 React 状态管理的"为什么"而非仅仅是"怎么用"。


前言

如果你刚接触 React Hooks,大概率第一个学会的就是 useState。它简单得不像话——一行代码就能让组件"活"起来:

const [count, setCount] = useState(0);

但真的就这么简单吗?如果你只是"会用",那么下面这些问题你未必答得上来:

  • 为什么连续三次 setCount(count + 1) 只加了 1,而不是 3?
  • 为什么 console.log(count) 打印的总是旧值?
  • useState(heavyComputation())useState(() => heavyComputation()) 有什么区别?
  • React 的 Fragment 和原生 DOM 的 DocumentFragment 有什么内在关联?

这篇文章,我们就从这几个问题出发,把 useState 及其周边知识一次性讲透。


一、Fragment:从 DOM 到 React 的"虚拟容器"

在聊 useState 之前,我们先看一段原生 JavaScript 代码——它很好地诠释了"批量操作"这一核心思想。

1.1 原生 DOM 的 DocumentFragment

<ul id="list"></ul>
<script>
  const data = ["任务1", "任务2", "任务3"];
  const oList = document.querySelector('#list');

  // 创建一个文档碎片——它存在于内存中,没有实体 DOM 节点
  const fragment = document.createDocumentFragment();

  for (const task of data) {
    const item = document.createElement('li');
    item.innerText = task;
    // 注意:这里不是逐个 append 到 oList,而是挂到 fragment 上
    fragment.appendChild(item);
  }

  // 最后一次性挂载——只触发一次页面重排重绘
  oList.appendChild(fragment);
</script>

DocumentFragment 是什么?它是一个"虚拟"的容器节点,存在于内存中而非 DOM 树上。你把子元素一个个挂到它上面,最后一次性插入到真实 DOM——fragment 本身不会出现在页面上,它功成身退,只留下它的子节点们。

这样做的好处是什么?性能。如果每次 appendChild 都直接操作真实 DOM,浏览器就需要反复计算布局(reflow)和重新绘制(repaint)。而先用 fragment 在内存中组装好,再一次提交,就只触发一次重排重绘。

1.2 React 的 Fragment:同一种思想

React 中的 <Fragment>(或简写 <>...</>)和 DocumentFragment 的理念如出一辙:

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

<>...</> 是一个"虚拟容器":

  • 它不会在真实 DOM 中生成任何额外的标签(想想如果换成 <div> 会多一层嵌套)。
  • 它的作用是承载多个子元素,一次性挂载到父容器(如 #root)。
  • 挂载完成后,Fragment 本身"功成身退",页面上只留下子元素。
特性DocumentFragmentReact Fragment
是否生成实体节点❌ 不生成❌ 不生成
作用批量挂载,减少重排分组子元素,避免多余 DOM
挂载方式appendChild(fragment)React 调和渲染
最终结果子节点直接成为父节点孩子子组件直接渲染

两种技术跨越了十几年,但核心的工程思想是一致的:能批量就别逐个,能省略就别多写


二、useState 基础:响应式数据的入口

const [count, setCount] = useState(0);

这行代码拆开来看:

  • 参数:初始值。可以是直接值(0"hello"),也可以是一个函数(后面细讲)。
  • 返回值:一个长度为 2 的数组,[state, setState]。用解构赋值接住,这是 Hooks 函数式编程的招牌写法。

useState 是 React Hooks 体系的"带头大哥"——它是最基础、最常用的 Hook,由此开启了函数组件的响应式时代。在 Class 组件时代,你得写 this.state + this.setState;而 Hooks 让你用更纯粹的 JavaScript 函数来表达状态逻辑,没有 this,没有生命周期方法的层层嵌套。


三、setState 的异步本质:为什么 count 没有立刻变?

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

const [count, setCount] = useState(0);

const addCount = () => {
  setCount(count + 1);  // 你以为 count 变成 1 了
  console.log(count);   // 但这里打印的还是 0 !
};

为什么? 因为 setCount异步调度更新——它不会立刻修改 count 的值,而是把更新任务放入一个队列,等当前代码全部执行完毕后,再统一触发组件重渲染。在重渲染时,useState 才会返回新的 count 值。

所以,在当前作用域内,count 始终是旧值——它就是一个普通的 JavaScript 常量,被闭包捕获了而已。

3.1 批量合并(Batching):性能优化的关键

再看一个更让人困惑的例子:

const addCount = () => {
  setCount(count + 1);  // 0 + 1 = 1
  setCount(count + 1);  // 还是 0 + 1 = 1
  setCount(count + 1);  // 还是 0 + 1 = 1
};
// 结果:count 只增加了 1,而不是 3!

三次调用 setCount(count + 1),但三次拿到的 count 都是 0(闭包中的旧值)。React 会将这些更新合并(batch),同一轮内对同一状态的多次更新只会以最后一次为准,避免不必要的重渲染。这是 React 内部的性能优化策略。

类比一下:就像当年的 DocumentFragment 把多个 DOM 操作合并成一次提交——React 把同一轮内的多次 setState 合并成一次重渲染。异曲同工。


四、函数式更新:打破闭包的枷锁

如果我就是需要在一次操作里让 count 加 3,怎么办?

答案是:传函数,而非值

const addCount = () => {
  setCount(prevCount => prevCount + 1);  // 基于上一次的结果:0 → 1
  setCount(prevCount => prevCount + 1);  // 基于上一次的结果:1 → 2
  setCount(prevCount => prevCount + 1);  // 基于上一次的结果:2 → 3
};
// 结果:count 增加了 3 ✓

当你传入一个**更新函数(updater function)**时,React 的行为发生了变化:

  1. 它不是直接拿当前渲染周期的闭包值来覆盖;
  2. 而是将你的函数放入更新队列,在真正执行更新时,依次把前一次的结果传给你的函数;
  3. 这样每一次都是从"最新的中间状态"出发,而不是一直盯着那个旧的闭包值。

用一张表对比:

写法含义适用场景
setCount(count + 1)基于当前渲染周期的值计算不依赖连续更新
setCount(prev => prev + 1)基于React 内部最新的状态计算需要多次连续更新同一状态

金科玉律:当你的新状态依赖旧状态时,始终用函数式更新。


五、Lazy Initialization:昂贵计算只做一次

useState 的参数不一定要是一个值,也可以是一个初始化函数。看这个场景:

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

function App() {
  // ❌ 坏写法:每次组件渲染都会执行 heavyComputation()
  const [users] = useState(heavyComputation());

  // ✅ 好写法:只在组件挂载时执行一次
  const [users] = useState(() => heavyComputation());
}

区别是什么?

  • useState(heavyComputation()):先调用 heavyComputation() 得到返回值,再把返回值传给 useState。也就是说,每次函数组件执行,heavyComputation() 都会跑一次——哪怕 useState 内部会忽略后续的值。

  • useState(() => heavyComputation()):传入一个函数引用,React 只在首次挂载时调用它来获取初始值。后续重渲染时,React 看到传的是函数,会直接跳过,不再执行。

这也是 React 官方文档强调的 lazy initial state(懒初始化)。当你需要从 localStorage 读取、需要复杂计算、需要生成大量模拟数据时,都应该用这个模式。

对比一下效果:

// 每次渲染都打日志
const [users] = useState(heavyComputation());
// 控制台:开始执行 heavyComputation...  ← 每次输入过滤文字都会再打印!

// 只在挂载时打一次
const [users] = useState(() => heavyComputation());
// 控制台:开始执行 heavyComputation...  ← 只打印一次,过滤时不再执行

结合用户过滤功能来看完整场景:

function App() {
  // 懒初始化:1000 个用户数据只在挂载时生成一次
  const [users] = useState(() => heavyComputation());
  const [filterText, setFilterText] = useState('');

  // computed:基于状态的计算属性
  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>
        {filteredUsers.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

这里 filteredUsers 就是 React 中的"计算属性"——它不由 useState 管理,而是由现有状态推导而来。每次 filterText 变化触发重渲染时,filteredUsers 会自动重新计算,而 users 因为使用了懒初始化,不会重复生成。


六、一张图总结 useState 的完整心智模型

┌─────────────────────────────────────────────────────────┐
│                    useState 核心概念                       │
├───────────────┬─────────────────────────────────────────┤
│  基础语法      │  const [state, setState] = useState(初始值) │
├───────────────┼─────────────────────────────────────────┤
│  初始值        │  直接值 → useState(0)                     │
│               │  函数(懒) → useState(() => heavyWork())   │
├───────────────┼─────────────────────────────────────────┤
│  更新方式      │  传值 → setCount(count + 1)               │
│               │  传函数 → setCount(prev => prev + 1)      │
├───────────────┼─────────────────────────────────────────┤
│  执行时机      │  异步批量处理,本轮代码跑完才更新              │
├───────────────┼─────────────────────────────────────────┤
│  性能考量      │  批量合并(Batching)减少不必要的渲染         │
│               │  懒初始化(Lazy Init)避免重复的昂贵计算       │
└───────────────┴─────────────────────────────────────────┘

七、写在最后

回到开头的几个问题,现在你应该能回答了:

  1. 为什么连续三次 setCount(count + 1) 只加了 1? → 因为三次调用拿到的 count 都是同一个闭包中的旧值(0),React 还会合并它们。用 setCount(prev => prev + 1) 即可解决。

  2. 为什么 console.log(count) 打印的是旧值? → 因为 setCount 是异步的,当前作用域内的 count 不会立即改变。新值在下一次渲染中才能拿到。

  3. useState(fn()) vs useState(() => fn()) 的区别? → 前者每次渲染都执行 fn()(浪费),后者只在首次挂载时执行(懒初始化)。

  4. Fragment 和 DocumentFragment 有什么关系? → 思想上同源:都是"不生成实体节点的虚拟容器",都是一次性批量挂载子元素,目的都是为了性能。

useState 看似简单,但它的设计蕴含了 React 团队对性能优化、函数式编程和开发者体验的深度思考。从 DOM 的 DocumentFragment 到 React 的批量更新,从闭包陷阱到函数式更新,理解了这些"为什么",你才能真正驾驭 React 的状态管理。


如果这篇文章对你有帮助,欢迎点赞、收藏、评论!也欢迎在掘金关注我,一起交流前端技术 🚀