React 内部机制:渲染模型、State 与 Hooks 基础
Dan Abramov 系列笔记——从渲染快照到 Fiber 架构,搞懂 React 是怎么工作的
1. 每次渲染都是独立的快照
-
count只是一个数字,不是“数据绑定” -
每次
setState→ 重新调用组件函数 → 新的count值 -
闭包捕获的永远是当时的值,不会变
// 第一次渲染:count = 0
// 第二次渲染:count = 1
// 第三次渲染:count = 2
// 每个渲染的 count 是独立的常量
2. 函数组件 vs 类组件
核心区别:函数组件用闭包,类组件用 this
| 函数组件 | 类组件 | |
|---|---|---|
| 读取值 | 闭包捕获当时的值 | this.props / this.state 读最新值 |
| 数据一致性 | 天然保证(闭包不可变) | 需要手动管理 |
| 异步操作 | 捕获的值永远正确 | 可能读到“未来”的值 |
// 函数组件:闭包捕获 user = 'Dan'
function ProfilePage({ user }) {
const showMessage = () => alert('Followed ' + user) // 永远是 Dan
}
// 类组件:this.props 可变
class ProfilePage extends React.Component {
showMessage = () => alert('Followed ' + this.props.user) // 可能变成 Sophie
}
3. React as a UI Runtime(渲染模型)
三层架构:
JSX → React Element(轻量描述对象)→ Reconciler(Fiber 协调)→ Host Tree(DOM)
核心流程:
渲染(Render):调用组件函数,生成 React Element 树(纯计算,不操作 DOM)
协调(Reconcile):对比新旧 Fiber 树,找出差异
提交(Commit):把差异应用到真实 DOM
协调规则:
-
同一位置 \+ 同类型 → 复用
-
类型变了 → 销毁重建
-
key让 React 按身份匹配,不按位置
组件返回值:
-
React Element → 描述 UI
-
null→ 不渲染 -
函数 → effect 的 cleanup
你的 JSX 写 <div className="card">hello</div>,React Element 层产出 { type: 'div', props: { className: 'card', children: 'hello' } }。
然后 Host Renderer 看到 type: 'div'——
-
react-dom 说:哦
div,我去调document.createElement('div') -
react-native 说:不认识
div,报错(你得写<View>) -
react-three-fiber 说:不认识
div,报错(你得写<mesh>)
4. Hooks 调用顺序
React 用调用顺序标识每个 Hook 的状态:
function Form() {
const [name, setName] = useState('Mary') // #1
const [surname, setSurname] = useState('Poppins') // #2
const [width, setWidth] = useState(window.innerWidth) // #3
}
为什么用调用顺序:
-
不需要命名 → 无冲突
-
复制粘贴安全
-
自由组合,无钻石问题
-
每次渲染形成树状调用栈,天然隔离
代价: Hooks 必须顶层无条件调用,不能放在 if/for 里
5. useEffect 依赖数组
核心:effect = 声明式同步,deps = 同步触发条件,cleanup = 同步终止条件
useEffect(() => {
const ws = new WebSocket(url)
return () => ws.close() // cleanup
}, [url]) // url 变了 → 先销毁旧同步,再建立新同步
deps 的规则:
-
useEffect(fn)→ 没传 deps → 每次渲染都执行 -
useEffect(fn, [])→ 空数组 → 只在挂载时执行(依赖没变所以跳过) -
useEffect(fn, [a, b])→ a 或 b 变了才执行
跟 Vue 的对比:
React:手动声明依赖 + 手动写 cleanup
Vue:编译器自动追踪依赖 + 自动 cleanup
本质一样:响应式 + 副作用管理
6. setInterval + Hooks(声明式 vs 命令式)
阻抗失配: React 声明式(每次渲染独立快照),setInterval 命令式(回调固定)
解法:useRef 穿透闭包
function useInterval(callback, delay) {
const savedCallback = useRef()
// 每次渲染后更新最新的 callback
useEffect(() => {
savedCallback.current = callback
})
// interval 只创建一次,调用的是 ref 里的最新 callback
useEffect(() => {
function tick() { savedCallback.current() }
if (delay !== null) {
let id = setInterval(tick, delay)
return () => clearInterval(id)
}
}, [delay])
}
useRef 的本质: 跨渲染共享可变值,类组件 this 的等价物
7. setState / useState 原理(依赖注入)
react 包只暴露 API,实现全在 renderer**
// 类组件:this.updater 由 renderer 注入
setState(partialState, callback) {
this.updater.enqueueSetState(this, partialState, callback)
}
// Hooks:__currentDispatcher 由 renderer 注入
useState(initialState) {
return React.__currentDispatcher.useState(initialState)
}
依赖注入: renderer 在渲染前把 updater/dispatcher 注入,组件通过它们跟 renderer 通信
这也是为什么 react 和 react-dom 版本必须匹配
8. React 如何区分类/函数
React.Component.prototype.isReactComponent = {}
// React 判断逻辑
function isClassComponent(fn) {
return fn.prototype && fn.prototype.isReactComponent
}
原理: 沿原型链查找 isReactComponent
-
找到 → 类组件 →
new Greeting(props).render() -
没找到 → 函数组件 →
Greeting(props)
9. Fiber 架构
让渲染可中断、可恢复。
Fiber 就是一个 JS 对象,描述当前 UI 的状态
{
tag: 'FunctionComponent',
type: Component,
pendingProps: {},
memoizedProps: {},
memoizedState: null, // hooks 链表挂在这里
child: Fiber,
sibling: Fiber,
return: Fiber,
lanes: 0, // 优先级
alternate: Fiber, // 双缓冲
}
核心机制:
-
双缓冲:current(屏幕)↔ workInProgress(构建中),构建完成后切换指针
-
lanes 优先级:位掩码,高优先级任务(用户点击)可以打断低优先级
-
hooks 链表:memoizedState → 链表,按调用顺序访问
10. Before You memo(性能优化)
先拆分组件结构,再用 memo
技巧 1:状态下移
// 把 state 和依赖它的 UI 抽成子组件
function App() {
return (
<>
<Form /> {/* state 在这里面 */}
<ExpensiveTree /> {/* 不受影响 */}
</>
)
}
技巧 2:内容上提
// 通过 children 传不变的内容
function App() {
return (
<ColorPicker> {/* state 在这里面 */}
<ExpensiveTree /> {/* children 引用不变,React 跳过 */}
</ColorPicker>
)
}
原理: React 协调只比较同一位置的类型和引用。children 引用不变 → 跳过子树
11. Development Mode
process.env.NODE_ENV 是构建时字符串替换,不是运行时变量**
// 构建后
if ('production' !== 'production') { // false → 删除
doSomethingDev()
} else {
doSomethingProd() // 保留
}
React 策略: npm 包预编译两份 bundle(dev \+ prod),入口文件用一次判断选择加载哪个
开发模式为什么慢: 额外 warning \+ StrictMode 双重渲染 \+ 详细错误追踪
12. 工程哲学
let vs const: 不值得争论,团队统一就好
Clean Code: 不是目标,是防御机制。代码的价值在于“改起来多容易”,不是重复行数
Let clean code guide you. Then let it go.
底层一句话
React 没有魔法。闭包、原型链、依赖注入、编译时替换——全是 JS 基础。写不好 React = 基础不够扎实。
来源:react.dev/learn/state-as-a-snapshot
核心观点:setState 不会立即改变当前渲染的 state,而是触发下一次渲染
setState 是"拍照",不是"改值"
当前渲染的 state 永远是固定的,不会因为你调了 setState 就变
1. setState 触发重新渲染,不是立即改变值
function Counter() {
const [number, setNumber] = useState(0)
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1)
setNumber(number + 1)
setNumber(number + 1)
}}>+3</button>
</>
)
}
点击后 number 只加 1,不是 3。 因为当前渲染的 number 是 0,三次 setNumber(number + 1) 实际上都是 setNumber(0 + 1)。
2. 快照替换法
每次渲染都是一张快照。把事件处理器里的 state 变量替换成它的值:
// 当前渲染 number = 0
<button onClick={() => {
setNumber(0 + 1) // → setNumber(1)
setNumber(0 + 1) // → setNumber(1)
setNumber(0 + 1) // → setNumber(1)
}}>+3</button>
// 下次渲染 number = 1
<button onClick={() => {
setNumber(1 + 1) // → setNumber(2)
setNumber(1 + 1) // → setNumber(2)
setNumber(1 + 1) // → setNumber(2)
}}>+3</button>
3. 异步代码也看到的是“当时的”快照
function Counter() {
const [number, setNumber] = useState(0)
return (
<button onClick={() => {
setNumber(number + 5)
setTimeout(() => {
alert(number) // 显示 0,不是 5
}, 3000)
}}>+5</button>
)
}
即使 3 秒后组件已经重新渲染了,alert 看到的仍然是“当时”的 number = 0。 因为事件处理器闭包捕获的是那次渲染的快照。
4. 同一事件处理器内,所有 setState 看到的是同一个快照
// 不管你在事件处理器里调了多少次 setState
// 它们读到的 state 值都是"这次渲染"的值
// 这就是为什么连续三次 setNumber(number + 1) 只加了 1
5. 如果你想基于“最新”state 更新,用函数式更新
// 🔴 三次都是 setNumber(0 + 1)
setNumber(number + 1)
setNumber(number + 1)
setNumber(number + 1)
// ✅ 三次分别是 1, 2, 3
setNumber(n => n + 1)
setNumber(n => n + 1)
setNumber(n => n + 1)
函数式更新 setNumber(n => n + 1) 不依赖当前渲染的快照,而是基于队列中最新的值。