聽過 WebAssembly 這個技術好一陣子了,不過現在瀏覽器裡的原生 HTML 與 JavaScript 已經非常強大,甚至於我之前遇過一個圖片處理需求、在選技術時,發現其實靠 canvas 元素就能達成,所以那時就沒再深入研究;最近剛好又遇上一個碰得著邊的需求,趁此機會來好好認識一下。
WebAssembly 是什麼?
是否有想過,把用其他程式語言寫的程式跑在瀏覽器裡?
待會再討論為什麼要這麼做,總之 WebAssembly 技術就是為此而生——只要把其他語言寫出的程式編譯為 WebAssembly 標準格式,就能在瀏覽器執行環境裡透過 JavaScript 調用其他語言寫出的 function!
什麼情境適合用 WebAssembly?
如前言,現在的 JavaScript 大概已經能做到大部分的事情,但它身為直譯式語言、高階語言,效能方面仍有其天生條件的侷限性;有些情境用 C、Rust 等更接近底層、能精準控制記憶體的語言來實作,可以讓效能再進一步提升。
這個差距尤其體現在需要逐個 byte 處理的情境,較常遇到的就是圖像處理、更進一步的則是網頁遊戲的畫面渲染,像是一些跑在瀏覽器上、用 Unity 引擎寫的遊戲,其「Unity 遊戲引擎」的部分就是大量用到 WebAssembly 技術而得以在瀏覽器裡流暢地運行。
C 編譯為 WebAssembly 的編譯器:Emscripten
稍早提到的「WebAssembly 標準格式」是副檔名為 wasm 的檔案,裡面是 WASM bytecode,因此無論原本用什麼程式語言,只要能編譯為 WASM bytecode、瀏覽器就都能執行。
要想把 C 編譯為這種檔案靠的不是 MinGW gcc、而是需要另一套專門的編譯器,目前最主流的是 Emscripten。
在 Windows 安裝 Emscripten
官方的安裝說明文件在此:Download and install — Emscripten documentation,這安裝方法很 developer——從 GitHub clone 腳本庫下來執行 XD
另外官方也有提供 docker image 的使用方式,熟悉容器化操作的話可能還比較輕鬆簡潔。
一般安裝流程有些眉角要注意,尤其是在 Windows 環境——比如開發者有為 Windows 的 cmd 與 PowerShell 分別寫了 bat 與 ps1 腳本檔,因此要根據 terminal 執行不同腳本,以下我用的是 cmd。
以下以 <path> 代指實際執行 git clone 的路徑。
-
確保已安裝 Python
Terminal window python -v -
在本機指定目錄下安裝所需工具
Terminal window git clone https://github.com/emscripten-core/emsdk.gitcd emsdkgit pullREM 安裝所需工具emsdk.bat install latest- 所需工具都是裝在
<path>\emsdk目錄下、不影響全域環境;未來如果想刪除這個 emsdk,也是很簡單地直接把整個 emsdk 目錄刪掉即可。
- 所需工具都是裝在
把 c 檔編譯為 wasm 檔
- 在要執行編譯的 terminal,先執行以下指令、以把所需 terminal 變數設好
Terminal window <path>\emsdk\emsdk.bat activate latest - 把 c 檔編譯為 wasm 檔
Terminal window emcc demo.c -o demo.js -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 -s "EXPORTED_RUNTIME_METHODS=['HEAPU8']" -s ALLOW_MEMORY_GROWTH=1-s MODULARIZE=1 -s EXPORT_ES6=1:編譯為 ES6 module 的 js 檔-s "EXPORTED_RUNTIME_METHODS=['HEAPU8']":匯出HEAPU8物件,型別為Uint8Array、對應到 Linear Memory-s ALLOW_MEMORY_GROWTH=1:允許在 Heap 記憶體空間不足時自動擴張
- 這樣會產生兩個檔案:「demo.wasm」和「demo.js」,其中 JS 檔已經幫我們包裝好
export default一個 async function,在要使用的 JSimport後呼叫、其回傳值是 Emscripten Module 物件,裡面包含了我們編譯時指定要匯出的 function 與物件。
從 C 把 function 匯出到 JS
在 C,要引入 #include <emscripten.h> 以讓 Emscripten 能夠編譯;在要匯出的 function 上加上一行 EMSCRIPTEN_KEEPALIVE,編譯時就不用顯式指定每個要匯出的 function 名稱。編譯就可以在 JS 調用,function 名稱會是原本 C 的名稱前面再加上一個底線。
假設我們在 C 寫了一個 function int add(int a, int b),以下是一個最基礎的調用範例:
import createModule from './image-process.js';createModule().then(module => { console.log(module._add(1, 2)); // 輸出:3});WebAssembly 的 Linear Memory
WebAssembly 是把其他語言寫出的 function 匯出到 JS、可以從 JS 調用,如果參數簡單、像是 add(int a, int b),那就直接傳參數呼叫即可;但如果是要做圖片處理、參數是 array,C function 沒有辦法直接接 array、只能接指標,那怎麼辦?
瀏覽器執行環境會給 WebAssembly 一塊以 byte 為單位的線性連續記憶體空間,在 C 的情境下就是 heap,可以像平常一樣用 malloc、free;而在這塊記憶體裡 malloc 取得的指標實際上都是整數,代表相對於這塊記憶體空間開頭的偏移量。
如果照前一步加了那些參數給 Emscripten 編譯,編譯出的 js 檔內會給我們一個型別為 Uint8Array 的 array HEAPU8,讓 JS 可以透過此 array 存取這塊記憶體空間;又因為它是「線性」、Uint8Array 的單位是一個 byte 就和這塊記憶體的單位相同,malloc 取得的是「偏移量」,那麼在 HEAPU8 裡用偏移量當作 array 的 index 來操作就可以了!
順帶一提,其實不只 HEAPU8,根據編譯參數、還可以匯出型別為 Int32Array 的 HEAP32 等對應這塊記憶體不同型別的 array,不過如果 array 的單位不是一個 byte、就不能這麼簡單的直接拿偏移量當 array index,需要經過一些計算,如 Int32Array 的一個元素是 4 bytes 的數值、所以偏移量除以 4 才是對應的 array index。
WebAssembly 的 JS object 與 C struct
很可惜,這兩者無法直接轉換,只能自己在 JS 序列化、透過 HEAP32 這類 array 放到正確的偏移量,再傳指標給 C function;而且 C 的 struct 還要特別注意 struct 的 padding 機制。
題外話,C++ 和 Rust 就有對應的轉換工具如 Embind 了。
後記閒聊
寫到舉運用 WebAssembly 實現的例子時,第一個想到的是「Photopea」、網頁版且免費的 PhotoShop 替代,想說它如此強大的功能應該就是靠這個實現的吧;為求正確去搜尋了一下,結果發現了作者在 Reddit 上的回覆、本體竟然是純 JavaScript 😮