fiber 架构
早起 react diff 的过程是同步递归,整个过程不可中断,可想而知嵌套层级比较深的结构,一定会阻塞的问题。
于是 fiber 架构应运而生,通过链表将整个 react-dom 串在一起。
结构骨架:
- fiber.children:子节点
- fiber.sibling:兄弟节点
- fiber.return:父节点
链表这种有向性的结构,天生就支持中断和恢复的。
hook 与 fiber
React Fiber 中的 Hook 本质是挂在 Fiber 节点上的单向链表结构,每个函数组件对应一条 Hook 链,通过顺序而不是 key 来定位。
这也是为什么不能在 if 判断中调用 hook 的原因,因为一单判断不成立,那么整个 hook 链都会对应不上。
type Hook = {
memoizedState: any; // 当前值(state / effect / memo 等)
baseState: any; // 用于跳过更新时的基础 state
baseQueue: Update<any> | null; // 被跳过的更新队列
queue: UpdateQueue<any> | null; // 更新队列(setState 就进这里)
next: Hook | null; // 指向下一个 hook(链表)
}
type UpdateQueue<S> = {
pending: Update<S> | null; // 环状链表(最后一个节点)
dispatch: (action) => void;
lastRenderedReducer: (S, A) => S;
lastRenderedState: S;
}
type Update<S> = {
lane: Lane; // 优先级
action: any; // setState 传入的值
hasEagerState: boolean;
eagerState: S;
next: Update<S>; // 环状链表
}那么 setState 的批处理是如何实现的呢?
实际上在 多次 setState 的时候,会通过全局变量(isMessageLoopRunning)进行控制,只会触发一次 render,剩下的 update 都收集到hook.queue中,从而实现批处理。
# 收集 update
setState -> update -> hook.queue ->
# 标记 lanes 并上浮
fiber.lanes -> root.lanes ->
# 调度 root render 任务,每 5ms 中断一次
scheduler.scheduleCallback -> performConcurrentWorkOnRoot -> renderRootConcurrent -> workLoopConcurrent -> performUnitOfWork -> commit
# 中断后,继续执行剩余的 update
workInProgress -> resume workLoopdiff 策略
同层级比较,如果存在更新,则创建新的 fiber 节点,否则复用 fiber 节点。
列表节点采用 key 比较,如果 key 相同,则复用 fiber 节点,否则创建新的 fiber 节点。
核心:
- beginWork:负责子节点 diff 与 Fiber 构建
- completeWork:负责 DOM 节点创建与 effect 收集
- commit:真实 DOM 操作
事件系统
jsx 不是真实的 Dom,因此上面的事件也不是直接 DOM 上的原生触发的事件,react 中事件系统是基于合成事件实现的。
- 事件委托:事件都在 root 节点上注册,比如说 click、mouse、touch 等。
- 合成事件:react 自己合成了事件对象,也就是通常回调接收的参数 event。
真实 Dom 触发事件的时候,根据 target 找到对应的 fiber,并且向上冒泡,执行事件回调:
- 冒泡过程通过 fiber.return 向上冒泡,直到 root 节点
- 事件是存储在 props 中
React 在初始化时, 会遍历所有的 DOM 事件名, 为每一个事件名注册对应的事件监听回调函数(包括事件捕获与事件冒泡). 并且在注册过程中, 会将事件回调包裹上 React 中的优先级机制.
常见面试题
useLayoutEffect 和 useEffect 的区别
一个是在渲染前执行,一个是在渲染后执行。
commit 阶段会分多次遍历带有副作用的fiber节点,根据 tag 的不同择时运行。
需要注意的是两者的调度方式不同,useLayoutEffect 是同步调用,useEffect 是调度器调度。
所以 useLayoutEffect 会阻塞页面渲染,而 useEffect 不会。
| 特性 | useLayoutEffect | useEffect |
|---|---|---|
| Fiber Tag (Flags) | Update / Layout 位掩码 | Passive 位掩码 |
| Effect 对象 Tag | HookLayout | HookPassive |
| 存储位置 | 同一个 updateQueue 环形链表 | 同一个 updateQueue 环形链表 |
| 执行函数 | commitLayoutEffects | flushPassiveEffects |
| 调度方式 | 同步调用 (Function Call) | 调度器调度 (scheduleCallback) |
| 执行时机 | Commit 的 Layout 阶段 (DOM 变了, 未屏显) | Commit 结束后 (屏显后) 的异步任务中 |
| 遍历逻辑 | 遍历链表,只摘取 tag 含 Layout 的执行 | 遍历链表,只摘取 tag 含 Passive 的执行 |
为什么不能把 Hook 写在条件渲染中?
在 React 中,Hook 不能写在条件判断(if)、循环(for、while)或嵌套函数中,根本原因在于 React 是完全依赖 Hook 的调用顺序(Order of Calls)来管理和关联状态(State)的。内部会按照调用顺序依次创建 Hook 节点,并挂载到链表中。
Fiber.memoizedState (Head)
↓
[Hook1 Node: { value: 'Alice', next: ... }]
↓
[Hook2 Node: { effect: fn, next: ... }]
↓
[Hook3 Node: { value: 25, next: null }]React Hook 不能写在条件里,是因为 React 底层是用链表存储 Hook 状态的,且完全依赖代码执行的顺序(Index)来将当前的 Hook 调用与链表中的数据节点进行匹配。
一旦顺序乱了(中间少了一个或多了一个),后续所有的 Hook 都会“拿错”数据,导致应用状态彻底崩溃。
hook 的顺序在编译阶段就已经是定的了,所以一定是一一对应的,没有必要用 map 来存储。
聊聊在react中对并发模式的理解
首先为什么要并发,为什么要时间切片?
关键点:
- 那么一定是存在长耗时的任务,所以需要进行任务拆分,从而达到性能优化的目的,因此我们需要 fiber。
- 那么什么数据结构可以支持可中断呢?因此我们需要链表。
- 那么拆分任务之后,就存在不同任务的优先级,因此我们需要 lane。
- 那么不同优先级之间如何调度呢?因此我们需要 scheduler。
- 那么既然会中断,用户可能会看到一个残缺的页面,因此我们需要 double buffering。
这就是 React 并发模式的底层原理:利用链表结构实现可中断计算,利用调度器实现时间切片,利用位运算实现精细的优先级插队,利用双缓存保证 UI 的最终一致性。
react 更新机制原理
State Change -> Dom Update
trigger/Schedule(触发/调度) -> Rerender/Reconciliation(渲染/调和) -> Commit(提交)
trigger/Schedule
触发在 setState 或 dispatch 之后:
- 创建 Update 对象:存储到 fiber.updateQueue** 中,其中包含 新的状态值、计算函数、优先级(lane)**
- 自动批处理:将多个 Update 塞到队列中,通过全局变量控制,只调度一次
- 开启调度:两层循环,第一层循环是调度器调度,第二层循环是 workLoop 循环
Rerender/Reconciliation
从根节点遍历 fiber,目标构建一棵新的 fiber-tree,核心在于 beginWork 和 completeWork。
- beginWork:向下递(diff)
- 逐个检查 fiber 节点
- 消费 Update,计算出新值
- reconcile:新旧fiber比对,打标签(Placement、Update、Deletion、Passive)
- completeWork:向上归
- 构建 Dom:如果是首次加载会创建真实 Dom 挂载到 stateNode
- 收集 Flag:将子节点副作用标记冒泡到父节点
Commit
此过程不可中断
- Before Mutation
- 调用 getSnapshotBeforeUpdate 获取快照,类组件的生命周期
- 将 useEffect 放入队列,等待调度
- mutation
- 遍历所有fiber节点,根据标签操作 Dom
- 切换 fiber 树,current -> workInProgress
- Layout
- 执行 useLayoutEffect:这里是同步执行,此时 Dom 已更新,但还没绘制(paint)。这里可以读到最新的Dom尺寸信息并同步修改样式,防止页面闪烁
最后开始调度 useEffect:异步执行,此时 Dom 已更新,并且绘制完成。
setState 是同步还是异步
- react 18 之前:合成事件中是异步的,原生事件或setTimeout或Promise中是同步的
- react 18 之后:绝大多数都是异步的(自动批处理)
react 18 之前:
通过全局变量 isBatchingUpdates 控制。
react 在调用组件方法(如 onClick、componentDidMount)时,会执行 isBatchingUpdates = true,开始执行批量更新。
后续所有的 setState 都会被推到队列中(dirtyComponents)
react 18 之后:
引入了 Scheduler 和 Lane,自动批处理。
setState 之后会开启调度,并且通过全局变量 isMessageLoopRunning 控制,只会调度一次。
后续所有的 setState 都会被推到队列中(taskQueue),并标记 fiber.lanes,最终在 workLoop 中消费。
flushSync API 可以强制同步更新,会立即执行队列中的更新。
原理:拉高 lane 优先级,立即执行该任务,从而避免自动批处理。
react 调度机制
时间切片本质上是将长耗时任务拆分成多个短任务分散到多帧中执行。
将早期同步递归的 diff 过程,通过 fiber 链表的模式,改成可中断的循环遍历。
核心:
- 如何拆:通过 fiber 链表,将长耗时任务拆分成多个短任务分散到多帧中执行。
- 如何停:后两层循环,设计 5ms 一帧,通过 shouldYield 判断是否需要中断。
- 如何插队:至于插队是通过 小顶堆和Lane位运算 实现的。
- 如何续:通过 workInProgress 指针保存,中断后继续执行。
注意:高优先级虽然会优先执行,但低优先级还是一定会被执行的,一旦低优先级的任务等了太久,react 内部会提升其优先级至最高,确保 UI 的一致性。
实现:
- setState 收集 Update、Lanes 并上浮到 root.lanes,并将 re-render 任务入队(小顶堆)
- 通过 MessageChannel.postMessage 触发宏任务(滞后执行),开启三层循环
- 三层循环:外层循环(schedulePerformWorkUntilDeadline)、内层循环(flushWork)、单个任务循环(workLoop)
useState 批处理
react 调度是通过 MessageChannel.postMessage 触发宏任务。
也就是说并非 setState 之后立马就进行 re-render 也就是 diff 等过程,是在下一轮事件循环的时候开始的。并且通过全局变量 isMessageLoopRunning 控制,只会调度一次。
那么当前轮事件循环中所有的 setState 都在做一件事:收集Update,并标记 lane。
在正在开始三层循环的时候才开始消费 Update,逐个计算出新的 state 值,并构建新的 fiber-tree。
jsx 如何转成真实 dom
jsx -> ReactElement -> fiber -> fiber.stateNode -> Real DOM
- 源码层:JSX (字符串/语法糖) ↓ Babel 编译
- JS 层:React.createElement() (函数调用) ↓ 执行
- VDOM 层:ReactElement (轻量级描述对象) ↓ Reconciler (Diff)
- 架构层:Fiber Node (带状态、副作用标记的工作单元) ↓ Renderer (Commit)
- 视图层:Real DOM (浏览器内的 C++ 对象)
useCallback 原理
实际上是特殊的 useMemo:useMemo(() => fn, [deps])。
很明显 useCallback 也是 hook,那么它必然是存在 fiber.memoizedState 上的。
所有 hook 的缓存能力,都源于 fiber 节点的持久性。
deps 通过 Object.is 进行引用比较。
diff 算法
- 同级比较
- 类型比较
- key 标识
列表节点:
- 列表节点移动,只考虑向右移动的情况
- 列表节点通过 key 判断是否可以复用
事件系统
React 的事件系统是一套模拟浏览器事件冒泡捕获机制的全套引擎。
核心目的:
- 磨平浏览器差异
- 性能优化
核心:
- 事件委托:事件都在 Root/Document 上注册
- 合成事件:React 自己合成了事件对象,也就是通常回调接收的参数 event
当用户点击一个按钮时,触发流程:
- 寻找目标 (Find Target):通过
nativeEvent.target拿到真正触发点击的原生 DOM 节点 - 找到 Fiber 节点 (Get Fiber):通过
__reactFiber$随机数拿到对应的 Fiber 节点(该属性是创建 Dom 时候挂上去的) - 收集路径 (Collect Path):从 Fiber 节点向上遍历,收集所有事件处理函数,放到
dispatchQueue中 - 模拟捕获:遍历
dispatchQueue,依次执行事件处理函数 - 模拟冒泡:倒序遍历
dispatchQueue,依次执行事件处理函数 - 停止冒泡:如果事件处理函数中调用了
e.stopPropagation()标记isPropagationStopped为 true,则停止冒泡
注意:原生事件和合成事件,相互独立,互不干扰。
Protals 事件是否冒泡到父组件
结论:是的,React Portal 中的事件完全会冒泡到父组件中
Protals 节点在 fiber-tree 中的位置是不变的,还是挂载在父层下,只是 Dom 的位置可能不太一样。
react 合成事件中冒泡是根据 fiber-tree 的 return 指针向上冒泡的。
useEffect 原理
首先 useEffect 也是个 hook,那么它就存在 fiber.memoizedState 上的。
另外它还存在 fiber.updateQueue 中,以环形链表的形式存储。
const effect = {
tag: Passive, // 标记类型(Passive 代表 useEffect,Layout 代表 useLayoutEffect)
create: () => {}, // 你的回调函数
destroy: undefined, // 你的回调函数返回的 cleanup 函数
deps: [count], // 依赖数组
next: null, // 指向下一个 effect(环形链表)
};工作流程:
- render 阶段:只负责生产、标记,不执行副作用
- Mount 阶段:创建 effect 节点,tag标记为
HookHasEffect | Passive,推入 fiber.updateQueue 中 - Update 阶段:
- 如果 deps 变化,重新创建 effect 节点,tag 标记为
HookHasEffect | Passive,推入 fiber.updateQueue 中 - 如果 deps 未变化,重新创建 effect 节点,tag 不打标记 ,推入 fiber.updateQueue 中
- 如果 deps 变化,重新创建 effect 节点,tag 标记为
- commit 阶段:执行副作用
- 先通过 MessageChannel 注册一个宏任务,下一轮事件循环中遍历 fiber.updateQueue 中的 effect 节点,执行副作用
- 执行的时候先执行所有 effect 节点的 destroy 函数,然后执行所有 effect 节点的 create 函数
commit 阶段创建并变更完 Dom 之后,会同步执行 LayoutEffect,此时 Dom 已更新,但还没绘制(paint)。
useContext 原理
全局变量 + 栈结构 + 依赖订阅
两个特点:
- 读取极快:只是一个变量,O(1)
- 更新成本高:需要遍历整颗子树
解决方案:Context 拆分、useMemo 缓存 ContextValue
Context 对象
通过 createContext 是,内部会创建一个对象,其中包含一个关键属性 _currentValue
实质上 Context 是一种单例模式:
// React 源码简化
const context = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue, // 关键:存储当前值的变量
Provider: null,
Consumer: null,
};栈式结构
同一个 Context 通过嵌套,给不同子组件提供不同的值。
核心就是在 render 阶段,通过栈结构保存当前的 Context 值。
比如说:<Provider value="foo"> 和 <Provider value="bar"> 嵌套在一起,那么子组件会优先读取离自己最近的 Provider 的 value。
<Provider value="foo">
<Provider value="bar">
<MyComponent />
</Provider>
</Provider> ┌──────────────────────────┐
│ Provider (value="bar") │ ← 栈顶(最近,优先被读取)
└──────────────────────────┘
┌──────────────────────────┐
│ Provider (value="foo") │
└──────────────────────────┘依赖订阅
调用 useContext 时,会在 fiber.dependencies (链表) 中添加一个依赖。
当 Context 的值发生变化时,会从 Provider 开始向下深度优先遍历(DFS),并检查 dependencies 列表,如果存在依赖,则给 fiber 标记一个 Update 优先级。