🤔 移动端 JS 引擎哪家强?美国硅谷找……

📌

如果你喜欢我写的文章,可以把我的公众号设为星标 🌟,这样每次有更新就可以及时推送给你啦


在一般的移动端开发场景中,每次更新应用功能都是通过 Native 语言开发并通过应用市场版本分发来实现的。但是市场瞬息万变,Native 语言在开发效率上存在一定不足,并且从 APP 版本更新应用市场审核发布 再到 用户下载更新,总会存在一定的时间差,这样就导致新的功能无法及时覆盖全量用户。

为了解决这个问题,开发者们一般会在项目里引入一门脚本语言,提速 APP 的研发流程。在移动端应用比较广泛的脚本语言有 Lua 和 JavaScript,前者在游戏领域用的比较多,后者在应用领域用的比较多。本篇文章主要是想探讨一下移动双端(iOS & Android)的 JavaScript 引擎选型。由于个人水平有限,文章总会有遗漏和不足的地方,还请各位大佬多多指教。

JS 引擎选型要点

JavaScript 作为世界上最热门的脚本语言,有着非常多的引擎实现:有 Apple 御用的 JavaScriptCore,有性能最强劲的 V8,还有最近热度很高的 QuickJS……如何从这些 JS 引擎里选出最适合的?我个人认为要有几个考量:

  • 性能:这个没话说,肯定是越快越好
  • 体积:JS 引擎会增加一定的包体积
  • 内存占用:内存占用越少越好
  • JavaScript 语法支持程度:支持的新语法越多越好
  • 调试的便捷性:是否直接支持 debug?还是需要自己编译实现调试工具链
  • 应用市场平台规范:主要是 iOS 平台,平台禁止应用集成带 JIT 功能的虚拟机

比较麻烦的是,上面的几个点都不是互相独立的,比如说开启 JIT 的 V8 引擎,性能肯定是最好的,但它引擎体积就很大,内存占用也很高;在包体积上很占优势的 QuickJS,由于没有 JIT 加持,和有 JIT 的引擎比起来平均会有 5-10 倍的性能差距。

下面我会综合刚刚提到的几个点,并选择了 JavaScriptCoreV8HermesQuickJS 这 4 个 JSVM,说说它们的优点和特点,再谈谈他们的不足。

JS 引擎功能大比拼

1.JavaScriptCore

mobile_JSVM_JSC
mobile_JSVM_JSC


JavaScriptCore 是 WebKit 默认的内嵌 JS 引擎,wikipedia 上都没有独立的词条,只在 WebKit 词条的三级目录里介绍了一下,个人感觉还是有些不像话,毕竟也是老牌 JS 引擎了。

由于 WebKit 是 Apple 率先开源的,所以 WebKit 引擎运用在 Apple 自家的 Safari 浏览器和 WebView 上,尤其是 iOS 系统上,因为 Apple 的限制,所有的网页只能用 WebKit 加载,所以 WebKit 在 iOS 上达到了事实垄断,作为 WebKit 模块一部分的 JSC,顺着政策春风,也「基本」垄断了 iOS 平台的 JS 引擎份额。

垄断归垄断,其实 JSC 的性能还是可以的,很多人不知道 JSC 的 JIT 功能其实比 V8 还要早,放在十几年前是最好的 JS 引擎,只不过后来被 V8 追了上来。而且 JSC 有个重大利好,在 iOS7 之后,JSC 作为一个系统级的 Framework 开放给开发者使用,也就是说,如果你的 APP 使用 JSC,只需要在项目里 import 一下,包体积是 0 开销的!这点在今天讨论的 JS 引擎中,JSC 是最能打的。


虽然开启 JIT 的 JSC 性能很好,但是只限于苹果御用的 Safari 浏览器和 WKWebView,只有这两个地方 JIT 功能才是默认开启的,如果在项目里直接引入 JSC,JIT 功能是关闭的。为什么这么做呢?RednaxelaFX 大佬 给出过非常专业的解释

📌

JIT 编译需要底层系统支持动态代码生成,对操作系统来说这意味着要支持动态分配带有“可写可执行”权限的内存页。当一个应用程序拥有请求分配可写可执行内存页的权限时,它会比较容易受到攻击从而允许任意代码动态生成并执行,这样就让恶意代码更容易有机可乘。

Apple 出于安全上的考虑,禁止了第三方 APP 使用 JSC 时开启 JIT,这些特点在 React Native 的 JS Runtime 页面也有过相关的解释。不过在实际应用中,不做重 CPU 的运算只当胶水语言使用,JSC 还是绰绰有余了。


上面的讨论都是针对 iOS 系统的,在 Android 系统上,JSC 的表现就不尽人意了。JSC 并没有对 Android 机型做很好的适配,虽然可以开启 JIT,但是性能表现并不好,这也是 Facebook 决心制作 Hermes 的一个原因,具体的性能对比分析可见本文的 Hermes 小节。


最后再说说 JSC 的调试支持情况。如果是 iOS 平台,我们可以直接用 Safari 的 debbuger 功能调试,如果是 Android 平台,目前我还没有找到一个很好的真机调试方法。


综合来看,JavaScriptCore 在 iOS 平台上有非常明显的主场优势,各个指标都是很优秀的,但在 Android 上因为缺乏优化,表现并不是很好。

2.V8

mobile_JSVM_V8
mobile_JSVM_V8


V8,我想我不用过多解释了,JavaScript 能有如今的地位,V8 功不可没。性能没得说,开启 JIT 后就是业内最强(不止是 JS),有很多介绍 V8 的文章,我这里就不多描述了,我们这里说说 V8 在移动端的表现。


同样作为 Google 家的产品,每一台 Android 手机上都安装了基于 Chromium 的 WebView,V8 也一并捆绑了。但是 V8 和 Chromium 捆绑的太紧密了,不像 iOS 上的 JavaScriptCore 封装为系统库可以被所有 App 调用。这就导致你想在 Android 上用 V8 还得自己封装,社区比较出名的项目是 J2V8,提供了 V8 的 Java bindings 案例。

V8 性能没得说,Android 上可以开启 JIT,但这些优势都是有代价的:开启 JIT 后内存占用高,并且 V8 的包体积也不小(大概 7 MB 左右),如果作为只是画 UI 的 Hybrid 系统,还是有些奢侈了。

我们再说说 V8 在 iOS 上的集成。V8 在 2019 年推出了 JIT-less V8,也就是关闭 JIT 只使用 Ignition interpreter 解释执行 JS 文件,那么我们在 iOS 上集成 V8 就成了可能,因为 Apple 还是支持接入只有解释器功能的虚拟机引擎的。但是个人认为关闭了 JIT 的 V8 接入 iOS 价值不大,因为只开启解释器的话,这时候的 V8 和 JSC 的性能其实是差不多的,引入反而会增加一定的体积开销。


V8 还有一个有意思的特性很少人提及,那就是——堆快照(Heap snapshots),这个是 V8 在 2015 年就支持的功能,但是社区里很少有人讨论它。

堆快照是什么原理呢?一般来说 JSVM 启动后,第一步往往是解析 JS 文件,这个还是比较耗时的,V8 支持预先生成 Heap snapshots,然后直接加载到堆内存中,快速的获得 JS 的初始化上下文。跨平台框架 NativeScript 就利用了这样的技术,可以让 JS 的加载速度提升 3 倍,技术细节可以看他们的博文

V8_heap_snapshots
V8_heap_snapshots

V8 真机调试也需要引入第三方库,Android 端社区上有人对 J2V8 做了 Chrome 调试协议的扩展,即 J2V8-Debugger 项目,iOS 我没有找到相关的项目,可能需要自己实现一套扩展。


综合来看 V8 的确是 JSVM 中的性能王者,Android 端使用时可以完全发挥它的威力,但是 iOS 平台因为主场劣势,并不是很推荐。

3.Hermes

mobile_JSVM_hermes
mobile_JSVM_hermes


Hermes 是 FaceBook 2019 年中旬开源的一款 JS 引擎,从 release 记录可以看出,这个是专为 React Native 打造的 JS 引擎,可以说从设计之初就是为 Hybrid UI 系统打造。

Hermes 一开始推出就是要替代原来 RN Android 端的 JS 引擎,即 JavaScriptCore(因为 JSC 在 Android 端表现太拉垮了)。我们可以理一下时间线,FaceBook 自从 2019-07-12 宣布 Hermes 开源后,jsc-android 的维护信息就永远的停在了 2019-06-25,这个信号暗示得非常的明显:JavaScriptCore Android 我们不再维护啦,大家都去用我们做的 Hermes 啊

最近 Hermes 已经计划伴随 React Native 0.64 版本登录 iOS 平台了,但是 RN 版本更新 blog 还没有出,大家可以看看我之前对 Apple 开发者协议的解读:Apple Agreement 3.3.2 规范解读,在这里我就不多说了。


Hermes 的特点主要是两个,一个是不支持 JIT,一个是支持直接生成/加载字节码,我们在下面分开讲一下。

Hermes 不支持 JIT 的主要原因有两个:加入 JIT 后,JS 引擎启动的预热时间会变长,一定程度上会加长首屏 TTI(页面首次加载可交互时间),现在的前端页面都讲究一个秒开,TTI 还是个挺重要的测量指标。另一个问题上 JIT 会增加包体积和内存占用,Chrome 内存占用高 V8 还是要承担一定责任的。

因为不支持 JIT,Hermes 在一些 CPU 密集计算的领域就不占优势了,所以在 Hybrid 系统里,最优的解决方案就是充分发挥 JavaScript 胶水语言的作用,CPU 密集的计算(例如矩阵变换,参数加密等)放在 Native 里做,算好了再传递给 JS 表现在 UI 上,这样可以兼顾性能和开发效率。


Hermes 最引人瞩目的就是支持生成字节码了,我在之前的博文《🎯 跨端框架的核心技术到底是什么?》也提到过,Hermes 加入 AOT 后,BabelMinifyParseCompile 这些流程全部都在开发者电脑上完成,直接下发字节码让 Hermes 运行就行,我们直接用个 demo 演示一下。

Hermes
Hermes

先写个 test.js 的文件,里面随便写点啥都行;然后编译一下 Hermes 的源码,编译过程直接按文档来就行,我这里就略过了。

首先 Hermes 支持直接解释运行 JS 代码,就是正常的 JS 加载编译运行流程。

hermes test.js

我们可以加入 -emit-binary 参数尝试一下生成 Bytecode 的功能:

hermes -emit-binary -out test.hbc test.js

然后就会生成一份 test.hbc 字节码文件:

hermes_bytecode
hermes_bytecode

最后我们可以让 Hermes 直接加载运行 test.hbc 文件:

hermes test.hbc

客观评价一下 Hermes 的字节码,首先省去了在 JS 引擎里解析编译的流程,JS 代码的加载速度将会大大加快,体现在 UI 上就是 TTI 时间会明显缩短;另一个优势 Hermes 的字节码在设计时就考虑了移动端的性能限制,支持增量加载而不是全量加载,对内存受限的中低端 Android 机更友好;不过字节码的体积会比原来的 JS 文件会大一些,但是考虑到 Hermes 引擎本身体积就不大,综合考虑下来这些体积增量还是可以接受的。


关于详细的 Hermes 性能测试情况,网上有两篇文章写的比较好:一篇是 React Native Memory profiling: JSC vs V8 vs Hermes,可以看到在 Android 设备上 Hermes 的表现还是很优异的,而 JSC 的表现非常拉垮:

JSCvsV8vsHermes
JSCvsV8vsHermes

另一篇是携程的文章:携程对 RN 新一代 JS 引擎 Hermes 的调研,可以看出 Hermes 综合成绩最高(JSC 还是一样的拉垮):

JSVM_CPU_Performance
JSVM_CPU_Performance


说完性能我们再说说 Hermes 的 JS 语法支持情况。Hermes 主要支持的是 ES6 语法,刚开源时不支持 Proxy,不过 v0.7.0 已经支持了。他们的团队也比较有想法,不支持 with eval() 等这种属于设计糟粕的 API,这种设计的权衡我个人还是比较认同的。


最后我们谈谈 Hermes 的调试功能。目前 Hermes 已经支持了 Chrome 的调试协议,我们可以直接用 Chrome 的 debugging 工具直接调试 Hermes 引擎,具体的操作可见文档:Debugging JS on Hermes using Google Chrome’s DevTools


综合来看,Hermes 是一款专为移动端 Hybrid UI System 打造的 JS 引擎,如果要自建一套 Hybrid 系统,Hermes 是一个非常好的选择。

4.QuickJS

mobile_JSVM_quickjs
mobile_JSVM_quickjs


正式介绍 QuickJS 前我们先说说它的作者:Fabrice Bellard

软件界一直有个说法,一个高级程序员创造的价值可以超过 20 个平庸的程序员,但 Fabrice Bellard 不是高级程序员,他是天才,在我看来他的创造力可以超过 20 个高级程序员,我们可以顺着时间轴理一下他创造过些什么:

📌

  • 1997年,发布了最快速的计算圆周率的算法,此算法是 Bailey-Borwein-Plouffe 公式的变体,前者的时间复杂度是O(n^3),他给优化成了O(n^2),使得计算速度提高了43%,这是他在数学上的成就
  • 2000 年,发布了 FFmpeg,这是他在音视频领域的一个成就
  • 2000,2001,2018 三年三度获得国际混淆 C 代码大赛
  • 2002 年,发布了TinyGL,这是他在图形学领域的成就
  • 2005 年,发布了 QEMU,这是他在虚拟化领域的成就
  • 2011 年,他用 JavaScript 写了一个 PC 虚拟机 Jslinux,一个跑在浏览器上的 Linux 操作系统
  • 2019 年,发布了 QuickJS,一个支持 ES2020 规范的 JS 虚拟机

当人和人之间的差距差了几个数量级后,羡慕嫉妒之类的情绪就会转变为崇拜了,Bellard 就是一个这样的人。


收复一下心情,我们来看一下 QuickJS 这个项目。QuickJS 继承了 Fabrice Bellard 作品的一贯特色——小巧而又强大

QuickJS 体积非常小,只有几个 C 文件,没有乱七八糟的第三方依赖。但是他的功能又非常完善,JS 语法支持ES2020Test262 的测试显示,QuickJS 的语法支持度比 V8 还要高。

test262
test262


那么 QuickJS 的性能如何呢?QuickJS 官上有个基准测试,综合比较了多款 JS 引擎对同一测试用例的跑分情况。下面是测试结果:

JSVM_Benchmark
JSVM_Benchmark

结合上面的表格和个人的一些测试,可以简单的得出一些结论:

  • 开启 JIT 的 V8 综合评分差不多是 QuickJS 的 35 倍,但是在同等主打轻量的 JS 引擎中,QuickJS 的性能还是很耀眼的
  • 在内存占用上,QuickJS 远低于 V8,毕竟 JIT 是是吃内存的大户,而且 QuickJS 的设计对嵌入式系统很友好(Bellard 成就奖杯 🏆 再 +1)
  • QuickJS 和 Hermes 的跑分情况是差不多的,我私下做了一些性能测试,这两个引擎的表现也很相近


因为 QuickJS 的设计,我不经好奇他和 Lua 的性能对比如何。Lua 是一门非常小巧精悍的语言,在游戏领域和 C/C++ 开发中一直充当胶水语言的作用,我个人写了一些测试用例,发现 QuickJS 和 Lua 的执行效率也是差不多的,后来在网上找到一篇博文 Lua vs QuickJS,这个老哥也做了一些测试,结论也是它俩的性能差不多,在部分场景 Lua 会比 QuickJS 快一些。


官方文档里有提到,QuickJS 支持生成字节码,这样可以免去 JS 文件编译解析的过程。我一开始以为 QuickJS 和 Hermes 一样,可以直接生成字节码,然后交给 QuickJS 解释执行。后来自己编译了一下才发现,QuickJS 的作用机制和 Hermes 还不太一样:qjsc 生成字节码的 -e-c 选项,都是先把 js 文件生成一份字节码,然后拼到一个 .c 文件里,大概长下面的这个样子:

#include <quickjs/quickjs-libc.h>

const uint32_t qjsc_hello_size = 87;

// JS 文件编译生成的字节码都在这个数组里
const uint8_t qjsc_hello[87] = {
 0x020x040x0e0x630x6f0x6e0x730x6f,
 0x6c0x650x060x6c0x6f0x670x160x48,
 0x650x6c0x6c0x6f0x200x570x6f0x72,
 0x6c0x640x220x650x780x610x6d0x70,
 0x6c0x650x730x2f0x680x650x6c0x6c,
 0x6f0x2e0x6a0x730x0e0x000x060x00,
 0x9e0x010x000x010x000x030x000x00,
 0x140x010xa00x010x000x000x000x39,
 0xf10x000x000x000x430xf20x000x00,
 0x000x040xf30x000x000x000x240x01,
 0x000xd10x280xe80x030x010x00,
};

int main(int argc, char **argv)
{
  JSRuntime *rt;
  JSContext *ctx;
  rt = JS_NewRuntime();
  ctx = JS_NewContextRaw(rt);
  JS_AddIntrinsicBaseObjects(ctx);
  js_std_add_helpers(ctx, argc, argv);
  js_std_eval_binary(ctx, qjsc_hello, qjsc_hello_size, 0);
  js_std_loop(ctx);
  JS_FreeContext(ctx);
  JS_FreeRuntime(rt);
  return 0;
}

因为这是个 .c 文件,想跑起来还得编译一次生成二进制文件。

从字节码这个设计点来看,QuickJS 和 Hermes 的定位还是不太一样的。虽然直接生成字节码可以大大减少 JS 文本文件的解析时间,但是 QuickJS 还是更偏嵌入式一些,生成的字节码放在一个 C 文件中,还需要进行编译才能运行;Hermes 为 React Native 而生,生成的字节码一开始就考虑到分发功能(热更新就是一个应用场景),支持字节码的直接加载运行,不需要再编译一次。


上面主要还是对性能的考量,下面我们看看开发体验,首先是 QuickJS 的调试功能支持。到目前为止(2021-02-22),QuickJS 还没有官方的调试器,也就是说 debugger 语句会被忽略,社区有人实现了一套基于 VSCode 的调试器支持 vscode-quickjs-debug,但是会对 QuickJS 做一些定制,个人还是蛮期待官方支持某个调试器协议的。

集成 的角度上看,社区上已经有了 iOSAndroid 的示例项目,可以拿来用来参考接入到自己的工程中。


综合来看,QuickJS 是一款潜力非常大的 JS 引擎,在 JS 语法高度支持的前提下,还把性能和体积都优化到了极致。在移动端的 Hybrid UI 架构和游戏脚本系统都可以考虑接入。

选型思路

1.单引擎

单引擎的意思就是 iOS 端和 Android 端统一采用一个引擎,这样做的话在 JS 层差异可以抹平,不容易出现同一份 JS 代码在 iOS 上运行是好的,Android 上就出错的奇异 BUG。结合市面上的跨端方案,大概有下面三种选型:

  • 统一采用 JSC:这个是 React Native 0.60 之前的方案
  • 统一使用 Hermes:这个是 React Native 0.64 之后的设计方案
  • 统一采用 QuickJS:QuickJS 体积很小,可以用来制作非常轻量的 Hybrid 系统

上面看出没有统一采用 V8,这个就是我前面说的,V8 在 iOS 平台没有主场优势,关闭 JIT 后性能和 JSC 差不多,还会增大包体积,并不是很划算。

2.双引擎

双引擎也很好理解,就是 iOS 端和 Android 端各用各的,优点是可以发挥各自的主场优势,缺点是可能会因为平台不一致导致双端运行结果不统一,现在的方案有这么几种:

  • iOS 用 JSC,Android 用 V8:Weex,NativeScript 都是这样的,可以在包体积和性能上有较好的均衡
  • iOS 用 JSC,Android 用 Hermes:React Natvie 现如今的方案
  • iOS 用 JSC,Android 用 QuickJS:滴滴的跨端框架 hummer 就是这样的设计

从选型上看,iOS 上都选择了 JSC,Android 各有各的选择,倒是充分发挥了两个平台的特色 : )

3.调试

无论是单引擎还是双引擎,集成后的业务开发体验也很重要。对于自带 debugger 功能的引擎来说一切都不在话下,但是对于没有实现调试协议的引擎来说,缺少 debugger 还是会影响体验的。

但不是也没有办法,一般来说我们可以曲线救国,类似于 React Native 的 Remote JS Debugging 的思路,我们可以加个开关,把 JS 代码通过 websocket 传送到 Chrome 的 Web Worker,然后用 Chrome 的 V8 进行调试。这样做的优势是可以调整一些业务上的 BUG,劣势就是又会引入一个 JS 引擎,万一遇到一些引擎实现的 BUG,就很难 debug 了。不过好在这种情况非常非常少见,我们也不能因噎废食对吧。

总结

本文从性能体积调试便捷性等功能点出发,分析了 JavaScriptCoreV8HermesQuickJS 这 4 款 JS 引擎,分别分析了它们的缺点和弱点,如果大家有移动端 JS 引擎选型的困惑,我认为从本文出发,还是可以给不少人以灵感的,希望我的这篇文章能帮助到大家。

参考链接

跨端框架的核心技术到底是什么?

如何隐藏你的热更新 bundle 文件?

深入理解JSCore

QuickJS 引擎一年见闻录


欢迎大家关注我的微信公众号:卤蛋实验室,目前专注前端技术,对图形学也有一些微小研究。

也可以加我的微信 egg_labs,欢迎大家来撩。