如何將 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
是您接下來要編寫的殼層指令碼!--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/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 使用 configure
和 make
建構。幸好,Emscripten 可確保 configure
和 make
使用 Emscripten 的編譯器。為此,我們提供包裝函式指令 emconfigure
和 emmake
:
# ... 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
檔案,且範例網頁確實會輸出常數:

您也會發現建構程序需要很長時間。建構時間過長的原因有很多種。以 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 相當強大,且有許多指令,但大多數時候只要使用 FROM
、RUN
和 ADD
即可。在這種情況下:
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。