尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

React 源码系统化阅读路线:从 Fiber 到 Hooks 的完整框架

React 源码系统化阅读路线:从 Fiber 到 Hooks 的完整框架 说实话在社区里待久了你会发现一个规律每隔一段时间就会有人立 flag说自己要通读 React 源码。然后大概率两周之后就没有下文了。我见过不少人买了源码解析课程、下载了源码包、甚至打印了几百行注释最后还是卡在useState的实现上。问题不是你不努力而是多数人把读 React 源码当成了一次性把书啃完的线性任务没有给它设主线也没有想清楚系统化三个字到底意味着什么。这篇内容是我自己啃源码几轮之后沉淀下来的完整路径适合前端基础扎实、想真正搞懂 React 运行机制的人尤其适合准备面试或者想自己动手写 mini React 的读者。我会按主线拆开讲第一阶段看什么、第二阶段看什么哪里该打断点、哪里直接跳过不看以及读完之后怎么把源码知识转化成面试和实战输出。你要是能按照这套路线走完一遍就不会再陷入看了就忘、忘了再看的死循环。1. 为什么大多数人读 React 源码都失败了先说一个反直觉的事实读 React 源码失败的人和智商、基础都没太大关系主要死在没有目标感。源码不是一本书它没有一个从第一页读到最后一页的正确姿势。你不设主线就一定会迷路。1.1 三种典型失败路径我把身边人的失败模式归成了三类你可以自己对号入座浅尝辄止型打开 GitHub从packages/react/index.js开始读发现这文件里全是export几行之后跳到ReactElement.js看到createElement的源码感觉还行。然后再往下读到react-reconciler直接被 Fiber 相关的一大堆类型定义劝退关掉编辑器再也没有打开过。耗时型很认真逐行读做了大量笔记。但读了三个星期还在ReactFiberLane.js里研究那些位运算。记住了SyncLane是几号但完全不知道这个 lane 在运行时是怎么影响渲染的。这种努力值得敬佩但路径效率太低。面试突击型背了一堆概念比如Fiber 是一个工作单元双缓存树diff 是 O(n) 复杂度。面试官追问一句那 setState 之后第一个被调用的函数是什么立刻卡住。因为他只记住了结论没有看过调用链。这三类人的共同问题是把读源码理解成了读代码而不是理解成完成几个具体任务。1.2 把源码阅读当成项目来做我的建议是把读源码当成一个有明确交付物的项目而不是一次阅读行为。每次阅读之前先问自己一个问题——我要搞清楚什么现象背后的机制比较有效的拆解方式是把 React 的运行时拆成几条主线首次渲染JSX 是怎么变成页面上的真实 DOM 的更新渲染setState之后界面是怎么变的Hooks 机制useState的数据存在哪为什么不能写在条件语句里并发调度React 是怎么做到高优先级任务插队的每一条主线都足够读一两周但都比漫无目的通读源码强得多。读完之后你能回答从 API 调用到 DOM 更新中间到底经过哪几个函数这样的问题才算这一条线过关。我自己第一遍读源码目标就定得非常窄只跟进setState这条链路其他东西统统不碰。等这条链路走通了再去看 Hooks、再去看调度就不会有挫败感反而会越读越顺。2. 阅读前的准备选对版本和看懂目录比读代码更重要很多人死在起点是因为准备工作没做好。这里说的准备不是装环境而是三件容易被忽略的事版本选择、目录地图、目标清单。2.1 版本怎么选如果你不是要做 React 源码贡献我强烈建议读React 18.2.0不要一开始就冲最新版。原因有两个。第一React 18 是一个相对稳定的版本大量社区文章、课程、面试题都是基于 18 写的你遇到不懂的地方很容易找到对照资料。第二React 19 之后源码有一些结构性调整比如并发特性进一步内建、react-dom/client的入口变化等对初学者来说等于在迷宫里又加了几堵墙。注意用git clone拿到源码后先切到 taggit checkout v18.2.0。直接读 main 分支对你没有好处。调试阶段我会建议你直接用react18.2.0和react-dom18.2.0的 development 构建。开发版的代码保有原来的函数名和注释打断点的时候看调用栈函数名都还是完整可读的比 production 版友好太多。2.2 源码目录地图React 仓库是一个 monorepo里面有几十个包但不是每个都需要读。我按重要程度排一下目录 / 包作用阅读优先级packages/react-reconciler协调器核心Fiber、调度、diff 都在这里最高80% 的精力在这packages/react-dom浏览器端的 host 配置负责 DOM 增删改查中看入口和 commit 阶段即可packages/react公共 API 出口createElement、Children等低随用随查packages/scheduler时间片调度中后期再看packages/shared公共工具函数忽略也就是说你读源码的主战场其实是react-reconciler。react-dom里绝大多数代码是处理 DOM 事件的不用细读。2.3 你需要的前置知识读源码之前有几个基础知识最好先熟悉否则会在细节里卡住链表数据结构Fiber 树的兄弟节点、子节点连接Hooks 的链式结构都依赖链表。不需要你能手写链表但要看得懂while (node ! null) { node node.next }这种遍历方式。位运算基础React 用二进制位来表示优先级lane合并、移除、判断都要用位运算。你至少得知道、|、~是在干什么。递归思想beginWork和completeWork的上下楼关系本质是深度优先遍历。构建流程的基本认识知道源码是用 Rollup 打包的理解__DEV__这种环境变量标记但不需要自己从头构建一遍。提示如果你连上面的数据结构都觉得很陌生说明现在还不是读源码的时候。先去刷一个月 LeetCode 链表题回来事半功倍。2.4 给这次阅读定一个交付目标打开代码之前把目标写下来。比如我第一遍的目标是画一张从createRoot到首次渲染完成的关键函数调用链路图。这个目标有三个好处可衡量——画得出图就算完成倒逼理解——画图需要你搞清楚每个函数的输入输出可复用——这张图之后讲面试、带新人、写博客都用得上。准备阶段做完可以正式开始了。3. 第一条主线从 createRoot 到屏幕上的第一个像素第一条主线建议走首次渲染也就是ReactDOM.createRoot(...).render(App /)到页面出现内容的完整链路。这条线覆盖了 React 运行时的骨架其他所有功能都是在这个骨架上长的。3.1 入口函数与两个 root的区别从入口开始你很快会遇到第一个坑源码里有两种 root。createRoot返回的是一个ReactDOMRoot对象在ReactDOMRoot.js里但这个对象本身不是 Fiber 树的一部分它只是暴露了render和unmount两个方法给你调用。当你调用root.render(element)时React 内部会发生一次关键的初始化创建FiberRootNode这是整个应用的根容器保存全局状态比如containerInfo真实 DOM 节点、当前树指针current、过期时间相关字段等。创建HostRootFiber这是 Fiber 树的根节点tag 是HostRoot。它的stateNode指向FiberRootNode。建议你在这里停下来打断点看一下createRoot之后FiberRootNode和HostRootFiber已经建立好对应关系但这时候还没有任何子节点。真正的子节点是在render调用之后才加上去的。3.2 render 阶段从上到下的 beginWork 和从下到上的 completeWork接下来进入核心链路。跟调用render之后代码会走到updateContainer然后经过scheduleUpdateOnFiber转到performConcurrentWorkOnRoot同步模式会走performSyncWorkOnRoot最终进入你必须要记住的两个函数renderRootSync或renderRootConcurrent和workLoopSync。workLoopSync的代码很短核心逻辑是一个 while 循环function workLoopSync() { while (workInProgress ! null) { performUnitOfWork(workInProgress); } }它的意思是只要还有没处理完的 Fiber 节点就一直处理。performUnitOfWork内部会做两件方向相反的事beginWork从上往下深度优先遍历。进入一个节点时根据fiber.tag做不同处理。对函数组件会调用组件函数拿到子节点对HostComponent会标记需要创建真实 DOM。completeUnitOfWork处理完子节点后从下往上返回。在completeWork中HostComponent会真正调用createInstance创建 DOM 节点并通过appendAllChildren把子树节点挂到父节点下。我用生活化类比解释一下beginWork 是你走进迷宫时在每个路口做决定决定往哪走completeWork 是你从终点往回走在退回来的路上把一路上的墙壁和门牌都装好。两趟动作合起来才完成一棵完整的 Fiber 树的构建。3.3 commit 阶段把 Fiber 树变成真实 DOMFiber 树构建完之后renderRootSync会把构建好的树交给commitRoot。commit 阶段动手改真实 DOM过程可以分成三个阶段before mutation 阶段处理 DOM 变更前的准备工作比如类组件的getSnapshotBeforeUpdate。mutation 阶段执行真正的 DOM 增删改。你在completeWork里创建的 DOM 节点在这个阶段被插入到containerInfo里。layout 阶段执行useLayoutEffect、componentDidMount等需要读取真实布局的副作用。首次渲染跑完这条链路屏幕上出现内容第一条主线就算走通了。为了验证你真的读懂了可以回答这几个问题beginWork主要是构建什么completeWork里对HostComponent做了什么commit 阶段哪个函数真正把 DOM 挂上去了3.4 这一条线的实操建议调试这条线的时候把断点打在ReactFiberWorkLoop.js的workLoopSync、beginWork和completeWork上。每执行一步在控制台打印workInProgress你会看到 Fiber 节点的 tag、type、return、child、sibling 字段是怎么变化的。这个动作非常有用。等你亲眼看到beginWork把函数组件执行了一遍、completeWork里createInstance创建了对应 div 节点你对 Fiber 的理解就会从抽象概念变成可以触摸的实体。4. 第二条主线setState 之后的世界Fiber 调和机制首次渲染通了之后第二条主线看更新链路。这条线能回答一个几乎所有面试都会问的问题setState之后到底发生了什么。4.1 setState 的幕后起点以函数组件的useState为例你调用setState(1)实际进入的是dispatchSetState。它会做三件事创建一个update对象把新状态塞进去。把update加到当前 fiber 的updateQueue环形链表中。调用scheduleUpdateOnFiber。注意到这里你只是把更新排进了队列组件函数还没重新执行。这是个很重要的认知setState不是立刻改掉memoizedState而是进了队列等调度。然后scheduleUpdateOnFiber会一直往上走到 root请求调度。后面会重新进入workLoop走一遍和首次渲染类似的过程。区别在于这次beginWork会遇到已经存在的组件它会走update分支重新执行组件函数拿到新的 JSX再通过reconcileChildren对比新旧节点。4.2 双缓存与 alternate更新这条路线上最值得花时间研究的是双缓存机制。源码里每个 Fiber 节点都有一个alternate字段指向它的替身。React 内部同时维护两棵树current树上一次渲染结果对应屏幕上看到的内容。workInProgress树本次更新正在构建的新树。更新开始前React 会调用createWorkInProgress复制一棵current的替身树作为workInProgress。所有 beginWork 和 completeWork 都在workInProgress上进行不动current。等整棵树构建完毕commitRoot时直接把current指针切到workInProgress上。这就是 React 官方所说的双缓冲策略。它的价值在于如果这次更新中途被更高优先级任务打断workInProgress直接被丢弃重建就行current树还是完整的屏幕不会闪、不会缺内容。这种牺牲内存换稳定性的做法是 Fiber 架构相对旧 Stack 架构最大的优势之一。4.3 diff 的具体策略一层层比较绝不跨层更新链路中最有趣的部分是 diff。React 在beginWork的更新分支里会调用reconcileChildren而下层真正做比较的是reconcileChildFibers在ReactChildFiber.js里。diff 的核心策略看源码可以归纳成几条只对同一层级做比较reconcileChildFibers只处理一个 fiber 的child链表不会递归去跨层级找节点。如果某个节点从第一层挪到了第二层React 不会尝试移动它而是销毁重建。单节点 diff新子节点只有一个时会比较 key 是否相同、type 是否相同。满足就复用旧 fiber只更新 props不满足就删除旧节点新建新节点。多节点 diff新子节点是数组时分两轮处理。第一轮遍历旧的链表按 key 找能复用的节点找到就移动并更新第二轮处理剩下没有匹配到的旧节点以及新增的节点。第二轮里有一个优化逻辑会用lastPlacedIndex判断节点是否真的需要移动能省则省。这一块的代码非常值得精读。读的时候你会发现 React 团队为了减少不必要的 DOM 操作做了大量细节优化比如key 相同但 type 不同直接删掉重建的判断兜底逻辑就藏在reconcileSingleElement里。4.4 从源码理解为什么要写 key很多人知道 list 渲染要写 key但说不清不写的后果是什么。读了 diff 源码你就懂了没有 key 时React 只能按 index 一一对应。你在数组中间插入一项后面所有节点都会被判定为需要更新它们的真实 DOM 会被逐个修改如果节点里有受控输入框还会出现状态错乱的问题——因为 React 复用了旧的 fiber却把新数据塞了进去。有了稳定 key 之后map的第一轮就能精确定位每个旧节点插入操作就变成 O(1) 的新建一个节点 其他地方不动。我建议你亲手做一个实验写一个 1000 行的列表分别用 index 和 id 作为 key在 React DevTools Profiler 里对比 commit 耗时。数据结构一变性能差异一目了然。注意key 必须在兄弟节点中保持唯一不需要全局唯一。这条更新链路读完你对React 的调和reconciliation就不再只是背书上的概念而是能说清楚它依赖reconcileChildFibers和createWorkInProgress具体做了什么。5. 第三条主线Hooks 挂在 Fiber 上的完整链路Hooks 是面试重灾区也是源码里设计感最明显的一部分。useState、useEffect背后不是魔法而是靠一套链表和 dispatcher 分发机制。5.1 Hook 对象与链表结构打开ReactFiberHooks.js你会看到 Hook 的数据结构type Hook { memoizedState: any, baseState: any, baseQueue: Updateany, any | null, queue: UpdateQueueany, any | null, next: Hook | null, };每个 Hook 对象都挂在 fiber 的memoizedState上多个 Hook 通过next串成一个单向链表。useState的memoizedState保存的是状态值useEffect的memoizedState保存的是 effect 对象。也就是说不同 Hook 的 memoizedState 里装的东西语义不同但存储结构是一样的。这个设计暴露了一个关键含义Hook 之间没有名字、只有位置。你写useState再写useEffect本质是在链表的头部不断插入/追加节点React 是沿next顺序一路走。每次渲染React 按照相同的顺序重新读这条链表。5.2 mount 与 update 的 dispatcher 切换Hooks 源码里有一个非常优雅的设计renderWithHooks会根据当前是初次挂载还是更新给ReactCurrentDispatcher.current赋不同的 dispatcher 对象。首次渲染时赋值的是HooksDispatcherOnMount里面useState指向mountState更新阶段赋值HooksDispatcherOnUpdateuseState指向updateState。你直接在源码里搜mountState和updateState对比一下就知道mountState主要做初始化、把 hook 加进链表、返回dispatch而updateState内部实际上是调用updateReducer——因为useState在更新阶段本质是一个简化版useReducer。这就解释了一个现象为什么你在函数组件里直接写useState每次渲染都能拿到最新的状态因为updateReducer会取出挂在hook.queue上的更新链按顺序计算出一个新的memoizedState然后返回给你。5.3 updateQueue 与环状链表useState的更新队列在源码里是一个环形链表。queue.pending指向链表的最后一个节点pending.next指向第一个节点。为什么不直接用一个普通数组因为更新会来自不同优先级的事件需要快速合并、去重、按 lane 排序链表更适合这种场景。每次dispatchSetState触发都会把新 update 节点挂到这条环形链上。下次组件重新渲染时updateReducer从queue里取出 pending 链逐个处理跳过优先级不够的最终算出baseStatenewState。5.4 从源码解释为什么 Hooks 不能写在条件语句里这个问题是判断一个人有没有真读过源码的分水岭。因为 Hook 靠next位置匹配React 在更新阶段要用旧 fiber 的 Hook 链表逐个对应新 Hook 的调用顺序。updateWorkInProgressHook里有一条关键逻辑它会在旧链表上找next在新链表上靠「你当前这个 Hook 在源码中被调用的第几次」去匹配。如果第一次渲染if (a) { useState() }执行了第二次渲染if (a)不成立少调用了一个 Hook新链表就比旧链表短了。React 拿新 Hook 去next遍历发现对不上号就会在 DevTools 里给你报一句著名的错误Rendered fewer hooks than expected. 或者 Rendered more hooks than expected.所以你可以这样跟面试官解释Hooks 的约束不是语法层面的约定而是 React 运行时存储结构的必然结果。它没有用 key 或者下标去标记每个 Hook用的是调用顺序。顺序一旦改变匹配就失败。5.5 并发下的 lanes 优先级如果你读到第 5 章还有精力可以顺手看一下ReactFiberLane.js。React 18 用 lane车道模型表达更新优先级一个 32 位的整数中每一位是一条 lane。低优先级更新和高优先级更新进入不同的 lane调度器在合并更新时用位运算快速比较、取交集。这一块不建议第一遍深挖。你只需要建立认知setState产生的更新带 lane调度器按 lane 排序高优先级任务可以打断低优先级任务。等你在实际项目中遇到低优先级渲染被推迟的卡顿再回头看ensureRootIsScheduled体会会深很多。6. 一套可以抄作业的调试方法源码读不懂很大程度是因为没有调试技巧。很多人在编辑器里搜函数名看代码脑补执行顺序回过头来还是不知道发生了什么。我的方法比较笨但极其有效直接让浏览器中断执行亲眼看看当前工作栈上有什么。6.1 断点位置清单按我推荐的阅读顺序以下位置最适合打断点断点位置观察目标ReactFiberWorkLoop.js的workLoopSync看到整个 render 阶段的工作循环观察workInProgress的推进ReactFiberWorkLoop.js的beginWork看到每个 fiber 进入时的 tag、type能区分FunctionComponent/HostComponentReactFiberWorkLoop.js的completeWork看到 DOM 节点创建、插入的过程ReactFiberWorkLoop.js的commitRoot看到 render 阶段结束、commit 阶段开始ReactFiberHooks.js的mountState/updateState看到 Hook 链表如何初始化/更新ReactChildFiber.js的reconcileChildrenArray看到多节点 diff 的具体分支实操时我建议不要加太多条件断点直接在workLoopSync进两次然后在beginWork上打断点。每次命中时在 Console 里敲一句console.log(workInProgress.tag, workInProgress.type);就能看到都是从哪个组件节点经过的了。6.2 带注释精读法我的一个笨办法是把最关键的几个文件下载到本地建立自己的注释副本。比如ReactFiberWorkLoop.js、ReactFiberHooks.js、ReactChildFiber.js每看一个函数就在旁边写一行中文注释记录这个函数在什么时候被谁调用、返回了什么。不要小看这个工作量它会让记忆深度完全不同。你读一遍源码记不住写 100 行注释基本就把一条调用链刻在脑子里了。之后面试、写文章、做分享直接翻注释副本就行了。GitHub 上不少高星笔记就是这么来的。6.3 工具组合拳除了打断点我还推荐三个辅助手段React DevTools Profiler录制一次渲染点开某个 Fiber 的耗时再对应源码里那个阶段用来验证你对哪个阶段最耗时的猜测。Performance 面板看主线程上的 Task能直观看到 render 阶段与 commit 阶段各自占用的时间。源码里的__DEV__警告开发版里会触发很多console.error、invariant 报错报错信息里通常会带上函数名和 fiber 信息是天然的调试线索。6.4 什么不值得读同样重要的是知道哪些代码可以跳过。第一遍读源码建议跳过react-dom里的全部事件系统SyntheticEvent——它很庞杂和核心 Fiber 机制关系不大。scheduler包里的MessageChannel调度细节——等读第二遍再看。ReactFiberLane.js里所有 lane 分配规则的具体数字——先用概念理解别背。测试代码和ReactFiberConfig里不同 host 环境的差异代码。跳过这些你大概能少读一半内容把时间留给真正的主干链路。7. 把源码能力翻译成面试和实战输出读源码本身不是目的读完之后能输出才是。很多人读完源码觉得好像懂了但说不出那是因为没做翻译。下面是几个高频场景的落地方案。7.1 面试高频题的源码级回答我整理了三个最常被问的问题用源码证据来回答面试官会明显感受到你有没有深挖过。问题一为什么 React 列表要写 key源码依据在reconcileChildrenArray。React 先按 key 遍历旧 children匹配上就复用 fiber没有 key 就只能按 index 对应导致节点错位、重复渲染、输入状态串数据。所以 key 的意义是帮助 diff 更精确地复用节点。问题二为什么 setState 看起来是异步的源码依据在dispatchSetStatescheduleUpdateOnFiber 事件系统里的batchedUpdates。状态更新不是立即修改memoizedState而是先进入updateQueue等待调度。React 会在批处理机制内合并多个更新然后统一走一次渲染。你在一个事件处理器里连续 setState 三次只会触发一次 commit但在setTimeout或原生事件里批处理失效18 之前所以表现不同。18 以后batchedUpdates覆盖更广行为更统一。问题三useEffect 什么时候执行源码依据在 commit 阶段的三个子阶段。useEffect属于 passive effect它的执行发生在 layout 阶段之后被异步调度不会阻塞浏览器绘制。而useLayoutEffect在 layout 阶段同步执行会阻塞绘制。这就是你在页面上能直接观察到的两者区别。问题四Fiber 是什么一句话版本是Fiber 是一个工作单元是 React 自己实现的一种虚拟栈帧用来替代传统递归遍历 VDOM 的过程使渲染可以被拆分、中断和恢复。 展开版本可以说它的字段设计return指向父节点、child指向第一个子节点、sibling指向兄弟节点这组指针让深度优先遍历变成可暂停/可恢复的迭代。这套话术你直接从源码里能撑起来。7.2 手写一个 mini React如果你想更极端地输出我强烈建议在读完上面几条主线后自己动手写一个 mini React。不需要实现并发调度也不需要支持所有生命周期只需要实现 4 个核心函数createElement生成虚拟 DOM 节点。render把虚拟 DOM 转成真实 DOM挂到容器上。reconcile比较新旧虚拟 DOM找出需要增删改的部分。commit把变更应用到真实 DOM。写 mini React 的过程本质是把你在源码里看到的所有抽象概念重新走一遍。等你写出一个能跑通的列表渲染再回头看reconcileChildrenArray会有一种原来这段代码我早就认识了的感觉。提示写 mini React 时不要照抄源码。用你自己的逻辑实现一遍就好写完了再去对照官方实现你会发现差距和最优解所在。7.3 长期维护源码阅读能力的三点体会源码阅读不是一次性的它跟所有技能一样会生锈。给三点建议按需回查遇到一个诡异 bug比如组件不更新、Effect 多次触发第一时间想源码里这一条应该走哪条分支然后回查对应函数。源码是排查问题的终极参考比任何博客都准。跟进版本变化读新版 release notes 时回源码里找对应改动。比如 React 18 的自动批处理、React 19 的useOptimistic都值得回到源码看实现。形成输出闭环读完一个模块写一篇短文或录个小视频讲一遍。能讲清楚才是真的读完了。最后分享一个我自己的节奏第一遍读源码花了大概三周每天 2 小时只走通了 setState 这条链路还把workLoopSync到commitRoot的调用链图重新画了三遍。第二遍是两个月后因为手写 mini React 卡在 diff 上回头精读了reconcileChildrenArray这次只用了一周。第三遍是在研究 React 19 新特性时按需翻了useTransition和useOptimistic的实现。三遍加起来我对 React 的认识才算真正立体起来。源码这东西从来不是读完一次就毕业而是读一次有一次的理解边界。希望这套系统化路线能帮你少走我走过的弯路。
返回列表