編寫與 npm

如何將 WebAssembly 整合至這項設定?本文將以 C/C++ 和 Emscripten 為例,說明如何解決這個問題。

WebAssembly (wasm) 通常會被視為效能基本型別,或是用來在網路上執行現有 C++ 程式碼基底的方法。我們希望透過 squoosh.app 說明,wasm 至少還有第三種觀點:善用其他程式設計語言的龐大生態系統。有了 Emscripten,您可以使用 C/C++ 程式碼,Rust 內建 wasm 支援Go 團隊也正在努力。相信不久後就會支援其他語言。

在這些情境中,wasm 並非應用程式的核心,而是拼圖的一塊:另一個模組。您的應用程式已有 JavaScript、CSS、圖片資產、以網路為中心的建構系統,甚至可能還有 React 等架構。如何將 WebAssembly 整合到這項設定中?本文將以 C/C++ 和 Emscripten 為例,說明如何解決這個問題。

Docker

使用 Emscripten 時,我發現 Docker 非常實用。C/C++ 程式庫通常是為搭配建構時所用的作業系統而編寫。一致的環境非常實用。Docker 提供虛擬 Linux 系統,已設定完畢可搭配 Emscripten 運作,並安裝所有工具和依附元件。如果缺少某些項目,您可以直接安裝,不必擔心這會對自己的電腦或其他專案造成影響。如果發生錯誤,請丟棄容器並重新開始。如果運作一次,即可確保會繼續運作並產生相同結果。

Docker Registrytrzeci 提供的 Emscripten 映像檔,我經常使用這個映像檔。

與 npm 整合

在大多數情況下,網頁專案的進入點是 npm 的 package.json。按照慣例,大多數專案都可以使用 npm install && npm run build 建構。

一般來說,Emscripten 產生的建構成果 (.js.wasm 檔案) 應視為另一個 JavaScript 模組和另一個資產。JavaScript 檔案可由 webpack 或 rollup 等打包工具處理,wasm 檔案則應視為任何其他較大的二進位素材資源,例如圖片。

因此,您需要先建構 Emscripten 建構構件,然後再啟動「一般」建構程序:

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

新的 build:emscripten 工作可以直接叫用 Emscripten,但如先前所述,建議使用 Docker 確保建構環境一致。

docker run ... trzeci/emscripten ./build.sh 會指示 Docker 使用 trzeci/emscripten 映像檔啟動新容器,並執行 ./build.sh 指令。build.sh 是您接下來要編寫的殼層指令碼!--rm 會告知 Docker 在容器執行完畢後刪除容器。這樣一來,您就不會隨著時間累積過時的機器映像檔。-v $(pwd):/src 表示您希望 Docker 將目前目錄 ($(pwd))「鏡像」到容器內的 /src。您在容器內 /src 目錄中對檔案所做的任何變更,都會反映在實際專案中。這些鏡像目錄稱為「繫結掛接」。

讓我們看看 build.sh

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

這裡有很多內容可以剖析!

set -e 會將殼層設為「快速失敗」模式。如果指令碼中的任何指令傳回錯誤,整個指令碼會立即中止。這項功能非常實用,因為指令碼的最後輸出內容一律是成功訊息,或是導致建構作業失敗的錯誤。

您可以使用 export 陳述式定義幾個環境變數的值。您可以使用這些選項,將額外的指令列參數傳遞至 C 編譯器 (CFLAGS)、C++ 編譯器 (CXXFLAGS) 和連結器 (LDFLAGS)。這些選項都會透過 OPTIMIZE 接收最佳化工具設定,確保所有項目都以相同方式最佳化。OPTIMIZE 變數有幾種可能的值:

  • -O0:不進行任何最佳化。不會移除無用的程式碼,Emscripten 也不會壓縮發出的 JavaScript 程式碼。適合用於偵錯。
  • -O3:積極提升效能。
  • -Os:以效能和大小為次要條件,積極進行最佳化。
  • -Oz:積極縮減大小,必要時可犧牲效能。

就網路而言,我大多建議使用 -Os

emcc 指令本身有許多選項。請注意,emcc「應可取代 GCC 或 clang 等編譯器」。因此,您可能從 GCC 瞭解的所有標記,很可能也會由 emcc 實作。-s 標記很特別,因為它可讓我們專門設定 Emscripten。如需所有可用選項,請參閱 Emscripten 的 settings.js,但該檔案可能相當龐大。以下列出我認為對網頁開發人員最重要的 Emscripten 標記:

  • --bind 啟用 embind
  • -s STRICT=1 會停止支援所有已淘汰的建構選項。確保程式碼以向前相容的方式建構。
  • -s ALLOW_MEMORY_GROWTH=1 可在必要時自動增加記憶體。撰寫本文時,Emscripten 會先分配 16 MB 的記憶體。當程式碼配置記憶體區塊時,這個選項會決定這些作業是否會在記憶體用盡時導致整個 WASM 模組失敗,或是允許黏著程式碼擴充總記憶體來配合配置。
  • -s MALLOC=... 會選擇要使用的 malloc() 實作方式。emmalloc 是專為 Emscripten 打造的小型快速 malloc() 實作項目。替代方案是 dlmalloc,這是功能齊全的 malloc() 實作。只有在您經常分配大量小型物件,或想使用執行緒時,才需要切換至 dlmalloc
  • -s EXPORT_ES6=1 會將 JavaScript 程式碼轉換為 ES6 模組,並提供適用於任何打包工具的預設匯出項目。也需要設定 -s MODULARIZE=1

下列旗標不一定必要,或僅適用於偵錯:

  • -s FILESYSTEM=0 是與 Emscripten 相關的標記,當 C/C++ 程式碼使用檔案系統作業時,這個標記可模擬檔案系統。編譯器會對編譯的程式碼進行一些分析,以決定是否要在膠合程式碼中加入檔案系統模擬。不過,有時這項分析可能會出錯,導致您為可能不需要的檔案系統模擬支付相當可觀的 70 KB 額外黏合程式碼。使用 -s FILESYSTEM=0 可強制 Emscripten 不納入這段程式碼。
  • -g4 會讓 Emscripten 在 .wasm 中加入偵錯資訊,並為 wasm 模組發出來源對應檔。如要進一步瞭解如何使用 Emscripten 進行偵錯,請參閱偵錯章節

就是這樣!如要測試這項設定,請快速建立一個小型 my-module.cpp

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

以及 index.html

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(這裡有包含所有檔案的 gist。)

如要建構所有項目,請執行

$ npm install
$ npm run build
$ npm run serve

前往 localhost:8080 時,您應該會在開發人員工具控制台中看到下列輸出內容:

開發人員工具顯示透過 C++ 和 Emscripten 列印的訊息。

將 C/C++ 程式碼新增為依附元件

如要為網頁應用程式建構 C/C++ 程式庫,程式碼必須是專案的一部分。您可以手動將程式碼新增至專案的存放區,也可以使用 npm 管理這類依附元件。假設我想在網頁應用程式中使用 libvpx。libvpx 是 C++ 程式庫,可使用 VP8 編碼圖片,而 VP8 是 .webm 檔案使用的轉碼器。不過,libvpx 不在 npm 上,也沒有 package.json,因此我無法直接使用 npm 安裝。

為解決這個難題,我們推出了 napa。有了 napa,您就能將任何 Git 存放區網址安裝為 node_modules 資料夾中的依附元件。

將 napa 安裝為依附元件:

$ npm install --save napa

並確保以安裝指令碼的形式執行 napa

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

執行 npm install 時,napa 會負責將 libvpx GitHub 存放區複製到 node_modules 中,並命名為 libvpx

您現在可以擴充建構指令碼來建構 libvpx。libvpx 使用 configuremake 建構。幸好,Emscripten 可確保 configuremake 使用 Emscripten 的編譯器。為此,我們提供包裝函式指令 emconfigureemmake

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

C/C++ 程式庫會分成兩部分:標頭 (傳統上是 .h.hpp 檔案),用於定義程式庫公開的資料結構、類別、常數等,以及實際的程式庫 (傳統上是 .so.a 檔案)。如要在程式碼中使用程式庫的 VPX_CODEC_ABI_VERSION 常數,必須使用 #include 陳述式加入程式庫的標頭檔案:

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

問題在於編譯器不知道要在哪裡尋找 vpxenc.h。這就是 -I 旗標的用途。這會告知編譯器要檢查哪些目錄的標頭檔。此外,您也需要提供編譯器實際的程式庫檔案:

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

如果您現在執行 npm run build,會發現程序會建構新的 .js 和新的 .wasm 檔案,且範例網頁確實會輸出常數:

開發人員工具顯示透過 emscripten 列印的 libvpx ABI 版本。

您也會發現建構程序需要很長時間。建構時間過長的原因有很多種。以 libvpx 來說,每次執行建構指令時,即使來源檔案沒有變更,系統都會編譯 VP8 和 VP9 的編碼器和解碼器,因此需要很長時間。即使只是稍微變更 my-module.cpp,也需要很長時間才能建構完成。首次建構 libvpx 後,保留建構構件會很有幫助。

其中一種做法是使用環境變數。

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

(這裡有包含所有檔案的 gist)。

eval 指令可讓我們將參數傳遞至建構指令碼,藉此設定環境變數。如果已設定 $SKIP_LIBVPX (任何值),test 指令會略過建構 libvpx。

現在可以編譯模組,但請略過重建 libvpx 的步驟:

$ npm run build:emscripten -- SKIP_LIBVPX=1

自訂建構環境

有時程式庫會依附其他工具來建構。如果 Docker 映像檔提供的建構環境缺少這些依附元件,您需要自行新增。舉例來說,假設您也想使用 doxygen 建構 libvpx 的文件,Doxygen 無法在 Docker 容器內使用,但您可以使用 apt 安裝。

如果您在 build.sh 中執行這項操作,每次想建構程式庫時,都必須重新下載並重新安裝 Doxygen。這樣不僅浪費資源,也會導致您無法在離線時處理專案。

因此建議您自行建構 Docker 映像檔。撰寫 Dockerfile 說明建構步驟,即可建構 Docker 映像檔。Dockerfile 相當強大,且有許多指令,但大多數時候只要使用 FROMRUNADD 即可。在這種情況下:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

您可以使用 FROM 宣告要使用的 Docker 映像檔做為起點。我選擇 trzeci/emscripten 做為基礎,也就是您一直使用的映像檔。使用 RUN,指示 Docker 在容器內執行殼層指令。這些指令對容器所做的任何變更,現在都會成為 Docker 映像檔的一部分。為確保 Docker 映像檔已建構並可供使用,請先調整 package.json,再執行 build.sh

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

(這裡有包含所有檔案的 gist)。

這會建構 Docker 映像檔,但前提是尚未建構。接著一切照舊執行,但現在建構環境有 doxygen 指令可用,這也會導致 libvpx 的說明文件一併建構。

結論

C/C++ 程式碼和 npm 不相容並不令人意外,但您可以透過一些額外工具和 Docker 提供的隔離功能,讓兩者順利運作。這項設定不適用於所有專案,但可做為不錯的起點,您可視需求進行調整。如有任何改善建議,歡迎提供。

附錄:善用 Docker 映像檔層

另一種解決方法是使用 Docker 和 Docker 的智慧快取方法,封裝更多這類問題。Docker 會逐步執行 Dockerfile,並為每個步驟的結果指派專屬映像檔。這些中間圖片通常稱為「圖層」。如果 Dockerfile 中的指令沒有變更,重新建構 Dockerfile 時,Docker 實際上不會重新執行該步驟。而是重複使用上次建構映像檔時的層。

先前,您必須費一番功夫,才能避免每次建構應用程式時都重建 libvpx。現在,您可以將 libvpx 的建構指令從 build.sh 移至 Dockerfile,利用 Docker 的快取機制:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

(這裡有包含所有檔案的 gist)。

請注意,您需要手動安裝 git 並複製 libvpx,因為執行 docker build 時沒有繫結掛接。因此不再需要 napa。