20分鐘上手 webAssembly

奇舞週刊發表於2018-08-20

本文作者:劉觀宇,360奇舞團高階前端工程師、技術經理,曾參與360導航、360影視、360金融、360遊戲等多個大型前端專案。關注W3C標準、IOT、機器學習的最新進展,現為W3C CSS工作組成員。

背景

Web應用的蓬勃發展,使得JavaScript、Web前端,乃至整個網際網路都發生了深刻的變化。前端開始承擔起了更多的職責,於是對於執行效率的訴求也就更為急迫。除了在語言本身的進化,Web從業者以及各大瀏覽器廠商,也在不停地進行探索。2012年Mozillia的工程師提出了Asm.js和Emscripten,使得C/C++以及多種程式語言編寫的高效程式轉譯為JavaScript並在瀏覽器執行成為可能。

更進一步地,WebAssembly(簡稱wasm)技術被提出,並迅速成立了各種研發組織,各種周邊工具鏈的不斷完善,相關實驗資料也有力地佐證了這條優化和加速路線的可行性。

特別是2018年,W3C的WebAssembly工作組釋出了第一個工作草案,包含了核心標準JavaScript API以及Web API。另外,除了C/C++和Rust之外,Golang語言也正式支援了wasm的編譯。我們罕見的看到,各大主流瀏覽器一致表示支援這一新的技術,也許一個嶄新的Web時代即將到來。

簡單介紹wasm

開啟wasm的官網,我們可以看到其巨集偉的技術目標。除了定義一個可移植、精悍、載入迅捷的二進位制格式之外,還有對移動裝置、非瀏覽器乃至IoT裝置支援的規劃,並且還會逐步建立一系列工具鏈。感興趣的讀者,可以從這裡看到wasm官方的闡述。

簡單的說,wasm並不是一種程式語言,而是一種新的位元組碼格式,目前,主流瀏覽器都已經支援 wasm。與 JavaScript 需要解釋執行不同的是,wasm位元組碼和底層機器碼很相似可快速裝載執行,因此效能相對於 JavaScript 解釋執行有了很大的提升。

下面這張圖,展示了目前(2018年7月)主流瀏覽器對於wasm的支援情況。

20分鐘上手 webAssembly

除了在瀏覽器上可以執行外,目前wasm已經可以在包括NodeJS等命令列環境下執行。

wasm的工具鏈結構

按照最初的設想,各種高階語言通過自己的前端編譯工具,將自己的原始碼編譯成為底層虛擬機器(LLVM)可識別的中間語言表示(LLVM IR)。此時,底層的LLVM可以將LLVM IR根據不同的CPU架構生成不同的機器碼,同時可以對這些機器碼進行編譯時的空間與效能的優化。大多數的高階語言都是按照這樣的結構來支援wasm的。上述提到的兩個步驟,也依次被成為編譯器前端和編譯器後端。

編譯到wasm的程式碼,是最終進行實際工作的程式。對此,有一種名為S-表示式的文字格式,副檔名為.wast,以方便程式猿閱讀。藉助wabt工具鏈可以實現wasm和wast的互轉。一個S-表示式形如:

(module
 (type $iii (func (param i32 i32) (result i32)))
 (memory $0 0)
 (export "memory" (memory $0))
 (export "add" (func $assembly/module/add))
 (func $assembly/module/add (; 0 ;) (type $iii) (param $0 i32) (param $1 i32) (result i32)
  ;;@ assembly/module.ts:2:13
  (i32.add
   ;;@ assembly/module.ts:2:9
   (get_local $0)
   ;;@ assembly/module.ts:2:13
   (get_local $1)
  )
 )
)
複製程式碼

目前已經有多種高階語言支援對wasm的編譯,特別是AssemblyScript,這種以TypeScript為基礎語言,通過AssemblyScript的工具鏈支援,可以完成最終到wasm的轉換。

根據上述架構,瀏覽器以及各種執行環境提供者,各自通過提供不同的執行支援以抹平各個CPU架構不同造成的差異,使得需要支援wasm高階語言,只需要支援編譯到中間語言表示層。可以預見的是,隨著開發環境的舒適度逐步提高,越來越多的高階語言也會加入支援wasm的陣營。

使用AssemblyScript編寫wasm

下面的實踐,我們需要藉助AssemblyScript來完成,AssemblyScript定義了一個TypeScript的子集,意在幫助TS背景的同學,通過標準的JavaScript API來完成到wasm的編譯,從而消除語言的差異,讓程式猿可以快樂的編碼。

AssemblyScript專案主要分為三個子專案:

  • AssemblyScript:將TypeScript轉化為wasm的主程式
  • binaryen.js:AssemblyScript主程式轉化為wasm的底層實現,依託於binaryen庫,是對binaryen的TypeScript封裝。
  • wast.js:AssemblyScript主程式轉化為wasm的底層實現,依託於wast庫,是對wast的TypeScript封裝。

這裡需要說明的是,目前工具鏈還在開發過程中,個別步驟可能還不太穩定。我們儘量保證安裝配置過程的嚴謹,如果遇到有變動,請以官方描述為準。

為了支援編譯,我們首先需要安裝AssemblyScript的支援。為了編譯的順利進行,首先需要保證你的Node版本在8.0以上。同時,你需要安裝好TypeScript執行環境。

下面讓我們開始吧:

第一步:安裝依賴

為了避免後面依賴的問題,我們首先安裝AssemblyScript支援

git clone https://github.com/AssemblyScript/assemblyscript.git
cd assemblyscript
npm install
npm link
複製程式碼

執行上述命令後,你可以使用命令asc來判定是否安裝正確。如果正常安裝,命令列會顯示asc命令的使用說明。

20分鐘上手 webAssembly

第二步:新建專案

接下來,我們新建一個NPM專案,如:wasmExample。如果需要,可以加入ts-node和typescript的devDependencies,並安裝好依賴。 然後,在專案根目錄下,我們新建一個目錄:assembly。 我們進入assembly目錄,同時我們在這裡加入tsconfig.json,內容如下:

{
  "extends": "../node_modules/assemblyscript/std/assembly.json",
  "include": [
    "./**/*.ts"
  ]
}
複製程式碼

第三步:寫程式碼

下面,我們在這個目錄下加入簡單的ts程式碼,如下:

export function add(a: i32, b: i32): i32 {
  return a + b;
}
複製程式碼

我們把上面這段TypeScript程式碼儲存為:module.ts。 那麼,現在從專案根目錄來看,我們的檔案結構如下圖:

20分鐘上手 webAssembly

第四步:配置NPM Scripts

為了後面執行簡便,我們把build步驟加入到npm scripts裡面,方法是開啟專案根目錄的package.json,更新scripts欄位為:

 "scripts": {
    "build": "npm run build:untouched && npm run build:optimized",
    "build:untouched": "asc assembly/module.ts -t dist/module.untouched.wat -b dist/module.untouched.wasm --validate --sourceMap --measure",
    "build:optimized": "asc assembly/module.ts -t dist/module.optimized.wat -b dist/module.optimized.wasm --validate --sourceMap --measure --optimize"
   }
複製程式碼

為了專案整潔,我們把編譯目標放到專案根目錄的dist資料夾,此時,我們需要在專案根目錄下新建dist目錄。

第五步:編譯

現在,在專案根目錄下,我們來執行:npm run build 如果沒有報錯的話,你會看到,在dist目錄下生成了6個檔案。

我們先不必深究檔案的具體內容。此時,我們的編譯工作已經做好。細心的讀者可能看到了,在上面的編譯命令裡面使用了不同的引數。這些引數,我們可以直接在命令列下鍵入asc來查詢命令以及引數的使用細節。

第六步:引入編譯結果

現在我們有了編譯的結果。目前,由於wasm還只能由JavaScript引入,因此我們還需要將編譯出的wasm引入到JavaScript程式中。

我們在專案根目錄加入一個module引入程式碼:module.js,如下:

const fs = require("fs");
const wasm = new WebAssembly.Module(
    fs.readFileSync(__dirname + "/dist/module.optimized.wasm"), {}
);
module.exports = new WebAssembly.Instance(wasm).exports;
複製程式碼

同時,我們需要一個使用module的程式碼。如:index.js,如下:

var myModule = require("./module.js");
console.log(myModule.add(1, 2));
複製程式碼

激動人心的時刻到了,我們在專案根目錄下執行node index.js,看看結果是否正如我們所期待。讀者自行可以修改index.js裡面的呼叫資料,來測試模組的正確性。需要注意的是,因為是wasm是有資料型別概念的,而且資料型別比TypeScript 更為精確。所以,上面的例子中,如果輸入的不是整數(上例指wasm定義的i32),會和傳統的JavaScript結果不一致,比如你的呼叫是myModule.add(2.5, 2),結果可能是4。因此,我們需要在呼叫wasm程式時候,嚴格關注資料型別。

在瀏覽器使用

上面的段落,我們展示瞭如何與NodeJS整合,其實對於效率提升更為顯著的,當屬在瀏覽器中。那麼如何在瀏覽器中使用我們編譯好的程式碼呢?

JavaScript呼叫wasm

對於JavaScript呼叫wasm,一般採用如下步驟:

  1. 載入wasm的位元組碼。
  2. 將獲取到位元組碼後轉換成 ArrayBuffer,只有這種結構才能被正確編譯。編譯時會對上述ArrayBuffer進行驗證。驗證通過方可編譯。編譯後會通過Promise resolve一個 WebAssembly.Module。
  3. 在獲取到 module 後需要通過 WebAssembly.Instance API 去同步的例項化 module。
  4. 上述第2、3步驟可以用instaniate 非同步API等價代替。
  5. 之後就可以和像使用JavaScript模組一樣呼叫了。

完整的步驟,也可以參見下面的流程圖:

20分鐘上手 webAssembly

這裡提供一個非同步程式碼的例子,我們將其命名為async_module.js:

// 非同步引入例子
const fs = require("fs");
const readFile = require("util").promisify(fs.readFile);

const getInstance = async (wasm, importObject={}) => {
	let buffer = new Uint8Array(wasm)
	return await WebAssembly.instantiate(wasm, importObject)
}

let ins;

const noop = () => {};

const exportFun = (obj, funName) => {
	return (typeof obj[funName] === "function") 
		? obj[funName] : noop;
}

async function getModuleFun(filePath, funName ,importObject={}) {
	if (ins){
		return exportFun(ins, funName)
	}

	const wasmText = await readFile(filePath);
	const mod = await getInstance(wasmText, importObject);

	return exportFun(mod.instance.exports, funName)
}

module.exports = getModuleFun;
複製程式碼

呼叫時候,我們只需如下程式碼,即可愉快地利用wasm物件進行編碼了:

var myModule = require("./async_module.js");

// 呼叫程式碼
(async () => {
	const fun = await myModule(__dirname + "/dist/module.optimized.wasm", "add")
	console.log(fun(1, 2))
	console.log(fun(4, 10000))
})()
複製程式碼

這裡是目前全部的JavaScript中與wasm協作的API說明

使用webpack整合載入工作流

從webpack4開始,官方提供了預設的wasm的載入方案。如果你的webpack是webpack4以前的版本,可能需要安裝諸如assemblyscript-typescript-loader等開發包。

筆者目前所使用的webpack版本為:4.16.2,對於wasm的原生支援已經比較完善。根據官方的資訊,之後的webpack5,會對wasm進行更為穩健的支援。

如下程式碼即可簡單的引入wasm模組,執行npx webpack可以將程式碼自動編譯:

import("./module.optimized.wasm").then(module => {
    const container = document.createElement("div");
    container.innerText = "Hello, WebAssembly.";
    container.innerText += " add(1, 2) is " + module.add(1, 2);
    document.body.appendChild(container);
});
複製程式碼

在wasm中操作JavaScript

由於wasm目前不能直接操作Dom,如果需要這種操作,可能需要藉助JavaScript的能力,這種情況下,我們需要在wasm中呼叫JavaScript。

WebAssembly.instance 和 WebAssembly.instantiate 函式均支援第二個引數 importObject,這個importObject 引數的作用就是 JavaScript 向 wasm 傳入需要呼叫的JavaScript模組。

作為演示,我們把上面的module.js程式碼修改一下,把相加的結果,用“*”的個數來表示。這裡我們為了演示方便,依然使用同步程式碼,實際上,非同步程式碼更為常用。

const fs = require("fs");
const wasm = new WebAssembly.Module(
    fs.readFileSync(__dirname + "/dist/module.optimized.wasm"), {}
);
module.exports = new WebAssembly.Instance(wasm, {
  window:{
    show: function (num){
        console.log(Array(num).fill("*").join(""))
    }
  }
}).exports;
複製程式碼

呼叫方index.js修改為:

var myModule = require("./module.js");
myModule.add(1, 2);
複製程式碼

同時,我們需要修改TypeScript原始碼:

// 宣告從外部匯入的模組型別
declare namespace window {
    export function show(v: number): void;
}

export function add(a: i32, b: i32): void {
  window.show(a + b);
}
複製程式碼

我們回到專案根目錄,重新執行npm run build

之後,執行node index.js 我們看到,原來的結果,改為用*的個數來表示了。說明WebAssembly呼叫JavaScript程式碼成功。

20分鐘上手 webAssembly

小結

對於wasm技術,我們總結如下:

  • 標準尚屬工作草案階段,暫不建議在實際穩定專案中使用。
  • 目標遠大,各大瀏覽器廠商、各大主流語言跟進積極性很高,適合作為一種新技術長期跟進。
  • 目前主流瀏覽器的最新版本都已基本支援。如果需要相容過往瀏覽器、尤其是IE系列,現在還沒有特別好的解決方案,個別介面存在不相容狀況。
  • 工具鏈開發目前活躍度很高,但也帶來介面不穩定,使用方式可能有變化的可能。各個工具鏈還沒有特別壓倒性的效率及成熟度優勢,都處於起步階段。
  • 學習資料、尤其是中文資料偏少。需要一定的精力投入,必要時候需要跟進原始碼。

儘管如此,筆者仍然非常看好wasm的前景,在效能要求很高的如遊戲、影音應用等領域,或許會有不錯的發展。

參考資料

致謝

本文選題過程,參考了安佳、李鬆峰、劉宇晨等同事的建議。成文後,李鬆峰老師和劉宇晨給出了很多中肯的修訂意見,在此一併表示誠摯的謝意。

相關文章

關於奇舞週刊

《奇舞週刊》是360公司專業前端團隊「奇舞團」運營的前端技術社群。關注公眾號後,直接傳送連結到後臺即可給我們投稿。

20分鐘上手 webAssembly

相關文章