用了三个月 Hermes,从最初的无感切到后面专门盯它的 GC 和启动链路,踩了不少坑才把收益吃透。很多人一入门就冲着各种黑科技优化去了,结果基础没打牢,后面排查问题像猜谜。标题里说的 4 件事,其实是我在项目里反复交学费之后总结的顺序——别的花活都可以往后放,这四样搞不清楚,你后面的所有优化都建立在不稳的地基上。
先说清楚,Hermes 是给 React Native 场景设计的开源 JavaScript 引擎,核心目标就两个:让 App 启动更快、内存占用更低。它跟普通 JavaScriptCore 的最大区别在于,源码在打包阶段就能被编译成专有的字节码,真机上直接加载执行,省掉了运行时解析和编译那一大截开销。刚接触的时候你可能觉得这只是一个开关,打开就行,但实际上它的运行机制、接入方式、调试思维和传统 RN 开发完全不同。这篇文章就是我三个月实战下来的核心总结,按我说的顺序学,弯路能少走一大半。
1. 先搞懂 Hermes 的运行机制,比急着开开关重要
很多人一上来就在构建配置里把hermesEnabled改成 true,跑起来发现确实快了,但一旦出问题就完全不知道从哪下手。这不是你的问题,是缺了对引擎本身的认知。我建议你在碰任何配置之前,先花一天时间把 Hermes 的运行机制读明白。机制通了,后面所有优化决策都有依据。
1.1 Hermes 在整个 App 里到底承担什么角色
在 React Native 框架里,JavaScript 代码不是在系统浏览器里跑的,而是由一个内嵌的 JS 引擎解释执行。传统方案用的是系统自带的 JavaScriptCore,现在很多国内团队会换成了 V8,而 Hermes 是专为 React Native 量身打造的选择。它的定位很简单:针对移动端内存小、CPU 受限、启动要快的场景做了大量底层优化。
我打个比方。JavaScriptCore 像是一个多功能工具车,什么活儿都能干,但每次出发前都要现场整理工具、熟悉路线,所以起步慢、油耗高。Hermes 则像是提前打包好的专车——所有工具都按固定位置摆好,路线也提前规划完了,上车就能走。这个提前规划的动作就叫预编译,是 Hermes 与普通引擎最本质的性能分水岭。
理解了这层角色关系,你才能明白为什么 Hermes 切换后启动速度能提升 20% 到 50%,为什么低端机上内存占用明显下降,也才能理解后面要讲的内存调优为什么和 V8、JavaScriptCore 的思路完全不同。它们解决的是同一个问题,但实现路径南辕北辙。
1.2 字节码预编译:快在哪,省在哪
Hermes 在构建 RN 应用时,会先把 JavaScript 源码编译成一种 Hermes 专用的字节码文件,后缀是.hbc。这个字节码不是机器码,它仍然需要 Hermes 引擎来解释执行,但相比纯源码,它少了两大耗时环节:语法解析和字节码生成。
我做一个简单的分解。传统 JavaScriptCore 加载一份 JS 源码,需要经历以下步骤:
- 读取源码字符串
- 词法分析,把字符串拆成一个个 token
- 语法分析,构建抽象语法树
- 生成字节码
- 执行
Hermes 直接把步骤 1 到 4 挪到了构建时完成。App 启动后引擎加载.hbc文件,直接进入解释执行阶段,这一步就省掉了整个解析编译过程。对于一份动辄几 MB 的 JS bundle,省下的时间非常可观,尤其在低端 Android 机上,光这一项就能让启动时间缩短几百毫秒。
字节码文件还有另一个优势:体积通常比源码加 source map 更紧凑,同时它不暴露原始代码结构,能起到一定的代码保护作用。当然,懂行的开发者都知道这只是混淆的一部分,不能当作完备的安全方案,但它确实能挡住大部分好奇的浏览。
1.3 关闭 JIT 不是倒退,是在换一条路
很多做前端出身的朋友第一次听说 Hermes 默认关闭 JIT,第一反应是“这不是性能倒退吗”。我要解释一下这个设计背后的取舍逻辑。
JIT(即时编译)是很多高性能 JS 引擎的利器,它的思路是在运行时监测热点代码,把它们编译成机器码直接执行,从而大幅提升长时运行的性能。但 JIT 有一个特点:它需要预热,而且在预热过程中要消耗额外的内存来存放编译产物。对于移动端来说,这个预热过程可能发生在用户最敏感的启动阶段,内存峰值也会往上顶。
Hermes 选择放弃 JIT,全部走 AOT 或解释执行,换来的是两个可预测的收益:启动时间稳定,不会出现“越跑越快但前面卡顿”的体验;内存占用平稳,不因为动态编译在短时间内突然飙升。对于多数中低复杂度的 RN 页面来说,这个取舍非常划算,因为移动端 JS 任务往往短平快,JIT 还没来得及发挥优势,任务就结束了。
所以别再纠结“为什么没有 JIT”了,你要想清楚的是你产品的真实场景。如果你的页面有大量长驻的复杂计算逻辑、动画循环、数据密集型任务,那 Hermes 可能不适合你;如果你的瓶颈在启动和首屏,Hermes 几乎是最优解。这也解释了为什么这三个月我越用越觉得,先学机制再动手是正确的开场。
2. 项目接入的正确姿势,决定后面省不省心
运行机制有了底,第二步就是把 Hermes 正确接到项目里。很多人觉得这就是改一个配置项的事,实际操作中问题不少,尤其是 Android 和 iOS 的行为不对称、老项目的历史包袱,都能让你卡上好几天。我把我接入过程的完整经验和盘托出。
2.1 Android 端接入:不只开关,还有构建变体
对于 React Native 0.70 以上的项目,Hermes 基本是默认开启的。如果你用的版本比较新,打开android/app/build.gradle,大概率能看到类似这样一段配置。
react { def reactNativeVersion = "0.73.0" enableHermes = true }但别以为这事到这就算完了。Hermes 开启后,Android 构建会多出几个与引擎相关的构建变体,比如hermes-release、hermes-debug。这是因为 Hermes 在 debug 和 release 下的行为不同——debug 模式为了支持调试器,可能会保留更多的运行时信息,release 才是真正的全量字节码。
实际操作中我遇到过这么个坑:项目有多个 flavor,比如dev、prod两个渠道,我在dev下开了 Hermes,结果prod的构建脚本因为没有同步更新就挂了。排了半天才发现,是 flavor 维度和构建变体组合出来的任务名对不上。后来我把所有渠道构建脚本都统一检查一遍,确保enableHermes在所有变体下都保持一致,才算彻底干净。
2.2 iOS 端接入:Pod 依赖的隐形变化
iOS 端接入 Hermes 的方式和 Android 略有差异。从 RN 0.70 开始,iOS 默认也用 Hermes,但如果你是从老版本升级上来的,需要重新执行一次pod install,它会根据Podfile里的:hermes_enabled参数拉取对应的RNDHermes编译产物。
值得留意的是,Hermes 在 iOS 上会以预编译的静态库形式集成,这意味着首次pod install的时间明显变长,有时候甚至会让人觉得卡死了。我们团队的构建机器上第一次集成就跑了接近二十分钟,后来发现是 Hermes 的预编译库需要下载,网络稍慢就会出现这种情况。
如果你切换之后发现 iOS 的构建产物体积变大了,别慌,这是 Hermes 静态库的正常表现。相比 JavaScriptCore 由系统提供,Hermes 是打包进 App 的,体积自然会上涨。对于大多数商业 App 来说,用这几 MB 的空间换几十毫秒的启动提升,是划算的,但也需要你在团队内部说清楚这个取舍。
2.3 老项目迁移时最容易翻车的三个环节
如果你是老项目从 JavaScriptCore 迁移到 Hermes,我踩过的坑基本可以总结成三个高发区。
第一是原生模块兼容性。Hermes 对 JSI(JavaScript Interface)的支持与 JavaScriptCore 不完全一致,某些第三方原生模块如果直接操作 JS 引擎的 API,可能出现行为异常。我在项目里遇到过图像处理的 SDK 在打开 Hermes 后偶发调用失败,后来发现是它的原生代码里用了一些与 JSC 深度绑定的接口,换了一种实现方式才解决。
第二是 Hermes 对某些 ES 新特性的支持。虽然 Hermes 一直在跟进 ECMAScript 规范,但它毕竟有自己的优先级,个别新 API 或语法可能暂不支持。代码里如果用了比较新的对象方法、数组方法,建议在切换前做一次全量扫描,或者直接跑一遍完整的自动化测试。
第三是 Source Map 与堆栈还原。Hermes 的堆栈格式与 JavaScriptCore 不同,报错信息里出现的不是源文件路径,而是字节码偏移。如果你没有提前配置 Hermes 的 symbol 映射和 source map 处理,线上报错基本等于不可读。这个点我在后面调试章节会展开,这里先提醒你,千万别等到线上出问题了才回头看。
3. 入门最该学的 4 件事,顺序别乱
现在可以讲标题里承诺的 4 件事了。为什么我强调顺序?因为如果你跳过前两件事直接来学第四件,你会像没学四则运算就去解方程一样,每一步都走得通,但每一步都解释不了为什么。
3.1 第一件事:学会把启动优化做成可量化的指标
三个月前我第一次切 Hermes,发现启动快了,但问自己“快了多少”,我说不出来。这是入门者最典型的问题——把“感觉快了”当成优化成果。真正专业的做法是,在动工之前就建立起一套完整的启动测量方案。
我现在的方案是分三段量:
- 引擎初始化耗时:从引擎开始加载到执行第一个 JS 语句之间的时间
- 业务代码执行耗时:从 JS 入口文件开始执行到首页核心组件完成渲染
- 首帧可用时间:用户看到第一个可交互界面的时间点
具体的测量工具,我推荐这几个:
// 引擎内记录关键时间点 const timing = global.performance.timing || {} console.log('[HermesTiming] AppStart', Date.now()) // ... 业务初始化逻辑 console.log('[HermesTiming] AppReady', Date.now())配合 Android 的adb logcat或者 iOS 的 Instruments,就可以把不同时间段的耗时拆出来对比。我在项目里用这个方法对比了 JavaScriptCore 和 Hermes 的差异,发现引擎初始化这一块从平均 320ms 降到了 140ms,业务代码执行从 600ms 左右降到了 450ms,整体启动流程优化效果非常直观。
有了这套量化指标,你后续做的任何优化——比如延迟初始化、拆包加载、预加载数据——都能准确评估是正向还是负向,再也不用拍脑袋。
3.2 第二件事:理解内存模型,盯住 GC 行为
Hermes 的垃圾回收机制和其他引擎不一样,不乱调参数,但必须会在日志里读懂 GC 行为。Hermes 默认使用一种分代式 GC,针对移动端弱化了 Full GC 频率,更依赖新生代的快速回收。这个设计带来一个特点:短期内大量创建临时对象,开销会比传统引擎小,但如果代码里存在隐性的内存泄漏,GC 日志会有很明显的特征。
我在接入后大约第三周,发现某低端测试机上 App 的内存随时间持续上升,通过 Hermes 的调试工具抓 GC 日志,看到fullGC的标记频繁出现,每次回收后内存并没有回落到健康水位。最终定位到的根因是一个全局事件监听器在页面销毁时没有移除,导致页面对象一直被引用。这个问题在 JavaScriptCore 下也存在,但 Hermes 的分代 GC 对这类问题的暴露方式更明显。
Hermes 内存调优的正确姿势是,先抓内存快照分析对象保留路径,而不是盲目调 GC 参数。我后面会在调试章节讲具体怎么抓快照,这里你只要记住一个核心概念:Hermes 的 GC 对临时对象友好,对驻留对象严厉。代码里如果写了“长期持有不释放”的模式,遇到 Hermes 会放大问题。
3.3 第三件事:用预加载和缓存盘活首屏
引擎再快,如果首屏接口要等网络返回才能渲染,体验照样拉胯。这里是我三个月里收获最大的一件事——把 Hermes 的启动收益和业务缓存策略叠加起来。
我做的第一个实验是首屏数据的本地缓存预热。做法很简单:
- 在 App 冷启动时,优先展示本地缓存的历史数据
- 同时后台发起网络请求,拿到最新数据后对比版本号
- 如果远端数据更新,再触发增量渲染
这个策略配合 Hermes 的快速启动,整体效果是我用过的方案里最顺滑的。JavaScriptCore 阶段,冷启动到缓存数据渲染出来差不多要 900ms,切到 Hermes 后直接压到 600ms,用户几乎感觉不到“加载”这个过程。
另一个值得做的是 JS 代码的按需加载。Hermes 支持多个字节码模块的加载,你可以在业务侧使用类似的动态 import 机制,把不常访问的页面代码做成懒加载模块,而不是把所有代码打在一个大包里。首屏只需要执行一小部分字节码,渲染速度自然更快。但注意,这个优化有代价,页面跳转时会有一次额外的模块加载等待,需要在体验和首屏速度之间做权衡。
3.4 第四件事:吃透 Hermes 的调试与排错工具链
前三件事解决的是“优化”,这件事解决的是“出了问题怎么办”。我在正式切换到 Hermes 后的第一周就遇到线上报错看不明白的情况,那个崩溃堆栈显示的不是行号,而是一串字节码偏移数字,当时整个人是懵的。
Hermes 的调试工具有几个关键的,你必须提前熟悉:
Hermes Debugger:支持在 Chrome DevTools 里直接调试运行在真机上的 Hermes 引擎,断点、单步、查看作用域变量都不在话下。启动方式是在 RN 的 dev 菜单里选择 Open Debugger,或者直接用 Metro 提供的调试端口。
内存快照工具:Hermes 可以从引擎内部导出内存快照,用 Chrome DevTools 的 Memory 面板分析堆结构。我能定位到前面说的全局事件监听器问题,靠的就是这套东西。
符号化工具:线上堆栈的字节码偏移需要配合构建时生成的 source map 与符号文件才能映射回源码位置。RN 社区有现成的脚本可以做这步转换,我的建议是把它集成到持续集成流程里,每次构建自动保存产物,避免线上出问题时找不到对应版本的映射文件。
调试这件事宁可提前三天准备,也不要等线上出问题再学。我在团队里推了一个小规矩:凡是开启 Hermes 的项目,必须配置好 source map 自动上传和堆栈还原脚本,没配好的不给发版。这半条规矩确实挡掉过好几次发版事故。
4. 一次完整的接入与验证实操
前面讲的都是认知和拆解,这一章我按时间顺序回放一遍自己接手项目的完整操作过程,包含每一步的命令、代码和验证指标,你可以直接照着做一个 POC。
4.1 接入前的检查与准备
我建议你正式切换 Hermes 之前,先花半天做一次现状盘点,避免后面横生枝节。
- 确认 React Native 版本:0.70 以上默认支持 Hermes,0.64 到 0.69 需要手动打开,0.63 以下不建议直接切
- 梳理第三方原生依赖:列出所有含原生代码的 SDK,逐个确认其对 Hermes 的兼容性
- 准备至少两台测试机:一台中高端 Android,一台低端 Android,低端机才能测出 Hermes 的真实优势
- 建立性能基线:用你现有的 JavaScriptCore 版本跑一遍启动耗时、首屏渲染耗时、内存峰值,记录成文档
这个准备过程非常重要,尤其是最后一条。没有基线的优化都是自嗨,你必须清楚地知道“从哪个数字优化到了哪个数字”,这个对比会在你向团队汇报成果的时候成为最有说服力的证据。
4.2 切换 Hermes 后的构建与验证步骤
准备工作完成后,按下面的顺序操作。
第一步,修改构建配置。以 React Native 0.73 版本为例,Android 端的配置在android/app/build.gradle:
react { enableHermes = true }iOS 端,如果你的 Podfile 没有显式指定,在 React Native 自动生成的 Podfile 里会默认使用 Hermes。老版本项目建议自己维护Podfile,确认包含:
:hermes_enabled => true修改之后,执行bundle exec pod install。这一步如果网络不稳定,可能会因为拉取预编译产物失败而中断,建议在稳定的网络环境下执行,同时注意磁盘空间。
第二步,打一个 release 包验证。注意用 debug 包验证是不准的,debug 模式下 Metro 服务器会走 JIT,Hermes 的字节码预编译优势完全发挥不出来。必须打 release 包,在真机上安装测试。
cd android && ./gradlew assembleRelease第三步,确认 Hermes 真的生效。启动 App,用adb logcat抓启动日志。
adb logcat | grep -i hermes如果看到HermesVM或者hermes相关的初始化和 GC 日志,说明引擎已正确加载。没有日志的话,多半是配置没刷进去,或者有缓存,先清理一下重新构建。
第四步,验证业务功能。这一条很多人会漏。Hermes 切换后,不止要跑主流程测试,建议把边界场景也跑一遍。特别是涉及大量字符串拼接、正则匹配、日期处理这类会调用引擎内建函数的逻辑,行为可能和 JavaScriptCore 有差异。
4.3 量化收益的对比方法
对比阶段我用的方法是 A/B 构建双包测试:同一份业务代码,分别用旧引擎和新引擎打两个 release 包,在相同的测试机上,用相同的操作路径各跑 10 次启动,记录数据。
具体可以量化的维度有:
| 指标 | 测量方式 | 预期变化方向 |
|---|---|---|
| 冷启动时间 | adb shell am start -W或 iOS Instruments | 下降 15% - 40% |
| 首屏渲染完成时间 | 业务侧埋点 | 下降 10% - 30% |
| 内存峰值 | Android Profiler / Xcode Memory Graph | 下降或持平 |
| 安装包体积 | 构建产物直接查看 | 上涨,预期在 3-8 MB 范围 |
这份对比表做完,你就可以很有底气地说“Hermes 适不适合当前项目”,而不是停留在“感觉快了”的模糊状态。
5. 常见问题与排查技巧实录
最后这部分,我把三个月里遇到的高频问题和对应的排查思路全部整理出来。这些问题技术文档里不会写,但几乎每个人都会遇到。
5.1 高频问题速查表
| 问题现象 | 优先排查方向 | 参考解法 |
|---|---|---|
| 切换后首包体积明显变大 | Hermes 静态库是否同时被多个架构打进包 | 检查app/build.gradle的 ABI 过滤,按需保留arm64-v8a |
| debug 模式根本看不出性能提升 | 你用的是 debug 包 | 必须打 release 包测试 |
| 线上堆栈全是字节码偏移 | 没有配置 source map 还原 | 保存构建产物并配置堆栈还原服务 |
| 部分第三方原生模块报错 | 该模块底层依赖 JSC 特定接口 | 联系维护方升级兼容版本,或回退 JS 实现 |
| 内存曲线持续上涨 | 业务代码存在未释放的全局引用 | 用内存快照工具分析保留路径 |
| 偶发启动卡顿 | 可能在加载超大 JS 模块 | 拆包或调整动态导入边界 |
| 某些 ES API 不可用 | Hermes 版本对新语法支持不全 | 升级 Hermes 版本或在构建时做语法转译(配合 Babel 处理) |
5.2 三个典型现场复盘
第一个现场是真机性能异常。当时在低端 Android 上切了 Hermes,启动不降反升,查了半天发现是android:minSdkLevel的兼容层在搞鬼,Hermes 对旧版系统的支持要额外启动一个 software 模式,性能反而下降。解决方案是放弃对老旧系统的等待和妥协,提高了最低系统版本要求。
第二个现场是调试器连不上。Hermes 的调试端口和 Metro 有一定联动关系,如果项目里设置了自定义端口,dev server 的转发要对应修改。查了一圈才发现是手机和电脑不在同一网段,dev server 地址不匹配。这类问题排查起来最磨人,但结论往往很简单。
第三个现场是内存快照打不开。Hermes 的内存快照格式是.heapsnapshot,要用 Chrome DevTools 打开,而快照文件可能因为版本不匹配而无法解析。我后来统一了 Hermes 版本和 Chrome 版本之间的对应关系,再没出过问题。
5.3 想做 Hermes 深度优化的几条独门心得
最后分享几个踩过几次坑之后沉淀下来的心得。
第一,Hermes 的 GC 日志是宝藏,不要只盯着业务日志。搭建字节码 SDK 的时候记得把引擎侧的 GC 指标透出到监控平台,后续优化内存会省很多事。
第二,Hermes 字节码的缓存机制值得深入研究。如果你有多个 JS 模块需要反复切换,合理利用模块缓存可以减少重复编译,这是很多性能专题不会提的细节。
第三,不要迷信默认配置。Hermes 的很多参数在 release 和 debug 下行为不一致,一定要理解你能调哪些参数、然后根据项目实际场景调整。比如某些业务为了减少首次渲染压力,可以调整 GC 触发阈值,但这个必须跑过完整的性能测试之后才能定。
第四,团队协作时一定要约定共同标准。我后来给团队定了四个默认:SA 发布必须附带 Hermes 版本与 JS bundle 版本;总是保存 source map;性能回归测试默认跑 Hermes 模式;遇到疑难问题时优先切 debug 抓 GC 日志。有了这几条铁律,团队里后来不管是新人还是熟手,遇到 Hermes 相关问题都有章可循,不会互相踢皮球。
我个人在实际操作中的体会是,Hermes 不是一个“换了就完事”的开关,而是一条值得投入时间去熟悉的完整技术栈。这三个月里,最值钱的不是那 20% 的启动提速,而是我通过它把 React Native 底层的执行原理、内存管理、构建链路全部串了起来。如果你刚入门,别急着折腾那些花哨的优化项,按我说的顺序把运行机制、接入配置、核心四件事、调试工具链一样一样吃透,后面你会发现,那些所谓的“弯路”,其实大部分是因为基础认知缺失造成的。