本节摘要:RN 应用至少有三类执行体在协作:跑业务逻辑与 React 渲染的 JavaScript 线程、负责绘制与输入响应的原生主线程、以及承载模块任务的后台线程。旧架构下三者之间隔着一条异步序列化的消息桥,所有跨线程协作都要排队。理解谁在哪条线程、消息如何过桥,你就能解释大部分「代码看着没毛病、滑起来就是卡」的现象。本节是第 6 章性能诊断的理论地基,也是 2.2 节新架构登场前必须交代的「病理」。
先看一个典型事故。凌晨一点,值班工程师收到告警:商品列表页在 Android 低端机上快速滑动时帧率骤降,用户评论里「滑不动」的抱怨正在积攒。初步排查令人困惑——这一版没有改列表组件,网络请求耗时正常,图片也做了裁剪。真正的病因藏在线程视角里:列表滚动时,上拉加载的回调在 JavaScript 线程里触发了一轮较重的数据加工,而旧架构下用户的手势事件同样需要送到这条线程处理;数据加工占住了线程,手势响应排队等待,画面上就是「跟手性」突然变差。理解这个案例,需要先把线程分工这张图装进脑子。

JavaScript 侧是「蓝图办公室」。你的组件树、状态计算、接口数据加工全部发生在 JS 线程,由 Hermes 这类引擎执行。它很忙,但它不画任何东西——它只产出「应该有什么」的描述。
原生侧是「施工现场」。原生主线程掌管真正的绘制与布局,iOS 上对应主线程的运行循环,Android 上对应主线程的消息队列;两侧的共同点是「主线程被占就是掉帧」,差异点在调度细节与渲染管线实现。原生模块的耗时任务(网络、文件、传感器)则被安排到后台线程,避免堵塞主线程。
两边的协作全靠通信。把常见协作流量摊开看:界面更新从 JS 流向原生,手势与系统事件从原生流向 JS,而长列表恰恰是双向大流量场景——滚动事件高频上行、视图批量下行。这就是为什么性能优化章节(第 6 章)会反复回到线程视角:多数卡顿不是某行代码慢,而是流量的「路线设计」出了问题。
旧架构的通信机制有三个特征,每个特征都是一种病根。特征一是异步:JS 发出消息后不等待结果,继续跑自己的;好处是互不阻塞,代价是任何「需要即时回应」的交互(比如跟手拖动)都要跨线程往返,延迟感知被放大。特征二是序列化:消息在桥上以跨语言通用的形式传输,对象要被拍平再复原,大对象、深层嵌套、大数组都会让翻译税显著上升。特征三是批量排队:消息攒批发送以摊薄成本,这在天花板之内很划算,但流量洪峰时队列堆积,延迟从毫秒级滑向肉眼可见的百毫秒级。
把病理翻译成工程守则,就是四条:不要在 JS 线程做重计算(挪到后台模块或用增量算法);不要高频传递大对象(传引用标识或做分页分片);不要让交互回调与重逻辑挤在同一条线程的时间片里(错峰调度);监控时把「JS 帧耗时」与「UI 帧耗时」分开看(它们指认不同的凶手)。
下面这段代码演示「错峰」的标准姿势——一个在页面进入后等待交互空闲再执行重加工的钩子:
import React from 'react'; import { InteractionManager, Text } from 'react-native'; // 场景:列表页进入后需要加工大批数据,但不能拖慢滚动跟手性 export function useDeferredWork(deferredFn) { const [done, setDone] = React.useState(false); React.useEffect(() => { // 先等当前交互(滚动、转场动画)结束,再执行重活 const task = InteractionManager.runAfterInteractions(() => { deferredFn(); setDone(true); }); // 组件卸载时取消排队,避免泄漏(第 6 章还会回到这个主题) return () => task.cancel(); }, []); return done; } // 用法:加载文案先显示,重活让路给滚动 function ReportBadge({ computeReport }) { const ready = useDeferredWork(computeReport); return <Text>{ready ? '报表已就绪' : '加载中,滑动不受影响'}</Text>; }
回到开头的工单,完整复盘一遍。复现:用性能监视器录制滚动过程,发现 JS 帧耗时在「上拉加载触发」的瞬间出现尖峰,同帧的手势响应延迟明显。定位:尖峰源头是加载回调里一段对两万条历史数据做排序合并的同步代码。修复:把排序合并拆成分片异步任务,每片只处理固定批量,片间让出线程;同时给上拉阈值提前了缓冲距离,错开滚动高峰。验证:低端 Android 真机上滚动帧率恢复稳定,跟手性投诉清零。变式思考:假如这版应用运行在新架构上,手势与某些更新路径的同步化会缓解跟手性问题,但 JS 线程里的重计算依然是病——线程拥堵的病理不会因架构升级而失效,只是发病阈值提高了。这也是为什么我把本节标为「全册病理学」:第 6 章的所有诊断手法,都是在为这条推理链配备仪器。
问:既然界面是原生的,为什么 JS 卡顿会让动画也卡? 因为动画的驱动方式决定了账记在谁头上。默认模式下动画的每一帧参数由 JavaScript 计算后下发,JS 线程堵塞,参数流中断,画面随之冻结——这正是 3.4 节「动画跑在哪条线程」判据的理论来源。只有把动画迁移到原生驱动或工作在 UI 线程的方案,账才从 JS 头上挪走。
问:把重计算放进 setTimeout 里算「挪窝」吗? 不算。定时器回调依然在 JS 线程执行,只是推迟了拥堵时刻。真正的挪窝有两条路:把计算改造成分片异步(每片处理一小批,片间让出线程),或把计算下沉为原生模块(在后端线程跑,结果以事件或回调送回)。选哪条看计算的性质:可分解的用分片,依赖系统能力的用下沉。
问:怎么快速判断眼前的问题该查 JS 帧还是 UI 帧? 开内置监视器看两条曲线对故障时刻的响应:操作发生时 JS 帧飙升,嫌疑在逻辑与渲染计算;JS 平稳而 UI 帧飙升,嫌疑在绘制成本(大图、层级、透明度叠加)。若两条同时飙升,按「先修 JS」处理——JS 线程的拥堵会连锁拖慢 UI 帧的提交,先解开上游再看下游。这个三分法能在一分钟内把排查空间砍掉一半。
病理清楚了,下一节看处方:JSI、Fabric 与 TurboModules 如何逐一拆除这些瓶颈。