¿Cómo integras WebAssembly en esta configuración? En este artículo, trabajaremos con C/C++ y Emscripten como ejemplo.
WebAssembly (WASM) suele presentarse como una primitiva de rendimiento o una forma de ejecutar tu base de código existente de C++ en la Web. Con squoosh.app, queríamos demostrar que existe al menos una tercera perspectiva para wasm: aprovechar los enormes ecosistemas de otros lenguajes de programación. Con Emscripten, puedes usar código C/C++, Rust tiene compatibilidad con wasm integrada y el equipo de Go también está trabajando en ello. Estoy seguro de que se agregarán muchos otros idiomas.
En estos casos, wasm no es la pieza central de tu app, sino una pieza más del rompecabezas: otro módulo. Tu app ya tiene JavaScript, CSS, recursos de imágenes, un sistema de compilación centrado en la Web y tal vez incluso un framework como React. ¿Cómo integras WebAssembly en esta configuración? En este artículo, trabajaremos con C/C++ y Emscripten como ejemplo.
Docker
Descubrí que Docker es muy valioso cuando trabajo con Emscripten. Las bibliotecas de C/C++ a menudo se escriben para que funcionen con el sistema operativo en el que se compilan. Es muy útil tener un entorno coherente. Con Docker, obtienes un sistema Linux virtualizado que ya está configurado para funcionar con Emscripten y tiene todas las herramientas y dependencias instaladas. Si falta algo, puedes instalarlo sin preocuparte por cómo afectará tu propia máquina o tus otros proyectos. Si algo sale mal, desecha el contenedor y vuelve a empezar. Si funciona una vez, puedes tener la certeza de que seguirá funcionando y producirá resultados idénticos.
El registro de Docker tiene una imagen de Emscripten de trzeci que he estado usando de forma extensiva.
Integración con npm
En la mayoría de los casos, el punto de entrada a un proyecto web es package.json
de npm. Por convención, la mayoría de los proyectos se pueden compilar con npm install &&
npm run build
.
En general, los artefactos de compilación que produce Emscripten (un archivo .js
y un archivo .wasm
) deben tratarse como un módulo de JavaScript más y como un recurso más. Un bundler como webpack o rollup puede controlar el archivo JavaScript, y el archivo wasm se debe tratar como cualquier otro activo binario más grande, como las imágenes.
Por lo tanto, los artefactos de compilación de Emscripten deben compilarse antes de que se inicie el proceso de compilación "normal":
{
"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",
// ...
},
// ...
}
La nueva tarea build:emscripten
podría invocar Emscripten directamente, pero, como se mencionó antes, recomiendo usar Docker para asegurarme de que el entorno de compilación sea coherente.
docker run ... trzeci/emscripten ./build.sh
le indica a Docker que inicie un nuevo contenedor con la imagen trzeci/emscripten
y ejecute el comando ./build.sh
.
build.sh
es una secuencia de comandos de shell que escribirás a continuación. --rm
le indica a Docker que borre el contenedor después de que termine de ejecutarse. De esta manera, no acumularás una colección de imágenes de máquina obsoletas con el tiempo. -v $(pwd):/src
significa que quieres que Docker "duplique" el directorio actual ($(pwd)
) en /src
dentro del contenedor. Los cambios que realices en los archivos del directorio /src
dentro del contenedor se reflejarán en tu proyecto real. Estos directorios duplicados se denominan "vinculaciones de montaje".
Veamos 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 "============================================="
Hay mucho que analizar aquí.
set -e
coloca el shell en el modo "fail fast". Si algún comando de la secuencia de comandos devuelve un error, se anula de inmediato toda la secuencia de comandos. Esto puede ser muy útil, ya que el último resultado de la secuencia de comandos siempre será un mensaje de éxito o el error que provocó la falla de la compilación.
Con las instrucciones export
, defines los valores de un par de variables de entorno. Te permiten pasar parámetros adicionales de la línea de comandos al compilador de C (CFLAGS
), al compilador de C++ (CXXFLAGS
) y al vinculador (LDFLAGS
). Todos reciben la configuración del optimizador a través de OPTIMIZE
para garantizar que todo se optimice de la misma manera. Hay algunos valores posibles para la variable OPTIMIZE
:
-O0
: No se realiza ninguna optimización. No se elimina ningún código no alcanzado, y Emscripten tampoco reduce el código de JavaScript que emite. Es útil para la depuración.-O3
: Optimiza de forma agresiva el rendimiento.-Os
: Optimiza de forma agresiva el rendimiento y el tamaño como criterio secundario.-Oz
: Optimiza de forma agresiva el tamaño y sacrifica el rendimiento si es necesario.
Para la Web, recomiendo principalmente -Os
.
El comando emcc
tiene una gran cantidad de opciones propias. Ten en cuenta que se supone que emcc es un "reemplazo directo para compiladores como GCC o clang". Por lo tanto, es muy probable que emcc también implemente todas las marcas que conozcas de GCC. La marca -s
es especial porque nos permite configurar Emscripten de forma específica. Todas las opciones disponibles se pueden encontrar en settings.js
de Emscripten, pero ese archivo puede ser bastante abrumador. A continuación, se incluye una lista de las marcas de Emscripten que considero más importantes para los desarrolladores web:
--bind
habilita embind.-s STRICT=1
ya no admite las opciones de compilación obsoletas. Esto garantiza que tu código se compile de manera compatible con versiones futuras.-s ALLOW_MEMORY_GROWTH=1
permite que la memoria crezca automáticamente si es necesario. En el momento de la escritura, Emscripten asignará 16 MB de memoria de forma inicial. A medida que tu código asigna fragmentos de memoria, esta opción decide si estas operaciones harán que falle todo el módulo wasm cuando se agote la memoria o si se permite que el código de vinculación expanda la memoria total para admitir la asignación.-s MALLOC=...
elige qué implementación demalloc()
usar.emmalloc
es una implementación demalloc()
pequeña y rápida, específicamente para Emscripten. La alternativa esdlmalloc
, una implementación completa demalloc()
. Solo necesitas cambiar adlmalloc
si asignas muchos objetos pequeños con frecuencia o si deseas usar subprocesos.-s EXPORT_ES6=1
convertirá el código JavaScript en un módulo ES6 con una exportación predeterminada que funciona con cualquier bundler. También requiere que se establezca-s MODULARIZE=1
.
Las siguientes marcas no siempre son necesarias o solo son útiles para la depuración:
-s FILESYSTEM=0
es una marca relacionada con Emscripten y su capacidad de emular un sistema de archivos para ti cuando tu código C/C++ usa operaciones del sistema de archivos. Realiza un análisis del código que compila para decidir si incluye la emulación del sistema de archivos en el código de vinculación o no. Sin embargo, a veces, este análisis puede equivocarse y pagas unos considerables 70 kB en código de vinculación adicional para una emulación del sistema de archivos que tal vez no necesites. Con-s FILESYSTEM=0
, puedes forzar a Emscripten a no incluir este código.-g4
hará que Emscripten incluya información de depuración en.wasm
y también emitirá un archivo de mapas de origen para el módulo wasm. Puedes obtener más información sobre la depuración con Emscripten en su sección de depuración.
Listo. Para probar esta configuración, creemos un pequeño 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);
}
Y un 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>
(Aquí tienes un gist que contiene todos los archivos).
Para compilar todo, ejecuta
$ npm install
$ npm run build
$ npm run serve
Si navegas a localhost:8080, deberías ver el siguiente resultado en la consola de Herramientas para desarrolladores:

Cómo agregar código C/C++ como dependencia
Si deseas compilar una biblioteca de C/C++ para tu app web, necesitas que su código forme parte de tu proyecto. Puedes agregar el código al repositorio de tu proyecto de forma manual o usar npm para administrar este tipo de dependencias. Supongamos que quiero usar libvpx en mi app web. libvpx es una biblioteca de C++ para codificar imágenes con VP8, el códec que se usa en los archivos .webm
.
Sin embargo, libvpx no está en npm y no tiene un package.json
, por lo que no puedo instalarlo directamente con npm.
Para salir de este dilema, existe napa, que te permite instalar cualquier URL de repositorio de Git como dependencia en tu carpeta node_modules
.
Instala napa como dependencia:
$ npm install --save napa
y asegúrate de ejecutar napa
como una secuencia de comandos de instalación:
{
// ...
"scripts": {
"install": "napa",
// ...
},
"napa": {
"libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}
Cuando ejecutas npm install
, napa se encarga de clonar el repositorio de GitHub de libvpx en tu node_modules
con el nombre libvpx
.
Ahora puedes extender tu secuencia de comandos de compilación para compilar libvpx. libvpx usa configure
y make
para compilarse. Por suerte, Emscripten puede ayudar a garantizar que configure
y make
usen el compilador de Emscripten. Para este propósito, existen los comandos de wrapper emconfigure
y 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 ...
Una biblioteca de C/C++ se divide en dos partes: los encabezados (tradicionalmente archivos .h
o .hpp
) que definen las estructuras de datos, las clases, las constantes, etcétera, que expone una biblioteca, y la biblioteca real (tradicionalmente archivos .so
o .a
). Para usar la constante VPX_CODEC_ABI_VERSION
de la biblioteca en tu código, debes incluir los archivos de encabezado de la biblioteca con una sentencia #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;
}
El problema es que el compilador no sabe dónde buscar vpxenc.h
.
Para eso sirve la marca -I
. Le indica al compilador qué directorios debe verificar para encontrar archivos de encabezado. Además, también debes proporcionarle al compilador el archivo de biblioteca real:
# ... 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 ...
Si ejecutas npm run build
ahora, verás que el proceso compila un nuevo archivo .js
y un nuevo archivo .wasm
, y que la página de demostración mostrará la constante:

También notarás que el proceso de compilación tarda mucho tiempo. Los motivos de los tiempos de compilación prolongados pueden variar. En el caso de libvpx, lleva mucho tiempo porque compila un codificador y un decodificador para VP8 y VP9 cada vez que ejecutas el comando de compilación, aunque los archivos fuente no hayan cambiado. Incluso un pequeño cambio en tu my-module.cpp
tardará mucho en compilarse. Sería muy beneficioso conservar los artefactos de compilación de libvpx una vez que se hayan compilado por primera vez.
Una forma de lograrlo es usar variables de entorno.
# ... 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 ...
(Aquí tienes un gist que contiene todos los archivos).
El comando eval
nos permite establecer variables de entorno pasando parámetros a la secuencia de comandos de compilación. El comando test
omitirá la compilación de libvpx si se establece $SKIP_LIBVPX
(en cualquier valor).
Ahora puedes compilar tu módulo, pero omite la recompilación de libvpx:
$ npm run build:emscripten -- SKIP_LIBVPX=1
Cómo personalizar el entorno de compilación
A veces, las bibliotecas dependen de herramientas adicionales para compilarse. Si estas dependencias no están presentes en el entorno de compilación que proporciona la imagen de Docker, debes agregarlas por tu cuenta. Por ejemplo, supongamos que también deseas compilar la documentación de libvpx con doxygen. Doxygen no está disponible dentro de tu contenedor de Docker, pero puedes instalarlo con apt
.
Si lo hicieras en tu build.sh
, volverías a descargar y reinstalar doxygen cada vez que quisieras compilar tu biblioteca. No solo sería un desperdicio, sino que también te impediría trabajar en tu proyecto sin conexión.
Aquí tiene sentido compilar tu propia imagen de Docker. Las imágenes de Docker se compilan escribiendo un Dockerfile
que describe los pasos de compilación. Los Dockerfiles son bastante potentes y tienen muchos comandos, pero, la mayoría de las veces, puedes usar solo FROM
, RUN
y ADD
. En este caso, ocurre lo siguiente:
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
Con FROM
, puedes declarar qué imagen de Docker deseas usar como punto de partida. Elegí trzeci/emscripten
como base, la imagen que usaste todo el tiempo. Con RUN
, le indicas a Docker que ejecute comandos de shell dentro del contenedor. Cualquier cambio que realicen estos comandos en el contenedor ahora forma parte de la imagen de Docker. Para asegurarte de que tu imagen de Docker se haya compilado y esté disponible antes de ejecutar build.sh
, debes ajustar un poco tu 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",
// ...
},
// ...
}
(Aquí tienes un gist que contiene todos los archivos).
Esto compilará tu imagen de Docker, pero solo si aún no se compiló. Luego, todo se ejecuta como antes, pero ahora el entorno de compilación tiene disponible el comando doxygen
, lo que hará que también se compile la documentación de libvpx.
Conclusión
No es sorprendente que el código C/C++ y npm no encajen de forma natural, pero puedes hacer que funcionen de manera bastante cómoda con algunas herramientas adicionales y el aislamiento que proporciona Docker. Esta configuración no funcionará para todos los proyectos, pero es un buen punto de partida que puedes ajustar según tus necesidades. Si tienes sugerencias para mejorar el proceso, compártelas.
Apéndice: Uso de capas de imágenes de Docker
Una solución alternativa es encapsular más de estos problemas con Docker y su enfoque inteligente para el almacenamiento en caché. Docker ejecuta los Dockerfiles paso a paso y asigna el resultado de cada paso a una imagen propia. Estas imágenes intermedias suelen llamarse "capas". Si un comando en un Dockerfile no cambió, Docker no volverá a ejecutar ese paso cuando vuelvas a compilar el Dockerfile. En cambio, reutiliza la capa de la última vez que se compiló la imagen.
Anteriormente, debías esforzarte para no volver a compilar libvpx cada vez que compilabas tu app. En cambio, puedes mover las instrucciones de compilación de libvpx de tu build.sh
a Dockerfile
para aprovechar el mecanismo de almacenamiento en caché de 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
(Aquí tienes un gist que contiene todos los archivos).
Ten en cuenta que debes instalar git y clonar libvpx de forma manual, ya que no tienes vinculaciones de montaje cuando ejecutas docker build
. Como efecto secundario, ya no se necesita Napa.