搞懂 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>
执行顺序走读(这是今天讨论的第一个重点):
document.querySelector('#list')------ 原生 DOM API,用 CSS 选择器拿到页面上的空<ul>document.createElement('li')------ 浏览器通用工厂方法,在 JS 内存 中创建<li>节点。注意:此时节点不在 DOM 树上,浏览器看不见它item.innerText = task------ 给这个内存里的节点填文字内容olist.appendChild(item)------ 把节点从内存挂到 DOM 树上,这一步才触发浏览器真实渲染。三次循环,三次渲染
老师在这里埋了一个概念锚点 :createElement 和 appendChild 必须分开理解。前者只是 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 节点对象 ------它有innerText、style、appendChild等方法,具备<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.log 在 setCount 后面同步执行,打印的还是旧值。
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 | 原生要手动 querySelector、createElement、appendChild(还涉及 DOM 清空重建),核心差异不在行数------React 你只管改数据,DOM 更新你完全不用管;原生你要自己决定何时渲染、渲染什么。 |
「面试官看到 setCount(count + 1) 连写三次,问你这样写结果是什么?底层为什么?」 |
阶段三 v2/v3------闭包陷阱 + 更新队列 | 结果只+1。三次的 count 都是同一轮渲染的闭包变量,传给 React 的都是同一个值 1。React 遍历更新队列,后面的值直接覆盖前面的。函数式更新 prev => prev + 1 才能让上一步结果传递到下一步。 |
#前端 #React #useState #JavaScript #学习笔记