在 WebAssembly 裡面跑 JavaScript 引擎

我相信很多人看到這個標題會覺得我在想什麼,然後我也是這麼覺得的 = =

反正就是無聊腦洞大開想到可不可以在 WebAssembly 中跑個小型的 JS 引擎,然後腦一熱就做下去了。不過好處是有學到怎麼用 Emscripten。

目前弄出的專案是這個: wasm-jseval ,有 DuktapeQuickJS 兩種引擎可以選擇使用。

至於那個的使用方法其實看 README 就知道怎麼用了,所以這篇文章只是想記錄我是怎麼弄的而已。

環境設置

我是在 WSL 的 Ubuntu 18.04 下面弄的,基本上只需要有 make 和 Emscripten 的 toolchain 就好了

Emscripten 的安裝方法其實參考官方的教學就好了: https://emscripten.org/docs/getting_started/downloads.html 不過為了方便我還是會在下面放我安裝的指令

1
2
3
4
5
6
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest # 下載和安裝
./emsdk activate latest # 啟用,這個會寫入 ~/.emscripten
source ./emsdk_env.sh # 幫你把 PATH 設定好
emcc # 測試有沒有安裝成功

Duktape 的 eval

下載

到 Duktape 的下載頁 選個想要的版本,然後下載後解壓縮到你想要的地方。例如我是用 2.5.0 版本的。

1
2
3
wget https://duktape.org/duktape-2.5.0.tar.xz
tar xf duktape-2.5.0.tar.xz
cd duktape-2.5.0

寫個簡單的 eval binding

我的目的是弄出一個簡單的 eval 函數可以在 js 端呼叫,所以我們需要用 C 寫個簡單的 binding。至於怎麼寫就自己看 Duktape 網站上的教學和 Document 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "./src/duktape.h" // 你這個檔案到 duktape.h 的相對路徑

const char* eval(const char* js_code) {
duk_context* ctx = duk_create_heap_default();
duk_push_string(ctx, js_code);
duk_int_t rc = duk_peval(ctx);
if (rc != 0) { // 如果執行失敗
duk_safe_to_stacktrace(ctx, -1);
const char* stacktrace = duk_get_string(ctx, -1);
duk_destroy_heap(ctx);
return stacktrace; // 取得 stacktrace 後傳回去
}
const char* json = duk_json_encode(ctx, -1);
duk_destroy_heap(ctx);
return json; // 成功就把值用 json encode 起來傳回去
}

編譯

其實因為 Duktape 本身編譯就很簡單,而 emcc 的用法也和 gcc 很像,所以最簡單就像這樣就能編譯了。輸出格式用 html 是為了方便測試。

1
2
# eval.c 是你上面那個 binding 檔案的名稱
emcc eval.c ./src/duktape.c -lm -o eval.html

然後用個簡單的 webserver 如 httpsrv 或是 python 內建的在當前目錄建立 server,然後用瀏覽器瀏覽伺服器上的 eval.html。 然後打開瀏覽器的 devtool console,然後輸入看看 Module 就代表成功了。

不過目前還沒有 export 出需要的 function,所以要加上需要的參數把它 export 給 js 使用。

1
emcc eval.c ./src/duktape.c -o eval.html -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'

然後重整頁面再回到 console,輸入看看 Module.cwrap('eval', 'string', ['string'])('1+1'),這樣應該會回傳一個 "2" 的 string,這就代表已經成功了。 那個 cwrap 是個方便的函數能讓你直接從 js 呼叫 C 的函數,並且幫你處裡型別轉換。

不過這樣弄出來是兩個檔案 eval.jseval.wasm,在作為 library 時比較不方便。不過其實 Emscripten 有支援幫你把它轉 base64 塞入 js 的功能 -s SINGLE_FILE=1,不過這樣會讓檔案變很大。 所以要順便加上一些最佳化參數給它,這樣會比較好。如 -Oz

下面這行是我最終的編譯指令,它只會產生一個 eval.js,而且是 UMD 格式的,可以用 node.js 去 import。至於如何包裝成 library 就自己看 code 了。

1
emcc -o eval.js eval.c ../duktape/src/duktape.c -lm -Oz --closure 1 -s WASM=1 -s SINGLE_FILE=1 -s AGGRESSIVE_VARIABLE_ELIMINATION -s MODULARIZE=1 -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'

src/index.js 頂端的那個 EVALJS 是會給 bundler 自動替換的,把它想成是剛剛編譯出來的 eval.js 就好了。

QuickJS 的 eval

下載

這個就不多說了,在這邊下載。我使用的版本是 2019-12-21。

1
2
3
wget https://bellard.org/quickjs/quickjs-2019-12-21.tar.xz
tar xf quickjs-2019-12-21.tar.gz
cd quickjs-2019-12-21

寫個簡單的 eval binding

這個因為它的 Document 比較少,很多函數要去它給的 quickjs.h 裡面翻了,不過其實比 Duktape 還容易使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <string.h>
#include "./quickjs.h"

const char* eval(const char* str) {
JSRuntime* runtime = JS_NewRuntime();
JSContext* ctx = JS_NewContext(runtime);
JSValue result = JS_Eval(ctx, str, strlen(str), "<evalScript>", JS_EVAL_TYPE_GLOBAL); // 執行
if (JS_IsException(result)) {
JSValue realException = JS_GetException(ctx);
return JS_ToCString(ctx, realException); // 如果是 Exception 就取得 string 回傳
}
JSValue json = JS_JSONStringify(ctx, result, JS_UNDEFINED, JS_UNDEFINED);
JS_FreeValue(ctx, result);
return JS_ToCString(ctx, json); // 成功就一樣 json encode 回傳
}

編譯

這個因為它不是一個檔案的,當你發現找不到 symbol 時就搜尋一下它在哪個檔案,然後都加進編譯指令中就可以編譯了。 有個要特別注意的是你需要自己定義 CONFIG_VERSION 這個 macro,不然編譯不給過,因為它本身有用到。

1
emcc -o eval.html eval.c ./quickjs.c ./cutils.c ./libregexp.c ./libbf.c ./libunicode.c -DCONFIG_VERSION="\"1.0.0\"" -lm -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'

然後一樣是開伺服器,用瀏覽器到 eval.html 中試試看 Module.cwrap('eval', 'string', ['string'])('1+1'),回傳值一樣是 "2"

再來弄成一個檔案,最佳化等等都和上面一樣。最終編譯指令長這樣:

1
emcc -o eval.js eval.c ./quickjs.c ./cutils.c ./libregexp.c ./libbf.c ./libunicode.c -DCONFIG_VERSION="\"1.0.0\"" -s WASM=1 -s SINGLE_FILE -s MODULARIZE=1 -lm -Oz -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' --llvm-lto 3 -s AGGRESSIVE_VARIABLE_ELIMINATION=1 --closure 1

然後把它包裝成 library 的 code 其實和上面包裝 Duktape 版本的 code 是一模一樣的,因為我寫的 binding 介面設計的完全一樣。