Webassembly(WASM)和CSS的Grid布局一樣都是一個(gè)新東西,Chrome從57開始支持。在講wasm之前我們先看代碼是怎么編譯的成機(jī)器碼,因?yàn)橛?jì)算機(jī)只認(rèn)識(shí)機(jī)器碼。
1. 機(jī)器碼
計(jì)算機(jī)只能運(yùn)行機(jī)器碼,機(jī)器碼是一串二進(jìn)制的數(shù)字,如下面的可執(zhí)行文件a.out:

上面顯示成16進(jìn)制,是為了節(jié)省空間。
例如我用C寫一個(gè)函數(shù),如下:
int main(){
int a = 5;
int b = 6;
int c = a + b;
return 0;
}
然后把它編譯成一個(gè)可執(zhí)行文件,就變成了上面的a.out。a.out是一條條的指令組成的,如下圖所示,研究一下為了做一個(gè)加法是怎么進(jìn)行的:

第一個(gè)字節(jié)表示它是哪條指令,每條指令的長(zhǎng)度可能不一樣。上面總共有四條指令,第一條指令的意思是把0x5即5這個(gè)數(shù)放到內(nèi)存內(nèi)置為[rbp - 0x8]的位置,第二條指令的意思是把6放到內(nèi)存地址為[rbp - 0xc]的位置,為什么內(nèi)存的位置是這樣呢,因?yàn)槲覀兌x了兩個(gè)局部變量a和b,局部變量是放在棧里面的,而new出來的是放在內(nèi)存堆里面的。上面main函數(shù)的內(nèi)存??臻g如下所示:

rbp是一個(gè)base pointer,即當(dāng)前棧的基地址,這里應(yīng)該為main函數(shù)入口地地址,然后又定義了兩個(gè)局部變量,它們依次入棧,棧由下往上增長(zhǎng),向內(nèi)存的低位增長(zhǎng),在我的這個(gè)Linux操作系統(tǒng)上是這樣的。最后return返回的時(shí)候這個(gè)棧就會(huì)一直pop到入口地址位置,回到調(diào)它的那個(gè)函數(shù)的地址,這樣你就知道函數(shù)棧調(diào)用是怎么回事了。
一個(gè)棧最大的空間為多少呢?可以執(zhí)行ulimit -s或者ulimit -a命令,它會(huì)打印出當(dāng)前操作系統(tǒng)的內(nèi)存棧最大值:
ulimit -a
stack size (kbytes, -s) 8192
這里為8Mb,相對(duì)于一些OS默認(rèn)的64Kb,已經(jīng)是一個(gè)比較大的值了。一旦超出這個(gè)值,就會(huì)發(fā)生棧溢出stack overflow.
理解了第一條指令和第二條指令的意思后就不難理解第三條和第四條了。第三條是把內(nèi)存地址為[rbp - 8]放到ecx寄存器里面,第四條做一個(gè)加法,把[rbp - 12]加到ecx寄存器。就樣就完成了c = a + b的加法。
更多匯編和機(jī)器碼的運(yùn)算讀者有興趣可以自行去查資料繼續(xù)擴(kuò)展,這里我提了一下,幫助讀者理解這種比較較陌生的機(jī)器碼是怎么回事,也是為了下面講解WASM.
2. 編譯和解釋
我們知道編程語言分為兩種,一種是編譯型的如C/C++,另一種是解釋型如Java/Python/JS等。
在編譯型語言里面,代碼需經(jīng)過以下步驟轉(zhuǎn)成機(jī)器碼:

先把代碼文本進(jìn)行詞法分析、語法分析、語義分析,轉(zhuǎn)成匯編語言,其實(shí)解釋型語言也是需要經(jīng)過這些步驟。通過詞法分析識(shí)別單詞,例如知道了var是一個(gè)關(guān)鍵詞,people這個(gè)單詞是自定義的變量名字;語法分析把單詞組成了短句,例如知道了定義了一個(gè)變量,寫了一個(gè)賦值表達(dá)式,還有一個(gè)for循環(huán);而語義分析是看邏輯合不合法,例如如果賦值給了this常量將會(huì)報(bào)錯(cuò)。
再把匯編再翻譯成機(jī)器碼,匯編和機(jī)器碼是兩個(gè)比較接近的語言,只是匯編不需要去記住哪個(gè)數(shù)字代表哪個(gè)指令。
編譯型語言需要在運(yùn)行之前生成機(jī)器碼,所以它的執(zhí)行速度比較快,比解釋型的要快若干倍,缺點(diǎn)是由于它生成的機(jī)器碼是依賴于那個(gè)平臺(tái)的,所以可執(zhí)行的二進(jìn)制文件無法在另一個(gè)平臺(tái)運(yùn)行,需要再重新編譯。
相反,解釋型為了達(dá)到一次書寫,處處運(yùn)行(write once, run evrywhere)的目的,它不能先編譯好,只能在運(yùn)行的時(shí)候,根據(jù)不同的平臺(tái)再一行行解釋成機(jī)器碼,導(dǎo)致運(yùn)行速度要明顯低于編譯型語言。
如果你看Chrome源碼的話,你會(huì)發(fā)現(xiàn)V8的解釋器是一個(gè)很復(fù)雜的工程,有200多個(gè)文件:

最后終于可以來講WebAssembly了。
3. WebAssembly介紹
WASM的意義在于它不需要JS解釋器,可直接轉(zhuǎn)成匯編代碼(assembly code),所以運(yùn)行速度明顯提升,速度比較如下:

通過一些實(shí)驗(yàn)的數(shù)據(jù),JS大概比C++慢了7倍,ASM.js官網(wǎng)認(rèn)為它們的代碼運(yùn)行效率是用clang編譯的代碼的1/2,所以就得到了上面比較粗糙的對(duì)比。
Mozilla公司最開始開發(fā)asm.js,后來受到Chrome等瀏覽器公司的支持,慢慢發(fā)展成WASM,W3C還有一個(gè)專門的社區(qū),叫WebAssembly Community Group。
WASM是JS的一個(gè)子集,它必須是強(qiáng)類型的,并且只支持整數(shù)、浮點(diǎn)數(shù)、函數(shù)調(diào)用、數(shù)組、算術(shù)計(jì)算,如下使用asm規(guī)范寫的代碼做兩數(shù)的加法:
function () {
"use asm";
function add(x, y) {
x = x | 0;
y = y | 0;
return x | 0 + y | 0;
}
return {add: add};
}
正如asm.js官網(wǎng)提到的:
An extremely restricted subset of JavaScript that provides only strictly-typed integers, floats, arithmetic, function calls, and heap accesses
WASM的兼容性,如caniuse所示:

最新的主流瀏覽器基本上已經(jīng)支持。
4. WASM Demo
(1)準(zhǔn)備
Mac電腦需要安裝以下工具:
cmake make Clang/XCode
Windows需要安裝:
cmake make VS2015 以上
然后再裝一個(gè)
WebAssembly binaryen (asm2Wasm)
(2)開始
寫一個(gè)add.asm.js,按照asm規(guī)范,如下圖所示:

然后再運(yùn)行剛剛裝的工具asm2Wasm,就可以得到生成的wasm格式的文本,如下圖所示

可以看到WASM比較接近匯編格式,可以比較方便地轉(zhuǎn)成匯編。
如果不是在控制臺(tái)輸出,而是輸出到一個(gè)文件,那么它是二進(jìn)制的。運(yùn)行以下命令:
../bin/asm2wasm add.asm.js -o add.wasm
打開生成的add.wasm,可以看到它是一個(gè)二進(jìn)制的:

有了這個(gè)文件之后怎么在瀏覽器上面使用呢,如下代碼所示,使用Promise,與WebAssembly相關(guān)的對(duì)象本身就是Promise對(duì)象:
fetch("add.wasm").then(response =>
response.arrayBuffer())
.then(buffer =>
WebAssembly.compile(buffer))
.then(module => {
var imports = {env: {}};
Object.assign(imports.env, {
memoryBase: 0,
tableBase: 0,
memory: new WebAssembly.Memory({ initial: 256, maximum: 256 }),
table: new WebAssembly.Table({ initial: 0, maximum: 0, element: 'anyfunc' })
})
var instance = new WebAssembly.Instance(module, imports)
var add = instance.exports.add;
console.log(add, add(5, 6));
})
先去加載add.wasm文件,接著把它編譯成機(jī)器碼,再new一個(gè)實(shí)例,然后就可以用exports的add函數(shù)了,如下控制臺(tái)的輸出:

可以看到add函數(shù)已經(jīng)變成機(jī)器碼了。
現(xiàn)在來寫一個(gè)比較有用的函數(shù),斐波那契函數(shù),先寫一個(gè)asm.js格式的,如下所示:
function fibonacci(fn, fn1, fn2, i, num) {
num = num | 0;
fn2 = fn2 | 0;
fn = fn | 0;
fn1 = fn1 | 0;
i = i | 0;
if(num < 0) return 0;
else if(num == 1) return 1;
else if(num == 2) return 1;
while(i <= num){
fn = fn1;
fn1 = fn2;
fn2 = fn + fn1;
i = i + 1;
}
return fn2 | 0;
}
這里筆者最到一個(gè)問題,就是定義的局部變量無法使用,它的值始終是0,所以先用傳參的方式。
然后再把剛剛那個(gè)加載編譯的函數(shù)封裝成一個(gè)函數(shù),如下所示:
loadWebAssembly("fibonacci.wasm").then(instance => {
var fibonacci = instance.exports.fibonacci;
var i = 4, fn = 1, fn1 = 1, fn2 = 2;
console.log(i, fn, fn1, fn2, "f(5) = " + fibonacci(5));
});
最后觀察控制臺(tái)的輸出:

可以看到在f(47)的時(shí)候發(fā)生了溢出,在《JS與多線程》這一篇提到JS溢出了會(huì)自動(dòng)轉(zhuǎn)成浮點(diǎn)數(shù),但是WASM就不會(huì)了,所以可以看到WASM/ASM其實(shí)和JS沒有直接的關(guān)系,只是說你可以用JS寫WASM,雖然官網(wǎng)的說法是ASM是JS的一個(gè)子集,但其實(shí)兩者沒有血肉關(guān)系,用JS寫ASM你會(huì)發(fā)現(xiàn)非常地笨拙和不靈活,編譯成WASM會(huì)有各種報(bào)錯(cuò),提示信息非常簡(jiǎn)陋,總之很難寫。但是不用沮喪,因?yàn)橄旅嫖覀儠?huì)提到還可以用C寫。
然后我們可以做一個(gè)兼容,如果支持WASM就去加載wasm格式的,否則加載JS格式,如下所示:

5. JS和WASM的速度比較
(1)運(yùn)行速度的比較
如下代碼所示,計(jì)算1到46的斐波那契值,然后重復(fù)一百萬次,分別比較wasm和JS的時(shí)間:
//wasm運(yùn)行時(shí)間
loadWebAssembly("fib.wasm").then(instance => {
var fibonacci = instance.exports._fibonacci;
var num = 46;
var count = 1000000;
console.time("wasm fibonacci");
for(var k = 0; k < count; k++){
for(var j = 0; j < num; j++){
var i = 4, fn = 1, fn1 = 1, fn2 = 2;
fibonacci(fn, fn1, fn2, i, j);
}
}
console.timeEnd("wasm fibonacci");
});
//js運(yùn)行時(shí)間
loadWebAssembly("fibonacci.js", {}, "js").then(instance => {
var fibonacci = instance.exports.fibonacci;
var num = 46;
var count = 1000000;
console.time("js fibonacci");
for(var k = 0; k < count; k++){
for(var j = 0; j < num; j++){
var i = 4, fn = 1, fn1 = 1, fn2 = 2;
fibonacci(fn, fn1, fn2, i, j);
}
}
console.timeEnd("js fibonacci");
});
運(yùn)行四次,比較如下:

可以看到,在這個(gè)例子里面WASM要比JS快了一倍。
然后再比較解析的時(shí)間
(2)解析時(shí)間比較
如下代碼所示:
console.time("wasm big content parse");
loadWebAssembly("big.wasm").then(instance => {
var fibonacci = instance.exports._fibonacci;
console.timeEnd("wasm big content parse");
console.time("js big content parse");
loadJs();
});
function loadJs(){
loadWebAssembly("big.js", {}, "js").then(instance => {
var fibonacci = instance.exports.fibonacci;
console.timeEnd("js big content parse");
});
}
分別比較解析100、2000、20000行代碼的時(shí)間,統(tǒng)計(jì)結(jié)果如下:

WASM的編譯時(shí)間要高于JS,因?yàn)镴S定義的函數(shù)只有被執(zhí)行的時(shí)候才去解析,而WASM需要一口氣把它們都解析了。
上面表格的時(shí)間是一個(gè)什么概念呢,可以比較一下常用庫(kù)的解析時(shí)間,如下圖所示:

(3)文件大小比較
20000行代碼,wasm格式只有3.4k,而壓縮后的js還有165K,如下圖所示:

所以wasm文件小,它的加載時(shí)間就會(huì)少,可以一定程度上彌補(bǔ)解析上的時(shí)間缺陷,另外可以做一些懶惰解析的策略。
6. WASM的優(yōu)缺點(diǎn)
WASM適合于那種對(duì)計(jì)算性能特別高的,如圖形計(jì)算方面的,缺點(diǎn)是它的類型檢驗(yàn)比較嚴(yán)格,寫JS編譯經(jīng)常會(huì)報(bào)錯(cuò),不方便debug。
WASM官網(wǎng)提供的一個(gè)WebGL + WebAssembly坦克游戲如下所示:

它的數(shù)據(jù)和函數(shù)都是用的wasm格式:

7. C/Rust寫前端
WASM還支持用C/Rust寫,需要安裝一個(gè)emsdk。然后用C函數(shù)寫一個(gè)fibonacci.c文件如下所示:
/* 不考慮溢出 */
int fibonacci(int num){
if(num <= 0) return 0;
if(num == 1 || num == 2) return 1;
int fn = 1,
fn1 = 1,
fn2 = fn + fn1;
for(int i = 4; i <= num; i++){
fn = fn1;
fn1 = fn2;
fn2 = fn1 + fn;
}
return fn2;
}
運(yùn)行以下命令編譯成一個(gè)wasm文件:
emcc fibonacci.c -Os -s WASM=1 -s SIDE_MODULE=1 -o fibonacci.wasm
這個(gè)wasm和上面的是一樣的格式,然后再用同樣的方式在瀏覽器加載使用。
用C寫比用JS寫更加地流暢,定義一個(gè)變量不用在后面寫一個(gè)“| 0”,編譯起來也非常順暢,一次就過了,如果出錯(cuò)了,提示非常友好。這就可以把一些C庫(kù)直接挪過來前端用。
8. WASM對(duì)寫JS的提示
WASM為什么非得強(qiáng)類型的呢?因?yàn)樗D(zhuǎn)成匯編,匯編里面就得是強(qiáng)類型,這個(gè)對(duì)于JS解釋器也是一樣的,如果一個(gè)變量一下子是數(shù)字,一下子又變成字符串,那么解釋器就得額外的工作,例如把原本的變量銷毀再創(chuàng)建一個(gè)新的變量,同時(shí)代碼可讀性也會(huì)變差。所以提倡:
定義變量的時(shí)候告訴解釋器變量的類型
不要隨意改變變量的類型
函數(shù)返回值類型是要確定的
這個(gè)我在《Effective前端8:JS書寫優(yōu)化》已經(jīng)提到.
到此,介紹完畢,通過本文應(yīng)該對(duì)程序的編譯有一個(gè)直觀的了解,特別是代碼是怎么變成機(jī)器碼的,還有WebAssembly和JS的關(guān)系又是怎么樣的,Webassembly是如何提高運(yùn)行速度,為什么要提倡強(qiáng)類型風(fēng)格代碼書寫。對(duì)這些問題應(yīng)該可以有一個(gè)理解。
另外一方面,web前端技術(shù)的發(fā)展真的是非常地活躍,在學(xué)這些新技術(shù)的同時(shí),別忘了打好基本功。
原文:極樂科技知乎專欄