
1. 项目启动入口文件里的两个关键角色不管你是用 Create React App、Vite 还是 Next.js 初始化项目也不管项目结构差了多少React 应用的起点几乎都长一个样——一个index.js或main.tsx一段createRoot加render的调用。我第一次带团队从 Vue 切到 React 时组里新人问得最多的问题就是这两行代码到底干了什么为什么 Vue 只需要new Vue({ el: #app })一行React 却要搞两个步骤先看一段最普通的入口代码import React from react; import { createRoot } from react-dom/client; import App from ./App; const container document.getElementById(root); const root createRoot(container); root.render(App /);拆开来看createRoot做的事情是在浏览器里注册一个“React 根”这个根和document.getElementById(root)这个普通 div 不是一回事。root是 React 内部管理的一个容器结构它记住了 DOM 容器是谁、当前挂载的 Fiber 树是哪棵、有哪些待处理的更新任务。而root.render(App /)才是真正把App /这个元素交给 React 去调度渲染的动作。有人可能会问为什么不直接ReactDOM.render(App /, document.getElementById(root))那是 React 17 及以前的写法。React 18 之后把“创建根”和“渲染内容”拆成两步本质上是为并发渲染铺路——createRoot创建的根默认开启了并发特性React 可以在渲染中途被打断、恢复、丢弃而不是像旧版那样一旦开始就必须一口气执行完。这一步拆分是理解 React 18 之后所有渲染行为变化的基础也是面试里常被追问的“为什么”之一。还有一个容易被忽略的细节App /这里并不是在创建 DOM它只是一个描述“我想渲染什么”的 React 元素对象。元素是一个普通的 JavaScript 对象形如{ type: App, props: {}, key: null, ref: null }。真正把元素变成页面上可交互的 DOM要经过后面两大部分的流水线处理。我们常说的“React 渲染流程”其实指的就是从root.render()被调用开始到屏幕上出现可交互界面为止的全过程。2. 初次渲染React 内部在忙着什么2.1 三大角色调度器、协调器、渲染器React 源码里对渲染流程有个经典划分调度器Scheduler、协调器Reconciler、渲染器Renderer。这三者各司其职又通过 Fiber 结构串成一条流水线。调度器负责决定“什么时候干活”。它维护着一个任务队列根据任务的优先级决定先执行哪个更新、哪些可以延后。浏览器的主线程是单线程的React 不能长时间霸占它否则用户点击、滚动都会卡住。调度器通过requestIdleCallback或自己实现的MessageChannel机制把一个大的渲染任务拆成多个小片每片执行完就把控制权还给浏览器等下一帧空闲了再接续。协调器负责计算“DOM 应该长成什么样”。它拿到 React 元素之后会去比较新旧两棵 Fiber 树找出差异并标记出需要增删改的节点。这个过程就是我们常说的 ReconciliationReact 15 之前用的是栈结构递归遍历React 16 之后改成了 Fiber 链表结构支持中断恢复。渲染器负责把协调器计算出的结果“落到具体平台”。在浏览器里就是react-dom它负责创建真实 DOM 节点、设置属性、绑定事件、执行插入删除。在 React Native 里则是react-native-renderer落到的是原生视图。你在业务代码里不会直接接触渲染器但它决定了 React 的跨端能力边界。这三个角色配合起来一次从root.render()到页面显示的完整过程在 React 内部被划分为两个大阶段render 阶段也叫协调阶段和 commit 阶段。render 阶段可以被打断commit 阶段则必须一次性同步完成——因为 DOM 的增删改一旦做了一半被中断页面会处于不一致的中间态用户会看到闪屏或错乱内容。2.2 Fiber 节点到底长什么样理解 Fiber 是理解整个渲染流程的钥匙。Fiber 不是虚拟 DOM 的另一个叫法它是 React 内部用来组织工作单元的数据结构。每个组件实例、每个 DOM 元素在 React 内部都会对应一个 Fiber 节点。你可以把它想象成工地上的一块施工牌——上面写着这块区域当前是什么状态、接下来要做什么活、做完之后找谁继续。一个 Fiber 节点上挂载了这些关键字段{ tag: HostComponent, // 节点类型函数组件、类组件、原生标签、文本节点等 key: item-1, // 用于 diff 的标识 type: div, // 对应的元素类型 stateNode: domNode, // 对应的真实 DOM 节点或组件实例 return: parentFiber, // 指向父节点 child: childFiber, // 指向第一个子节点 sibling: siblingFiber, // 指向下一个兄弟节点 lanes: 1, // 这个节点上的更新优先级 alternate: currentFiber, // 指向旧树上的对应节点 memoizedProps: {...}, // 上次渲染时的 props memoizedState: {...}, // 上次渲染时的 statehooks 链表也挂在这 updateQueue: null, // 状态更新队列 flags: 0, // 副作用标记是否需要插入、更新、删除 subtreeFlags: 0, // 子树副作用汇总 }注意return、child、sibling这三个字段Fiber 树不是用递归调用栈表示的而是用这三个指针连成的一条链表。父节点通过child指向第一个孩子这个孩子通过sibling指向它的兄弟所有节点通过return找到自己的父节点。React 遍历这棵链表时用循环加指针移动代替了函数递归调用这就是它能做到“随时暂停、记住位置、之后继续”的根本原因——函数递归调用栈一旦被中断恢复成本很高链表则只需要记住当前走到哪个节点了。初次渲染时 React 会构建一棵完整的 Fiber 树这棵树叫current树代表当前屏幕上已经显示的内容。同时React 会在内存里再构建一棵workInProgress树这棵树是本次更新要产出的新状态。两棵树的节点通过alternate字段互相引用。这种双缓存机制保证了即使渲染过程被中断用户看到的永远是完整的一棵树不会闪烁。2.3 render 阶段从根节点遍历到叶子root.render(App /)被调用后React 第一件事是把App /这个 React 元素放进一个更新队列然后开始performConcurrentWorkOnRoot。这个过程从根 Fiber 开始深度优先遍历整棵组件树对每个节点执行“协调”逻辑。协调的核心是一个叫beginWork的函数。它拿到当前 Fiber 节点根据tag字段判断这是函数组件、类组件还是原生 DOM 标签然后做不同处理函数组件调用组件函数传入props拿到返回的 React 元素再对子元素调和类组件实例化组件如果还没实例化调用render方法拿到元素后继续调和原生标签创建对应的 DOM 节点在 render 阶段不会真的插入页面继续调和子节点调和的本质是“用 React 元素去对比 Fiber 子节点”。以初次渲染为例App函数执行后返回了divHeader /Content //div这样的元素结构React 便创建对应的 Fiber 节点挂到当前节点的child指针上然后继续往下走。一直走到最底层的文本节点比如页面上一个spanHello/span里的Hello字符串React 会为它创建文本 Fiber 节点。整个遍历顺序是“先深度后宽度”从根节点深度优先进入第一个子节点处理完这个子节点的整棵子树后再通过sibling指针回到兄弟节点继续处理。处理完一个节点并标记好副作用后completeWork会被调用它主要负责构建 DOM 节点的属性、注册事件、收集子树副作用到父节点上。这就是为什么函数组件内部不能随便写副作用代码——render 阶段可能被中断、重复执行如果里面有document.title xxx这种操作React 中断一次就执行一次页面状态就被反复污染了。真正需要副作用的地方只能放在useEffect或事件回调里。2.4 Commit 阶段Fiber 树变成真实 DOMrender 阶段结束后workInProgress树已经完整构建所有需要操作的节点都打上了flags标记比如Placement代表需要插入、Update代表需要更新属性、Deletion代表需要删除。接下来进入 commit 阶段这个阶段由commitRoot驱动分三个子阶段执行。第一个子阶段是before mutation。React 会遍历整棵 Fiber 树执行getSnapshotBeforeUpdate生命周期方法。注意这个阶段发生在 DOM 被修改之前所以还可以读取到旧的 DOM 状态比如旧的滚动位置。React 17 之后还在这里处理useEffect的清理函数注册——等一下useEffect的副效应是在这个阶段标记为待执行而不是立即执行。第二个子阶段是mutation。React 再次遍历 Fiber 树这次是真正操作 DOM 的时候。执行插入、删除、更新属性调用componentWillUnmount和useLayoutEffect的清理函数。为什么useLayoutEffect在这里执行而useEffect不在这里因为useLayoutEffect的语义是“在浏览器绘制前同步执行”它会影响视觉必须赶在浏览器 paint 之前完成。第三个子阶段是layout。React 会调用componentDidMount、componentDidUpdate、useLayoutEffect的回调函数。到这里整个 commit 流程走完workInProgress树正式切换为current树代表“屏幕上现在显示的就是这棵树”。页面上已经能看到内容了React 在 commit 阶段收集了一批需要异步执行的useEffect回调。浏览器完成绘制后React 通过调度器一次性执行这些回调。这就是useEffect和useLayoutEffect的本质区别一个在绘制后异步执行一个在绘制前同步执行。如果在useEffect里读取 DOM 的布局信息比如offsetHeight经常拿到旧值就是因为代码执行时浏览器已经 paint 过了。2.5 一个组件从元素到页面的完整旅程把上面这些串起来用App /里的一个简单组件举例完整流程是这样的root.render(App /)调用React 创建更新进入调度调度器在浏览器空闲时启动 render 阶段beginWork走到 App 这个函数组件节点调用App()返回div元素React 为这个div创建 HostComponent Fiber 节点继续向下调和子元素对每个后代元素重复步骤 3-4构建出整棵workInProgress树completeWork为每个原生标签节点创建真实 DOM 实例设置属性但还不挂载render 阶段结束“模拟施工完成”workInProgress树准备就绪commit 阶段启动before mutation、mutation、layout三连mutation 阶段把构建好的 DOM 树一次性插入到#root容器页面第一次出现内容current树指针切换到workInProgress浏览器 paintpaint 完成后执行useEffect回调这中间每一步都是面试里可能往深里挖的点。比如第 9 步“一次性插入”React 是把整棵 DOM 树都建好之后才插入而不是边遍历边插入。这也是为什么初次渲染通常比逐节点手动操作 DOM 更快的原因之一——减少了浏览器的回流和重绘次数。3. 更新渲染一次点击背后的完整闭环3.1 setState 之后发生了什么初次渲染讲完接下来是更常见的场景——用户点击了一个按钮组件内部调用了setState。这一步启动的流程和初次渲染大体相同但多了几个关键环节标记更新、调度更新、diff 比较。setState或者函数组件里的dispatch本质上是往当前 Fiber 节点的updateQueue里追加一个更新对象。这个对象包含了action你要设置的新值或函数和lane优先级。之后从当前 Fiber 节点开始向上回溯到根节点把沿途所有节点的lanes字段更新一遍表示“我的子树里有更新待处理”。这个过程叫“标记更新”。它做得很轻量只是打标记没有真正计算新状态。真正的计算要等 render 阶段执行到那个组件时才做。这也是 React 性能好的原因之一——你连续调用三次setStateReact 不会渲染三次而是把三个更新对象都放进队列在下一轮 render 时一次性合并处理。打个比方你让厨房里的三个厨师分别告诉你今天想吃红烧肉、想吃糖醋鱼、想吃清蒸虾如果每个人说完厨师就立刻做一次菜那得做三顿。React 的做法是让他们都写在点菜单上最后汇总成一张菜单厨师一次性把三盘菜都端上来。批量更新这个行为在 React 18 之前只会在事件处理函数里自动生效React 18 之后通过自动批处理连setTimeout、Promise 回调、原生事件监听器里的更新也一并合并了。3.2 函数组件为什么每次都重新执行一遍很多初学 React 的人遇到过这样的怪现象父组件里定义了一个普通函数传给子组件当 prop每次父组件更新子组件也跟着更新明明子组件的 props 值看起来没变。原因就是——函数组件每次渲染都是从头到尾把函数体重新执行一遍新生成的函数引用和上一次不相等。这也是热搜词里“React 为什么每次都返回一个 render 函数”这个问题的出处。回答是重新执行组件函数是 React 计算新状态的手段。函数组件本身是一个“输入 props输出 元素”的纯函数React 需要重新执行它才能知道在最新状态下 UI 应该长什么样。如果跳过执行React 就没有办法拿到新的 UI 描述。组件函数重新执行后useState要回答一个关键问题如何找到“上一次”的状态答案就藏在 Fiber 节点的memoizedState字段里。useState返回的当前值不是存在闭包里而是存在组件对应的 Fiber 节点的memoizedState上。每次渲染时 React 按 hooks 的调用顺序从memoizedState链表中依次取值。这也解释了为什么 hooks 不能写在条件语句里。如果第一次渲染时useState被调用三次形成了一个三节点的 hook 链表第二次渲染时条件变化导致只调用两次React 就会在取值时错位——第二个useState拿到的是第三次调用时的值整个组件状态就乱了。还有那个著名的 “Rules of Hooks” eslint 报错就是为了防止这个。3.3 diff 算法如何高效复用节点render 阶段开始后beginWork会执行新旧子节点的对比这个对比就是 diff。React 的 diff 算法不是业界最强最精准的理论上可以用最长递增子序列做到最少的移动次数但它追求的是在保证可接受性能的前提下让算法足够简单。diff 的大原则有两条不同类型的元素产生不同的树。比如div变成spanReact 直接认为整棵子树都作废旧节点全部销毁重建新树通过key来匹配同一层级的孩子节点。没有 key 时用 index 顺序比较有 key 时能精准判断节点是移动还是新增这两条原则在日常开发里对应的实操建议是列表渲染一定要给稳定的 key不要用数组 index。原因很直接——如果你在列表头部插入一项用 index 做 key 会导致所有后续项的 key 都改变React 会认为整个列表都变了全部重建。而实际上只有一项是新增的其余全是移动位置。用不上 key 的正确性极端场景是列表项内部有本地状态比如 input 输入框的文本key 一变React 就销毁重建这个组件输入框的内容就丢了。用 index 做 key 不会丢内容但会有多余的 DOM 操作性能差一些。因为是双缓存机制diff 过程是在新旧两棵树之间进行的。对每个子节点React 会看 oldFiber 是否存在、key 是否相同、type 是否相同。如果三者都匹配就复用旧的 Fiber 节点只更新 props如果 key 不同就认为这个节点不存在了走插入。整个 diff 过程属于 render 阶段所以同样可以被中断配合优先级调度React 能在高优先级任务插入时丢弃正在进行的低优先级 diff先处理紧急的交互。3.4 优先级与并发渲染React 18 带来的改变聊到更新流程绕不开 React 18 的并发渲染。React 会根据更新的类型赋予不同的lane优先级SyncLane最高优先级比如点击事件、输入事件里的更新需要立即渲染DefaultLane普通更新比如网络请求完成后 setState 的数据IdleLane最低优先级比如用户离开页面后的数据同步当一次低优先级更新正在 render 阶段执行时用户突然点击了一个按钮触发了高优先级更新React 会怎么做它不会等低优先级任务执行完而是直接中断它丢弃当前已经做到一半的workInProgress树先处理高优先级更新等那边完成了再回来重新做低优先级任务。这里最典型的例子是搜索框输入。假设你有一个输入框每次输入都会触发搜索请求结果回来后会渲染一个长列表。如果用户快速输入了“ab”第一次搜索的请求还没返回第二次搜索的已经返回了。在非并发模式下React 会把两次结果依次渲染用户可能看到旧结果的闪烁。并发模式下React 会取消旧结果的渲染只显示和当前输入匹配的新结果。并发渲染不是让你能“并行执行 JS”它只是在“让出主线程”和“可中断渲染”这两件事上做了机制性改进。主线程还是只有一个但 React 通过把大任务拆小在每一片任务执行后检查是否有更高优先级的任务插队从而让用户交互总是能及时响应。理解了这一点再去读 React 源码里那些shouldYield、IsThisHostSchedule的逻辑就会顺很多。4. 常见问题与性能排查看这里4.1 启动白屏怎么排查“React Native 启动白屏”和“React 网页首屏白屏”在实际工作中是高频问题。如果是 Web 项目首屏白屏可以从这几个方向排查入口文件是否报错。最常见的是createRoot(container)时container为 null比如 script 标签放在了 head 里、DOM 还没解析完就去获取节点。解决办法是把 script 放到 body 底部或者加defer属性是否有阻塞渲染的长任务。如果入口文件里在render之前做了一个同步的耗时代码比如读取 localStorage 大对象并解析页面就处于白屏状态。可考虑用Suspense加React.lazy拆分首屏逻辑是否有未捕获的运行时错误。React 的错误边界Error Boundary能捕获渲染错误但如果入口顶层没有错误边界报错会导致整棵树卸载页面直接白屏CDN 上的 JS 资源加载失败。浏览器控制台看 Network 面板如果主 bundle 请求挂掉React 根本没机会执行到render在 React Native 里白屏问题往往和 JS Bundle 的加载、原生视图初始化时机、新架构的渲染引擎切换有关。排查思路也类似先确认 JS 层有没有报错再看原生层有没有日志最后检查是否到了AppRegistry.registerComponent这一步。4.2 组件重复渲染和死循环的定位React 里最让人头疼的问题之一就是“无限渲染循环”组件不断触发更新控制台刷屏页面直接卡死。常见原因有几个在 render 阶段直接调用了setState。函数组件执行过程中如果调用了dispatchReact 会立刻安排新一轮渲染渲染又执行了dispatch死循环就形成了useEffect里setState而这个setState每次都会生成一个新对象导致useEffect的依赖数组每次都变。比如useEffect(() { setState({ count: 1 }) }, [obj])如果obj的引用每次渲染都不同effect 会反复执行父组件每次渲染都useMemo返回新数组子组件的useEffect依赖了这个数组定位这类问题最快的方式是用 React DevTools 的 Profiler 面板查看是哪一层组件触发了更新更新链路是什么。如果看到某个组件在火焰图里呈循环阶梯状通常就是上面三类情况之一。生产环境里还可以加一个“渲染次数统计”的 hook超过阈值就打印告警方便远程排查。4.3 常见渲染问题速查表实际项目中会遇到的渲染相关问题远比教科书里的多这里整理一张速查表现象可能原因排查方向页面首屏白屏入口报错、脚本阻塞、资源加载失败查看控制台报错、Network 面板列表渲染卡顿缺少稳定 key、列表项组件过重检查 key 使用拆分列表项组件输入框打字卡顿每次渲染都执行大量计算高优先级更新被阻塞用useMemo缓存计算结果组件莫名重新挂载key 不稳定或每次渲染都变检查父组件传给列表项的 key切换路由后组件状态丢失组件被卸载没有保持状态考虑用useRef存跨渲染状态或用状态管理库持久化useEffect 重复请求接口依赖数组里放了一个不稳定引用检查依赖项是否每次渲染都变化所有组件全部重建根部组件状态变化导致整棵树 re-render组件拆分、React.memo、useMemo 配合使用更新延迟、点击后界面无响应低优先级渲染长时间占用主线程查找大列表渲染、复杂 diff考虑虚拟化4.4 一些关于渲染性能的使用心得React 的渲染性能优化网上有一大堆文章讲useMemo、React.memo、useCallback但我在实际项目中体会最深的一个原则是先测量再优化。不要在没有任何性能问题的时候盲目给每个组件套memo那样只会增加内存占用和代码复杂度。先用 DevTools Profiler 录制一次操作看火焰图里哪个组件的渲染时间占比最高再针对性地处理。如果确实需要优化优先级从高到低是减少渲染的节点数。能条件渲染的不要用display: none隐藏能拆分的懒加载就懒加载减少不必要的 re-render。组件拆分到合适粒度把状态放到离使用位置最近的组件里避免状态提升过高导致大范围 re-render稳定引用。useCallback和useMemo要用在依赖不稳定会导致下游大量更新的地方尤其是把函数传入被memo包裹的子组件时还有一点很多人忽略React 开发环境下的性能和生产环境差别很大。开发模式里 React 会做额外的类型检查、双重渲染严格模式、更详细的警告这些都会拖慢渲染速度。你拿开发环境的性能数据去判断线上性能结论往往是错的。要测性能一定要用生产构建最好再开上 React DevTools 的“Highlight updates when components render”直观看看哪些组件在更新时被高亮。5. 一个完整的调试案例误用 index 导致的渲染抖动把前面这些零散的知识串起来分享一个我最近在实际项目里处理的真实问题。一个后台管理系统报表页面有个可排序的表格组件同事反馈“表格排序后偶尔会闪一下而且排序速度快时还会出现内容跳变”。一开始怀疑是 CSS 动画问题后来用 React DevTools 高亮更新发现排序的瞬间整个表格组件的所有行都重新渲染了。顺着代码往下查发现问题出在表格渲染列表的 key 上。同事写的是key{index}排序操作会改变数据数组中元素的顺序但 index 是固定的所以每一行的 key 都没变。React 做 diff 时发现 key 全部一致就直接复用了所有行的 Fiber 节点只更新 props。理论上这不算错为什么会出现闪动和跳变因为表格里每行组件内部维护了一些本地 UI 状态比如行展开状态和编辑框的输入值。排序后响应新 props 的渲染和旧状态的残留并不匹配部分行展示的内容和它们的状态不一致视觉上就表现为“闪”和“跳”。修法其实很简单把 key 从index改成每一行数据里的唯一idReact diff 就能正确识别出行是移动而不是原地更新本地状态也跟随着真正移动的节点走。类似的小问题还有很多比如在useEffect里请求接口却在数组末尾追加数据导致key看着稳定但实际内容错位比如用Math.random()当 key每次渲染都是新 keyReact 把整棵子树全重建一遍。这些问题背后都是没有把“React 是怎么做 diff 的”这个底层逻辑理清楚。我在实际排查这类问题时发现最快的方式不是盯着代码看而是先在浏览器里打开 React DevTools 的 Profiler录制一次出问题的操作然后对火焰图里某个组件点右键查看它是为什么更新的——是 props 变了、state 变了还是父组件渲染导致的。定位到具体组件后再往代码里看一般很快就能找到根因。这比我早年用 console.log 一行一行打日志要高效得多。这台 React 执行流程的“机器”其实并不复杂它的核心就三件事一套 Fiber 树承载组件状态和 DOM 信息两条阶段流水线render 和 commit处理新旧状态切换一个调度器控制任务执行的时机和优先级。把这三件事想明白了无论是排查白屏、处理重复渲染还是设计组件拆分都会有一种“低处看海”的清晰感。