《没有银弹》是 Fred Brooks 在 1987 年所发表的一篇关于软件工程的经典论文。该论文的主要论点是,没有任何一项技术或方法可以能让软件工程的生产力在十年内提高十倍。 在 Web 开发这一领域,由于 JavaScript 一直存在着诸多从本质上来看无法解决的问题,那么解决 JavaScript 痼疾的银色子弹是否存在呢?
作为一门仅用了十天进行设计的语言,Brendan Eich 一定没有想到 JavaScript 会从一门简单的”脚本“发展成为 Web 开发的主宰,并且如火如荼的渗透到桌面或移动客户端开发甚至服务器端开发中。
之所以 JavaScript 如此火爆,甚至有了著名的 Atwood 定律,声称"任何可以使用 JavaScript 来实现的应用都最终都会使用 JavaScript 实现",主要原因在于两点:
- Web 应用可以越来越多的代替传统的客户端应用程序;
- JavaScript 引擎的运算速度大幅改进。
从辩证的角度来看,上面两个观点其实是相互影响、相互促进的关系。JavaScript 引擎运算速度的逐步提升,导致了一部分简单的客户端应用可以被 Web 应用代替,进而用户和开发者都希望更多复杂的客户端应用也用 Web 实现,进而促进 JavaScript 引擎运算速度的进一步提升。
Google V8 引擎是 JavaScript 引擎性能改善的现象级项目。V8 于 2007 年发布,由于采用了 JIT ( just-in-time)技术,V8 引擎可以将 JavaScript 代码在运行前编译为机器语言,这样运行速度就会有大幅提升。
客观存在的问题是,开发者对性能的要求是近乎无止境的,而基于 JIT 的设计思路带来的性能已经逐渐被挖掘到了极限,并且逐步暴露出了一些难以解决的问题。
首先是被称为“重优化”的性能瓶颈。编译为机器代码的前提条件是必须让编译器清晰的知道变量的类型,偏偏 JavaScript 是一门弱类型语言,参考如下代码:
function sum ( a , b ) { return a + b; } sum(1,2); sum("1","2");
V8 引擎在运行这段代码时,在第一次调用 sum 函数时,由于传递的类型是两个数字,所以会将 sum 这个函数的参数设置为数字类型并编译为机器码,但是紧接着,sum 函数又传递了字符串类型,这就导致编译器只能讲刚编译好的 sum 函数拆解,然后重新将其编译为参数类型为字符串类型的机器码。这种情况大大降低了 JavaScript 的运行性能。
其次是因为编译器的一些优化策略可能“弄巧成拙”,导致在特定情况下性能反而有负面影响。一个典型的例子是 TypeScript 编译器在编译代码时的性能优化:
https://github.com/Microsoft/TypeScript/pull/10270
通过这个优化可以看到,通过将一个对象添加 delete 操作,强制关闭该对象的“隐藏类”机制,将一个对象切换为字典模式,达到了性能提升的作用。
再次是 JavaScript 具备垃圾回收机制,虽然 V8 编译器已经对垃圾回收机制的算法进行了诸多的优化,但是在应用内存占用较大时,垃圾回收的瞬间明显仍然还有卡顿现象,这导致了复杂应用有可能出现不定时的卡顿现象。
这些问题都反映出,JavaScript 这种语言机制本身的灵活性,反而限制了 JavaScript 引擎的性能优化空间,如果希望彻底解决这一问题,必然需要抛弃 JavaScript 这门语言本身,采用一门强类型的编程语言才能达到最极致的性能,在这种技术思想的指引下,WebAssembly 技术应运而生。
提到了 WebAssembly,就必然首先提及对其有深远影响的 asm.js,这是 Mozilla 在 2013 年推出的一项新技术,它是 JavaScript 的一个子集,舍弃了大量会导致性能问题的语法,并且被设计为通过 C / C++ 代码编译生成,而非手工编写 asm.js 代码。上述的 sum 函数在 asm.js 中表现为:
function sum ( a ,b ) { a = a | 0; b = b | 0; return ( a + b ) | 0; }
上述代码中,标准的 JavaScript 引擎会对其进行解析,并生成正确的结果,而 asm.js 会根据一些不会对运行时造成计算结果错误的特殊标识对变量的类型进行声明(比如 a = a | 0 表示变量 a 是一个整数),通过这种方式,这种代码既可以在支持 asm.js 的 JavaScript 引擎上得到很高的性能,也会在不支持的设备上继续按照正确的逻辑进行执行,而非无法运行。
虽然如此,asm.js 仍然存在着一些问题,主要是基于 JavaScript 语法的文本格式解析速度不够快,并且代码尺寸偏大,为了解决这些问题,将 asm.js 进行二进制化的 WebAssembly 应运而生。
WebAssembly 是一种接近机器语言的跨平台二进制格式。2017 年 3 月份,四大主流浏览器厂商 Google Chrome、Apple Safari、Microsoft Edge 和 Mozilla FireFox 均宣布已经于最新版本的浏览器中支持了 WebAssembly 的初始版本,这意味着 WebAssembly 技术已经实际落地、可以在特定生产环境进行尝试。
WebAssembly 目前可以通过 Emscripten SDK 生成,下图是 WebAssembly 的编译原理:
上图展示了如何通过编写 C / C++ 代码生成 WebAssembly 内容。
首先通过 LLVM ,将 C/C++ 源代码编译为 LLVM bytecode。这 是一种跨语言的底层虚拟机字节码,理论上所有强类型编程语言均可以生成这种字节码。通过这一点可以得知,在未来理论上所有强类型编程语言(诸如 Java / C# 等)均可以开发 WebAssembly 程序。
其次,通过 EMScripten 中的后端编译器,将这种抽象字节码生成 asm.js 格式的文件。这是一种特殊的 JavaScript 代码,部分 JavaScript 引擎会将这种格式以比通常的 JavaScript 代码更快的速度运行,并且由于 asm.js 仍然是 JavaScript,所以哪怕 JavaScript 引擎不支持该特性,也会以通常的方式运行这段逻辑。这意味着使用 C/C++ 编写的源代码,哪怕用户设备不支持 WebAssembly,也可以回退到 JavaScript 运行并得到一致的结果。
第三,asm.js 会通过另一个编译器生成为 WebAssembly 的 .wasm 文件,由于 WebAssembly 是二进制格式,相比 JavaScript 而言,其代码体积同比小很多,并且由于已经是面向机器码的格式,也无需在运行前对源代码耗费时间进行 JIT 编译操作。
通过上述内容可以看出,WebAssembly 理论上可以通过任何强类型语言生成,不强制依赖用户的本地运行环境,代码体积小、解析速度快,几乎是 Web 开发未来的一颗“银色子弹”。
可惜的是在现阶段,WebAssembly 仍然存在着不少问题需要去解决。
首先是自身的稳定性,以 Chrome 浏览器为例,Chrome 57 支持 WebAssembly 的 MVP 版本,但是在 Chrome 58 上,大量的 WebAssembly 程序会直接导致进程崩溃,虽然后续的 Chrome 59 已经修复了绝大部分问题,但是仍然不得不对目前版本的稳定性持保留态度。
其次是可调试性,WebAssembly 被设计为了一种开放的、可调试的程序,但目前无论是 Chrome 还是 FireFox ,在调试方面还有很大的提升空间。由于在目前阶段调试较为困难,所以用 WebAssembly 编写业务逻辑代码对研发来说还是很不方便的。
还有就是与 Web 的互操作性。目前 WebAssembly 类似 WebWorker ,只能进行单纯的数值计算工作,不能在 C++ 层直接操作 DOM 节点。虽然在未来路线图中提及这一特性会在后续加入,但是在目前阶段 WebAssembly 更适合被用于更纯粹的密集型数据计算工作,而非直接编写业务逻辑。
综上所述,在目前阶段,WebAssembly 不适合直接编写具体的业务逻辑,而更适合编写应用程序中对性能要求比较高的库,并与 JavaScript 编写的业务逻辑进行通讯,并在 JavaScript 端对 DOM 节点进行操作。
以笔者最近开发的白鹭引擎 5.0 的渲染库为例,白鹭引擎对外提供 JavaScript API,开发者编写的 JavaScript 逻辑代码会汇总为一组命令队列发送给 WebAssembly 层,然后 WebAssembly 负责所有的计算工作,最终生成一组基于 WebGL 格式的数据流,最后 JavaScript 对这组数据流进行简单的解析并直接调用 DOM 的 WebGL 接口传递数据。
在实践过程中,我们总结出 WebAssembly 的几个不容易注意的优势和缺点:
- 代码体积很小,我们将大约 300k 左右(压缩后)JavaScript 逻辑改用 WebAssembly 重写后,体积仅有 90k 左右。虽然使用 WebAssembly 需要引入一个 50k-100k 的 JavaScript 类库作为基础设施,但是总体来看资源尺寸的优势还是很大的。
- 由于代码格式是二进制、无法直接在浏览器中看到源码,尽管理论上仍然可以通过逆向工程一定程度上得到原有的业务逻辑,但是由于开发者可以在编译时使用了 -O3 等激进的优化策略,所以最终反编译得到的业务逻辑也是很难阅读的。虽然理论上一切在客户端的内容都是不安全的,但是与所有代码都直接暴露给用户相比,代码安全性得到了很大的改善。
- 在运行 benchmark 等极限测试时,游戏引擎使用 WebAssembly 并不比 JavaScript 有几何量级的提升。笔者的推论是:由于 JavaScript 引擎的 JIT 机制会把经常运行的函数进行极限的编译优化,所以在 benchmark 这种代码大量反复执行的测试环境下,无论是 JavaScript 版本,还是 WebAssembly 版本,运行的都是高度优化后的机器码,虽然 WebAssembly 版本仍然比 JavaScript 版有一定的性能优势,但是并不明显。
- 在运行业务逻辑代码时,由于大部分业务逻辑代码只运行一次,所以 JavaScript 引擎只会对这部分代码进行简单的编译优化而非极限优化,所以运行这一部分代码 WebAssembly 相比 JavaScript 版本而言提升巨大,但是因为上文所述,不建议开发者在编写业务逻辑时使用 WebAssembly,所以这里陷入了一个两难。在目前而言,理想情况是除了底层库之外,部分关键的涉及性能问题的逻辑也可以使用 WebAssembly 进行编写。
综上所述,目前为止由于 WebAssembly 还不是非常完善,所以它目前的主要作用是作为 JavaScript 生态的有益补充,与 JavaScript 共存而不是取而代之。但是通过其路线图我们可以得知,WebAssembly 的设计思想非常优秀,目前所有存在的问题从长远的角度来说都是可以解决的问题。在加上 WebAssembly 是非常罕见的由四大浏览器厂商共同宣布会大力支持并实现的功能,其浏览器兼容性问题也终究可以得到解决,再退一步,哪怕旧式浏览器不支持,由于 WebAssembly 支持回退到 JavaScript,也可以保证正常运行。
在目前阶段,WebAssembly 适合大量密集计算、并且无需频繁与 JavaScript 及 DOM 进行数据通讯的场景。比如游戏渲染引擎、物理引擎、图像音频视频处理编辑、加密算法等。
笔者认为,WebAssembly 就像当初的 HTML5 标准一样,在公布之后最开始不被很多人看好,认为会有浏览器兼容性问题、各大浏览器厂商的实现问题、性能问题、用户需求与用户体验问题,但在近年来 HTML5 终于得到了广泛的使用,甚至有些人认为他可以在很多场景下取代 NativeApp ,而非仅仅是当年“取代 Flash”这一小目标。凭借着底层技术的跨越式发展,以及浏览器厂商的一致支持,WebAssembly 一定会有一个光明的未来,也许真的可以成为一颗 Web 开发的“银色子弹”。
作者介绍
王泽,白鹭引擎架构师
前端之巅关注「前端之巅」,紧跟前端发展,共享一线技术。各位淀粉投稿请发邮件到 editors@cn.infoq.com,注明“前端之巅投稿”。
评论