編寫與 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 Registry 有我經常使用的 trzeci 提供的 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 是您接下來要編寫的 Shell 指令碼!--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 模組,並提供可與任何 bundler 搭配使用的預設匯出功能。也需要設定 -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 後,DevTools 控制台中應會顯示以下輸出內容:

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

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

如果您想為網頁應用程式建構 C/C++ 程式庫,則需要將其程式碼納入專案。您可以手動將程式碼新增至專案的存放區,也可以使用 npm 來管理這類依附元件。假設我想在我的網路應用程式中使用 libvpx。libvpx 是用於以 VP8 編碼圖片的 C++ 程式庫,.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 映像檔。建構 Docker 映像檔時,您必須編寫 Dockerfile 來說明建構步驟。Dockerfile 相當強大,且有許多指令,但大多數情況下,您只需使用 FROMRUNADD 即可。在這種情況下:

FROM trzeci/emscripten

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

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

{
    // ...
    "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。