20分鐘上手 webAssembly

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

背景

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

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

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

簡單介紹wasm

打開wasm的官網,咱們能夠看到其宏偉的技術目標。除了定義一個可移植、精悍、載入迅捷的二進制格式以外,還有對移動設備、非瀏覽器乃至IoT設備支持的規劃,而且還會逐步創建一系列工具鏈。感興趣的讀者,能夠從這裏看到wasm官方的闡述。node

簡單的說,wasm並非一種編程語言,而是一種新的字節碼格式,目前,主流瀏覽器都已經支持 wasm。與 JavaScript 須要解釋執行不一樣的是,wasm字節碼和底層機器碼很類似可快速裝載運行,所以性能相對於 JavaScript 解釋執行有了很大的提高。webpack

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

除了在瀏覽器上能夠運行外,目前wasm已經能夠在包括NodeJS等命令行環境下運行。github

wasm的工具鏈結構

按照最初的設想,各類高級語言經過本身的前端編譯工具,將本身的源代碼編譯成爲底層虛擬機(LLVM)可識別的中間語言表示(LLVM IR)。此時,底層的LLVM能夠將LLVM IR根據不一樣的CPU架構生成不一樣的機器碼,同時能夠對這些機器碼進行編譯時的空間與性能的優化。大多數的高級語言都是按照這樣的結構來支持wasm的。上述提到的兩個步驟,也依次被成爲編譯器前端和編譯器後端。web

編譯到wasm的代碼,是最終進行實際工做的程序。對此,有一種名爲S-表達式的文本格式,擴展名爲.wast,以方便程序猿閱讀。藉助wabt工具鏈能夠實現wasm和wast的互轉。一個S-表達式形如:typescript

(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命令的使用說明。

第二步:新建項目

接下來,咱們新建一個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。 那麼,如今從項目根目錄來看,咱們的文件結構以下圖:

第四步:配置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. 上述第二、3步驟能夠用instaniate 異步API等價代替。
  5. 以後就能夠和像使用JavaScript模塊同樣調用了。

完整的步驟,也能夠參見下面的流程圖:

這裏提供一個異步代碼的例子,咱們將其命名爲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代碼成功。

小結

對於wasm技術,咱們總結以下:

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

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

參考資料

致謝

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

相關文章

關於奇舞週刊

《奇舞週刊》是360公司專業前端團隊「奇舞團」運營的前端技術社區。關注公衆號後,直接發送連接到後臺便可給咱們投稿。

相關文章
相關標籤/搜索