Wie integrieren Sie WebAssembly in diese Einrichtung? In diesem Artikel wird das anhand von C/C++ und Emscripten erläutert.
WebAssembly (wasm) wird oft als Leistungsprimitive oder als Möglichkeit beschrieben, Ihren vorhandenen C++-Code im Web auszuführen. Mit squoosh.app wollten wir zeigen, dass es mindestens eine dritte Perspektive für WASM gibt: die Nutzung der riesigen Ökosysteme anderer Programmiersprachen. Mit Emscripten können Sie C/C++-Code verwenden. Rust hat integrierte wasm-Unterstützung und das Go-Team arbeitet ebenfalls daran. Ich bin mir sicher, dass viele andere Sprachen folgen werden.
In diesen Szenarien ist WASM nicht das Herzstück Ihrer App, sondern ein weiteres Modul. Ihre App enthält bereits JavaScript, CSS, Bild-Assets, ein webzentriertes Build-System und möglicherweise sogar ein Framework wie React. Wie integrieren Sie WebAssembly in diese Einrichtung? In diesem Artikel werden wir das anhand von C/C++ und Emscripten als Beispiel durchgehen.
Docker
Docker ist für mich bei der Arbeit mit Emscripten von unschätzbarem Wert. C/C++-Bibliotheken sind oft für das Betriebssystem konzipiert, auf dem sie erstellt wurden. Eine einheitliche Umgebung ist unglaublich hilfreich. Mit Docker erhalten Sie ein virtualisiertes Linux-System, das bereits für die Verwendung mit Emscripten eingerichtet ist und in dem alle Tools und Abhängigkeiten installiert sind. Wenn etwas fehlt, können Sie es einfach installieren, ohne sich Gedanken darüber machen zu müssen, wie sich das auf Ihren eigenen Computer oder Ihre anderen Projekte auswirkt. Wenn etwas schiefgeht, entsorgen Sie den Behälter und beginnen Sie von vorn. Wenn es einmal funktioniert, können Sie sicher sein, dass es weiterhin funktioniert und identische Ergebnisse liefert.
Die Docker-Registry enthält ein Emscripten-Image von trzeci, das ich häufig verwendet habe.
Einbindung in npm
In den meisten Fällen ist der Einstiegspunkt für ein Webprojekt package.json
von npm. Die meisten Projekte können mit npm install &&
npm run build
erstellt werden.
Im Allgemeinen sollten die von Emscripten erstellten Build-Artefakte (eine .js
- und eine .wasm
-Datei) als ein weiteres JavaScript-Modul und ein weiteres Asset behandelt werden. Die JavaScript-Datei kann von einem Bundler wie webpack oder rollup verarbeitet werden. Die WASM-Datei sollte wie jedes andere größere binäre Asset, z. B. Bilder, behandelt werden.
Daher müssen die Emscripten-Build-Artefakte erstellt werden, bevor Ihr „normaler“ Build-Prozess beginnt:
{
"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",
// ...
},
// ...
}
Mit der neuen Aufgabe build:emscripten
könnte Emscripten direkt aufgerufen werden. Wie bereits erwähnt, empfehle ich jedoch, Docker zu verwenden, um sicherzustellen, dass die Build-Umgebung konsistent ist.
docker run ... trzeci/emscripten ./build.sh
weist Docker an, einen neuen Container mit dem Image trzeci/emscripten
zu starten und den Befehl ./build.sh
auszuführen.
build.sh
ist ein Shell-Skript, das Sie als Nächstes schreiben. --rm
weist Docker an, den Container nach Abschluss der Ausführung zu löschen. So vermeiden Sie, dass im Laufe der Zeit eine Sammlung veralteter Maschinen-Images entsteht. -v $(pwd):/src
bedeutet, dass Docker das aktuelle Verzeichnis ($(pwd)
) in /src
im Container spiegeln soll. Alle Änderungen, die Sie an Dateien im Verzeichnis /src
im Container vornehmen, werden in Ihrem tatsächlichen Projekt gespiegelt. Diese gespiegelten Verzeichnisse werden als „Bind-Mounts“ bezeichnet.
Sehen wir uns build.sh
an:
#!/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 "============================================="
Hier gibt es viel zu analysieren.
Mit set -e
wird die Shell in den „Fail Fast“-Modus versetzt. Wenn bei einem Befehl im Skript ein Fehler auftritt, wird das gesamte Skript sofort abgebrochen. Das kann sehr hilfreich sein, da die letzte Ausgabe des Skripts immer eine Erfolgsmeldung oder der Fehler ist, der zum Fehlschlagen des Builds geführt hat.
Mit den export
-Anweisungen definieren Sie die Werte einiger Umgebungsvariablen. Damit können Sie zusätzliche Befehlszeilenparameter an den C-Compiler (CFLAGS
), den C++-Compiler (CXXFLAGS
) und den Linker (LDFLAGS
) übergeben. Alle erhalten die Optimierereinstellungen über OPTIMIZE
, damit alles auf dieselbe Weise optimiert wird. Für die Variable OPTIMIZE
gibt es einige mögliche Werte:
-O0
: Es wird keine Optimierung durchgeführt. Es wird kein nicht mehr benötigter Code entfernt und Emscripten minimiert den ausgegebenen JavaScript-Code auch nicht. Gut für die Fehlerbehebung.-O3
: Aggressiv auf Leistung optimieren.-Os
: Optimieren Sie aggressiv für Leistung und Größe als sekundäres Kriterium.-Oz
: Aggressiv für die Größe optimieren und dabei gegebenenfalls die Leistung beeinträchtigen.
Für das Web empfehle ich hauptsächlich -Os
.
Der Befehl emcc
hat eine Vielzahl eigener Optionen. Beachten Sie, dass emcc als „Drop-in-Ersatz für Compiler wie GCC oder clang“ gedacht ist. Alle Flags, die Sie von GCC kennen, werden also höchstwahrscheinlich auch von emcc implementiert. Das Flag -s
ist insofern besonders, als es uns erlaubt, Emscripten speziell zu konfigurieren. Alle verfügbaren Optionen finden Sie in der settings.js
von Emscripten. Diese Datei kann jedoch sehr umfangreich sein. Hier ist eine Liste der Emscripten-Flags, die meiner Meinung nach für Webentwickler am wichtigsten sind:
--bind
aktiviert embind.-s STRICT=1
bietet keine Unterstützung mehr für alle eingestellten Build-Optionen. So wird sichergestellt, dass Ihr Code zukunftssicher erstellt wird.- Mit
-s ALLOW_MEMORY_GROWTH=1
kann der Speicher bei Bedarf automatisch erweitert werden. Zum Zeitpunkt der Erstellung dieses Dokuments weist Emscripten anfangs 16 MB Arbeitsspeicher zu. Wenn Ihr Code Speicherblöcke zuweist, wird mit dieser Option festgelegt, ob diese Vorgänge dazu führen, dass das gesamte WASM-Modul fehlschlägt, wenn der Speicher erschöpft ist, oder ob der Glue-Code den Gesamtspeicher erweitern darf, um die Zuweisung zu ermöglichen. - Mit
-s MALLOC=...
wird ausgewählt, welchemalloc()
-Implementierung verwendet werden soll.emmalloc
ist eine kleine und schnellemalloc()
-Implementierung speziell für Emscripten. Die Alternative istdlmalloc
, eine vollwertigemalloc()
-Implementierung. Sie müssen nur zudlmalloc
wechseln, wenn Sie häufig viele kleine Objekte zuweisen oder Threads verwenden möchten. - Mit
-s EXPORT_ES6=1
wird der JavaScript-Code in ein ES6-Modul mit einem Standardexport umgewandelt, das mit jedem Bundler funktioniert. Erfordert auch, dass-s MODULARIZE=1
festgelegt ist.
Die folgenden Flags sind nicht immer erforderlich oder nur für das Debugging hilfreich:
-s FILESYSTEM=0
ist ein Flag, das sich auf Emscripten bezieht und die Möglichkeit, ein Dateisystem für Sie zu emulieren, wenn Ihr C/C++-Code Dateisystemoperationen verwendet. Dabei wird der kompilierte Code analysiert, um zu entscheiden, ob die Dateisystememulation in den Glue-Code aufgenommen werden soll. Manchmal kann diese Analyse jedoch falsch sein und Sie zahlen für eine Dateisystememulation, die Sie möglicherweise nicht benötigen, zusätzliche 70 KB an Glue-Code. Mit-s FILESYSTEM=0
können Sie Emscripten zwingen, diesen Code nicht einzufügen.- Mit
-g4
wird Emscripten dazu veranlasst, Debugging-Informationen in.wasm
aufzunehmen und auch eine Quellzuordnungsdatei für das WASM-Modul auszugeben. Weitere Informationen zum Debuggen mit Emscripten finden Sie im entsprechenden Abschnitt.
Das war schon alles. Um diese Einrichtung zu testen, erstellen wir ein kleines 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);
}
Und ein 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>
Hier finden Sie ein Gist mit allen Dateien.
Führen Sie Folgendes aus, um alles zu erstellen:
$ npm install
$ npm run build
$ npm run serve
Wenn Sie zu localhost:8080 navigieren, sollte in der DevTools-Konsole die folgende Ausgabe angezeigt werden:

C/C++-Code als Abhängigkeit hinzufügen
Wenn Sie eine C/C++-Bibliothek für Ihre Web-App erstellen möchten, muss der Code Teil Ihres Projekts sein. Sie können den Code manuell zum Repository Ihres Projekts hinzufügen oder npm verwenden, um diese Art von Abhängigkeiten zu verwalten. Angenommen, ich möchte libvpx in meiner Web-App verwenden. libvpx ist eine C++-Bibliothek zum Codieren von Bildern mit VP8, dem in .webm
-Dateien verwendeten Codec.
libvpx ist jedoch nicht auf npm verfügbar und hat keine package.json
, sodass ich es nicht direkt über npm installieren kann.
Um dieses Problem zu lösen, gibt es napa. Mit napa können Sie eine beliebige Git-Repository-URL als Abhängigkeit in Ihrem node_modules
-Ordner installieren.
Installieren Sie napa als Abhängigkeit:
$ npm install --save napa
Achten Sie darauf, napa
als Installationsskript auszuführen:
{
// ...
"scripts": {
"install": "napa",
// ...
},
"napa": {
"libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}
Wenn Sie npm install
ausführen, klont napa das libvpx-GitHub-Repository in Ihr node_modules
unter dem Namen libvpx
.
Sie können Ihr Build-Skript jetzt erweitern, um libvpx zu erstellen. Für die Erstellung von libvpx werden configure
und make
verwendet. Glücklicherweise kann Emscripten dafür sorgen, dass configure
und make
den Compiler von Emscripten verwenden. Dazu gibt es die Wrapper-Befehle emconfigure
und 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 ...
Eine C/C++-Bibliothek ist in zwei Teile unterteilt: die Header (traditionell .h
- oder .hpp
-Dateien), die die Datenstrukturen, Klassen, Konstanten usw. definieren, die eine Bibliothek bereitstellt, und die eigentliche Bibliothek (traditionell .so
- oder .a
-Dateien). Wenn Sie die VPX_CODEC_ABI_VERSION
-Konstante der Bibliothek in Ihrem Code verwenden möchten, müssen Sie die Headerdateien der Bibliothek mit einer #include
-Anweisung einfügen:
#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;
}
Das Problem ist, dass der Compiler nicht weiß, wo er nach vpxenc.h
suchen soll.
Dafür ist das Flag -I
vorgesehen. Sie teilt dem Compiler mit, in welchen Verzeichnissen nach Headerdateien gesucht werden soll. Außerdem müssen Sie dem Compiler die eigentliche Bibliotheksdatei übergeben:
# ... 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 ...
Wenn Sie npm run build
jetzt ausführen, wird eine neue .js
- und eine neue .wasm
-Datei erstellt und auf der Demoseite wird die Konstante ausgegeben:

Außerdem werden Sie feststellen, dass der Build-Prozess lange dauert. Die Gründe für lange Build-Zeiten können variieren. Im Fall von libvpx dauert es lange, weil bei jedem Ausführen des Build-Befehls ein Encoder und ein Decoder für VP8 und VP9 kompiliert werden, obwohl sich die Quelldateien nicht geändert haben. Selbst eine kleine Änderung an Ihrem my-module.cpp
dauert lange. Es wäre sehr hilfreich, die Build-Artefakte von libvpx beizubehalten, nachdem sie zum ersten Mal erstellt wurden.
Eine Möglichkeit, dies zu erreichen, ist die Verwendung von Umgebungsvariablen.
# ... 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 ...
Hier finden Sie ein Gist mit allen Dateien.
Mit dem Befehl eval
können wir Umgebungsvariablen festlegen, indem wir Parameter an das Build-Skript übergeben. Mit dem Befehl test
wird das Erstellen von libvpx übersprungen, wenn $SKIP_LIBVPX
festgelegt ist (auf einen beliebigen Wert).
Jetzt können Sie Ihr Modul kompilieren, ohne libvpx neu zu erstellen:
$ npm run build:emscripten -- SKIP_LIBVPX=1
Build-Umgebung anpassen
Manchmal hängen Bibliotheken von zusätzlichen Tools ab, um erstellt zu werden. Wenn diese Abhängigkeiten in der vom Docker-Image bereitgestellten Build-Umgebung fehlen, müssen Sie sie selbst hinzufügen. Angenommen, Sie möchten auch die Dokumentation von libvpx mit doxygen erstellen. Doxygen ist in Ihrem Docker-Container nicht verfügbar, Sie können es aber mit apt
installieren.
Wenn Sie das in Ihrem build.sh
tun würden, müssten Sie Doxygen jedes Mal neu herunterladen und installieren, wenn Sie Ihre Bibliothek erstellen möchten. Das wäre nicht nur verschwenderisch, sondern würde Sie auch daran hindern, offline an Ihrem Projekt zu arbeiten.
Hier ist es sinnvoll, ein eigenes Docker-Image zu erstellen. Docker-Images werden erstellt, indem eine Dockerfile
geschrieben wird, in der die Build-Schritte beschrieben werden. Dockerfiles sind sehr leistungsstark und haben viele Befehle. Meistens reichen jedoch FROM
, RUN
und ADD
aus. In diesem Fall gilt:
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
Mit FROM
können Sie deklarieren, welches Docker-Image Sie als Ausgangspunkt verwenden möchten. Ich habe trzeci/emscripten
als Grundlage gewählt – das Bild, das Sie die ganze Zeit verwendet haben. Mit RUN
weisen Sie Docker an, Shell-Befehle im Container auszuführen. Alle Änderungen, die durch diese Befehle am Container vorgenommen werden, sind jetzt Teil des Docker-Images. Damit Ihr Docker-Image erstellt wird und verfügbar ist, bevor Sie build.sh
ausführen, müssen Sie package.json
etwas anpassen:
{
// ...
"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",
// ...
},
// ...
}
Hier finden Sie ein Gist mit allen Dateien.
Dadurch wird Ihr Docker-Image erstellt, sofern es noch nicht erstellt wurde. Danach läuft alles wie zuvor, aber jetzt ist in der Build-Umgebung der Befehl doxygen
verfügbar, der dazu führt, dass auch die Dokumentation von libvpx erstellt wird.
Fazit
Es ist nicht verwunderlich, dass C/C++-Code und npm nicht gut zusammenpassen. Mit einigen zusätzlichen Tools und der Isolation, die Docker bietet, lässt sich das Problem jedoch lösen. Diese Einrichtung funktioniert nicht für jedes Projekt, ist aber ein guter Ausgangspunkt, den Sie an Ihre Bedürfnisse anpassen können. Wenn Sie Verbesserungsvorschläge haben, teilen Sie uns diese bitte mit.
Anhang: Docker-Image-Ebenen verwenden
Eine alternative Lösung besteht darin, mehr dieser Probleme mit Docker und dem intelligenten Caching-Ansatz von Docker zu kapseln. Docker führt Dockerfiles Schritt für Schritt aus und weist dem Ergebnis jedes Schritts ein eigenes Image zu. Diese Zwischenbilder werden oft als „Ebenen“ bezeichnet. Wenn sich ein Befehl in einem Dockerfile nicht geändert hat, wird dieser Schritt beim erneuten Erstellen des Dockerfile von Docker nicht noch einmal ausgeführt. Stattdessen wird die Ebene aus dem letzten Build des Images wiederverwendet.
Bisher mussten Sie libvpx bei jedem Erstellen Ihrer App neu erstellen. Stattdessen können Sie die Build-Anleitung für libvpx aus Ihrem build.sh
in das Dockerfile
verschieben, um den Caching-Mechanismus von Docker zu nutzen:
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
Hier finden Sie ein Gist mit allen Dateien.
Sie müssen Git manuell installieren und libvpx klonen, da beim Ausführen von docker build
keine Bind-Mounts vorhanden sind. Als Nebeneffekt ist napa nicht mehr erforderlich.