开始之前:为什么要深挖 React Native 的异步状态更新
做 React Native 开发这几年,我踩过最多的坑,几乎都集中在"状态更新了,但界面没反应"或者"界面闪了一下,数据才慢吞吞地出现"这类问题上。你明明调用了setState,数据也改了,但组件就是没重新渲染;你明明在fetch的回调里赋值了,页面却一直停留在加载状态。这些现象背后的根源,基本都是对 React Native 的异步状态更新和组件渲染机制理解不到位。
这篇文章我打算从底层机制讲起,结合我实际踩坑和排查的经历,把异步状态更新怎么运作、组件渲染链路怎么走、为什么会出现时序错乱、以及真正在生产环境里怎么调优,一次说透。适合刚接手 React Native 项目的新手,也适合已经写了一阵子但老是被诡异渲染问题折磨的中级开发者。你如果能把这套机制吃透,以后再遇到"界面不对"的问题,至少能判断出问题出在哪一层,不会再瞎改一气。
1. React Native 的异步模型:一切从双线程说起
1.1 为什么 React Native 天生就是异步的
很多前端转 React Native 的开发者,容易把 RN 当成一个"套了壳的手机网页",这是最大的认知误区。RN 应用运行时有两条核心线程:JavaScript 线程和 UI 线程。你写的所有业务逻辑、状态计算、事件处理都跑在 JS 线程上;而视图的布局、绘制、原生控件的更新,则发生在 UI 线程(严格来说还涉及 Shadow Tree 和 Yoga 布局引擎,但主线就是这两条)。
两条线程之间靠什么通信?答案是异步消息队列,也就是 RN 框架的 Bridge 机制。JS 线程想要通知 UI 线程"某个组件的样式需要变化",它不会直接操作原生视图,而是把指令序列化成消息,扔到队列里,UI 线程空闲时再取出来执行。反过来,用户在屏幕上点击、滑动产生的原生事件,也是先由原生端捕获,异步转发给 JS 线程处理。
这就带来一个本质约束:你写的setState并不会同步触发 UI 更新,它只是往队列里塞了一条消息。JS 线程继续往下跑,UI 线程什么时候处理这条消息,取决于事件循环的调度。这个模型和浏览器里 JavaScript 的单线程事件循环很像,但多了一层线程间的通信开销,时序问题也更明显。
1.2 异步与同步的取舍:为什么 RN 没有选择同步渲染
你可能会问,为什么不让 JS 线程同步等待 UI 更新完再继续执行?原因很直接:同步阻塞会卡死 UI。假设一次 setState 需要 50ms 完成布局计算和原生视图更新,如果你的 JS 线程同步等 50ms,那用户滑动列表时就会感觉到掉帧、卡顿。RN 选择异步消息队列,本质上是在"保证 UI 操作流畅度"和"状态到界面的实时同步"之间,选择了前者。
这里有个经典类比:你把 JS 线程想成餐厅的前台点单员,UI 线程是后厨。点单员每接到一桌客人的订单,就把单子递给后厨,然后立刻去服务下一桌客人,而不是站在后厨门口等着菜炒好。如果点单员每次都等菜上齐才去服务下一桌,餐厅早就乱套了。React Native 的异步模型就是这个逻辑——点单员(JS线程)快速收单、快速派发,后厨(UI线程)按队列慢慢做菜,整个系统才能在高压下保持流畅。
理解了这套模型,很多现象就说得通了:为什么state刚更新完立即读取,拿到的还是旧值?因为状态存储和 UI 更新本来就是解耦的,你读的那一瞬间,新状态还没被广播出去。为什么用setTimeout(() => console.log(this.state), 0)偶尔能拿到新值?因为消息队列处理完了一圈,UI 线程有机会把更新应用回去了。
2. 状态更新机制:setState 背后的完整链路
2.1 setState 到底在做什么:批处理与事务队列
React Native 中的setState(以及 React 18 之后的useState的 setter)不是简单的"改一个值",它背后有一整套调度机制。JS 线程在调用setState时,React 会把新的 state 放进一个待处理队列,并且标记当前组件"需要更新"。这个队列不会立即清空,而是在一个"批处理窗口"内统一处理。批处理窗口通常就是一个事件循环 tick:同一事件内连续调多次setState,React 会合并成一次更新,渲染一次。
我举个例子,你在onPress回调里连续写三行:
setCount(count + 1); setCount(count + 1); setCount(count + 1);这三行代码执行完,count并不会变成 3,而是只会加 1。因为 React 把三次更新合并了,最后一次执行时,count的闭包值还是旧值。这就是著名的"函数式更新"问题的场景:如果你真的想让 count 加 3,应该写成:
setCount(prev => prev + 1); setCount(prev => prev + 1); setCount(prev => prev + 1);每个更新函数都接收上一次计算后的最新值,这样合并时也会按顺序叠加。这个细节我在生产代码评审时见过无数次,很多人以为 React 的 setter 是"立即赋值",其实它更像是"提交一个变更请求"。
2.2 状态更新的异步时序:为什么读不到最新值
我们拿一个实际开发场景来说明时序。假设你有一个获取用户信息的逻辑:
const fetchUser = async () => { const data = await api.getUser(); setUser(data); console.log(user); // 这里打印的仍然是旧值 };代码执行到console.log(user)时,setUser(data)只是把这个更新动作加入了调度队列,user这个变量在本次渲染闭包中仍然保持旧值。等你下次渲染函数重新执行,user才会拿到新值。
很多新手不理解这一点,以为是 React 的 bug,其实不是。这是 React 设计上的一致性原则:在一次渲染周期内,组件的 props 和 state 是固定的、快照式的。你看到的所有 UI 都是某一次渲染的产物。更新状态 = 开启一次新的渲染,但不是"原地修改"上一次渲染的变量。这就像拍照片,你按一次快门得到一张照片,照片里的内容不会因为你改变了实景而变化,想得到新照片得再拍一次。
这一点在 React Native 里比 Web 端更明显,因为 Web 端 DOM 更新在同一线程里可以很快地"追"上状态,而 RN 里 JS 线程和 UI 线程之间的消息往返天然有延迟,你更容易在时序上感知到这种"快照"特性。
2.3 setState 是异步的,但副作用不一定
有一点必须强调:setState的"异步"不等于"延迟到很久之后"。它只是保证在一次事件循环的批处理周期内完成更新,实际延迟通常只有几毫秒。但如果在同一个事件里,你既更新了状态又读取了一个全局变量的值,这个全局变量的变化是立即生效的。因为全局变量不在 React 的调度范围内:
let globalCache = {}; const handlePress = () => { setUser(newUser); globalCache = newUser; // 这个赋值立即生效,读取 immediate console.log(globalCache); // 能拿到新值 };所以如果你真的需要在状态更新后立即基于新值做额外操作,优先用函数式更新,或者把数据先存到外部变量,再镜像到 state。当然,最推荐的做法还是useEffect监听 state 变化,这是 React 官方推荐的"状态改变后处理副作用"的通道。
3. 组件渲染链路:从状态到屏幕上的像素
3.1 render 函数的执行时机与触发的三个阶段
React 组件重新渲染,通常有三个触发来源:props 变化、state 变化、父组件重新渲染。在 React Native 中,这三个来源最终都会走同一条链路:调度器(Scheduler)决定某组件需要更新 → 执行函数组件体(或类组件的 render 方法) → 生成新的 React 元素树 → 与旧树做 Diff → 计算出需要变更的最小范围 → 生成原生 UI 指令 → 通过 Bridge 发送给 UI 线程 → 原生端完成布局和绘制。
很多人只关注到第一个环节(函数体执行),忽略了后面的 Diff 和 Bridge 传输阶段。实际上,RN 性能优化的关键点往往在 Diff 阶段之后:如果你的组件树很大,即使 Diff 算法很高效,光是把变更指令序列化、传输给原生端,也需要时间。一个常见的性能问题是:列表项中某个小状态变化,导致整个长列表重新渲染,Bridge 消息量瞬间暴涨,界面明显卡顿。
3.2 React Native 渲染与 Web 渲染的核心差异
很多 Web 开发者一开始写 RN 时都会踩一个坑:在 Web 端,组件重新渲染会直接操作 DOM,修改样式和结构,变更几乎是"纯同步"的感知;但在 RN 端,UI 线程和 JS 线程是分离的,组件重新渲染只是"计算出了新状态的描述",真正改变屏幕内容的是原生端的布局和绘制。这中间多了一层"跨线程消息传递",如果消息过于频繁,或者单条消息的 payload 过大,就会出现渲染延迟。
所以 RN 里有一个经验法则:JS 线程的耗时操作,最终都会反映成 UI 的卡顿。因为 JS 线程被长任务占用时,没法及时收集状态更新、产生 Diff 结果、发送指令。UI 线程就算空闲,也只能干等着新指令。这也是为什么"异步"在 RN 里不仅是一个概念,更是一个性能红线:你在 JS 线程做了太多同步的复杂计算,就是在直接抢占渲染的带宽。
3.3 组件的 React.memo 与渲染优化
为了减少不必要的渲染,React 提供了React.memo。它的作用是:当父组件重新渲染时,子组件的 props 没有变化,就跳过子组件的重新渲染。初看很简单,但在 React Native 的异步更新背景下,这个机制的细节值得推敲。
React.memo的默认比较是"浅比较"——逐个比较 props 对象的每个 key 的值是否相同。如果每次父组件渲染时,你都传入一个新的对象字面量,比如:
<Child config={{ id: 1, name: 'test' }} />即使对象内容一模一样,浅比较也会认为 props 变了,因为两个对象的引用不同。这就是为什么 React 社区一直强调用useCallback和useMemo去稳定函数和对象的引用:
const config = useMemo(() => ({ id: 1, name: 'test' }), []); const handlePress = useCallback(() => { /* ... */ }, []);只有引用稳定了,React.memo才能在"父组件频繁渲染"的场景下真正发挥作用。我在项目里见过不少"用了 memo 但没什么效果"的案例,排查下来基本都是父组件传入了新的内联对象导致浅比较失效。
这里还要提醒一个 RN 特有问题:即使组件本身渲染开销不大,只要它出现在一个频繁更新的父组件下,每次重新渲染都会额外产生一棵子组件树的 Diff 计算和指令生成,累积起来就是明显的性能损耗。所以优化不是等到卡了才做,而是在组件设计初就考虑渲染边界。
4. 异步更新背后的性能陷阱:启动白屏、渲染阻塞与死循环
4.1 生产环境最常见的启动白屏问题
React Native 启动白屏(首屏渲染延迟)是这个框架最著名的问题之一。它的本质是:应用启动时,JS 线程需要先下载/加载 JS 包,然后执行入口代码,这个过程是异步的。在 JS 包执行完成之前,UI 线程虽然已经启动了原生容器,但没有任何视图指令可以渲染,所以屏幕上就是一片空白,直到 JS 端完成初始化并发送第一批渲染指令。
白屏时间的长短取决于几个因素:JS 包的体积(Bundle Size)、设备的 CPU 性能、启动时是否有大量的同步初始化逻辑。我在实际项目中测过,一个中等复杂的电商应用,Debug 模式的 JS 包可能高达几十 MB,启动白屏可以持续 3 到 5 秒;Release 模式做了字节码和压缩处理后,通常能压到 1 秒左右,但如果你在入口处同步执行了大量初始化操作(比如读取 AsyncStorage、初始化各种 SDK、构建复杂导航栈),白屏时间照样会反弹。
启动白屏的优化方向,我从实践角度总结三个:
第一,减少 JS 包体积。用 Metro 的--platform构建特定平台的包,开启minify,把不必要的 polyfill 去掉。如果项目用了很多大型库(比如地图、图表),考虑拆分加载,用require.context或者按需加载的方式延迟引入。
第二,让首个可交互帧更早出现。在根组件渲染前,不要做耗时的同步操作,比如检查登录态、读取本地配置,这些都可以放到首帧渲染完成之后,用一个异步任务去执行。UI 先渲染出一个安全的框架,数据到了再填充。
第三,如果条件允许,用原生启动页(Splash Screen)过渡,至少让用户在等待时不觉得是"白屏"。这个问题在 iOS 上可以通过 storyboard 配置,在 Android 上用 windowBackground 设置,尽量让启动页和首帧之间的切换显得平滑。
4.2 异步更新顺序的不确定性:你永远不能假设回调的执行顺序
React Native 中的异步操作(网络请求、定时器、Native Module 回调、事件监听)之间,没有严格的执行顺序保证。你发出两个网络请求,哪个先返回不一定,即使第一个请求先发出。这会造成一个典型 bug:竞态条件导致 UI 渲染旧数据。
举个例子,一个搜索框,用户输入了关键词 A,发出请求;随后又改成关键词 B,发出请求。如果请求 A 的网络延迟较大,返回时间晚于请求 B,那么 UI 最终显示的可能还是 A 的结果,尽管用户已经在输入框里看到了 B。
解决方案是加请求序号标志(Request ID)或者使用AbortController取消过期请求。我用过最简单可靠的方法,是在组件里维护一个自增 ref:
const requestSeq = useRef(0); const handleSearch = async (keyword) => { const seq = ++requestSeq.current; const result = await api.search(keyword); if (seq === requestSeq.current) { setSearchResult(result); } };这样即使旧的请求晚返回,它的序号已经落后,更新被丢弃,不会污染 UI。
4.3 setState 触发的隐性循环:依赖数组写错的连锁反应
React Native 开发中另一种常见的渲染性能陷阱,是在useEffect里调用会更新状态的函数,而这个函数又依赖了被更新的状态,形成循环:
const [page, setPage] = useState(1); const [list, setList] = useState([]); useEffect(() => { fetchList(page).then(setList); }, [page]); useEffect(() => { if (list.length < 20) { setPage(page + 1); // 这里会再次触发上面的 effect } }, [list]);这段代码在特定数据条件下,可能产生"拉取一页→列表变长→继续翻页→拉取下一页"的连锁效应,直到列表长度超过阈值才会停。如果阈值条件永远不满足,就变成无限循环,RN 应用会直接白屏或卡死。调试这类问题最好的办法,是在每个 effect 里加 console 日志,打印依赖值的变化,一眼就能看出是哪个依赖项在生产环。
我个人建议的规范是:effect 中的状态更新必须附带明确的条件判断,而且条件不要依赖"可推测变化的数值"。比如上面的例子,正确的做法是把"是否需要继续翻页"的判断放到数据返回的那一刻:
useEffect(() => { const load = async () => { const newList = await fetchList(page); setList(prev => { if (newList.length < 20) { return [...prev, ...newList]; } return prev; }); }; load(); }, [page]);不要在列表数据变化时再去回写页码,而是把"是否还有更多"作为请求结果的一部分来判长。
5. 实战调优:把异步状态更新控制在期望的时间线上
5.1 用 useReducer 统一管理异步状态机
在 React Native 中,处理异步流程(如登录、文件上传、表单提交)的最佳实践,是用useReducer来管理一个状态机,而不是分散地写多个useState。原因在于,异步流程往往存在多个状态维度:空闲中、请求中、成功、失败、数据内容、错误信息。如果用多个useState,更新这些状态的时序很难控制,容易产生"数据到了但 loading 没关""错误信息被后来的成功覆盖"等问题。
我通常这样组织:
const initialState = { status: 'idle', // idle | loading | success | error data: null, error: null, }; function reducer(state, action) { switch (action.type) { case 'FETCH_START': return { ...state, status: 'loading', error: null }; case 'FETCH_SUCCESS': return { ...state, status: 'success', data: action.payload }; case 'FETCH_FAILURE': return { ...state, status: 'error', error: action.payload }; default: return state; } }dispatch操作可以在异步任务的不同阶段调用,每个 action 对应一个明确的状态迁移,组件渲染时只需要根据status决定展示加载页、数据页还是错误页。这样做的最大好处是:异步更新的每个中间态都被显式建模,不会再出现"渲染时状态自相矛盾"的问题。
5.2 减少 Bridge 通信压力:批量更新与布局裁剪
如果你的应用状态更新非常频繁(比如实时滚动位置上报、进度条更新),你会发现 RN 的渲染效率明显低于 Web。核心瓶颈不在 Diff 算法,而是在 Bridge 传输上。每一条状态更新触发一次渲染指令,如果更新频率超过 UI 线程的处理能力,消息队列就会堆积,表现为 UI 卡顿和延迟。
针对这个痛点,我常用的方法是合并更新:不要在每次回调里都 setState,而是用一个短窗口(如 50ms)内的节流或防抖,把多次变更累积成一次提交。比如进度条:
const [progress, setProgress] = useState(0); // 模拟大量进度更新 const updateProgress = (val) => { // 错误示范:每秒 60 次 setState setProgress(val); }; // 正确示范:用 requestAnimationFrame 合并聚合 const lastValue = useRef(0); const requestRef = useRef(null); const scheduleProgressUpdate = (val) => { lastValue.current = val; if (requestRef.current != null) return; requestRef.current = requestAnimationFrame(() => { setProgress(lastValue.current); requestRef.current = null; }); };用requestAnimationFrame把一帧内的多次更新合并成一次渲染,能显著减少 Bridge 消息量。我在实际项目中拿这个方式优化过图片上传进度,UI 刷新从肉眼可见的卡顿变成完全平滑。
另一个减少 Bridge 压力的手段是布局裁剪:对于大列表,用FlatList而不是ScrollView包一个map。FlatList 内部实现了虚拟化渲染,只渲染屏幕内可见的项,而不是一次性渲染所有数据。这个机制在 Web 端也有,但 RN 里因为原生列表控件的存在,收益更大。配合getItemLayout可以跳过高度测量,进一步减少渲染开销。
5.3 useCallback 和 useMemo:控制子组件的异步渲染节奏
子组件重新渲染,本质上是父组件状态更新后的连锁反应。在异步场景下,如果子组件有自己的异步逻辑(比如内部弹窗的状态、动画的触发),频繁重新渲染会导致子组件内部的状态被重置、动画中断。这时候需要用稳定引用的方式控制节奏。
一份很实用的"引用稳定清单":
- 传给子组件的回调函数,一律用
useCallback包裹; - 传给子组件的对象、数组,尽量用
useMemo生成; - 如果子组件只是展示,用
React.memo包裹; - 如果子组件内部有动画、轮播、倒计时这类持续性异步任务,务必把它做成
memo,并且保证 props 引用稳定。
我在项目里就遇过一次滑点:一个跑马灯公告组件,只要父组件轮询更新了某个状态,跑马灯就会"闪跳"一下。排查后发现父组件每次渲染都创建了一个新的announcement数组对象,导致 memo 失效。修复方案就是在父组件里做useMemo缓存,数据没变时引用保持一致,跑马灯就正常了。
5.4 启动白屏与异步初始化的深入优化
回到启动白屏问题,前面提到要"让首帧尽早出现"。在实际代码层面,我建议把初始化流程拆成三个阶段。
第一个阶段是"可渲染"阶段:入口组件挂载时,只渲染一个通用的 Loading 占位结构,不渲染任何依赖业务数据的组件。这个阶段耗时应该控制在几十毫秒内。
第二个阶段是"数据准备"阶段:在useEffect里执行网络请求、读取本地存储、初始化 SDK 等异步操作。这个阶段并不阻塞渲染,Loading 已经展示开,用户能感知到应用"活了"。
第三个阶段是"业务界面呈现"阶段:数据到达后,状态更新触发业务界面渲染。如果数据准备阶段做了良好的分层(核心数据优先、边缘数据延后),用户会先看到主页面框架,再看到各个模块逐渐填充内容,体验远好于"白屏等待 → 一整块界面突然出现"。
这里我还想强调一个容易被忽视的启动优化:JS 执行环境的初始化。React Native 在启动时要初始化 Hermes(或者 JavaScriptCore)引擎、加载全局对象、执行入口模块。如果你在全局作用域里做了大量操作,比如定义一堆工具函数、注册全局事件监听、初始化数据库连接,那么这些都会延长白屏期。把全局性的初始化延后到主组件挂载后再做,收益通常比你想象的大。
5.5 用 InteractionManager 延迟非关键任务
React Native 提供了一个专门处理"关键渲染 vs 非关键任务"的 API:InteractionManager。它的作用是:在动画、导航转场等交互过程结束后,才执行回调任务。这个机制对异步状态更新特别有用,因为很多"非关键更新"(比如预加载下一页数据、统计上报、日志记录)如果放在交互过程中执行,会干扰动画的流畅度。
用法很简单:
InteractionManager.runAfterInteractions(() => { // 执行预加载或者复杂计算 loadNextPageData(); });我在做列表无限滚动时常用这个组合:用户滚动的过程中,不立即发起下一页请求,而是等滚动结束、交互空闲后才请求。虽然加载时机稍晚,但滚动手感明显更顺滑,用户几乎感知不到"异步等待"的存在,因为离底部还有一段距离时请求早就完成了。
6. 常见问题与排查技巧速查
| 问题现象 | 根因 | 排查方向 |
|---|---|---|
| 状态更新后 UI 没反应 | 更新被批处理合并,或组件未正确连接 state | 检查组件的 key 是否变化、是否用了 memo 且 props 引用不稳定 |
| 读取 state 总是旧值 | 对"渲染快照"模型的误解 | 改在useEffect里读取,或用函数式更新处理"基于旧值的新值" |
| 启动白屏时间过长 | JS 包体积大 / 同步初始化阻塞 | 用 Metro 优化 bundle,把初始化逻辑延后到首帧之后 |
| 列表渲染很卡 | 单次渲染产生大量 Bridge 指令 | 改用 FlatList、添加getItemLayout、合并高频更新的 setState |
| 界面显示过期数据 | 请求竞态条件 | 给请求加序号标记,丢弃过期返回值 |
| 无限渲染循环 | useEffect 依赖与状态更新互相触发 | 打印 effect 日志,找到"更新→依赖→再更新"的链路,添加守卫条件 |
| 动画/轮播组件闪跳 | memo 失效,props 引用不稳定 | 用 useCallback/useMemo 稳定引用,必要用 memo 包裹子组件 |
| 导航切换卡顿 | 转场期间执行了复杂异步任务 | 用 InteractionManager 延迟非关键任务 |
6.1 一个真实排查案例:为什么我的列表滚着滚着就白了
去年做一个资讯 App,遇到一个诡异 bug:列表快速滑动时,偶尔整个列表变成白屏,停留好几秒才恢复。刚开始我以为是数据请求问题,加了各种日志后发现,请求正常返回,state 也更新了,但渲染就是卡着不动。
后来在 Profiler 里看到,问题出在 FlatList 的renderItem函数里。我在每次渲染时,给新闻 item 创建了一个新的"标签配置"对象,这个对象又传给了一个 memo 包裹的标签组件。因为引用每次都变,标签组件每次都要重新渲染,而且每次渲染都会触发一次轻微的布局计算。在列表高速滚动时,大量 item 同时重新渲染,产生了海量布局指令,UI 线程直接被淹没了。
修复方案很简单:把标签配置对象改成模块级常量,不在 renderItem 里创建。改完后滑动顺畅多了,一个看起来像"数据加载慢"的问题,实际是渲染优化的锅。
6.2 排查工具:怎么定位是 JS 线程还是 UI 线程的问题
遇到渲染卡顿或延时,第一步是判断瓶颈在哪条线程。RN 官方调试工具里有 Perf Monitor,可以显示 JS 线程和 UI 线程的帧率。如果 JS 帧率很低,说明 JS 线程有长时间同步计算在阻塞;如果 UI 帧率低,而 JS 帧率正常,说明 Bridge 传输或原生布局/绘制有瓶颈。
另一个常用的手段是console.time和PerformanceAPI 配合,给异步任务加计时,看耗时分布。比如:
console.time('fetchData'); const data = await api.getData(); console.timeEnd('fetchData');如果fetchData耗时很长,问题在网络层;如果很短但 UI 还是慢,问题在渲染层。这种"分层计时"的思路能帮你快速锁定问题域。
7. 我对异步状态更新这个主题的几点个人体会
写 React Native 和写 Web 的最大感官差异,就是你始终要记得"有一个跨线程的通道在传递消息",这个通道既是效率瓶颈,也是所有时序诡异 bug 的源头。我个人的习惯是,在写任何一段涉及状态更新的代码之前,先在脑子里过一遍"这次的更新要经过几条线程、几个队列、多少次调度",如果链路过长,就主动考虑优化方案,而不是等出 bug 再排查。
第二点是:React Native 的状态更新真的"快",是因为它在异步批处理中做了大量合并;真的"慢",也是因为异步消息队列的堆积。高效地使用它,核心不是去背诵 API,而是理解"渲染是一次快照"这个根本心智模型。当你不再纠结"为什么 state 是旧值",而是主动设计"状态更新后做什么",很多问题自然就没机会出现。
最后分享一个小技巧:在排查异步更新问题时,给关键的setState加一个唯一的console.log标记,条目太多直接影响调试效率。我用得最多的方法,是给每个业务状态块加一个会打印的useEffect,专门看状态怎么变化的流转路线,这比到处打断点高效得多。经过几次这样的排查,你对组件的状态编排会形成直觉,写得越多,这种直觉越准。