Hermes将成为React Native默认的JS引擎

语言: CN / TW / HK

自 2019 年首次发布以来,小巧轻便的 JavaScript 引擎 Hermes 在社区中的名气越来越高,很多的框架也开始支持Hermes。作为 React Native 领域高人气元框架的缔造者,Expo 团队此前公布了对 Hermes 的实验性支持。另外,流行移动数据库 Realm 团队近期也决定为 Hermes 提供 alpha 支持。

在本文中,我们希望重点介绍过去两年来在推动 Hermes 成为 React Native 最佳 JavaScript 引擎方面取得的各项激动人心的进展。展望未来,我们有信心通过更多改进让 Hermes 成为各类平台上 React Native 中的默认 JavaScript 引擎。

专为 React Native 而优化

Hermes 中的功能定义,负责指示要如何提前执行编译工作。换言之,启用 Hermes 的 React Native 应用程序会附带经过预编译优化的字节码,而非纯 JavaScript 源代码。这就大大减少了用户启动产品所需要的工作量。来自 Facebook 及社区其他应用的量化测试表明,启用 Hermes 通常能够将产品的 TTI(即交互时间)指标缩短近一半。

但我们不会止步于此,始终致力于对 Hermes 进行全方位改进,努力让它成为最出色的 React Native 专用 JavaScript 引擎。

为 Fabric 建立新的垃圾收集器

在新一代 React Native 架构中崭露头角的Fabric 渲染器可谓万众瞩目,它能够在 UI 线程上同步调用 JavaScript。但如果 JavaScript 线程的执行时间过长,则会导致明显的 UI 丢帧、令用户无法正常输入。

React Fiber 提供的并发渲染机制能够将渲染工作拆分成多个块,由此避免单一 JavaScript 任务占用过长时间。此外,JavaScript 线程当中还有另一大常见延迟来源——垃圾收集(GC)机制。因为一旦开始垃圾收集,整个 JavaScript 引擎必须放下手头的所有工作去执行垃圾收集。

Hermes 当中的原有默认垃圾收集器GenGC 属于单线程分代垃圾收集方案。其中会对新生代采用典型的半空间复制策略,而对老年代则使用 mark-compact 策略、从而更主动将内存返还至操作系统。

在像 Facebook for Android 这样的复杂应用上,我们观察到的平均暂停时长为 200 毫秒,而第 99 百分位暂停则为 1.4 秒。考虑到 Facebook for Android 庞大且多样化的用户群体,最极端的暂停时间甚至长达 7 秒。

为了缓解这种情况,我们建立起全新的、以并发为主要取向的垃圾回收方案,即Hades。Hades 同样采用分代设计,其新生代回收方式与 GenGC 完全相同,而老年代回收方式则通过快照式标记扫描收集器进行管理。

Hades 能够将大部分工作负载交由后台线程执行,从而显著缩短垃圾回收暂停时长,同时不会阻止引擎主线程继续执行 JavaScript 代码。我们的统计数据显示,Hades 在 64 位设备上第 99.9 百分位上的延迟为 48 毫秒(比 GenGC 快 34 倍!),而在 32 位设备上第 99.9 百分位上的延迟约 88 毫秒(以单线程增量 GC 的形式运行)。

但由于需要资源成本更高的写屏障、速度更慢的基于空闲列表的分配机制(与碰撞指针分配器相反)以及更多的堆碎片,Hades 实际是在用整体吞吐量来换取更短的暂停时间。我们认为这样的取舍符合用户习惯,也将通过合并与接下来将要讨论的其他内存优化机制,实现更低的整体内存占用量。

改善性能问题

应用程序的启动时长对很多应用产品来说至关重要,我们也希望不断提升 React Native 的性能上限。对于在 Hermes 当中实现的一切 JavaScript 功能,我们都会认真监测它们对生产性能造成的影响,并确保它们不会拉低性能指标。

在 Facebook,我们目前正在为 Metro 中的 Hermes 试验一个专用的 Babel transform profile ,希望用 Hermes 中的原生 ESNext 实现替换掉原本的十余种 Babel transform。通过这种方式,我们已经在直接观察中将 TTI 改进了 18% 至 25%,整体字节码获得显著瘦身;希望接下来也能在 OSS 中得到类似的结果。

除了启动性能之外,我们还将内存占用量视为改进 React Native 应用程序的重要机会,这也是成就良好虚拟现实体验的前提。因此在 JavaScript 引擎的底层控制当中,我们利用压缩位与字节实现了多轮内存优化:

  1. 此前,所有 JavaScript 值都被表示为 64 位 NaN 装箱编码的标记值形式,用以表示 64
    位架构上的双精度浮点值与指针。但这种方式在实践中属于巨大浪费,因为大多数数字实际都属于小整数(SMI),而且客户端应用程序的JavaScript 堆通常不会大于 4 GiB。为了解决这个问题,我们引入了一种新的 32 位编码,其中 SMI 与指针以 29位编码(因为指针为 8 字节对齐,可以假设底部 3 位始终为零),其余的 JS 数字则装箱在堆上。如此一来,JavaScript的堆大小就缩减了约 30%。
  2. 不同类别的 JavaScript 对象在 JS 堆上表示为不同的 GC 管理单元。通过主动优化这些单元头的内存布局,我们得以将内存占用量进一步削减约 15%。

对此,我们在 Hermes 中还做出一项关键决定,即不再采用即时(JIT)编译器。因为我们认为对于大多数 React Native 应用程序来说,额外的预热成本与额外的二进制文件乃至内存占用量并没有实际意义。

多年以来,我们在解释器性能与编译器的优化方面投入了大量精力,也让 Hermes 获得了远超其他引擎的 React Native 工作负载吞吐量优势。我们将继续关注广泛存在的性能瓶颈(解释器调度循环、堆栈布局、对象模型、垃圾回收等)以提高吞吐量。

垂直整合领域

在 Facebook,我们习惯于使用大型 monorepo 托管项目。通过将引擎(Hermes)与 host(React Native)紧密迭代在一起,现在我们为垂直整合开辟出广阔空间。以下是几个具体的例子:

  • Hermes 使用 Chrome DevTools 协议支持使用 Chrome 调试器在设备上执行 JavaScript 调试。这种方法比传统“远程 JS 调试”(使用应用内代理在桌面 Chrome 上运行 JS)效果更好,因为它支持调试同步本机调用并能保证统一的运行时环境。与 React DevTools、Metro 以及 Inspector 等一道,Hermes 调试器现已成为 Flipper 中的组成部分,共同提供良好的一站式开发者体验。
  • 在 React Native 应用的初始化路径中分配的对象往往长期存在,而且并不符合分代 GC 所提出的分代假设。因此,我们在 React Native 中配置 Hermes 时,会将前 32 MiB 直接分配至老年代(即 pre-tenuring)以避免触发 GC 暂停与延迟 TTI。
  • 新的 React Native 架构在很大程度上基于 JSI(即 JavaScript Interface),这是一种轻量级通用 API,用于将 JavaScript 引擎嵌入至 C++ 程序当中。通过让维护 JS 引擎的团队同时维护 JSI API 实现,我们有信心提供最佳集成效果。而且这套集成方案已经在 Facebook 的大规模业务之上经过实战测试,拥有良好的可靠性与运行效率。
  • 拥有语义正确且性能良好的 JavaScript 并发原语(例如 promises)及平台并发原语(例如 microtasks),对于 React 并发渲染以及 React Native 应用程序的未来发展可谓至关重要。从历史上看,React Native 中的 promise 是使用非标准化 setImmediate API 作为腻子脚本。我们正努力通过 JSI 实现来自 JS 引擎的原生 promises 和 microtasks,并将 queueMicrotask(Web 标准中的新增项目)引入平台,从而更好地支持现代异步 JavaScript 代码。

社区发展

Facebook 公司非常重视 Hermes 项目,但只有为 Hermes 建立起完整的生态系统、特别是技术社区,开发工作才算真正告一段落。也只有这样,每个人才能充分运用 Hermes 的功能并发挥其潜力。

扩展至更多新平台

Hermes 最初仅面向 React Native on Android 开源。在此之后,我们很高兴看到社区成员们逐渐拓展 Hermes 的支持范围,目前已经将其扩展到 React Native 生态系统 所覆盖的多种其他平台。

Callstack 率先在 React Native 0.64 当中将Hermes 引入 iOS。他们还发布了系列专题文章,并发起播客向用户们介绍他们如何实现这一目标。根据基准测试,与 Mattermost 应用的 JSC 相比,Hermes 在 iOS 上的启动性能可稳定提升约 40%、内存占用量减少约 18%、应用程序运行期间的内存用量仅为 2.4 MiB。

微软则不断将Hermes 引入 Windows 与 MacOS 上的 React Native。在微软 Build 2020 大会上,软件巨头表示相较于原本的 Chakra 引擎,Hermes 能够将 React Native for Windows 的内存占用量降低 13%。而在最近的一些综合基准测试中,微软发现 Hermes 0.8(包含 Hades 及之前提到的 SMI 与指针压缩优化功能)占用的内存量比其他引擎少 30% 至 40%。毫无疑问,基于 React Native 的桌面版 Messenger 视频通话体验也在 Hermes 的支持下得到显著改善。

更重要的是,Hermes 还一直在为 Oculus 上使用 React 系列技术构建的各类虚拟现实体验提供支持,其中也包括 Oculus Home。

社区支持

我们承认,目前 Hermes 身上仍有一些问题阻碍着更多社区的顺畅介入,我们也在努力为这些缺失的功能建立支持。我们的目标是尽快实现功能完备,让 Hermes 成为大多数 React Native 应用程序的最佳选择。以下是社区正在筹划的 Hermes 发展路线图:

  • 由于 Facebook 并不使用,所以Proxy 与 Reflect 最初被排除在 Hermes 之外。我们当时担心即使不真正使用,贸然添加 Proxy 也会损害属性查找性能。但随着MobX 与Immer 等库的流行,Proxy 很快成为 Hermes 当中最受欢迎的功能。经过认真评估,我们决定针对社区提供专用 Proxy,而且设法以极低的成本完成实现。由于 Facebook 并不使用此功能,所以只能依靠技术社区证明其稳定性。我们首先在 0.4 与 0.5 版本中以标记和创建 opt-in npm 包的形式启动了 Proxy 测试,并从0.7 版本开始将其默认启用。
  • ECMAScript 国际化 API 规范 (简称 ECMA-402 或 Intl)同样是用户呼声中的焦点。Intl 代表一组庞大的 API,通常需要包含 6 MB 大小的 Unicode CLDR 数据才能实现。正因为如此,FormatJS(又名 react-intl)等腻子脚本以及 社区 JSC 的国际变体 build 等 JS 引擎才如此臃肿笨拙。为了避免 Hermes 二进制文件体积的不必要膨胀,我们决定直接使用并映射操作系统内置库所提供的 ICU facilities 来实现,相应的代价就是给某些跨平台行为引入一些(通常较为微小的)差异。
  • 微软合作完成了 Android 上的 build 支持工作。其中几乎涵盖从 ECMA-402 到 ES2020 的所有内容,而对体积的影响仅有 3%(每个 ABI 仅为 57 K 到 62 K)。我们在Twitter 上发起的民意调查发现,用户们强烈支持默认包含 Intl,所以我们决定从 0.8 版本开始引入这项功能。
  • Facebook 已经赞助 Major League Hacking 发起远程开源奖学金计划。去年,我们推出了 Hermes 采样分析器;今年,我们的研究员将与 Hermes、React Native 以及 Callstack 的成员们合作,在 iOS 上实现对 Hermes Intl 的支持。
  • 感谢大家反馈中提到的默认堆大小上限太低问题,导致很多不熟悉自定义 Hermes GC 配置的用户会产生不必要的GC 压力乃至OOM 崩溃。因此在默认情况下,我们将上限由 512 MiB 增加到 3 GiB,这样的配置对大多数用户来说应该绰绰有余。
  • 有报告称,我们专用的 Function.prototype.toString 实现会导致库执行不正确的特征检测并导致性能下降,而且令用户无法执行源代码注入。在解决问题的同时,我们也更加坚定了 Hermes 应该尊重事实、尽量避免妨碍开发者顺畅使用的决心。

总结

总之,我们的愿景是让 Hermes 成为一切 React Native 平台上的默认 JavaScript 引擎。我们正在朝着这个方向努力,也希望充分听取大家来自不同角度的反馈意见。

只有做好万全准备,我们才能为生态系统广泛接纳 Hermes 奠定坚实的基础。在这里,我们诚邀大家体验 Hermes,并将您发现的一切建议、意见、功能请求与不兼容性错误提交给我们的 GitHub repo。

原文链接: Toward Hermes being the Default