前几天有个同事跑过来让我帮他看面试复盘,面试官问了他一个问题:从你在终端敲下npm run dev那一刻起,到浏览器里看到页面渲染完成,React 中间到底经历了哪些步骤?他支支吾吾说不太清楚,只记得大概有个 VDOM,然后 diff 一下就渲染了。这个场景太典型了——很多人写 React 业务写了一两年,组件、Hooks、状态管理都玩得很熟,但真要体系化地把渲染链路讲明白,还是容易卡壳。
这篇文章就跟大家把这条链路从头到尾捋一遍。我不打算只停留在“JSX 转成虚拟 DOM,然后 diff”这种面试八股层面,而是从入口函数开始,一步步走到最终的 DOM 绘制,把初始化、调度、协调、提交这几个关键环节都拆开看,顺带把 Fiber、双缓存、生命周期执行时机、React 和 Vue 渲染思路差异这些高频考点一起讲透。无论你是准备 React 面试,还是已经在项目里遇到白屏、卡顿、组件不更新这类问题,这篇文章都能给你一个完整的排查视角。
1. 项目启动:一切都是从 createRoot 开始
1.1 createRoot 初始化阶段到底做了什么
现在 React 18+ 的项目标准入口基本长这样:
import { createRoot } from 'react-dom/client'; import App from './App'; const root = createRoot(document.getElementById('root')); root.render(<App />);很多同学觉得createRoot只是老 API 的换壳,真正的渲染逻辑在render里。实际上createRoot这一步远没有表面看起来那么简单。它会在内部调用createContainer,创建两个非常核心的数据结构:一个是FiberRootNode,另一个是HostRootFiber。
FiberRootNode是整个 React 应用的“总调度中心”,它不直接对应任何一个组件,而是维护了全局的优先级信息、当前调度任务、挂载的容器 DOM 元素等。HostRootFiber则是整棵 Fiber 树最顶端的根节点,你可以理解成所有组件的“祖先”。这两个结构通过current指针关联起来,FiberRootNode.current指向HostRootFiber。
用个比喻帮你理解:FiberRootNode像公司的总经理办公室,它不干具体业务,但所有工作都要汇总到它这里;HostRootFiber像公司的董事长席位,整个组织树的第一把交椅。createRoot这步完成后,页面上其实还没渲染任何内容,因为还没有任何“任务”被派发下去。真正触发流程的是下面要讲的render调用。
1.2 JSX 是如何变成 React Element 的
在createRoot完成之后,root.render(<App />)里的<App />并不是浏览器能直接识别的 JavaScript 语法,它是 JSX。JSX 本质上是一层语法糖,最终会被构建工具里的 Babel 编译掉。
React 17 之前,Babel 默认会把 JSX 编译成React.createElement调用:
// 你写的代码 const element = <h1 className="title">Hello React</h1>; // 编译后 const element = React.createElement('h1', { className: 'title' }, 'Hello React');React 17 之后引入了新的 JSX transform,编译产物变成了jsx或jsxs:
// 编译后 import { jsx } from 'react/jsx-runtime'; const element = jsx('h1', { className: 'title', children: 'Hello React' });不管你走哪条编译路径,最终得到的都是一个普通的 JavaScript 对象,也就是我们常说的 React Element。这个对象长这样:
{ $$typeof: Symbol(react.element), type: 'h1', // 原生标签是字符串,组件则是函数/类 key: null, ref: null, props: { className: 'title', children: 'Hello React' } }这个结构非常关键。type字段决定了后续 Fiber 节点如何被处理,如果是'h1'这种字符串,说明是原生 DOM 标签;如果是一个函数,说明是函数组件;如果是一个 class,说明是类组件。props里的children是子节点的核心载体,组件树就是靠这个字段保持嵌套关系的。
1.3 首次更新是怎么排进队里的
createRoot只完成了静态初始化,直到调用root.render才会真正触发渲染。这一调用的内部链路大致是:updateContainer→ 计算更新优先级 lane →scheduleUpdateOnFiber→ensureRootIsScheduled→ 根据环境决定走同步渲染还是并发渲染。
为什么要绕这么大一圈?因为 React 在 18 里引入了一个非常重要的机制——调度器 Scheduler。浏览器的主线程只有一个,它既要执行 JavaScript,又要处理用户交互、布局、绘制。如果 React 的渲染计算一下子占用主线程几百毫秒,页面就会卡住,用户滚动鼠标、点击按钮都没反应。Scheduler 的作用就是给不同的更新任务分配优先级,高优先级的先执行,低优先级的可以在时间片不够时主动让出主线程。
root.render传进去的<App />会先被包装成一个 update 对象,挂在HostRootFiber的updateQueue上,然后进入调度流程。这里多说一句,React 18 里root.render使用并发特性,但同时你也可以用flushSync强制同步执行,两种模式的入口不同,最终都会走performConcurrentWorkOnRoot或performSyncWorkOnRoot,区别在于能不能被中断。
2. 协调阶段:Fiber 树的构建与输出
2.1 为什么 React 要把组件树改成 Fiber 链表
这是面试里最高频的问题之一:Fiber 到底是什么?解决了什么问题?
在 React 15 时代,协调(reconciliation)走的是栈调和器,递归遍历组件树。递归的执行方式是一口气到底、不可中断的,一旦开始就只能执行完,中间不能暂停。如果组件树很深,一次渲染耗时会很长,用户输入、动画都会被阻塞,页面就开始掉帧卡顿。
React 16 开始重写了这一套,用 Fiber 替代了原来的递归栈。Fiber 不是虚拟 DOM,而是“带调度信息的工作单元”。每个组件都会对应一个 Fiber 节点,这些节点不是简单的树结构,而是通过child、sibling、return三个指针连成的链表结构。
一个 Fiber 节点内部会保存很多关键信息:
tag:节点类型,函数组件、类组件、原生标签、Fragment 等key:Diff 时用来复用节点的标识type:节点的真实类型,用于后续创建实例stateNode:对应的真实实例,函数组件里可能就是 null,类组件是实例,原生标签是 DOM 节点child:指向第一个子节点sibling:指向下一个兄弟节点return:指向父节点alternate:指向另一棵 Fiber 树中对应的节点,这是双缓存的基石lanes:该节点参与更新的优先级memoizedState:函数组件里保存 Hook 链表的地方flags:标记这个节点需要做什么操作,比如 Place、Update、Deletion
链表结构的好处在于,React 可以在处理完一个 Fiber 节点后,看看时间是否充足,不够就挂起,把控制权交回浏览器,等下一帧再接着上次的进度继续。这就是“可中断渲染”的技术基础。
2.2 beginWork 到 completeWork:一次完整的遍历
协调阶段的核心目标是生成一棵新的 Fiber 树,同时给每个 Fiber 节点打上更新标记。这个阶段的入口是renderRootSync(同步模式)或renderRootConcurrent(并发模式),内部会调用workLoopSync或workLoopConcurrent不断处理 Fiber 节点。
这个遍历不是简单的前序遍历,它分了两个动作:beginWork和completeWork。beginWork是“向下”的过程,针对当前 Fiber 节点,根据它的tag执行对应的更新逻辑。比如函数组件会执行函数体拿到返回的 React Element,类组件会执行render方法,原生标签会处理 props 和事件。拿到新的 children 之后,调用reconcileChildren创建子 Fiber 节点。
completeWork是“向上”的过程。当某个 Fiber 节点的所有子节点都处理完了,React 会执行completeWork来收尾。对原生标签来说,completeWork会创建对应的 DOM 实例,并把属性、事件都挂上去,然后通过return指针回到父节点,继续处理父节点的下一个sibling。这个过程一直重复,直到回到根节点,意味着整棵 Fiber 树构建完成。
这段过程看起来复杂,但你只要记住一个核心结论:协调阶段不会真实改动页面上的 DOM,它只是在内存里构建一棵新的 Fiber 树,并且给每个节点打上“我要新增”“我要更新”“我要删除”的标记。真正的 DOM 操作,要等提交阶段才发生。
2.3 初次渲染和更新时的 Diff 差异
很多同学把虚拟 DOM 的 diff 理解成“新老两棵树逐层比较”,这在宏观上没错,但在 Fiber 的实现里,初次渲染和更新时的 Diff 策略差别非常大。
初次渲染时,旧的 Fiber 树基本是空的,React 不需要比较什么,直接调用mountChildFibers为每个 React Element 创建对应的 Fiber 节点。这个过程是低成本的,因为所有节点都是全新的。
更新时走的是reconcileChildFibers,这才是大家口中真正的 diff。React 的 diff 分单节点和多节点两种情况:
单节点 diff 相对简单,比如一个<Foo />变成了<Bar />,React 会比较新旧节点的key和type。如果 key 相同但 type 不同,直接删除旧节点并新建;如果 key 和 type 都相同,则复用旧 Fiber,只更新 props。
多节点 diff 稍微复杂,典型的场景是一个列表:[A, B, C]变成[A, C, B]。React 会先遍历新列表,看旧列表中是否存在相同 key 的节点,如果存在就复用并标记移动,如果不存在就新增,同时把旧列表中多余且没被匹配到的节点标记为删除。这也是为什么开发列表时key不能乱传,最好用业务唯一的 id。如果使用数组index作为 key,当列表头部插入一条数据时,后面所有元素的 index 都变了,key 全部失效,React 会认为整个列表都更新了,导致不必要的 DOM 重建,严重情况下还会出现状态错乱的问题。
2.4 双缓存机制:为什么切换 Fiber 树不会白屏
Fiber 架构里有一个很重要的设计叫“双缓存”。React 同时维护两棵 Fiber 树:一棵是当前正在页面上展示的,叫 current 树;另一棵是正在内存中构建的,叫 workInProgress 树。
对应的 Fiber 节点之间通过alternate指针互相连接。每次更新时,React 基于 current 树创建一棵全新的 workInProgress 树,所有 beginWork 和 completeWork 都在 workInProgress 树上进行,current 树不受影响。等整棵树构建完成,FiberRootNode.current指针会直接切换到 workInProgress 树,这棵新树变成 current 树,旧的树则成为下一次更新的 workInProgress 树,等待被重新利用。
这个机制的好处非常明显:第一,即使协调阶段耗时再长,用户看到的始终是旧的、完整的内容,不会出现“渲染到一半的页面”;第二,通过复用 Fiber 节点而不是全部销毁重建,大大降低了内存分配和垃圾回收的压力。你可以把它理解成游戏里的“双缓冲绘图”,先画好一帧再整体切换,避免闪屏。
3. 提交阶段:从 Fiber 树到真实的 DOM
3.1 commit 阶段的三个子阶段
协调阶段完成后,workInProgress 树已经构建完毕,DOM 更新标记也打好了,但页面还是老的。接下来进入 commit 阶段,这个阶段才是真正会改动 DOM 的阶段,所以 React 必须保证它是一次性的、不可中断的。否则用户可能看到页面改到一半的中间状态。
commit 阶段内部又分了三个子阶段:before mutation、mutation、layout。
before mutation阶段主要做收尾工作:处理getSnapshotBeforeUpdate这类生命周期,读取 DOM 更新前的快照状态,同时开始调度useEffect等 passive effects 的执行。
mutation阶段是整个提交过程的核心,React 会深度遍历 Fiber 树,根据flags标记执行真实的 DOM 操作。常见的 flags 有Placement(插入)、Update(更新)、Deletion(删除)。如果某个 Fiber 节点有Placement标记,React 会调用原生方法把对应的 DOM 插入指定位置;Update则会更新 DOM 属性、事件等;Deletion会清理对应的 DOM 节点并触发卸载相关的生命周期。
layout阶段跑完,新的 DOM 已经完全挂在页面上了。React 会在这个阶段执行componentDidMount、componentDidUpdate、useLayoutEffect等,然后把FiberRootNode.current的指针切换到新的 workInProgress 树,整个渲染流程在 React 层面就算结束了。
3.2 生命周期和 Hook 的真实执行时机
生命周期和 Hook 的执行时机是面试爱考、实际排错也容易踩坑的点。我整理了一个常用对照表:
| 生命周期 / Hook | 执行阶段 | 特点 |
|---|---|---|
constructor | beginWork 阶段 | 类组件初始化 |
getDerivedStateFromProps | beginWork 阶段 | 每次渲染都会调用 |
render | beginWork 阶段 | 返回新的 React Element |
componentDidMount | commit 的 layout 阶段 | DOM 已更新,可以操作 DOM |
componentDidUpdate | commit 的 layout 阶段 | DOM 已更新,prev props/state 可用 |
componentWillUnmount | commit 的 mutation 阶段 | DOM 删除前执行清理 |
useEffect | commit 之后异步执行 | 不阻塞浏览器绘制 |
useLayoutEffect | commit 的 layout 阶段 | 同步执行,会阻塞浏览器绘制 |
这里很容易踩坑的是useEffect和useLayoutEffect的区别。从名字看useEffect更像“副作用”,它是在 commit 之后,浏览器绘制之前或之后异步执行的。React 官方文档也明确说过,useEffect可能会延迟执行,不适合做必须同步读取 DOM 样式这类操作。
useLayoutEffect则是在 mutation 阶段完成后、layout 阶段同步执行的。如果你需要在 DOM 更新后马上测量布局、调整节点位置,又不想让用户看到闪烁,那就要用useLayoutEffect。它和componentDidMount的执行时机基本一致。
还有个实际经验:在服务端渲染下,useLayoutEffect会在控制台警告,因为服务端根本没有布局过程。这时候要么换useEffect,要么做环境判断。
3.3 提交完成后浏览器何时绘制
React 的 commit 阶段执行完,DOM 已经更新到位,但浏览器此时并不一定立刻把内容画到屏幕上。浏览器有自己的渲染管线:JavaScript 执行 → 样式计算 → 布局 → 绘制 → 合成。React 只负责 JavaScript 部分和 DOM 修改,剩下的样式计算、布局、绘制都是浏览器引擎根据 DOM 变更之后自动跑的。
这也是为什么有时候 React 渲染本身很快,但页面依然会卡:真正耗时可能不在 React,而在“改了 DOM 之后触发的大面积样式重算和重排”。比如你在一个列表渲染的容器上用了不合理的 CSS 动画,或者让某个元素频繁改变宽度,哪怕 React 只更新了一个小文本节点,浏览器也可能要把整棵布局树重新算一遍。
理解了提交阶段之后,你再去看 React Native 的渲染,会发现底层也是一套相似的链路。这也自然引出一个很实际的问题:React Native 为什么经常出现启动白屏?为什么低端安卓机上特别容易卡?
4. 实战场景:React Native 启动白屏与渲染引擎的问题
4.1 React Native 首屏白屏链路拆解
React Native 的白屏问题在社区里讨论度一直很高,尤其是“react native 启动白屏”“安卓低端机很卡”这类关键词几乎每隔一段时间就会出现一次。要排查这个问题,首先要清楚 RN 启动经历了什么。
一次典型的 RN 启动,链路大概是这样的:原生容器先被创建出来,然后加载 JavaScript Bundle。在开发模式下,Bundle 需要通过 Metro 打包和本地服务拉取,本身就含有网络耗时;在正式包中,Bundle 一般本地集成或从服务器下载,但也要先读取并执行。
JS 引擎执行 Bundle 之后,React 渲染流程会在 JS 线程上跑,构建出虚拟的组件树,再通过桥接(旧架构)或 JSI(新架构)把布局信息传给原生端。原生端拿到指令后,再根据布局计算创建真正的原生视图,最终呈现到屏幕上。
这几个环节里任何一个卡住,都会表现为白屏。开发模式常见的是 Metro 编译慢;真机低端机常见的是 JS Bundle 解析执行太慢;旧架构下还存在一个容易被忽略的问题:大量渲染指令在异步桥上传输,如果一次性要创建的组件太多,桥会拥堵,导致视图迟迟出不来。
4.2 旧架构的瓶颈与新架构的方向
旧版 React Native 的架构被人诟病最多的两个点:一个是 JS 和原生通信必须走异步批量处理的 Bridge,消息传递有额外开销且不能直接调用原生方法;另一个是旧的 JS 引擎 JavaScriptCore 在安卓低端机上的解释执行性能和垃圾回收表现并不理想,启动时解析几百 KB 甚至几 MB 的 JS 很吃力。
新架构则引入了 JSI(JavaScript Interface),JS 可以直接持有 C++ 对象的引用,绕过异步桥直接同步调用原生方法。Fabric 渲染器则把渲染管线统一到了 C++ 层,JS 端生成的 Shadow Tree 可以直接和原生层共享数据,大幅减少了跨线程通信。
顺带提一句,大家偶尔会看到 Impeller 渲染引擎这个词。Impeller 最早是 Flutter 为了消除 Skia 在 iOS 上的 shader 编译卡顿而开发的渲染引擎,它用预编译的 shader 替代运行时编译,从根本上消除了很多首次渲染的白屏和掉帧问题。React Native 社区也一直在研究类似的方案来优化自身的渲染性能。对前端来说,理解这些引擎层面的事情,能帮你更清楚地判断“白屏到底是业务代码问题,JS 执行问题,还是底层渲染引擎问题”。
4.3 页面渲染安全策略拦截:一个容易被忽略的白屏元凶
除了性能链路,还有一个经常被忽视的白屏原因:安全策略拦截。一些站点会配置Content-Security-Policy(CSP),限制页面能加载的脚本、样式来源,比如script-src 'self'禁止内联脚本。如果开发环境的构建工具往 HTML 里注入了内联脚本,或者 React 应用依赖内联样式、eval 执行,就可能被 CSP 直接拦掉,页面就白屏了。
React 本身的事件系统在 React 17 之后是委托到根容器节点上的,不再依赖在具体 DOM 上绑定内联事件,所以事件相关 CSP 报错会比老版本少很多。但样式内联、动态脚本、Web Worker 等场景仍可能触发 CSP 拦截。
排查这类问题有个很笨但有效的方法:打开 DevTools 的 Console 面板看有没有红色报错。CSP 被拦会有明确的Content Security Policy错误提示,指向被阻止的资源地址。很多时候页面白屏并不是“代码逻辑错误”,而是资源根本没被允许加载,顺序一定要先看控制台。
5. React 和 Vue:两套渲染思路的正面 PK
5.1 调度模型:Fiber 可中断 vs Vue 批量更新
“React 和 Vue 的区别”是面试高频题,也是选型时经常讨论的话题。两者底层差异,其实从渲染调度那一刻就开始了。
React 的核心思路是“UI = f(state)”,你把整个应用当作一个函数,输入状态,输出视图。状态变了,React 会默认对整棵组件树重新执行一遍渲染逻辑,再用 diff 找出需要更新 DOM 的地方。因为整个过程可能很耗时,React 设计了 Fiber + Scheduler,可以把渲染拆成一个个小任务,在时间片之间切换,甚至让高优先级任务插队。这让 React 能实现并发渲染、可中断更新、transition 这些高级能力。
Vue 的核心思路则是“响应式 + 模板编译”。Vue 3 用 Proxy 做了一个非常细粒度的依赖追踪系统,数据一变,框架能立刻知道哪个组件依赖了这个数据,然后精准地只更新那个组件。Vue 的更新也是异步批量的,会把同一轮数据变更产生的更新合并到一次,但它没有像 React Fiber 那样把渲染拆分成可中断的时间片任务。
这里没有谁绝对更好,只是取舍不同。React 把“可中断性”作为底层能力,代价是默认更新粒度粗,需要开发者用 memo、useMemo 等手段来自我优化;Vue 把“精确更新”作为默认行为,代价是实现复杂度高,但使用时心智负担更小。
5.2 模板编译:Vue 的编译时优化 vs React 的运行时递归
Vue 有一个 React 没有的优势:编译时优化。Vue 的模板是静态分析友好的,编译器会在编译阶段对模板做静态提升——那些完全不依赖响应式状态的节点会被提升成常量,只在初始化时创建一次;还会给动态绑定的节点打上 patch flag,比如某个节点的 class 是动态的,Vue 更新时只精确更新 class,不会去 diff 整棵子树。
React 的 JSX 则更灵活,因为它是完整的 JavaScript 表达式,可以写任意逻辑。这让 React 可以更方便地做动态渲染、条件渲染、在组件里闭包调用函数,但也意味着编译阶段很难做太多静态优化,必须到运行时通过 Fiber 树 diff 来找出变化。
这就回答了很多人的一个困惑:为什么 Vue 模板上手容易、性能下限高?为什么 React 写起来自由,但性能需要自己操心?本质是编译时和运行时两种设计路线的差异。
5.3 为什么 React 每次渲染都会重新执行整个 render 函数
React 社区里有个经典问题:为什么每次状态更新,组件里定义的普通函数、内联对象都会“重新创建一遍”?比如这样一段代码:
function Counter() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); }; return <button onClick={handleClick}>{count}</button>; }每次点击按钮,handleClick都是一个全新的函数。这是因为 React 的函数组件本质上就是 render 函数本身,状态更新后 React 重新执行整个函数体,才能收集到最新的 UI 描述。React 不具备 Vue 那样的响应式依赖追踪,“哪个组件依赖了 count”这种信息对 React 来说不是天然可知的,它只能通吃——不管你哪个状态变了,相关组件全部重跑一遍。
这也是为什么React.memo、useMemo、useCallback这些 API 会存在。它们的作用是给 React 一个提示:某些组件或计算结果可以跳过重新计算。对 React 来说,优化不是一个默认发生的事,而是你需要主动告诉它“哪里可以偷懒”。
5.4 实际选型建议
从渲染机制看,如果你的团队希望有一个强一致性的“框架托管”体验,Vue 的模板约束和响应式自动优化会更容易写出性能还不错的页面;如果你的团队喜欢 JSX 的灵活、希望底层能应对非常复杂的调度需求,或者要做大型跨端应用(React Native),React 的生态和架构积累更有优势。
我自己在两个项目里都用过,一个直观感受是:写 Vue 时数据更新后的渲染结果基本不用操心,只要不滥用响应式嵌套基本不会出大问题;写 React 时你能更清楚地感知到“我在驱动一次渲染”,也正因为能感知到,优化空间反而更大。两者没有绝对优劣,关键是团队擅长哪套心智模型。
6. 高频问题与排查经验实录
6.1 面试高频问题速查
先放一份面试问答速查表,都是我筛选过的、最常出现的问题和答案思路:
| 问题 | 答题要点 |
|---|---|
| React 生命周期有哪些阶段? | 挂载、更新、卸载三大阶段;函数组件对应 Hooks 时机 |
| Fiber 是什么?有什么作用? | 可中断的工作单元;链表结构支持调度、并发、双缓存 |
| key 有什么用? | 帮助 diff 复用自己的组件实例;不要用 index |
| useEffect 和 useLayoutEffect 区别? | 同步/异步时机不同;布局测量要用 useLayoutEffect |
| React 18 的并发模式是什么? | 渲染可被中断;配合 startTransition、useDeferredValue |
| React 和 Vue 的主要区别? | 运行时 vs 编译优化;可中断调度 vs 精确响应式;粒度差异 |
| createRoot 和 ReactDOM.render 区别? | 老 API 不支持并发特性;createRoot 是新入口 |
| setState 是同步还是异步? | React 18 中自动批处理;事件外也可批处理;flushSync 可强制同步 |
这里面有几个细节我提示一下:很多人背概念背得很熟,但一问到“为什么需要双缓存”就说不清。答题时尽量往“保证用户体验一致、避免中途渲染被用户看到”这个方向去靠,面试官会更认可。
还有setState的问题,React 18 和之前版本的批处理行为变化很大。React 18 里,即使是在 Promise、setTimeout 回调里触发的 setState,也会被自动批处理,不再需要手动unstable_batchedUpdates。这也是为什么很多人升级 React 18 后发现某些“依赖同步更新”的逻辑行为变了。
6.2 React Error #130 排查实录
热搜词里提到了一个很具体的报错:minified React error #130; visit https://reactjs.org/docs/error-decoder.html?args[]=object。这条报错在压缩后的生产环境里很常见,对应的完整报错信息一般长这样:
Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.
这就是典型的组件类型不对。常见触发场景有三种:
第一种是默认导出和命名导出混用。比如某个组件文件是export default Button,但业务代码里写成import { Button } from './Button',这时候 Button 是一个模块对象而不是组件函数,React 一渲染就报 object 类型非法。
第二种是循环依赖。组件 A 引用了组件 B,组件 B 又间接引用了组件 A,模块加载顺序错乱,可能导致某个组件在被真正初始化前就被调用。
第三种是第三方包导出结构变化。比如某库升级后不再默认导出组件,而是export const Component,旧代码没改就中招了。
排查核心是先打开报错堆栈,找到是“哪个组件被渲染时”报的错,然后检查对应的 import/export 语句。如果是循环依赖,可以把相互引用的代码拆到一个公共模块,或者在函数内部再依赖引入,尽量延迟引用时机。这类报错在开发模式会有很明显的红色 overlay,定位到文件后基本五分钟内能解决。
6.3 白屏问题通用排查顺序
最后分享一下白屏问题通用的排查思路,不光 React Native 适用,普通 Web React 应用也完全套用。我总结成一个固定顺序:
第一,看 Console。任何 JS 报错、CSP 报错、资源加载失败都会出现在这里。这一步能筛掉百分之六七十的问题。
第二,看 Network。重点确认 JS Bundle、CSS、图片等资源的状态码,有没有 404、500、超时。生产环境白屏很多时候是资源没部署上去,或者 CDN 路径错了。
第三,看页面结构。用 DevTools 检查<div id="root">里到底有没有内容。如果 root 是空的,说明 React 根本没成功挂载;如果 root 里有内容但页面还是白屏,可能是 CSS 的问题,比如全局样式把容器隐藏了。
第四,看本地能不能复现。本地开发模式能复现,多半是代码问题;只有生产环境复现,优先怀疑环境差异、构建配置、安全策略。
第五,看错误监控。如果上了 Sentry 这类平台,直接查对应报错。没有的话,用压缩后的报错信息去 React 官方错误解码器查原始错误。
我个人在实际工作中遇到最多的情况,反而是最简单的原因——某个组件的高阶封装里 catch 住了异常,导致整棵子树渲染失败,页面只剩一个空的容器。这类问题看 Console 可能只有一条被吞掉的警告。排查过程很考验人对渲染链路的理解:你至少要知道createRoot是挂载到哪个容器,协调阶段构建了什么,提交阶段改了什么,才能快速判断“白屏是发生在哪一步”。把前五章的内容吃透,排错时你会比大多数人快不少。