如果让我只选一个最容易被低估的 React 知识点绝对就是从 JSX 到界面落地这条链路。你天天写setState、每天看着组件重新跑了一遍但要是真被问到浏览器到底是怎么把你这几行声明式代码变成最终页面的能说清楚的人其实不多。这篇不做源码逐行注释我会按一次真实更新的路径走一遍JSX 怎么编译、元素如何变成 Fiber、渲染为什么可以中断、DOM 最终由谁写入。你不需要有源码阅读基础但看完应该能看懂 React 开发者工具里的火焰图也能说清楚那些组件为什么多跑了一次的悬案。1. 从一行 JSX 到整条渲染链路先建立整体感知1.1 你写的 JSX浏览器根本不认识先讲个最基础的事实浏览器不认识 JSX。你把.jsx文件直接丢进浏览器浏览器只会报语法错误。JSX 本质上是 JavaScript 的语法扩展它必须经过编译工具最常见的是 Babel或者打包器内置的语法转换翻译成普通的函数调用浏览器才能执行。比如我们天天写的这段代码function App() { const [count, setCount] useState(0) return ( div classNamecounter span{count}/span button onClick{() setCount(c c 1)}加一/button /div ) }经过编译后大概会变成这样用新版react/jsx-runtime转换器简化表达import { jsx as _jsx } from react/jsx-runtime function App() { const [count, setCount] useState(0) return _jsx(div, { className: counter, children: [ _jsx(span, { children: count }), _jsx(button, { onClick: () setCount(c c 1), children: 加一 }) ] }) }看到重点了吗_jsx是普通函数返回的产物在 React 内部叫React 元素React element。它就是一个普通 JavaScript 对象没有任何魔法。所以你在控制台打印一个组件返回的结果看到的是一堆{ type: div, props: {...}, key: null }之类的结构化数据而不是真实 DOM 节点。从这一步开始从 JSX 到 Fiber的链路就已经启动。整个流程可以简化成一张地图JSX 源码 │ 编译 ▼ React 元素普通对象描述 UI 长什么样 │ 首次渲染 / setState 触发更新 ▼ Fiber 树构建render 阶段可中断 │ 标记出所有需要变化的节点 ▼ DOM 变更commit 阶段不可中断 │ ▼ 浏览器把 DOM 样式落到像素上我把它叫做声明式代码到命令式操作的翻译过程。你写的是UI 应该长什么样React 替你做的是具体怎么把这个 UI 变成真实 DOM。1.2 渲染的两个大阶段Render 与 Commit 到底在忙什么React 官方文档里经常出现两个词render 阶段和commit 阶段。这俩词特别容易被误解成同一个动作其实它们干的是完全不同的活。Render 阶段也叫协调reconciliation阶段。这个阶段做的是计算差异从组件树根部开始遍历所有组件调用函数组件或者类组件的 render 方法得到一个新的 React 元素树然后跟之前已经存在的虚拟 UI 结构做对比找出哪些节点需要新增、哪些需要更新、哪些需要删除。这个阶段的特点是你写什么组件函数它就跑什么组件函数所以你在渲染阶段里写console.log每次更新都会打印——这只是计算不碰真实 DOM。Commit 阶段才是真正把差异落盘的阶段。React 会拿着 render 阶段标记好的副作用清单挨个操作真实 DOM插入一个节点、改一个属性、删除一个元素全部都是同步执行、不可中断的命令式操作。这个阶段你写的自定义逻辑不会被执行组件函数不会再调用但各种生命周期函数和 effect 会在这个阶段前后被触发。在 Fiber 架构出现之前React 15 和更早版本的处理方式非常粗暴整个 render 过程是同步递归的一旦开始就不能停必须一口气遍历完整棵树。问题很现实——如果你的组件树很深很宽一次 setState 就可能让主线程忙几毫秒甚至几十毫秒中间动画没法跑、用户输入没响应页面就呈现出明显的卡顿感。Fiber 架构的诞生本质上就是解决渲染过程不可中断这个痛点的。2. 元对象与工作单元React 元素和 Fiber 节点的区别2.1 React 元素只是贴在虚拟层上的菜单描述很多人会把 React 元素误解成虚拟 DOM它俩有关系但严格说并不是同一个东西。虚拟 DOM 在社区语境里通常指代React 内存中用于描述 UI 的整棵树而 React 元素是一棵树里的最小描述单元。我们再看一眼_jsx返回的对象{ type: div, props: { className: counter, children: [ { type: span, props: { children: 0 }, ... }, { type: button, props: { onClick: fn, children: 加一 }, ... } ] } }这里的type可能是字符串宿主元素比如div、span也可能是一个组件函数或类自定义组件。如果type是一个组件函数React 看到它的方式是这个元素不是一个真实 DOM它是一次调用这个函数的指令。React 元素本身不携带任何状态它很无脑每次组件重新执行都会生成全新的一批元素。打个比方React 元素像是一张菜单上写的菜品描述写着宫保鸡丁鸡肉、花生、辣椒、葱段。真正的菜还没做出来菜单只是告诉你后厨该准备什么。2.2 Fiber 节点带指针、带优先级的工作单元Fiber 是 React 16 之后核心架构的名称同时也指代这种架构里的基本工作单元——Fiber 节点。React 元素是期望样子的抽象描述而 Fiber 节点是实际计算过程的单位。每个 Fiber 节点保存的信息比 React 元素多得多。简化一下一个 Fiber 节点大概长这样{ tag: 5, // 节点类型0 函数组件、1 类组件、3 根节点、5 原生 DOM 元素 type: div, // 组件函数或 DOM 标签名 key: null, // 协调用的 key child: FiberNode, // 指向第一个子节点 sibling: FiberNode, // 指向下一个兄弟节点 return: FiberNode, // 指向父节点 alternate: FiberNode, // 指向上一棵树里对应的节点 memoizedProps: {...}, // 上次渲染时的 props memoizedState: {...}, // 组件的 state挂载在这里 updateQueue: null, // 待处理的更新队列 lanes: 0, // 优先级标记 flags: 4 // 副作用标记Placement / Update / Deletion 等 }关键是那三个指针child、sibling、return。这不是一颗普通的递归树而是一张单向链表结构的树。为什么用链表因为链表非常适合可中断 可恢复。只要我用一个游标记住当前正在处理哪个 Fiber 节点被打断后从游标位置继续即可不需要递归调用栈——递归栈一旦中断再想恢复就麻烦了。我把 Fiber 理解成施工任务清单上的一个任务项。整个页面是一栋楼要装修清单上每一项写清楚这个房间要贴什么瓷砖、装什么灯施工队可以干一个任务暂停一下回头接着清单往下走。2.3 双缓存机制为什么需要 current 和 workInProgress 两棵树如果翻开 React 源码你会看到两个反复出现的名字current和workInProgress。这是 Fiber 架构非常精妙的设计也是理解整个渲染流程的钥匙。内存里永远至少有两棵 Fiber 树current 树当前屏幕上已经渲染完成、正在展示的 UI 对应的 Fiber 树。workInProgress 树正在构建的、下一版 UI 对应的 Fiber 树本质上是上一棵树的一份草稿。每次更新React 不会直接在 current 树上改而是基于 current 树重新创建或复用节点构建一棵新的 workInProgress 树。这样有两个明显好处第一构建到一半被高优先级任务打断时current 树不受影响。用户屏幕上的内容还是旧的、稳定的不会出现改了一半的半成品页面。等高优先级任务处理完再回到草稿树接着往下做。第二复用节点非常方便。每个 current 树的节点都有一个alternate指针指向它在 workInProgress 树里的对应节点反之亦然。如果这次更新某个节点没变化React 可以直接把 alternate 指向的旧节点搬过来用连重新创建对象都省了。你可以把双缓存理解成你正在用一份定稿文档对外发布同时另起一份草稿做下一版内容。草稿没定稿之前对外展示的永远是旧版。等草稿彻底完成React 会在一瞬间把根节点的 current 指针切换到新树这个动作发生在 commit 阶段。3. 完整拆解 Render 阶段从元素描述到 Fiber 树3.1 beginWork 与 completeWork一次深度优先遍历Render 阶段做的事情可以概括为一句话从根节点开始深度优先遍历逐个创建或更新 Fiber 节点最终生成一棵完整的 workInProgress 树。整个过程由两个核心函数协作完成beginWork处理当前 Fiber 节点。如果这个节点是组件节点就调用组件函数拿到新的 React 元素然后通过reconcileChildren对比旧的子 Fiber生成新的子 Fiber 节点。completeWork一个节点的所有子节点都处理完后轮到它收尾。对原生 DOM 节点来说首次挂载时这里会创建真实的 DOM 实例、把属性填进去更新阶段会标记出需要更新的属性。遍历顺序是这样的从根节点开始做 beginWork如果有child就往下走走到没有子节点的节点后开始 completeWork然后看它有没有sibling有就转向兄弟节点继续没有兄弟节点就回到父节点做父节点的 completeWork。整个过程一直持续到回到根节点workInProgress 树才算完整。还是用装修来比喻你从门口开始查看第一个房间发现这个房间还有套间就继续往套间里走套间看完发现没别的房间了就把这间房的施工要点记录下来然后回头看旁边的兄弟房间直到整层楼全部勘察完毕。以我们的计数器组件为例首次挂载时生成的 Fiber 树结构大致是FiberRoot │ ▼ HostRoot根节点对应页面挂载点 │ child ▼ AppFunctionComponent 类型的 Fiber │ child ▼ divHostComponent │ ├── child ──► span │ └── sibling ──► button每个箭头都对应一个指针。整个 render 阶段结束后React 会拿着这棵树去跟 current 树对比并把差异汇总成副作用链表。3.2 协调与 Difftype、key、props 三方博弈render 阶段最核心的决策是这个 Fiber 节点到底复用还是新建这个决策过程叫协调reconciliation我们常说的 diff 就在这一步。React 的 diff 算法非常务实它不做百之百精确的最小变更计算而是基于三个朴素假设做了大量取舍只对比同层节点不跨层级移动比较。哪怕一个组件从父节点 A 移到了父节点 BReact 的常规 diff 不考虑移动这种操作它的处理方式是旧节点删除、新位置新建跨层级移动造成的重渲染代价极高。key 和 type 决定身份。两个节点如果 key 相同且 type 相同React 就认定它们是同一个节点可以复用这个 Fiber保留 DOM 实例和组件内部 state只要 type 不同哪怕 key 一样也会整棵子树重建。通过 key 优化列表。数组项之间的对比才会用到 key。key 的作用不是提高性能而是帮 React 识别谁是谁。我用一个列表举例这是面试和工作中都绕不开的场景const items [A, B, C] items.map((item, index) li key{index}{item}/li)如果后来在第二位插入了一个新项D变成[A, D, B, C]会发生什么由于 key 是 index原本 index 1 的 B 会收到新 key index 2 对应的位置React 会认为原来的 B 被改变成了 D原来的 C 被改变成了 B最后新增了一个项变成 C。结果就是所有有机会复用 DOM 的节点全部对不上号列表项如果带输入框你还会看到输入内容错位。这就是key 不要用 index这条金科玉律的底层原因。如果 key 用列表项本身的稳定标识比如 id插入后 React 能准确判断 D 是新增的B、C 就该原封不动复用只做一次插入操作性能差异非常明显。3.3 可中断渲染时间切片与 Lane 优先级现在聊 Fiber 最出圈的特性可中断渲染。React 是怎么做到算着算着停下来回头还能接着算的核心在于调度器Scheduler的设计。在 render 阶段React 把整棵 Fiber 树的遍历拆成一个一个小的工作单元——每处理完一个 Fiber 节点就停下来问调度器我还剩下多少时间如果主线程应该腾出去给用户交互或者动画就立刻让出主线程把当前处理到哪个 Fiber 节点的游标保存下来。等浏览器处理完紧急事情再回来从游标位置继续。这个机制叫时间切片time slicing。它的实现并不神秘调度器利用类似MessageChannel的宏任务机制把一个工作单元放进消息队列浏览器执行完当前任务、进入下一帧之前会看到这个待执行的回调于是见缝插针地执行它。执行完一小块后又把下一个工作单元重新入队如此反复。早期方案还用过requestIdleCallback后来因为兼容性和触发频率不稳定被主要实现替代了。再说优先级。React 需要区分这个更新有多着急。用户输入的反馈必须立刻响应而后台收到推送导致的列表更新就可以慢慢来。React 用一套叫Lane的优先级模型来表达这种差别——用一个或多个二进制位lane标记每个更新的紧急程度位越靠前越紧急。渲染任务会带着自己的 lane 值参与调度高 lane 的任务到达时可以打断正在进行的低优先级渲染等它完成后低优先级任务再从中断处继续。但是中断归中断旧版同步模式下的普通setState依然是快速的同步路径。React 18 默认开启并发特性后真正能用上这套调度能力的是startTransition、useDeferredValue这类非紧急更新场景。所以不要再跟同事说Fiber 让所有 setState 都变成异步了这是最常见的误解。4. Commit 阶段不可中断地把变化落盘4.1 三个子阶段分别做了什么render 阶段把该算的都算完了workInProgress 树也构建好了接下来进入 commit 阶段。这段过程有个铁律一旦开始绝不回头。因为这里已经是在操作真实 DOM 了做到一半被打断用户看到的就是一个残缺页面这是不可接受的。commit 阶段在 React 源码里被拆成三个子阶段它们的执行顺序严格固定before mutationDOM 变更之前。主要是给类组件调用getSnapshotBeforeUpdate让组件在 DOM 真正变化前抓取一些信息比如当前滚动位置。mutation真正动 DOM。遍历副作用链表执行插入、更新、删除卸载相关的生命周期在这个阶段执行。layoutDOM 变更之后浏览器绘制之前。类组件的componentDidMount、componentDidUpdate、函数组件的useLayoutEffect在这里同步执行。此时新 DOM 已经在内存里可访问了但浏览器还没真正绘制到屏幕上所以你能在这里同步读到最新的 DOM 尺寸、位置。我习惯把这三个阶段记成动手前、动手时、动手后。4.2 双缓存指针切换与副作用清理前面提到的双缓存在 commit 阶段有个关键动作把树根上的 current 指针从旧树切换到 workInProgress 新树。这个切换发生在 mutation 阶段前后不同 React 版本的位置略有差异但它一定发生在 commit 阶段内。切换之后旧树不再被引用等空闲时间到了会被清理掉新树成为新的 current屏幕上展示的就是最新内容。另外有个很多初学者注意不到的细节副作用的清理cleanup时间点。函数组件useEffect返回的清理函数并不是在当前组件卸载时才执行它会在下一次渲染的 commit 阶段动手前执行同时也会在组件真正卸载时执行。React 源码里有一个专门处理被动 effectpassive effect的流程flushPassiveEffects会在下一次 commit 之前把上一次遗留的被动 effect 全部清空。这也是为什么你在开发模式下看到 useEffect 的执行顺序往往不按常理出牌——因为被动 effect 的调度真的不在你组件代码里而是由 React 在 commit 流程之外找时间异步执行的。4.3 useEffect、useLayoutEffect 的时机差异这两个 hook 再常见不过但能准确说出它们执行时机的开发者比想象中少。useLayoutEffect在layout 阶段同步执行。也就是说 DOM 已经被修改完、但浏览器还没有绘制到屏幕之前。它适合读 DOM 布局、做同步的尺寸调整因为如果在这里改布局浏览器不会经历绘制旧样式再绘制新样式的闪烁。useEffect在浏览器完成绘制之后异步执行。注意这里的异步不只是比 useLayoutEffect 晚而是它根本不会阻塞浏览器渲染。React 的调度器会在 commit 完成、浏览器把当前帧绘制出来后再找时间运行这些 effect。举个直观例子如果组件里用useEffect读取一个刚改成的 DOM 节点高度在某些场景下你会拿到旧值或者读到 0但换成useLayoutEffect一定是新值。反过来如果你在useLayoutEffect里做大量同步计算页面会出现明显的卡一下才显示新界面的感觉因为它在阻塞绘制。业务代码里能用 useEffect 解决的尽量不要碰 useLayoutEffect这个顺序不是我拍脑袋定的而是两个 API 的底层时机决定的。5. 性能排查一次页面卡顿到底卡在哪5.1 用 Profile 定位渲染耗时很多人在项目里抱怨页面卡但卡顿的类型其实并不一样。借助 React 开发者工具里的 Profiler 面板你能看到一次更新里每个组件的渲染耗时。I主要看两个维度整个 render 过程组件函数重新执行、协调耗时。组件之间树形上的耗时占比。某个组件如果占了整棵树的 70%那它大概率是渲染大头。排查时我会带着一个刻板印象问自己render 阶段可中断但大量同步执行还是会造成主线程长时间忙碌commit 阶段虽然不可中断但正常的 DOM 操作量不大时耗时通常很低。如果 Profiler 显示的渲染时间很高问题通常出在组件本身比如props 每次都变导致不该更新的组件也更新了、组件内部做了重度计算、列表巨大且 diff 代价高。如果显示 commit 阶段耗时高那要考虑的是开发模式下警告太多、DOM 结构过于复杂、SVG 或者复杂 CSS 引起的重排重绘。这时纯前端能做的有限需要去服务端或数据层寻找方案。5.2 常见性能陷阱排查这里分享三个我实际排查过的典型情况第一个父组件每次渲染都传入一个新对象字面量function Parent() { return Child config{{ theme: dark }} / }父组件每次 renderconfig都是一个新对象引用子组件如果用React.memo包裹memo 的浅比较会发现 props 变了于是子组件还是跟着渲染。这类问题要用useMemo或把常量提升到组件外。第二个列表更新每次都重建全部项。如果列表项组件没有稳定的 key或者没包 memo局部插入一个元素会导致整列表重渲。优化路径不是缩短 render 时间而是减少参与 render 的组件数量。Fiber 架构在这里的最大价值不是让单个组件算得飞快而是让不需要更新的组件能精确地跳过。第三个动画与高优先级任务互相挤兑。比如一个列表在不断更新同时鼠标移入有动画。列表更新如果是紧急的动画会被打断。合理的方式是给列表更新包一层startTransition告诉 React 这不是紧急工作可以走并发可中断的调度路径这样动画能保持流畅列表慢慢追上最新数据即可。这个方案在 React 18 之后的代码里是真的能落地的。6. 常见问题与排查技巧实录6.1 开发环境组件多跑一次StrictMode 与双渲染这是全社区问过不知道多少遍的问题为什么我的useEffect在开发环境执行了两次为什么组件函数打印了两遍先别急着修代码。如果你的组件外面包了StrictModeReact 19 和 18 的开发模式会故意执行双调用——组件函数被执行两次useEffect 也会走一次安装、卸载、再安装的完整流程。这不是 bug是 React 帮你暴露副作用隐患的手段。比如你写了一个进入页面就请求接口的 effect如果没写清理函数在严格模式下你就能直观看到两个请求发出去这就是在提醒你你的 effect 不是幂等的会埋下隐患。生产环境的构建里这所有额外执行都会被自动去掉。所以看到开发环境多跑一次第一反应应该是看看我的 effect 有没有正确清理而不是去关掉 StrictMode。6.2 列表更新后滚动位置丢失key 与复用这类问题更像一个操作细节列表更新后React 没有复用旧的 DOM 项而是重新创建了它们导致浏览器滚动位置异常或者列表项在视觉上闪一下。原因通常是列表项的 key 不稳定。比如用了Math.random()当 key、或者在数组里用了 index 当 key。每次渲染 key 都不一样React 只能判定所有节点都是新节点旧节点全部删除新节点全部插入滚动位置自然崩了。稳定的 key 应该是业务实体的唯一标识比如id。支撑这个结论的原理正是 3.2 节提到的 diff 规则——key 决定节点身份。6.3 我的三条排查思路实际面对渲染行为不符合预期时我的排查顺序几乎是固定的先打开 React 开发者工具的 Profiler 录制一遍操作确认组件渲染次数和耗时把感觉哪里卡变成具体哪个组件卡。然后检查组件的 key 是不是稳定、父级是否传了必然变化的新对象、有没有在渲染函数里直接创建函数/对象作为 props。这几条命中率极高。最后看 effect 的执行时机如果需要拿到最新 DOM 信息却用了 useEffect改成 useLayoutEffect 试试如果发现项目里大量 useLayoutEffect反过来怀疑它是否是拖慢首屏的元凶。用排除法缩小到具体那一行代码比对着源码猜快得多。做完这些你会发现对 React 的理解已经从写组件提升到了能推理更新过程的层面。这也是 Fiber 架构真正值钱的地方——它把渲染从不可控的递归变成了可调度、可中断、可恢复的计算模型而我们开发者要做的就是顺着它的模型写代码别在它设计的棱角上硬碰。遇到关不掉的双调用、神秘的耗时曲线先回到这篇文章讲的双缓存和两阶段模型里找答案大部分问题其实都有迹可循。