搞懂 useState 从原生 DOM 到 React 惰性初始化的完整链路

搞懂 useState 从原生 DOM 到 React 惰性初始化的完整链路

今天的学习路径非常清晰:老师先在 readme 写下理论框架,然后分别打开三个文件------从原生 JS 手动操作 DOM 开始,一步步过渡到 React 的 useState、Fragment、受控组件和惰性初始化。这不是零散的知识点堆砌,而是一条完整的、有因果关系的知识链。

教学全景图

markdown 复制代码
readme0723.md(设计蓝图)
    ├─→ test.html ------ 原生对照实验:DocumentFragment
    ├─→ App.jsx  ------ useState 基础:闭包陷阱 + 函数式更新
    └─→ App2.jsx ------ 综合实战:受控组件 + 惰性初始化 + 实时过滤

老师今天的思路是:先用原生 JS 让你体会"手动操作 DOM 有多麻烦",再用 React 告诉你"这些麻烦事我帮你做了"。 每个 React 概念都能在原生 JS 里找到对应物,新旧知识始终焊接在一起。


阶段一:readme 设计------先写下今天的教学骨架

老师在 readme0723.md 里写下了两条笔记,它们是今天学习的完整骨架

markdown 复制代码
# useState
- 响应式数据状态
- hooks 函数式编程的带头大哥
- 参数 初始值|函数
- 返回值 [状态, 更新状态]

## Fragment 组件
- 它可以作为容器,内部挂载子元素们(dom树功能)
- 一次性的挂载到页面 #root,fragment 元素就会功成身退

这两条笔记不是随便记的------每一条都是接下来代码实验的指导思想:

  • # useState 的四个要点:是什么(响应式数据状态)、地位(带头大哥)、入参(初始值或函数)、出参(值 + setter)
  • ## Fragment 组件 的两个要点:容器能力(挂载子元素)、生命周期(功成身退)

🤔 学到这会想问: Fragment 和 div 渲染出来有什么区别?"功成身退"是什么意思?

💡 解答:div 会在最终 DOM 里留下一个实实在在的节点,Fragment 不会。它像一个"临时集装箱"------把子元素运到页面上后,自己消失。这个设计思路和原生 JS 的 DocumentFragment 一脉相承,往下看就知道了。


阶段二:test.html------原生 JS 世界里的"不可见容器"

老师打开的第一个代码文件是 test.html。这个文件经历了两个版本的演进,每个版本都在讲一个关键概念。

v1:直接 appendChild------每次循环渲染一次

xml 复制代码
<ul id="list"></ul>
<script>
  const data = ['任务1', '任务2', '任务3']
  const olist = document.querySelector('#list')
  for (const task of data) {
    const item = document.createElement('li')  // 在 JS 内存里造节点
    item.innerText = task                       // 填文字
    olist.appendChild(item)                     // 挂上 DOM 树 → 触发渲染
  }
</script>

执行顺序走读(这是今天讨论的第一个重点):

  1. document.querySelector('#list') ------ 原生 DOM API,用 CSS 选择器拿到页面上的空 <ul>
  2. document.createElement('li') ------ 浏览器通用工厂方法,在 JS 内存 中创建 <li> 节点。注意:此时节点不在 DOM 树上,浏览器看不见它
  3. item.innerText = task ------ 给这个内存里的节点填文字内容
  4. olist.appendChild(item) ------ 把节点从内存挂到 DOM 树上,这一步才触发浏览器真实渲染。三次循环,三次渲染

老师在这里埋了一个概念锚点createElementappendChild 必须分开理解。前者只是 JS 内存里的对象,后者才是真正让浏览器"看见"的操作。后面 React 的 diff 机制也建立在这个"虚拟 vs 真实"的区分之上。

v2:用 DocumentFragment 批量绘制------三次循环只渲染一次

老师随后改进了代码:

xml 复制代码
<script>
  const data = ['任务1', '任务2', '任务3']
  const olist = document.querySelector('#list')
  // 文档碎片 可以是一个标签,没有实体 在内存中先批量的挂载一批元素
  const fragment = document.createDocumentFragment()
  for (const task of data) {
    // 原生dom api 创建
    // 批量绘制
    const item = document.createElement('li')  // js运行
    item.innerText = task
    // olist.appendChild(item) 页面的渲染 css绘制
    fragment.appendChild(item)                 // 挂到碎片上,不渲染!
  }
  // 一次性,性能好
  olist.appendChild(fragment)
</script>

注释是老师留下的教学路标,逐条解读:

注释 教学含义
// 文档碎片 可以是一个标签,没有实体 DocumentFragment 在 DOM 中没有对应节点
// 在内存中先批量的挂载一批元素 全部操作在内存里完成,不触发渲染
// 原生dom api 创建 createElement 是浏览器底层 API
// 批量绘制 强调"批量"------循环三次,但渲染只发生一次
// olist.appendChild(item) 页面的渲染 css绘制 被注释掉的那行才是真正触发渲染的代码
// 一次性,性能好 最终一行代码完成所有挂载

这段代码的底层流程

css 复制代码
循环中:
  createElement → 内存里造 <li>
  innerText     → 填文字
  fragment.appendChild → 挂到碎片上(内存操作,浏览器不可见)
                         fragment 里攒了三个 <li>

最后一步:
  olist.appendChild(fragment)
  → fragment 把自己肚子里的三个 <li> 一次性全部交给 <ul>
  → fragment 自己消失("功成身退"!)
  → 浏览器渲染一帧完成,用户看到三个任务

这就直接连到 readme 的 Fragment 了 ------老师在原生 JS 里先让你理解"无实体容器"这个设计模式,再告诉你 React 的 <Fragment> 也是同一个思路。两边一对照:

DocumentFragment(原生) <Fragment>(React)
创建方式 document.createDocumentFragment() <Fragment><>...</>
实体? 无,DOM 里看不到 无,不输出 DOM 节点
用途 批量操作减少回流 逻辑分组不污染 DOM
挂载后 消失 消失

🤔 学到这会想问 :为什么 appendChild 才触发渲染?createElement 造出来的东西不是 DOM 吗?

💡 解答createElement 返回的是真实的 DOM 节点对象 ------它有 innerTextstyleappendChild 等方法,具备 <li> 的全部能力。但它是游离的,不在 DOM 树上。DOM 树是浏览器用来绘制屏幕的数据结构,只有挂在树上的节点才被"画出来"。appendChild 就是把游离节点插到树上的操作。这个"内存 vs 树上"的区分,是后面理解 React 虚拟 DOM diff 的前提。


阶段三:App.jsx------useState 基础与闭包陷阱

老师打开 App.jsx,从最简单的计数器开始讲 useState。这个文件经历了四次代码修改,每一步都在推进对"setState 是异步的"的理解。

v1:最基础的计数器

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

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

  const addCount = () => {
    setCount(count + 1)  // 修改状态,异步
    console.log(count)   // 同步 0
  }

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

执行顺序走读(用户点击按钮)

markdown 复制代码
1. 点击按钮 → onClick 触发 addCount()
2. setCount(count + 1) → 不立即执行,更新任务放入队列
3. console.log(count)  → 打印当前闭包里的旧 count(还是 0)
4. addCount 执行完毕
5. React 处理队列 → count 变成 1 → 组件重渲染
6. <p> 从 "当前计数:0" 变为 "当前计数:1"

关键注释 // 修改状态,异步// 同步 0 老师的用意是:setState 不是 async 函数,它本质是同步执行的(入队),但状态的生效是异步的(等重渲染)。 所以 console.logsetCount 后面同步执行,打印的还是旧值。

v2:三次 setCount 为什么只 +1?

老师刻意把代码改成这样来制造困惑:

scss 复制代码
const addCount = () => {
  setCount(count + 1)
  setCount(count + 1)
  setCount(count + 1)
}

用户点击后 count 只从 0 变成 1,不是 3。

底层原因------这里第一次深入到 hooks 的更新队列:

ini 复制代码
当前渲染:count = 0(常量,函数执行期间不变)

三次调用 setCount(count + 1) 实际传给 React 的都是"值 1":
  { action: 1 } → { action: 1 } → { action: 1 }  (环形链表)

React 处理队列:
  newState = 0
  第1个:newState = 1
  第2个:newState = 1  ← 覆盖
  第3个:newState = 1  ← 又覆盖
  最终 = 1

传的是 ,每个都是 setCount(1),后面的直接覆盖前面的------React 认为"还是一样的值,不用重复算"。

v3:改成函数式更新 → +3

ini 复制代码
const addCount = () => {
  setCount(prevCount => prevCount + 1)
  setCount(prevCount => prevCount + 1)
  setCount(prevCount => prevCount + 1)
}

这次入队的是函数

ini 复制代码
{ action: prev => prev + 1 } → { action: prev => prev + 1 } → { action: prev => prev + 1 }

处理队列时,React 把上一步结果作为 prev 传入:
  newState = 0
  第1个:prev → 0+1 = 1
  第2个:prev → 1+1 = 2
  第3个:prev → 2+1 = 3
  最终 = 3
setCount(count + 1) setCount(prev => prev + 1)
入队内容 1 函数 prev => prev + 1
处理方式 直接赋值,全部相同 执行函数,prev 逐次递增
三次调用结果 +1 +3

🤔 学到这会想问 :setCount 凭什么能触发重渲染?count 这个变量到底存在哪里?为什么三次 setCount(count+1) 调用时 count 始终是 0?

💡 解答

count 在本轮渲染中是常量function App() 执行期间,count 被赋予了一个值(比如 0),在这个函数执行完之前它不会变。setCount 不是 count = 新值 的赋值操作,而是一个调度指令 ------告诉 React:"下一轮渲染时把这个状态更新成 X"。React 把这个指令放入 Fiber 节点的 hooks 链表中的更新队列,然后安排重渲染。下一次 App() 重新执行时,useState(0) 不再取初始值 0,而是从 hooks 链表读出上一次 set 进去的新值。

这就是为什么 setCount 后面的 console.log(count) 一定打印旧值------它打的是当前这一轮 渲染的 count,而 setCount 影响的是下一轮渲染的 count。两者本就不在同一时空中。


阶段四:App2.jsx------受控组件 + 惰性初始化 + 实时过滤

老师打开 App2.jsx,这是今天最完整的一个示例,把之前讲的所有概念串在了一起。这个文件也经历了多次迭代

v1:受控输入框 + 用户过滤

javascript 复制代码
function App() {
  const [users] = useState([
    { id: 1, name: '微风' },
    { id: 2, name: '布里茨' },
  ])
  const [filterText, setFilterText] = useState('')
  const filteredUsers = users.filter(user => user.name.includes(filterText))

  return (
    <div>
      <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>
  )
}

逐行拆解 <input> 的受控机制

ini 复制代码
<input
  type="text"                                    ← 固定,HTML 属性
  placeholder="请输入用户名过滤"                    ← 固定,占位提示
  value={filterText}                             ← 受控关键:输入框显示值永远=state
  onChange={e => setFilterText(e.target.value)}  ← 固定  ↑ 合成事件  ↑ 自己取名的setter  ↑ 用户输入
/>

事件触发顺序(这是今天展开最细的一段):

lua 复制代码
用户敲下 "微"
  ↓
① 浏览器层面:在内存中把 input.value 设为 "微",触发原生 input 事件
  (此时屏幕还没刷新,所有操作在内存里)
  ↓
② React 捕获原生事件 → 包装成合成事件 e
  (e.constructor.name === "SyntheticBaseEvent",不是 MouseEvent)
  ↓
③ onChange 回调执行:
    e.target 指向真实的 <input> DOM 节点
    e.target.value === "微"
    setFilterText("微") → 更新任务入队
  ↓
④ React 处理更新:filterText 从 '' 变为 '微'
   组件重渲染 → value={filterText} 把 "微" 设回 input(覆盖原生行为)
  ↓
⑤ 浏览器这一帧的渲染阶段 → 屏幕显示 "微"

关键细节 :步骤①和④都在同一帧的 JS 执行阶段,浏览器还来不及绘制。React 在原生设置之后、屏幕刷新之前"截胡"------把值覆盖为 state 里的值。这就是受控组件的本质:输入框没有自主权,state 说了算

🤔 学到这会想问onChange 是固定的事件名?e 是原生事件吗?原生事件和合成事件怎么区分?e.target.value 为什么能拿到用户输入?

💡 解答

  • onChange 是 React 规定的合成事件名,拼错就不生效。它被 React 重新定义了------行为等同于原生的 input 事件,每次内容变化立即触发(不等失焦)
  • e 是 React 包装的 SyntheticEvent,不是原生 MouseEvent/InputEvent。判断标准:e instanceof MouseEvent 为 false(原生为 true),e.constructor.name"SyntheticBaseEvent"
  • JSX 属性绑定(onClick={})得到的都是合成事件,通过 addEventListener 绑定的才是原生事件
  • 合成事件有一个 .nativeEvent 属性指向原始的原生事件
  • e.target 虽然是合成事件的属性,但它指向的是真实的 DOM 节点 <input>,React 没有在这个环节做代理,所以 e.target.value 拿的就是浏览器里那个 input 的当前值

v2:惰性初始化------从 2 条测试数据到 10000 条模拟数据

老师加入了 heavyComputation 函数,并把硬编码的 2 条用户换成了 10000 条模拟数据:

javascript 复制代码
function heavyComputation() {
  console.log('开始执行heavyComputation')
  // 网页性能优化指标
  const startTime = performance.now()  // 当前时间
  const result = []
  for (let i = 0; i < 10000; i++) {
    result.push({ id: i, name: `用户-${i}` })
  }
  const duration = performance.now() - startTime  // 执行时间
  console.log(duration)
  return result
}

function App() {
  // 状态的初始值,不是直接给,要经过计算
  const [users] = useState(() => heavyComputation())
  // ...
}

这里有两个关键写法放在一起对比

scss 复制代码
// ❌ 直接传值------每次渲染都执行 heavyComputation()
const [users] = useState(heavyComputation())
//  heavyComputation() 必须在参数位置先执行完毕
//  → 循环 10000 次 → 传给 useState
//  → 重渲染时 React:我链表里已经有 users 了,这坨丢掉 🗑️
//  → 用户每打一个字就白跑 10000 次循环

// ✅ 传函数------只在首次渲染执行一次
const [users] = useState(() => heavyComputation())
//  箭头函数引用传给 useState,不执行
//  → 首次渲染:React 判断是函数,调它拿初始值
//  → 重渲染:React 走 else 分支,从 hooks 链表读,函数不调

惰性初始化的底层逻辑(追问驱动的深度展开):

React 在 Fiber 节点上维护了一个 hooks 链表:

ini 复制代码
Fiber 节点
  └─ hooks 链表
       ┌──────────────────────────┐
       │ [0] useState             │ ← users 存这里
       │ memoizedState: [10000条] │
       │ queue: 更新队列          │
       └──────────────────────────┘

每次渲染,useState 内部逻辑简化如下:

csharp 复制代码
if (是首次渲染) {
  if (typeof initialValue === 'function') {
    hook.memoizedState = initialValue()  // 调函数拿初始值
  } else {
    hook.memoizedState = initialValue    // 直接用传的值
  }
} else {
  // 重渲染:直接从链表读,initialValue 参数完全被忽略
  // 你的函数根本没机会执行!
}

参数必须求值 是 JS 语法决定的,React 拦不住。useState(heavyComputation())heavyComputation()useState 执行前就跑完了------就像一个提前炒好的菜,服务员看到客人已走也只能倒掉。useState(() => heavyComputation()) 传的是菜单,React 自己决定炒不炒。

🤔 学到这会想问 :万一这个惰性函数每次执行结果不一样呢?比如 Math.random()

💡 解答 :重渲染时惰性函数根本不执行,所以差异毫无意义------结果永远是首次渲染时的值。如果你想要每次都重新计算的值,应该用 useMemo 或在渲染期间直接算,而不是 useState。useState 的使命是记住 ,不是重新生成

v3:派生数据------filteredUsers 不是 state,是计算属性

javascript 复制代码
// 数据状态 state props computed 计算属性
const filteredUsers = users.filter(user => user.name.includes(filterText))

这行注释 // computed 计算属性 很重要 ------filteredUsers 不是 state,它是一个 普通变量 + 实时计算

ini 复制代码
每次渲染都重新执行:
  users.filter(user => user.name.includes(filterText))
      ↑                        ↑              ↑
  总是那 10000 条          每次遍历一个用户      当前输入的文字
      惰性保护不变                         每次渲染取新值
users filteredUsers
身份 state(惰性初始化保护) 派生数据(每次重算)
为什么这样设计 数据源不应被重复计算 它依赖 filterText,filterText 变了它就该重算

惰性只保护 users 不被重复生成,filteredUsers 每次都要重新过滤------这才是正确的行为。如果 filteredUsers 也被缓存了,输入框里的字变了列表却不刷新,那就坏了。


阶段五:底层原理串讲

老师充分利用了追问,把"API 怎么用"推进到了"底层怎么实现":

1. setState 凭什么触发重渲染?

scss 复制代码
setCount(新值)
  → 更新任务入队(hooks 链表的 queue)
  → 标记 Fiber 节点为"需要更新"
  → React Scheduler 调度 → 安排重渲染
  → App() 重新执行 → useState 从链表读新值
  → 新 JSX → diff → DOM 更新

2. hooks 链表的顺序依赖

scss 复制代码
每次渲染,useState 按调用顺序从链表读取:
  hooks[0] → useState(() => heavyComputation()) → users
  hooks[1] → useState('') → filterText

顺序绝对不能变------这就是为什么 hooks 不能放在 if/for 里

3. 受控组件 vs 非受控组件

css 复制代码
非受控:键盘 → input 直接显示(值藏在 DOM 里)

受控:  键盘 → onChange → setState → value={state} → input 显示
               └── 值在 state 里,随时可用,不用查 DOM ──┘

受控的意义:state 是唯一真相来源。想获取输入值?读 filterText。想过滤?直接用 filterText。想提交?拿 filterText。不需要 document.querySelector('input').value


📝 今日学习清单

按教学节奏回顾:

  • readme 设计:useState 四要素(响应式、带头大哥、入参出参、惰性初始化)、Fragment 的容器哲学与"功成身退"
  • 原生对照实验(test.html) :手动 createElement + appendChild → DocumentFragment 批量渲染 → 对比 React Fragment
  • useState 基础(App.jsx) :setState 异步性、闭包陷阱、传值 vs 传函数的本质区别、hooks 链表与更新队列
  • 综合实战(App2.jsx) :受控组件 value/onChange 闭环、合成事件 vs 原生事件、惰性初始化的底层原理、派生数据(计算属性)
  • 底层原理:hooks 链表的读写机制、JS 参数求值顺序、Fiber 更新调度

🔄 代码演进路线

  • test.html:v1(直接 appendChild,三次渲染)→ v2(DocumentFragment 批量,一次渲染)
  • App.jsx:v1(基础计数器)→ v2(三次 setCount 只+1)→ v3(函数式更新 3)→ v4(传值 vs 传函数底层原理)
  • App2.jsx:v1(2 条数据 + 受控过滤)→ v2(10000 条数据 + 惰性初始化)→ v3(派生数据 computed)

🎯 今日面试题

面试题 指向文章位置 回答要点
「你这个过滤输入框,如果用户快速打 10 个字,heavyComputation 会跑几次?」 阶段四 v2------惰性初始化 0 次。useState(() => heavyComputation()) 只在首次渲染调函数,后续渲染直接从 hooks 链表读。但 users.filter() 会跑 10 次------它是派生数据不是 state,每次渲染都重算。
「如果不用 React,你写这个过滤功能要几行代码?关键的差异是什么?」 阶段二 test.html vs 阶段四 App2.jsx 原生要手动 querySelectorcreateElementappendChild(还涉及 DOM 清空重建),核心差异不在行数------React 你只管改数据,DOM 更新你完全不用管;原生你要自己决定何时渲染、渲染什么。
「面试官看到 setCount(count + 1) 连写三次,问你这样写结果是什么?底层为什么?」 阶段三 v2/v3------闭包陷阱 + 更新队列 结果只+1。三次的 count 都是同一轮渲染的闭包变量,传给 React 的都是同一个值 1。React 遍历更新队列,后面的值直接覆盖前面的。函数式更新 prev => prev + 1 才能让上一步结果传递到下一步。

#前端 #React #useState #JavaScript #学习笔记

相关推荐
牧艺2 小时前
cos-design RippleWater & SmokeFog:水面涟漪与烟雾雾气怎么做
前端·canvas·视觉设计
用户938515635073 小时前
写了这么久 useState,你真的知道它在干什么吗?
前端·javascript
慢功夫3 小时前
💡第七篇:VSCode语言服务中,代码跳转和诊断是怎么做的?
前端·visual studio code
Csvn3 小时前
✨ TypeScript `satisfies` 操作符实战——5 个让代码更「聪明」的生产场景
前端
Healer9183 小时前
vue3页面缓存-keepAlive
前端
一个有理想的摸鱼选手3 小时前
在三维地球上以另一种视角感受巴威台风
前端·gis·ai编程
程序员黑豆4 小时前
鸿蒙应用开发:Grid组件实现九宫格布局教程
前端·华为·harmonyos
浮江雾4 小时前
Flutter第十七节-----路由管理(3)
android·开发语言·前端·javascript·flutter·入门
阿懂在掘金4 小时前
企业级命令式弹窗方案:三行代码适配已有 Dialog
前端·vue.js·前端框架