时间轴
这是一次关于 React 运行时机制的深度外科手术。我们要剖开浏览器的时间轴,看看从“按下 F5”到“看到像素”的几毫秒内,到底发生了什么。
我们要突破仅仅“会写代码”的层面,进入**“上帝视角”**,理解浏览器的主线程(Main Thread)是如何被 React 调度和抢占的。
这将涉及:JS 执行上下文、React Fiber Reconciler(协调器)、Commit 阶段、浏览器渲染流水线(Pixel Pipeline)、以及微任务队列(Microtasks)。
第一幕:创世纪 (T = 0ms)
JS 引擎启动与初始化
当浏览器下载完 HTML 和 JS 文件后,V8 引擎(Chrome 的 JS 引擎)开始介入。此时 React 还没接管 DOM,一切是同步且阻塞的。
-
Parse & Compile: 浏览器解析 JS 文本,生成 AST(抽象语法树)。
-
Global Execution Context: 全局代码开始执行。
-
import React ...加载库。 -
const store = create(...)Zustand/Redux Store 初始化。 -
关键点:如果你的 Store 初始化代码写着
localStorage.getItem,它就在这一刻执行。这早于任何 React 组件的渲染。这就是为什么我说“把读取放在初始化里”最安全,因为它是绝对同步的。
-
第二幕:React 的思考 (Render Phase)
构建 Fiber 树 (纯计算,内存操作)
React 调用 root.render(<App />)。此时进入 Render Phase(渲染阶段)。请注意,这个阶段是纯 JS 计算,不涉及任何 DOM 修改。
-
Fiber Node 创建: React 开始遍历你的组件树。每个组件 (
<UserProvider>,<Header>) 都会被转换成一个 Fiber 节点对象。 -
组件函数执行:
-
React 调用你的函数:
UserProvider()。 -
Hook 链表初始化:
useState(initialValue)执行。React 会把initialValue存入该 Fiber 节点的memoizedState链表中。 -
useRef 创建:
useRef(val)创建一个{ current: val }对象。 -
useEffect 收集: React 看到
useEffect,但不执行它。它只是把这个 Effect 里的回调函数打上标签(Tag),挂在 Fiber 节点上,心里记着:“等会画完图要执行这个”。
-
-
Diffing (协调): React 拿新的 Fiber 树和旧的树对比(如果是首次渲染,就是和 null 对比),计算出需要做哪些 DOM 变更(增、删、改)。
⚠️ 竞态风险区:很多第三方库(如 React Query 的
useQuery)可能会在这个阶段或者紧接着这个阶段发起网络请求。如果此时你的 Context/Store 里没有 ID,请求参数就是错的。
第三幕:React 的动手 (Commit Phase)
DOM 突变 (绝对同步,不可打断)
Render 阶段计算出了“变化清单(Effect List)”,现在 React 要把这些变化应用到真实的 DOM 上。这个过程必须一气呵成,不能被浏览器打断,否则用户会看到破碎的界面。
-
Pre-mutation: 处理一些特殊的生命周期(getSnapshotBeforeUpdate)。
-
Mutation (DOM 操作):
-
React 对照清单,疯狂执行
document.createElement,appendChild,node.style.color = 'red'。 -
Ref 更新: 此时,DOM 节点已经创建好了,React 会把真实的 DOM 节点(或新值)赋值给
ref.current。
-
-
Layout Effects (同步执行):
-
执行
useLayoutEffect的回调。 -
关键知识点:
useLayoutEffect是在 DOM 更新后、但浏览器把像素画到屏幕上之前执行的。如果你在这里修改 State,React 会立即通过同步的方式重新渲染,用户不会看到中间状态(防止闪烁)。
-
第四幕:浏览器的喘息 (Browser Paint)
像素流水线 (Pixel Pipeline)
React 的 Commit 阶段结束,JS 线程暂时交出控制权,归还给浏览器宿主环境。浏览器终于可以把刚才 React 改过的 DOM 画出来了。
-
Style Calculation: 计算每个节点的最终样式(CSS 继承、层叠)。
-
Layout (Reflow): 计算每个节点在屏幕上的几何位置(x, y, width, height)。
-
Paint: 绘制像素(填充颜色、文字、阴影)。
-
Composite: 多个图层合成(GPU 加速)。
👁️ 用户所见: 就在这一刻,T = ~16ms(假设 60fps),用户第一次看到了页面内容。
第五幕:事后诸葛亮 (Passive Effects)
useEffect 的执行
只有当浏览器说:“好了,我画完了,用户已经看到新界面了”,JS 线程空闲下来了,React 才会去执行微任务队列里的 useEffect。
-
执行 useEffect:
-
你的
useEffect(() => { setDeptId(...) }, [])就在这里执行。 -
这就是为什么它“迟到了”: 它是在浏览器画完之后才跑的。此时整个前四幕都已经演完了。如果你的 API 请求在第二幕就发出了,那你在这里亡羊补牢为时已晚。
-
-
触发重渲染:
-
因为你在 useEffect 里调用了
setState,React 叹了口气:“唉,刚画完又要改”。 -
于是 React 再次启动 第二幕 (Render Phase)。
-
整个流程(Render -> Commit -> Paint)再跑一遍。用户可能会看到数据从“空”突然跳变成“有数据”(这就是所谓的Layout Shift / 闪烁)。
-
总结:你的 Bug 发生在哪个缝隙?
让我们把你之前的 Bug 放在这个显微镜下看:
-
T=0 (Init):
useState(null)。内存里 DeptID 是null。 -
T=5 (Render): 你的
<ProductList>组件渲染,调用useQuery。 -
T=6 (Fetch):
useQuery立即根据当前的 DeptID (null) 发起 HTTP 请求。❌ 错误发生。 -
T=15 (Commit): DOM 写入页面。
-
T=16 (Paint): 用户看到加载转圈。
-
T=20 (Effect):
useEffect终于运行,从 LocalStorage 读到 "ID=123",执行setDeptId("123")。 -
T=25 (Re-render): React 重新开始,但之前的错误请求已经发出去且被后端拒绝了。
怎么修?(Timeline Optimization)
我们要把 T=20 的动作,移到 T=0。
-
修改前: ID 在 T=20 才就位。
-
修改后:
const [id] = useState(localStorage.getItem(...))。-
这行代码在 T=0 执行。
-
当 T=5
<ProductList>渲染时,它拿到的已经是 "ID=123"。 -
T=6 发起的请求直接携带正确 ID。✅ 问题解决。
-
进阶:React 18 的并发模式 (Concurrent Features)
如果这个时候你问:“React 18 的 startTransition 或 Suspense 改变了什么?”
简单说,React 18 允许 第二幕 (Render Phase) 被打断。
React 可以计算一会儿 Fiber 树,发现浏览器由于用户点击变卡了,就先暂停计算,让浏览器去响应用户(处理 Input),然后再回来继续算。
但这属于更高级的调度策略,基本的“Init -> Render -> Paint -> Effect”顺序依然是铁律。