返回首页

时间轴

分类:elysia
发布于:
阅读时间:22 分钟

这是一次关于 React 运行时机制的深度外科手术。我们要剖开浏览器的时间轴,看看从“按下 F5”到“看到像素”的几毫秒内,到底发生了什么。

我们要突破仅仅“会写代码”的层面,进入**“上帝视角”**,理解浏览器的主线程(Main Thread)是如何被 React 调度和抢占的。

这将涉及:JS 执行上下文、React Fiber Reconciler(协调器)、Commit 阶段、浏览器渲染流水线(Pixel Pipeline)、以及微任务队列(Microtasks)。


第一幕:创世纪 (T = 0ms)

JS 引擎启动与初始化

当浏览器下载完 HTML 和 JS 文件后,V8 引擎(Chrome 的 JS 引擎)开始介入。此时 React 还没接管 DOM,一切是同步阻塞的。

  1. Parse & Compile: 浏览器解析 JS 文本,生成 AST(抽象语法树)。

  2. 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 修改

  1. Fiber Node 创建: React 开始遍历你的组件树。每个组件 (<UserProvider>, <Header>) 都会被转换成一个 Fiber 节点对象。

  2. 组件函数执行:

    • React 调用你的函数:UserProvider()

    • Hook 链表初始化: useState(initialValue) 执行。React 会把 initialValue 存入该 Fiber 节点的 memoizedState 链表中。

    • useRef 创建: useRef(val) 创建一个 { current: val } 对象。

    • useEffect 收集: React 看到 useEffect,但不执行它。它只是把这个 Effect 里的回调函数打上标签(Tag),挂在 Fiber 节点上,心里记着:“等会画完图要执行这个”。

  3. Diffing (协调): React 拿新的 Fiber 树和旧的树对比(如果是首次渲染,就是和 null 对比),计算出需要做哪些 DOM 变更(增、删、改)。

⚠️ 竞态风险区:很多第三方库(如 React Query 的 useQuery)可能会在这个阶段或者紧接着这个阶段发起网络请求。如果此时你的 Context/Store 里没有 ID,请求参数就是错的。

第三幕:React 的动手 (Commit Phase)

DOM 突变 (绝对同步,不可打断)

Render 阶段计算出了“变化清单(Effect List)”,现在 React 要把这些变化应用到真实的 DOM 上。这个过程必须一气呵成,不能被浏览器打断,否则用户会看到破碎的界面。

  1. Pre-mutation: 处理一些特殊的生命周期(getSnapshotBeforeUpdate)。

  2. Mutation (DOM 操作):

    • React 对照清单,疯狂执行 document.createElement, appendChild, node.style.color = 'red'

    • Ref 更新: 此时,DOM 节点已经创建好了,React 会把真实的 DOM 节点(或新值)赋值给 ref.current

  3. Layout Effects (同步执行):

    • 执行 useLayoutEffect 的回调。

    • 关键知识点: useLayoutEffect 是在 DOM 更新后、但浏览器把像素画到屏幕上之前执行的。如果你在这里修改 State,React 会立即通过同步的方式重新渲染,用户不会看到中间状态(防止闪烁)。

第四幕:浏览器的喘息 (Browser Paint)

像素流水线 (Pixel Pipeline)

React 的 Commit 阶段结束,JS 线程暂时交出控制权,归还给浏览器宿主环境。浏览器终于可以把刚才 React 改过的 DOM 画出来了。

  1. Style Calculation: 计算每个节点的最终样式(CSS 继承、层叠)。

  2. Layout (Reflow): 计算每个节点在屏幕上的几何位置(x, y, width, height)。

  3. Paint: 绘制像素(填充颜色、文字、阴影)。

  4. Composite: 多个图层合成(GPU 加速)。

👁️ 用户所见: 就在这一刻,T = ~16ms(假设 60fps),用户第一次看到了页面内容。

第五幕:事后诸葛亮 (Passive Effects)

useEffect 的执行

只有当浏览器说:“好了,我画完了,用户已经看到新界面了”,JS 线程空闲下来了,React 才会去执行微任务队列里的 useEffect

  1. 执行 useEffect:

    • 你的 useEffect(() => { setDeptId(...) }, []) 就在这里执行。

    • 这就是为什么它“迟到了”: 它是在浏览器画完之后才跑的。此时整个前四幕都已经演完了。如果你的 API 请求在第二幕就发出了,那你在这里亡羊补牢为时已晚。

  2. 触发重渲染:

    • 因为你在 useEffect 里调用了 setState,React 叹了口气:“唉,刚画完又要改”。

    • 于是 React 再次启动 第二幕 (Render Phase)

    • 整个流程(Render -> Commit -> Paint)再跑一遍。用户可能会看到数据从“空”突然跳变成“有数据”(这就是所谓的Layout Shift / 闪烁)。


总结:你的 Bug 发生在哪个缝隙?

让我们把你之前的 Bug 放在这个显微镜下看:

  1. T=0 (Init): useState(null)。内存里 DeptID 是 null

  2. T=5 (Render): 你的 <ProductList> 组件渲染,调用 useQuery

  3. T=6 (Fetch): useQuery 立即根据当前的 DeptID (null) 发起 HTTP 请求。❌ 错误发生。

  4. T=15 (Commit): DOM 写入页面。

  5. T=16 (Paint): 用户看到加载转圈。

  6. T=20 (Effect): useEffect 终于运行,从 LocalStorage 读到 "ID=123",执行 setDeptId("123")

  7. 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 的 startTransitionSuspense 改变了什么?”

简单说,React 18 允许 第二幕 (Render Phase) 被打断。

React 可以计算一会儿 Fiber 树,发现浏览器由于用户点击变卡了,就先暂停计算,让浏览器去响应用户(处理 Input),然后再回来继续算。

但这属于更高级的调度策略,基本的“Init -> Render -> Paint -> Effect”顺序依然是铁律。