资讯动态

从JSX到DOM:React Fiber渲染流程全解析

发布时间:2026/10/15 13:32:51 来源:尧图企业网站定制
先别急着往下滑先回想一个问题你在业务里写的那一堆 JSX到底是怎么变成浏览器里那些实在的 DOM 节点的很多人能说出“虚拟 DOM”“diff”这些词但一被问“Fiber 是什么”“render 阶段和 commit 阶段分界在哪”就开始含糊。本文就只讲一条线从你写下一个div开始到页面真正更新React 内部到底走过了哪些站。这条路线适合所有用过 React 的前端不管你是 React 18 的并发特性用户还是刚从入门教程出来理解这条链路后你对 hooks、性能优化、稀奇古怪的 bug 的认知会上一个台阶。1. 从 JSX 到 React 元素开胃菜不是 Fiber1.1 JSX 不是模板引擎它只是编译器按规则调用函数很多前端第一次看到 JSX 都觉得它像 HTML还有人会下意识拿它跟 Vue 的模板、Python 的 Jinja 做类比。但实际上 JSX 既不是字符串也不是模板语言它在编译阶段就会被打成普通的 JavaScript 函数调用。比如你写的const element div classNameboxHello/div;在 React 17 之前Babel 会把它编译成const element React.createElement( div, { className: box }, Hello );从 React 17 开始官方引入了新的 JSX 转换。编译产物不再依赖 React 变量处于作用域内所以实际编译结果为import { jsx } from react/jsx-runtime; const element jsx(div, { className: box, children: Hello });如果你在源码里搜索过jsx函数会发现它就是createElement的简化版本。关键差别在于createElement需要接收 key、ref 等参数而jsx通过 props 对象中的属性直接透传。换句话说JSX 最终产出的不是 DOM而是一个普通的对象这个对象就叫 React 元素。很多面试题喜欢问“小写组件标签和首字母大写的组件标签有什么区别”答案也在这个编译环节div会被编译成字符串div而MyComponent会被编译成变量引用MyComponent。Babel 就是靠这个规则判断“这是个原生 DOM 标签”还是“这是一个组件函数/类”。1.2 React 元素一个只做描述的普通对象jsx函数返回的 React 元素有自己的标准形状。拿上面的element来说实际结构大致是const element { $$typeof: Symbol.for(react.element), type: div, key: null, props: { className: box, children: Hello }, ref: null };需要注意几个点。$$typeof是一个 Symbol 标记用来防止一些故意构造的 JSON 对象被当作 React 元素类似于 XSS 防护机制。type在上面是字符串如果换成自定义组件type就是函数或类本身。props里会存所有的属性包括children。子节点也会被解析成 React 元素对象所以如果 JSX 里有嵌套结构你实际拿到的就是一个嵌套的多层普通对象树。很多人把“虚拟 DOM”直接理解为 React 元素树严格来说不够准确。React 元素树只是某一次渲染时对界面的描述快照它是不可变的一旦创建就不能再改。Fiber 节点才是 React 运行时的“活的工作单位”。你可以把 React 元素理解为一张图纸Fiber 则是工地上的工人、材料和进度表。图纸可以反复生成但真正动手干活的是 Fiber。1.3 为什么JSX 编译这一步跟渲染流程强相关理解了 JSX 到元素的转换你才明白 React 渲染流程的第一站其实极其无聊它只是递归地把 JSX 变成一个对象树。这个对象树会传递给后续的协调过程。如果在第一步你就对children做了奇怪的手工修改比如在 JSX 的 props 里塞数组、搞字符串拼接这些到最后都会被jsx处理为children字段再由后面的 reconciler 读取。另外children可以是一整个数组React 在协调阶段会把它当作一个多节点列表来 diff。这就是为什么你总能看到类似“请给列表项添加一个稳定的 key”这样的警告。所以从源头上说JSX 只是创建元素的语法糖真正的革命性工作从 React 元素交给 Fiber 调度器才开始。2. 从 React 元素到 Fiber 树架构切换的关键2.1 为什么老架构必须被推翻早期的 React 用的是 Stack reconciler处理更新时就是一层层递归同步地把整棵组件树一次性 diff 完再更新。这种方式简单但它有一个致命问题递归一旦开始函数调用栈就一路压到底中间想停就停不了。假设某个列表渲染需要 35ms而这 35ms 刚好集中在一帧的中段用户的点击、输入、滚动就全被卡住。页面出现掉帧、卡顿原因就是这个主线程被“不可中断的递归”占满了。Fiber 架构把可中断性视为第一优先级。它用一个新的“工作单元”概念替代了函数调用栈这个单元就是 Fiber 节点。你可以把旧架构比作“搬家公司一次把所有家具搬到新家搬完才能停下”新架构是“先把每件家具贴上标签、编号搬了几件如果累了就放下记录进度下次接着搬”。React 元素只是“家具清单”Fiber 才是那个“贴在每件家具上、能记录搬到哪里”的便签。2.2 Fiber 节点的真面目一门链表数据结构作为一个前端Fiber 最值得先记住的不是它的所有字段而是三条指针return、child、sibling。它们把非线性组件树变成了一条可以被线性遍历的链表这样整个“递归”就变成了循环。type Fiber { type: any; key: null | string; stateNode: any; // 树结构关系 return: Fiber | null; // 父节点 child: Fiber | null; // 子节点 sibling: Fiber | null; // 兄弟节点 index: number; // 在兄弟节点中的位置 // 和更新相关 pendingProps: any; memoizedProps: any; memoizedState: any; // 副作用 flags: number; subtreeFlags: number; // 调度优先级 lanes: number; childLanes: number; // 双缓存 alternate: Fiber | null; };这里我建议你亲自在调试器里跑一个最小 Demo打断点在某个组件的渲染函数里然后展开return/child/sibling看看。你会看到组件树其实是一棵用链表串起来的二叉树状结构。alternate字段尤其重要它指向旧的 Fiber 节点。“双缓存”就是靠它实现的——旧的叫current新的叫workInProgress这两个树通过alternate互相引用。2.3 初始挂载时的工作流程当你调用createRoot(container).render(App /)时React 会先创建一个特殊的根 Fibertag 是 HostRoot。这个根 Fiber 的 DOM 容器就是页面里的那个div idroot。接下来它会进入 render 阶段从根 Fiber 发出一条遍历链路。流程是这样的React 拿着从render()里得到的 React 元素树把它交给 reconciler。reconciler 从 HostRoot 开始逐个调用每个 Fiber 的协调逻辑。处理一个 Fiber 时读取它的 React 元素然后根据组件类型决定怎么生成它的子 Fiber。如果该元素是App/它就是一个 FunctionComponent 类型的 Fiberreconciler 会调用App()得到下一层 React 元素再为下一层元素创建子 Fiber。直到整棵 React 元素树都被转换成对应的 Fiber 树。所以说React 元素树是一次渲染的描述Fiber 树才是这次渲染真正的工作清单。这个从元素到 Fiber 的过程名字叫reconcileChildren。它是整个 render 阶段最核心的动作之一。3. render 阶段从 beginWork 到 completeWork 的一次旅行3.1 工作循环与“递”“归”两个动作进入 render 阶段后React 不会靠函数递归栈来跑完全程而是自己维护一个workInProgress节点的引用。每次从当前 Fiber 开始执行一个叫performUnitOfWork的动作这个动作内部又拆成两个成语beginWork递和completeWork归。你之前如果看过什么“蚂蚁进洞又出来”的比喻那是把 beginWork 理解为进入子树前completeWork 理解为回来时。实际遍历规则叫“深度优先、先子后兄”先从根向下进入第一个子节点如果没子节点就回退到父节点再处理兄弟节点。用伪代码可以写成这样let nextUnitOfWork workInProgressFiber; function workLoop(deadline) { let shouldYield false; while (nextUnitOfWork !shouldYield) { nextUnitOfWork performUnitOfWork(nextUnitOfWork); shouldYield deadline.timeRemaining() 5; } }看到没关键在于nextUnitOfWork是一个全局持有的引用循环在每次迭代之间随时可以被外部打断。这就是 Fiber 可中断的核心原因——不是靠恢复函数调用栈而是靠把工作单元存在内存里恢复时从上次那个节点继续遍历。3.2 beginWork按组件类型分岔路beginWork 收到一个 Fiber 节点后会按照它的tag分支处理。FunctionComponent 就调用这个函数拿到新的 React 元素ClassComponent 会实例化或更新实例并调用render()HostComponent 则判断是否需要更新 DOM 属性。它最核心的工作是调用reconcileChildren。这个函数对比当前 Fiber 上的旧子 Fiber来自current树和新 React 元素里的子元素然后决定是复用旧的 Fiber还是新建一个 Fiber。为什么key很重要因为 Diff 算法判断两个子节点能不能复用的条件一般是key 相同 节点类型相同如果 key 变了即使类型一样也不会复用而会销毁重建。这里有个很实际的坑很多人喜欢用数组的下标作为 key。如果列表只是静态展示问题不大但只要列表顺序一变React 会误以为所有子节点都换了位置导致原本不该重建的组件全部重建甚至引发输入框 state 丢失。Fiber 里的index字段就是用来记录在兄弟节点里的下标而被 diff 时它会和 key 配合使用。3.3 completeWork归途上把副作用挂上当child为空或者当前节点的子节点都处理完遍历就走到 completeWork。对原生 DOM 类型的 FibercompleteWork 会创建真实 DOM 节点或者把新的 props 更新到已有 DOM 节点上。ClassComponent 则可能在这里触发生命周期。FunctionComponent 本身没有独立 DOM所以它的 completeWork 通常只传递子树结果。更重要的是completeWork 会把当前 Fiber 的flags和subtreeFlags冒泡给父节点。这里的flags记录着“这个子树需要插入 DOM、更新属性、删除节点”等副作用。为什么需要subtreeFlags因为 commit 阶段要从 Fiber 树根一路往下找哪些节点有Placement、Update、ChildDeletion这些标记如果没有subtreeFlags就得遍历整棵树才能找到所有要动 DOM 的地方效率会很差。现在通过在 completeWork 阶段向上冒泡commit 阶段可以做到“跳过一整片没有变化的子树”。3.4 中断、恢复与 bailout为什么并发渲染不会白干也许你听过 render 阶段会被更高优先级任务打断。那被打断之后之前做的 workInProgress 树不就废了吗这里有两个关键机制兜底。一是工作单位是粒度很小的 Fiber 节点中断只能发生在一个节点处理完之后、下一个节点开始前所以不会出现改到一半的 DOM。二是 React 在每个节点都做了“性能优化”beginWork 时会判断当前 Fiber 的props和state是否变化。如果发现没有必要更新并且子组件也没有更新请求它可以直接“跳过”这个子树不重新协调它的子节点。这也就是bailout机制。所以高优先级任务来了被打断的低优先级 render 进度并不会完全浪费。已经被 completeWork 的节点就固定在 workInProgress 树上了恢复时直接继续从nextUnitOfWork往下走。真正浪费的至多是被打断节点之后的那些尚未 beginWork 的部分。这也是为什么 React 开发者经常建议你把组件拆分得尽量小——拆分粒度越细bailout 和中断恢复的收益就越明显。4. 调度器与 Lane 优先级可中断的真正幕后4.1 谁在决定“什么时候开工”React 内核里其实拆了好几个包。很多人以为全部是 react-dom 在驱动其实驱动“何时工作”的是scheduler包它是独立于 React 本身的通用调度工具。它不是用requestIdleCallback因为早年这个 API 兼容性和触达频率都不行。现在更常用MessageChannel来模拟宏任务配合setTimeout做降级。处理一次点击引起的setState时react-dom 会先创建一个更新任务扔进 scheduler。scheduler 根据优先级、过期时间排出先后决定什么时候开始进入 render 阶段。如果当前浏览器帧剩余时间还多就稍微延后一点如果一帧快画完了就等下一帧的空隙再跑一段 render。这里有一个概念需要分清调度器决定“什么时候干”协调器决定“干什么活”。render 阶段做的事属于协调器commit 阶段做的事则属于 react-dom 的 commit 逻辑但二者都需要调度器的时序安排来触发。4.2 Lane 模型用二进制位给每个更新排座次React 18 开始用了一组二进制位叫 Lane来表示不同更新的优先级。为什么不是简单的 number 1-10因为在一棵 Fiber 树上不同节点可能带着不同的 Lane 集合用二进制位可以同时标记多条更新路径而且做位运算很快。简单理解点击按钮产生的是默认同步优先级startTransition里放的更新是 Transition 优先级路由切换、首次渲染可能对应更高优先级。当多个更新同时存在时scheduler 会先处理高 Lane 对应的任务高 Lane 任务还能打断低 Lane 任务。你会不会担心低优先级任务被无限饿死React 里每个 Lane 都有过期时间。一旦低优先级更新接近过期它会被“饿死保护机制”提升到一个必须尽快完成的优先级。所以最终不会有更新永远完不成但它可能会因为被多次打断而晚一些才反映到界面上——这正是并发渲染的取舍。4.3 时间切片主线程怎么“吐”出口香糖刚才的workLoop里有一个关键判断deadline.timeRemaining()。每次 performUnitOfWork 处理完一个 Fiber 节点调度器都会看时间片还剩多少。如果剩余时间不足一个阈值就暂停循环把控制权交还给浏览器让浏览器去画帧、处理输入事件。等下一帧的空隙再继续执行。这种做法就是“时间切片”。一帧通常约 16.7msReact 一般不会霸占整整一帧它会留出空隙。你可以把主线程想象成一块口香糖同步渲染是直接一口闷时间切片则是嚼一下、停一下、再嚼一下。页面因此能始终保持流畅交互视频不掉帧输入不卡顿。这也是并发特性最直观的用户体验收益。但是需要注意并非所有更新都会被切片。如果是 legacy root 下同步的setStateReact 会直接同步执行完整个 render 和 commit不给你切的机会。React 18 的createRoot默认开启并发但同步优先级更新依然会被高质量完成不会被打断以保证用户操作的高响应性。4.4 双缓存让页面最终一步到位双缓存的核心是current树和workInProgress树。首次渲染时React 创建一份全新的workInProgress树commit 后它变成current树。之后某次更新React 会拿current树的对应 Fiber 作为模板克隆出一份workInProgress树在克隆出来的树上做各种 Diff、属性计算直到整棵树准备好了再一次性切换根节点的current指针。这个alternate字段就是连接两棵树的桥梁。如果你在 DevTools 里看 Fiber 对象的alternate会发现它总是指向另一个版本的自己。双缓存的最大好处是如果 render 阶段被打断了用户看到的依旧是旧的current树对应的界面只有workInProgress那个“未完成版本”被保留在内存里等中断恢复后继续完善。只有到了 commit 阶段新旧指针才交换界面才真正更新。5. commit 阶段真正的 DOM 操作不能中断5.1 三个小阶段before mutation、mutation、layout很多人学到 Fiber 以为就结束了其实 render 只是“算出来要改什么”commit 才是“真的去改”。React 把 commit 阶段拆成了三个子阶段依次执行。第一个是BeforeMutation阶段。这里会处理getSnapshotBeforeUpdate生命周期ClassComponent 可以在 DOM 即将变化前拿到当前 DOM 的快照。对普通函数组件来说这个阶段通常没什么动作。第二个是Mutation阶段。React 会遍历 Fiber 树里带有Placement、Update、ChildDeletion标记的节点执行真实的 DOM 插入、删除、更新。比如某段文字从 “0” 变成 “1”就是在这个阶段更新 textContent。这也是 commit 阶段危险的地方因为 DOM 一旦动了就不能反悔所以 Mutation 阶段必须是同步、不可中断的。如果这里被中断用户会看到一半新界面、一半旧界面那才是真正的视觉灾难。第三个是Layout阶段。类组件的componentDidMount、componentDidUpdate在这里调用useLayoutEffect 的清理函数和回调也在这里同步执行。为什么叫 Layout因为此时 DOM 已经变了可以同步读取布局尺寸了。紧接着 React 会安排被动 effect 的运行也就是我们熟悉的useEffect它通常放在后续的调度里异步执行不阻塞浏览器绘制。5.2 flags 如何驱动 commit 找到目标在 commit 阶段React 不需要重新遍历每个节点去问“你要不要更新”。因为 render 阶段已经在 completeWork 时把所有要做的副作用记录在flags和subtreeFlags上了。举个极简例子一个组件树有 1000 个节点但只有其中一个按钮的文字变了。completeWork 时React 会给那个按钮对应 Fiber 打上Update标记然后把这个标记向上冒泡最终根 Fibers 的subtreeFlags里包含Update的位。commit 阶段从根节点往下走看到某个子树副作用的subtreeFlags不包含需要处理的标记就整棵跳过。这就是 React 为什么能快速定位需要修改的真实 DOM 节点不用每次更新都递归比较整棵 Fiber 树。5.3 用点击按钮的案例串起完整流程我们把前面所有概念串成一个具体场景。页面上渲染一个显示数字的p和一个按钮点击按钮执行setCount(c c 1)。点击后setCount创建出一个更新对象进入组件的更新队列。React 调度器根据当前事件的优先级默认高优先级对应同步 Lane发起调度。进入 render 阶段后从根 Fiber 出发经过 beginWork 一路向下。走到Counter /这个 FunctionComponent Fiber 时React 执行Counter()函数重新得到新的 React 元素树。接着 reconciler 对比旧 Fiber 和新的元素发现p的 props 里文本变了就给p对应 Fiber 打上Update标记。继续向下到p的原生节点completeWork 把这个标记冒泡上去。当整个 workInProgress 树完成后commit 阶段开始Mutation 阶段根据标记找到p对应的真实 DOM 文本节点把数字从 0 改成 1。Layout 阶段执行useLayoutEffect随后异步执行useEffect。最后浏览器重绘用户看到“1”。而这一切真正的高潮在于中间的 render 阶段是有可能被打断的。比如用户点击按钮后马上又滚动了页面滚动事件的高优先级传入React 可以暂时放下尚未完成的文本更新先处理完滚动相关的更新再回来把数字改掉。从用户视角来看滚动一点都没卡数字在某一帧悄悄变了。6. 常见问题与排查技巧实录6.1 函数组件渲染了两次不是 bug是并发重放调试并发特性时经常发现某个组件在 DevTools 里被render 了好几次。这是 bug 吗大概率不是。React 18 的并发渲染下低优先级更新可能被打断并重新开始而每次重新开始都会重新调用组件函数。所以一次点击后render 可能被调用两三次但最终 commit 只发生一次。真正需要担心的是你在渲染函数里写了副作用比如直接改全局变量、发请求。在这种并发重放场景下副作用会被重复触发带来真实 bug。这一条是排查并发相关问题的第一把钥匙检查 render 函数的副作用。6.2 key 的坑比想象中要多另一个我无数次看到的故障是列表 key 使用数组的下标。当列表从[A, B, C]变成[B, A, C]时带有稳定 key 的 Fiber 会被复用只是节点顺序变了用 index 时React 会认为原本的 A 还在第一个位置B 在第二个只是 props 的内容变了。于是可能出现组件内部 state 被错误复用比如一个输入框里的内容被带到下一个行。排查这类问题最快的方法是给列表项加个在删除、排序场景下依然稳定的业务主键。如果确实没有唯一字段可以考虑生成一个留存于数据中的 ID。总之key的职责是帮助 Fiber 判断“谁是谁”而不是提供列表顺序。6.3 useEffect 和 useLayoutEffect 执行时机的差异React 文档里的表格写着useEffect 是异步的useLayoutEffect 是同步的。但你要真想把这两者放进渲染流程里理解就看它俩对应的 commit 阶段时机。useLayoutEffect 在 commit 的 Layout 阶段同步执行此时 DOM 已经完成变更浏览器还没绘制所以如果你的副作用是修改样式、测量尺寸又不希望用户看到闪烁就得用 useLayoutEffect。useEffect 则是在 commit 阶段结束后被调度到浏览器稍后的空闲时间执行。因此页面先更新再运行 effect期间用户完全感知不到你这么做的过程。如果某个 effect 修改了 DOM 导致视觉跳变多半是 useEffect 的晚执行引起的。6.4 用 React DevTools 观察 Fiber 渲染过程React 官方开发工具虽然不是用来逐个看 Fiber 内部对象的但它的 Profiler 面板可以记录一次点击的 render 和 commit 耗时展示哪个组件被重新渲染、渲染了多久。你可以在面板里看到两条竖线分别代表 render 阶段和 commit 阶段再结合组件的why did you render方法可以很快定位那些被无谓 re-render 的大组件。如果你想把源码级细节调出来在 Chrome 打断点后查看fiber.alternate、fiber.memoizedProps、fiber.flags会非常直观。但不要试图背源码理解“链表、双缓存、优先级、三个 commit 阶段”这几个关键词后看源码会顺畅很多。6.5 渲染死循环与性能问题排查一个老生常谈的问题是 setState 放在 component body 里导致无限循环。现在你从 Fiber 流程角度看render 阶段执行到 FunctionComponent 时重新调用了组件函数如果这个函数里又 setState就会再触发一次调度又进入 render 阶段。这跟浏览器里的死循环不一样它不一定立刻卡死页面但 commit 永远不会到界面持续不更新CPU 也居高不下。类似地一个超大列表如果每次点击都导致整棵列表 Fiber 重建性能也不会好。这时可以把列表项用 memo 包裹让 React 在 beginWork 阶段发现 props 没变触发 bailout跳过子树的协调。React 有很多这样的“偷懒机制”前提是你得知道它的存在。7. 我的几点实践心得研究 React 渲染流程最大的感受是不要试图一次性把所有源码细节都塞进脑子。我建议你把这件事拆成三轮。第一轮只记三句话JSX 出对象Fiber 出链表commit 出 DOM。第二轮再看 beginWork、completeWork、flags、alternate能够在 debugger 里认出它们。第三轮再去追 Lane 模型和时间切片这时候你已经不需要死记因为它们是从“可中断渲染”这个目标自然推导出来的。实操上我强烈建议你搭一个最小的 React 项目只渲染一个计数器然后在node_modules/react-reconciler或react-dom的源码里找beginWork和commitMutationEffects打断点。打断点不是让你去背调用栈而是观察一次点击后nextUnitOfWork怎么从根一路走到叶子再回头看 DOM 节点是什么时候变的。这个过程比读任何文章都直观。最后分享一个小技巧遇到任何“渲染不对、状态丢失、性能卡顿”的疑难 bug先别急着看业务代码先在纸上画出当前组件树的 Fiber 关系标出哪些节点可能被复用哪些节点会被卸载再去对照 DevTools 的 Profiler。八成的问题在你还原出这条链路的时候自己就有答案了。毕竟从 JSX 到 Fiber 这条路你走得越熟排查 bug 时就越有自己的底。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑