悄悄掀起 WebAssembly 的神秘面纱

前端开发人员想必对现代浏览器都已经很是熟悉了吧?HTML5,CSS4,JavaScript ES6,这些已经在现代浏览器中慢慢普及的技术为前端开发带来了极大的便利。javascript

得益于 JIT(Just-in-time)技术,JavaScript 的运行速度比原来快了 10 倍,这也是 JavaScript 被运用得愈来愈普遍的缘由之一。可是,这是极限了吗?前端

随着浏览器技术的发展,Web 游戏眼看着又要“卷土重来”了,不过这一次不是基于 Flash 的游戏,而是充分利用了现代 HTML5 技术实现。JavaScript 成为了 Web 游戏的开发语言,可是对于游戏这样须要大量运算的程序来讲,即使是有 JIT 加持,JavaScript 的性能仍是不能知足人类贪婪的欲望。java

JavaScript 在浏览器中是怎么跑起来的?

对于如今的计算机来讲,它们只能读懂“机器语言”,而人类的大脑能力有限,直接编写机器语言难度有点大,为了能让人更方便地编写程序,人类发明了大量的“高级编程语言”,JavaScript 就属于其中特殊的一种。git

为何说是特殊的一种呢?因为计算机并不认识“高级编程语言”写出来的东西,因此大部分“高级编程语言”在写好之后都须要通过一个叫作“编译”的过程,将“高级编程语言”翻译成“机器语言”,而后交给计算机来运行。可是,JavaScript 不同,它没有“编译”的过程,那么机器是怎么认识这种语言的呢?github

实际上,JavaScript 与其余一部分脚本语言采用的是一种“边解释边运行”的姿式来运行的,将代码一点一点地翻译给计算机。web

那么,JavaScript 的“解释”与其余语言的“编译”有什么区别呢?不都是翻译成“机器语言”吗?简单来说,“编译”相似于“全文翻译”,就是代码编写好后,一次性将全部代码所有编译成“机器语言”,而后直接交给计算机;而“解释”则相似于“实时翻译”,代码写好后不会翻译,运行到哪,翻译到哪。编程

“解释”和“编译”两种方法各有利弊。使用“解释”的方法,程序编写好后就能够直接运行了,而使用“编译”的方法,则须要先花费一段时间等待整个代码编译完成后才能够执行。这样一看彷佛是“解释”的方法更快,可是若是一段代码要执行屡次,使用“解释”的方法,程序每次运行时都须要从新“解释”一遍,而“编译”的方法则不须要了。这样一看,“编译”的总体效率彷佛更高,由于它永远只翻译一次,而“解释”是运行一次翻译一次。而且,“编译”因为是一开始就对整个代码进行的,因此能够对代码进行针对性的优化。浏览器

JavaScript 是使用“解释”的方案来运行的,这就形成了它的效率低下,由于代码每运行一次都要翻译一次,若是一个函数被循环调用了 10 次、100 次,这个执行效率可想而知。bash

好在聪明的人类发明了 JIT(Just-in-time)技术,它综合了“解释”与“编译”的优势,它的原理实际上就是在“解释”运行的同时进行跟踪,若是某一段代码执行了屡次,就会对这一段代码进行编译优化,这样,若是后续再运行到这一段代码,则不用再解释了。服务器

JIT 彷佛是一个好东西,可是,对于 JavaScript 这种动态数据类型的语言来讲,要实现一个完美的 JIT 很是难。为何呢?由于 JavaScript 中的不少东西都是在运行的时候才能肯定的。好比我写了一行代码:const sum = (a, b, c) => a + b + c;,这是一个使用 ES6 语法编写的 JavaScript 箭头函数,能够直接放在浏览器的控制台下运行,这将声明一个叫作 sum 的函数。而后咱们能够直接调用它,好比:console.log(sum(1, 2, 3)),任何一个合格的前端开发人员都能很快得口算出答案,这将输出一个数字 6。可是,若是咱们这样调用呢:console.log(sum('1', 2, 3)),第一个参数变成了一个字符串,这在 JavaScript 中是彻底容许的,可是这时获得的结果就彻底不一样了,这会致使一个字符串和两个数字进行链接,获得 "123"。这样一来,针对这一个函数的优化就变得很是困难了。

虽然说 JavaScript 自身的“特性”为 JIT 的实现带来了一些困难,可是不得不说 JIT 仍是为 JavaScript 带来了很是可观的性能提高。

WebAssembly

为了能让代码跑得更快,WebAssembly 出现了(而且如今主流浏览器也都开始支持了),它可以容许你预先使用“编译”的方法将代码编译好后,直接放在浏览器中运行,这一步就作得比较完全了,再也不须要 JIT 来动态得进行优化了,全部优化均可以在编译的时候直接肯定。

WebAssembly 究竟是什么呢?

首先,它不是直接的机器语言,由于世界上的机器太多了,它们都说着不一样的语言(架构不一样),因此不少状况下都是为各类不一样的机器架构专门生成对应的机器代码。可是要为各类机器都生成的话,太复杂了,每种语言都要为每种架构编写一个编译器。为了简化这个过程,就有了“中间代码(Intermediate representation,IR)”,只要将全部代码都翻译成 IR,再由 IR 来统一应对各类机器架构。

实际上,WebAssembly 和 IR 差很少,就是用于充当各类机器架构翻译官的角色。WebAssembly 并非直接的物理机器语言,而是抽象出来的一种虚拟的机器语言。从 WebAssembly 到机器语言虽然说也须要一个“翻译”过程,可是在这里的“翻译”就没有太多的套路了,属于机器语言到机器语言的翻译,因此速度上已经很是接近纯机器语言了。

这里有一个 WebAssembly 官网上提供的 Demo,是使用 Unity 开发并发布为 WebAssembly 的一个小游戏:webassembly.org/demo/,能够去体验体验。

.wasm 文件 与 .wat 文件

WebAssembly 是经过 *.wasm 文件进行存储的,这是编译好的二进制文件,它的体积很是的小。

在浏览器中,提供了一个全局的 window.WebAssembly 对象,能够用于实例化 WASM 模块。

window.WebAssembly

WebAssembly 是一种“虚拟机器语言”,因此它也有对应的“汇编语言”版本,也就是 *.wat 文件,这是 WebAssembly 模块的文本表示方法,采用“S-表达式(S-Expressions)”进行描述,能够直接经过工具将 *.wat 文件编译为 *.wasm 文件。熟悉 LISP 的同窗可能对这种表达式语法比较熟悉。

一个很是简单的例子

咱们来看一个很是简单的例子,这个已经在 Chrome 69 Canary 和 Chrome 70 Canary 中测试经过,理论上能够在全部已经支持 WebAssembly 的浏览器中运行。(在后文中有浏览器的支持状况)

首先,咱们先使用 S-表达式 编写一个十分简单的程序:

;; test.wat
(module
  (import "env" "mem" (memory 1)) ;; 这里指定了从 env.mem 中导入一个内存对象
  (func (export "get") (result i32)  ;; 定义并导出一个叫作“get”的函数,这个函数拥有一个 int32 类型的返回值,没有参数
    memory.size))  ;; 最终返回 memory 对象的“尺寸”(单位为“页”,目前规定 1 页 = 64 KiB = 65536 Bytes)
复制代码

可使用 wabt 中的 wasm2wat 工具将 wasm 文件转为使用“S-表达式”进行描述的 wat 文件。同时也可使用 wat2wasm 工具将 wat 转为 wasm。

在 wat 文件中,双分号 ;; 开头的内容都是注释。

上面这个 wat 文件定义了一个 module,并导入了一个内存对象,而后导出了一个叫作“get”的函数,这个函数返回当前内存的“尺寸”。

在 WebAssembly 中,线性内存能够在内部直接定义而后导出,也能够从外面导入,可是最多只能拥有一个内存。这个内存的大小并非固定的,只须要给一个初始大小 initial,后期还能够根据须要调用 grow 函数进行扩展,也能够指定最大大小 maximum(这里全部内存大小的单位都是“页”,目前规定的是 1 页 = 64 KiB = 65536 Bytes。)

上面这个 wat 文件使用 wat2wasm 编译为 wasm 后生成的文件体积很是小,只有 50 Bytes:

$ wat2wasm test.wat
$ xxd test.wasm
00000000: 0061 736d 0100 0000 0105 0160 0001 7f02  .asm.......`....
00000010: 0c01 0365 6e76 036d 656d 0200 0103 0201  ...env.mem......
00000020: 0007 0701 0367 6574 0000 0a06 0104 003f  .....get.......?
00000030: 000b                                     ..
复制代码

为了让这个程序能在浏览器中运行,咱们还必须使用 JavaScript 编写一段“胶水代码(glue code)”,以便这个程序能被加载到浏览器中并执行:

// main.js

const file = await fetch('./test.wasm');
const memory = new window.WebAssembly.Memory({ initial: 1 });
const mod = await window.WebAssembly.instantiateStreaming(file, {
  env: {
    mem: memory,
  },
});
let result;
result = mod.instance.exports.get();  // 调用 WebAssembly 模块导出的 get 函数
console.log(result);  // 1
memory.grow(2);
result = mod.instance.exports.get();  // 调用 WebAssembly 模块导出的 get 函数
console.log(result);  // 3
复制代码

这里我使用了现代浏览器都已经支持的 ES6 语法,首先,使用浏览器原生提供的 fetch 函数加载咱们编译好的 test.wasm 文件。注意,这里根据规范,HTTP 响应的 Content-Type 中指定的 MIME 类型必须为 application/wasm

接下来,咱们 new 了一个 WebAssembly.Memory 对象,经过这个对象,能够实现 JavaScript 与 WebAssembly 之间互通数据。

再接下来,咱们使用了 WebAssembly.instantiateStreaming 来实例化加载的 WebAssembly 模块,这里第一个参数是一个 Readable Stream,第二个参数是 importObject,用于指定导入 WebAssembly 的结构。由于上面的 wat 代码中指定了要从 env.mem 导入一个内存对象,因此这里就得要将咱们 new 出来的内存对象放到 env.mem 中。

WebAssembly 还提供了一个 instantiate 函数,这个函数的第一个参数能够提供一个 ArrayBuffer 或是 TypedArray。可是这个函数是不推荐使用的,具体缘由作过流量代理转发的同窗可能会比较清楚,这里就不具体解释了。

最后,咱们就能够调用 WebAssembly 导出的函数 get 了,首先输出的内容为 memoryinitial 的值。而后咱们调用了 memory.grow 方法来增加 memory 的尺寸,最后输出的内容就是增加后内存的大小 1 + 2 = 3

一个 WebAssembly 与 JavaScript 数据互通交互的例子

在 WebAssembly 中有一块内存,这块内存能够是内部定义的,也能够是从外面导入的,若是是内部定义的,则能够经过 export 进行导出。JavaScript 在拿到这块“内存”后,是拥有彻底操做的权利的。JavaScript 使用 DataViewMemory 对象进行包装后,就可使用 DataView 下面的函数对内存对象进行读取或写入操做。

这里是一个简单的例子:

;; example.wat
(module
  (import "env" "mem" (memory 1))
  (import "js" "log" (func $log (param i32)))
  (func (export "example")
    i32.const 0
    i64.const 8022916924116329800
    i64.store
    (i32.store (i32.const 8) (i32.const 560229490))
    (call $log (i32.const 0))))
复制代码

这个代码首先从 env.mem 导入一个内存对象做为默认内存,这和前面的例子是同样的。

而后从 js.log 导入一个函数,这个函数拥有一个 32 位整型的参数,不须要返回值,在 wat 内部被命名为“$log”,这个名字只存在于 wat 文件中,在编译为 wasm 后就不存在了,只存储一个偏移地址。

后面定义了一个函数,并导出为“example”函数。在 WebAssembly 中,函数里的内容都是在栈上的。

首先,使用 i32.const 0 在栈内压入一个 32 位整型常数 0,而后使用 i64.const 8022916924116329800 在栈内压入一个 64 位整型常数 8022916924116329800,以后调用 i64.store 指令,这个指令将会将栈顶部第一个位置的一个 64 位整数存储到栈顶部第二个位置指定的“内存地址”开始的连续 8 个字节空间中。

简而言之,就是在内存的第 0 个位置开始的连续 8 个字节的空间里,存入一个 64 位整型数字 8022916924116329800。这个数字转为 16 进制表示为:0x 6f 57 20 6f 6c 6c 65 48,可是因为 WebAssembly 中规定字节序是使用“小端序(Little-Endian Byte Order)”来存储数据,因此,在内存中第 0 个位置存储的是 0x48,第 1 个位置存储的是 0x65……因此,最终存储的其实是 0x 48 65 6c 6c 6f 20 57 6f,对应着 ASCII 码为:"Hello Wo"。

而后,后面的一句指令 (i32.store (i32.const 8) (i32.const 560229490)) 的格式是上面三条指令的“S-表达式”形式,只不过这里换成了 i32.store 来存储一个 32 位整型常数 560229490 到 8 号“内存地址”开始的连续 4 个字节空间中。

实际上这一句指令的写法写成上面三句的语法是彻底等效的:

i32.const 8
i32.const 560229490
i32.store
复制代码

相似的,这里是在内存的第 8 个位置开始的连续 4 个字节的空间里,存入一个 32 位整型数字 560229490。这个数字转为 16 进制表示位:0x 21 64 6c 72,一样采用“小端序”来存储,因此存储的其实是 0x 72 6c 64 21,对应着 ASCII 码为:"rld!"。

因此,最终,内存中前 12 个字节中的数据为 0x 48 65 6c 6c 6f 20 57 6f 72 6c 64 21,连起来就是对应着 ASCII 码:"Hello World!"。

将这个 wat 编译为 wasm 后,文件大小为 95 Bytes:

$ wat2wasm example.wat
$ xxd example.wasm
00000000: 0061 736d 0100 0000 0108 0260 017f 0060  .asm.......`...`
00000010: 0000 0215 0203 656e 7603 6d65 6d02 0001  ......env.mem...
00000020: 026a 7303 6c6f 6700 0003 0201 0107 0b01  .js.log.........
00000030: 0765 7861 6d70 6c65 0001 0a23 0121 0041  .example...#.!.A
00000040: 0042 c8ca b1e3 f68d c8ab ef00 3703 0041  .B..........7..A
00000050: 0841 f2d8 918b 0236 0200 4100 1000 0b    .A.....6..A....
复制代码

接下来,仍是使用 JavaScript 编写“胶水代码”:

// example.js

const file = await fetch('./example.wasm');
const memory = new window.WebAssembly.Memory({ initial: 1 });
const dv = new DataView(memory);
const log = offset => {
  let length = 0;
  let end = offset;
  while(end < dv.byteLength && dv.getUint8(end) > 0) {
    ++length;
    ++end;
  }
  if (length === 0) {
    console.log('');
    return;
  }
  const buf = new ArrayBuffer(length);
  const bufDv = new DataView(buf);
  for (let i = 0, p = offset; p < end; ++i, ++p) {
    bufDv.setUint8(i, dv.getUint8(p));
  }
  const result = new TextDecoder('utf-8').decode(buf);
  console.log(result);
};
const mod = await window.WebAssembly.instantiateStreaming(file, {
  env: {
    mem: memory,
  },
  js: { log },
});
mod.instance.exports.example();  // 调用 WebAssembly 模块导出的 example 函数
复制代码

这里,使用 DataViewmemory 进行了一次包装,这样就能够方便地对内存对象进行读写操做了。

而后,这里在 JavaScript 中实现了一个 log 函数,函数接受一个参数(这个参数在上面的 wat 中指定了是整数型)。下面的实现首先是肯定输出的字符串长度(字符串一般以 '\0' 结尾),而后将字符串复制到一个长度合适的 ArrayBuffer 中,而后使用浏览器中的 TextDecoder 类对其进行字符串解码,就获得了原始字符串。

最后,将 log 函数放入 importObject 的 js.log 中,实例化 WebAssembly 模块,最后调用导出的 example 函数,就能够看到打印的 Hello World

Example - Hello World!

经过 WebAssembly,咱们能够将不少其余语言编写的类库直接封装到浏览器中运行,好比 Google Developers 就给了一个使用 WebAssembly 加载一个使用 C 语言编写的 WebP 图片编码库,将一张 jpg 格式的图片转换为 webp 格式并显示出来的例子:developers.google.com/web/updates…

这个例子使用 Emscripten 工具对 C 语言代码进行编译,这个工具在安装的时候须要到 GitHub、亚马逊 S3 等服务器下载文件,在国内这神奇的网络环境下速度异常缓慢,总共几十兆的文件可能挂机一天都下不完。能够尝试修改 emsdk 文件(Python),增长代理配置(可是效果不明显),或是在下载的过程当中会提示下载连接和存放路径,使用其余工具下载后放到指定地方,从新安装会自动跳过已经下载的文件。

WebAssembly 的现状与将来

目前 WebAssembly 的二进制格式版本已经肯定,将来的改进也都将以兼容的形式进行更新,这表示 WebAssembly 已经进入现代标准了。

浏览器兼容性

如今的 WebAssembly 还并不完美,虽然说已经有使用 WebAssembly 开发的 Web 游戏出现了,可是还有不少不完美的地方。

好比,如今的 WebAssembly 还必须配合“JavaScript glue code”来使用,也就是必须使用 JavaScript 来 fetch WebAssembly 的文件,而后调用 window.WebAssembly.instantiatewindow.WebAssembly.instantiateStreaming 等函数进行实例化。部分状况下还须要 JavaScript 来管理堆栈。官方推荐的编译工具 Emscripten 虽然使用了各类黑科技来缩小编译后生成的代码的数量,可是最终生成的 JavaScript Glue Code 文件仍是至少有 15K。

将来,WebAssembly 将可能直接经过 HTML 标签进行引用,好比:<script src="./wa.wasm"></script>;或者能够经过 JavaScript ES6 模块的方式引用,好比:import xxx from './wa.wasm';

线程的支持,异常处理,垃圾收集,尾调用优化等,都已经加入 WebAssembly 的计划列表中了。

小结

WebAssembly 的出现,使得前端再也不只能使用 JavaScript 进行开发了,C、C++、Go 等等均可觉得浏览器前端贡献代码。

这里我使用 wat 文件来编写的两个例子仅供参考,实际上在生产环境不大可能直接使用 wat 来进行开发,而是会使用 C、C++、Go 等语言编写模块,而后发布为 WebAssembly。

WebAssembly 的出现不是要取代 JavaScript,而是与 JavaScript 相辅相成,为前端开发带来一种新的选择。将计算密集型的部分交给 WebAssembly 来处理,让浏览器发挥出最大的性能!


文 / jinliming2

一条对新鲜事物充满了好奇心的咸鱼

编 / 荧声

本文已由做者受权发布,版权属于创宇前端。欢迎注明出处转载本文。本文连接:knownsec-fed.com/2018-08-08-…

想要看到更多来自知道创宇开发一线的分享,请搜索关注咱们的微信公众号:创宇前端(KnownsecFED)。欢迎留言讨论,咱们会尽量回复。

感谢您的阅读。

相关文章
相关标签/搜索