埋坑
也许大家都不太了解,Javascript(ECMAScript)是上面这家伙用了十天的时间钻(早)研(产)出来的。
所以各位研发同事,要是 PM 再让你 10 天上线一个项目的时候,你觉得PM 丧心病狂,不妨多瞻仰一下这位老哥。
YouTube 上有这老哥的采访,可以听听老哥讲讲他的艺术人生,听说这位老哥因为做了这个技术 topic,晋升为 Mozilla 的 CTO 了,所以各位在 OKR 上多写写技术 topic 不是坏事儿。
码农和大神的区别也在于此:遇到这种事情,10 天以后码农死掉了,而大神成功了。
But(凡事总有 but)但凡急速上线的事情,都会留下一堆坑,大神和码农的区别也在于大神留下了小水洼,码农会留下天坑。 以下是坑列表:
- js的刚开始就设计为一种解释型语言,因为大神设计该语言的目标用户是“非专业编程人员&设计师”,大神觉得让他们了解编译器是一件很残忍的事情,所以该语言在当时环境可以媲美《21 天学会 XXX》 系列了
- 类型也是没有的,因为学习类型就要学习 CPU 工作原理(当时)学习 CPU 工作原理就要学习计算机组成原理,大神觉得让“非专业编程人员和设计师”去了解 1 和 1.0 一个是 CPU 上处理一个是 FPU(浮点处理器)也是一件很残忍的事情(看大神TMD多有人情味)
- 像 class 类,泛型,缺省参数,操作符重载等等成熟的编程语言的标配,大神的回答统统是:要啥自行车!!!
如果这个项目仅限于此,其实不算一个悲伤的故事,大神 10 天交上了项目完成了 KPI,老板也觉得不错,给加了薪升了职,从此喝酸奶不再舔盖,吃饭也是想蘸白糖蘸白糖想蘸红糖蘸红糖,其实也不错。 这里又来了个 but,but 问题是市场反馈有点儿超乎大神和他 leader 的预料,JavaScript 一路蹿红,在没有抖音的 90 年代,这可是一个不容易的事情,各大浏览器厂商纷纷支持 js,没过多久就成了浏览器里事实上的标准语言。在这个过程中还顺手干掉了 VBScript。
于是这个“非专业编程人员”专用语言没有一点点防备就突然成了互联网上面最重要的语言之一,被用来开发各种之前在互联网上想而不得或者想都不敢想的项目。这时候就完犊子了,大神之前留下的小水洼没来得及填上,已然被互联网放大成了天坑。这就导致之后多少年里 JS 的发展历史就是一部血泪填坑史,也让一大批专业填坑技术网红赚的盆满钵满。
出现的众天坑中,最大的天坑就是前端性能坑,来,各位游客,让我们到这个坑里走一圈。。。
性能填坑一战
严格意义上来说,其实这不是上面这位老哥的锅,因为 JavaScript 在 1995 年(当时我二年级)被着这位老哥匆匆忙忙的写下第一行代码的时候,他就不是为了快而设计的,本来嘛,要是图快的话,就不会一开始就设计成解释型语言了,并且在最初的十年,他的不快其实没有产应很大的影响(15 秒还是 30 秒。。。嗯。。。),只是后来浏览器大战愈演愈烈,网页的功能也迅速丰富起来,如果当时设计秒杀系统,估计玩不转,因为秒杀很可能变成 10 秒杀,这个当然不能忍,于是谷歌内部的一帮聪明的程序员想到了一个办法,虽然你是脚本语言,但是我可以偷偷编译啊,也不用告诉那帮“非专业编程人员和设计师”,我只要在你运行前的一瞬间编译好你即将运行的代码就好了,偷偷地编译,打枪滴不要,好机智哦!
于是 Google 在 2009 年的 V8 引擎中引入了 JIT 技术(Just In Time compiling),江湖人称即时编译。有了这个大杀器,JavaScript 执行速度瞬间飙升 20~40 倍。
这个节点我们盗了个图来说明一哈子:
性能的提升直接导致一大波具有新型交互的网站应用的出现,nodejs 也是在这个历史节点应运而生的,甚至有人还在浏览器里面写操作系统,Google 也因此奠定了浏览器一哥的地位,至今仍然引领前端性能的潮流,如下图(图是盗的):
生活中的人们对需求的增长是无穷无尽的,JIT 带来的性能提升很快就被压榨干了,这其实很容易预料到,JIT 的发明其实只是模式的创新,并没有在根子上把坑填上,只是在坑上加了一个小桥而已,下面我们分析下 JIT 存在哪些问题:
- JIT 是基于运行时分析编译,而 JavaScript 是一个弱类型语言,于是大部分时间,JIT 其实都在做你说我猜的游戏,虽然 JIT 里面会根据相同代码运行次数进行分类(warm code & hot code),但是对于广大前端程序员来讲,一个变量既能存数字也能存字符串开发起来才爽歪歪,举个日常例子:
function add(a, b) { return a + b;}
var c = add(1, 2);
JIT看到这里,觉得好开心,根据类型推断马上把 add 编译成了
function add(int a, int b) {return a+b;} 可是这时候你接下来又干了这么一个事情: var d = add("hello", "world") 我想 JIT 要是个人的话:
怎么办,已经刚编译成机器码,就成了垃圾了。。。。
这种情况下,JIT 只能从头再来,所以 JIT 的性能波动也是基于此,极端情况,带来的性能提升还没这个重新编译带来的开销大。事实上,大部分时间 JIT 都不会生成优化代码,有字节码的,直接字节码,没有字节码的,粗粗编译下就结了,因为 JIT 自己也需要时间,除非是一个函数被使用过很多遍,否则不会被编译成机器码,因为编译花的时间可能比直接跑字节码还多。
综上, JIT 带来的性能提升天花板没有想象的那么高,虽然提高了 20~50倍,那是因为之前 JS 的能行实在是慢到无语。
性能填坑二战
通过一战,我们既然发现JIT 遇到的天花板是类型不确定问题和一些语言功能(比如异常,for...in,JIT 起来很麻烦),那解决问题的思路就已经从问题本身给出了,只要搞个东西解决类型问题就行了。 按照这个思路,催生了两种实现路径:
- 一种是 以谷歌解题思路为代表的,基本思路是,我搞个其他语言,这个语言是强类型的,前端程序员,不要老想着爽了,该指定类型还是要指定类型 于是谷歌就在 2011 年在 Chrome 里面发布了一项技术,谷歌起名叫:NaCL,原理就是浏览器提供一个沙盒,然后用C/C++编写程序,在这个沙盒里安全的运行,这项技术全名叫 Native Client,看字面意思就知道原理了,这个思路很清奇,但是很有效,既然 JS 性能不行,那就让浏览器支持另外性能可以的,反正自己浏览器自己说了算。
如下图所示,一个标准 NaCl 应用的组成结构,与普通的 JavaScript Web 应用十分类似。NaCl 模块作为应用的一部分,主要用来进行复杂的数据处理和运算,JavaScript 则负责处理应用与外部用户的交互逻辑。NaCl 实例与 JavaScript 代码之间可以通过“订阅 / 发布”模型,来互相传递消息。
理想很丰满,现实却骨感的让人瑟瑟发抖。这项技术有个很大的问题--工具链复杂且平台依赖性严重,虽说自家浏览器可以随便做实验,但是成不了标准也是很打脸。
要想用 NACL,首先要在本地编码和编译,其次要根据操作系统,编译成各个平台的版本(X86_32 / X86_64 / ARM 等)。其次,由于平台的严重依赖性,由 NaCL编译的模块仅能在 Chrome 的 APP store 里面进行分发,还有就是如果你想要将已经存在的 C/C++ 代码库编译至 NaCl,并在浏览器中使用,你还需要通过名为 Pepper 的库来对这些代码进行重写。Pepper 提供了很多包装类型,以及用于和浏览器进行交互的 API,比如 “PP_Bool” 等。这些 API 和特殊类型可以便于整合传统 C/C++ 代码与 Web 浏览器的沙盒环境。
但是到这里实验还不能停,因为没有撞到南墙,谷歌在随后又推出了NACL 2.0(姑且这么叫),命名为:PNaCL, 这里的 P的意思就是 Portable(可移植)的意思。
PNaCl 采用了不一样的生命周期,参考下图我们可以看到,相较于 NaCl 模块直接包含有平台架构相关的代码,PNaCl 将源 C/C++ 代码编译到一种中间代码。这些中间代码会在浏览器实际加载这个 PNaCl 模块时,再被转换为对应的平台相关代码。因此,对于 PNaCl 模块而言,分发的过程变得更加简单,且不用担心移植性的问题。
不过,即使是对于 PNaCl 这类“可移植性”已经不再成为问题的技术而言,它们的面前还有很多“大山”难以逾越。比如:“需要使用 Pepper 重写 C/C++ 代码,标准较为封闭、仅 Chrome 浏览器支持”等等。
总而言之,无论是 NaCl 还是 PNaCl,它们都已经成为过去。现在,如果你再次回到 NaCl / PNaCl 在 Google 的官方文档网站,你会发现如下这样一段声明。Wasm 将会作为新一代的技术,接替并继续传承 Google 赋予给 NaCl / PNaCl 的使命。
- 另一种是以firfox 的 Asm.js 为代表的,做一个 JS 的类型严格的子集(TS 是类型严格的超集),同时试图利用标注的方法,给变量加上类型,如果觉得很抽象,举个 asm.js写出来的例子:
function asm (stdin, foreign, heap) {
"use asm";
function add (x, y) {
x = x|0; // 变量 x 存储了 int 类型值;
y = y|0; // 变量 y 存储了 int 类型值;
var addend = 1.0, sum = 0.0; // 变量 addend 和 sum 默认存放了"双精度浮点"类型值;
sum = sum + x + y;
return +sum; // 函数返回值为"双精度浮点"类型;
}
return { add: add };
}
既然是 JS 的子集,那么对于低版本浏览器,可以将 asm 代码视为普通的 js 代码 来运行,保障了浏览器的兼容性。
当 JavaScript 引擎满足一定条件后,便会通过 AOT 静态编译的方式,将这些被标记的 ASM.js 代码,编译成对应的机器码并加以保存。当 JavaScript 引擎再次执行(甚至在第一次执行)这段 ASM.js 代码时,便会直接使用先前已经存储好的机器码版本。因此,引擎的性能会得到大幅的提升。(在这里不得不叹服作为浏览器的发明者,还是有两把刷子的)
以下是Asm.js 相对于 JIT 和原生的性能对比:
webassembly的诞生
时间来到 2015 年 5 月。Chrome 团队的 Ben 正在为 V8 设计一种新的 Prototype(原型),而另一位团队成员 Rosbery ,正在为这种 Prototype 设计对应的字节码格式。
实际上,这个 Prototype 和对应的字节码格式,便是如今 Wasm 所分别对应的 WAT 可读文本格式与二进制字节码格式。在当时的谷歌内部,这两部分暂时被称为 ml-proto 与 v8-native-prototype。随着 V8 团队对 ml-proto 与 v8-native-prototype 的不断修改和优化,它们最终便成为了 Wasm 早期标准的一部分。
与此同期出现的,还有一个名为 “sexpr-wasm” 的内部工具 ,在当时这个工具用于对这两种格式进行相互转换。随着 Wasm 的标准化,它也同样成为了 Wasm 常用调试工具的一部分,这也就是我们所熟知的 —— WABT。
Chrome V8 团队作为参与过 PNaCL 与 ASM.js 这两个标准制定的团队,在设计和实现 Wasm 时也同样参考了很多从这两种技术中总结下来的优缺点。而这些经验也将会帮助 Wasm 做好准备,避开那些曾经走过的坑。最后,这些经验使得 Wasm 能够以一种更好的方式,展现在人们的面前。
经过各大浏览器厂商对解决浏览器端性能瓶颈的不断的摸索与实践,终于,在 2019年12月5日万维网联盟(W3C)宣布 WebAssembly 核心规范成为正式标准,这也是继 HTML、CSS、JavaScript 之后支持代码在浏览器中运行的第四种 Web 语言。
总结:关于前端性能那些事儿到这里就讲的差不多了,可能不太严谨,大家就看个乐呵,下一篇我们开始将 webassembly的原理,欲知后事如何,请听下回分解吧,拜了个拜!
参考:
- 极客时间《webassembly 入门课》作者:于航
- 部分图片节选自《A cartoon intro to WebAssembly》