本文作者:劉觀宇,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的支援情況。
除了在瀏覽器上可以執行外,目前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命令的使用說明。
第二步:新建專案
接下來,我們新建一個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,一般採用如下步驟:
- 載入wasm的位元組碼。
- 將獲取到位元組碼後轉換成 ArrayBuffer,只有這種結構才能被正確編譯。編譯時會對上述ArrayBuffer進行驗證。驗證通過方可編譯。編譯後會通過Promise resolve一個 WebAssembly.Module。
- 在獲取到 module 後需要通過 WebAssembly.Instance API 去同步的例項化 module。
- 上述第2、3步驟可以用instaniate 非同步API等價代替。
- 之後就可以和像使用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的前景,在效能要求很高的如遊戲、影音應用等領域,或許會有不錯的發展。
參考資料
- webassembly.org/
- www.npmjs.com/package/ass…
- github.com/AssemblyScr…
- www.w3.org/TR/2018/WD-…
- www.ibm.com/developerwo…
- segmentfault.com/a/119000000…
- developer.mozilla.org/en-US/docs/…
- www.npmjs.com/package/ass…
- blog.csdn.net/a986597353/…
- webpack.js.org/
致謝
本文選題過程,參考了安佳、李鬆峰、劉宇晨等同事的建議。成文後,李鬆峰老師和劉宇晨給出了很多中肯的修訂意見,在此一併表示誠摯的謝意。
相關文章
- WebAssembly 對比 JavaScript 及其使用場景 mp.weixin.qq.com/s/AiKvAnTWq…
- Rust, WebAssembly 與 Webpack 入門攻略 mp.weixin.qq.com/s/kVr04twJ4…
- 幾張圖讓你看懂WebAssembly mp.weixin.qq.com/s/8KBiHp2rK…
關於奇舞週刊
《奇舞週刊》是360公司專業前端團隊「奇舞團」運營的前端技術社群。關注公眾號後,直接傳送連結到後臺即可給我們投稿。