Como você integra o WebAssembly a essa configuração? Neste artigo, vamos trabalhar com C/C++ e Emscripten como exemplo.
O WebAssembly (wasm) geralmente é apresentado como uma primitiva de desempenho ou uma maneira de executar sua base de código C++ atual na Web. Com o squoosh.app, queríamos mostrar que há pelo menos uma terceira perspectiva para o wasm: usar os enormes ecossistemas de outras linguagens de programação. Com o Emscripten, você pode usar código C/C++. O Rust tem suporte a wasm integrado, e a equipe do Go também está trabalhando nisso. Tenho certeza de que muitos outros idiomas serão adicionados em breve.
Nesses cenários, o wasm não é a peça central do seu app, mas sim uma peça de quebra-cabeça: mais um módulo. Seu app já tem JavaScript, CSS, recursos de imagem, um sistema de build centrado na Web e talvez até um framework como o React. Como você integra o WebAssembly a essa configuração? Neste artigo, vamos trabalhar com C/C++ e Emscripten como exemplo.
Docker
O Docker é muito útil ao trabalhar com o Emscripten. As bibliotecas C/C++ geralmente são escritas para funcionar com o sistema operacional em que são criadas. É muito útil ter um ambiente consistente. Com o Docker, você tem um sistema Linux virtualizado que já está configurado para funcionar com o Emscripten e tem todas as ferramentas e dependências instaladas. Se algo estiver faltando, basta instalar sem se preocupar com o efeito na sua máquina ou em outros projetos. Se algo der errado, descarte o recipiente e comece de novo. Se ele funcionar uma vez, você pode ter certeza de que vai continuar funcionando e produzindo resultados idênticos.
O Docker Registry tem uma imagem do Emscripten de trzeci que tenho usado bastante.
Integração com npm
Na maioria dos casos, o ponto de entrada de um projeto da Web é o
package.json
do npm. Por convenção, a maioria dos projetos pode ser criada com npm install &&
npm run build
.
Em geral, os artefatos de build produzidos pelo Emscripten (um arquivo .js
e um .wasm
) devem ser tratados como apenas outro módulo JavaScript e outro recurso. O arquivo JavaScript pode ser processado por um bundler como webpack ou rollup,
e o arquivo wasm deve ser tratado como qualquer outro recurso binário maior, como
imagens.
Assim, os artefatos de build do Emscripten precisam ser criados antes que o processo de build "normal" seja iniciado:
{
"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",
// ...
},
// ...
}
A nova tarefa build:emscripten
pode invocar o Emscripten diretamente, mas, como
mencionado antes, recomendo usar o Docker para garantir que o ambiente de build seja
consistente.
docker run ... trzeci/emscripten ./build.sh
instrui o Docker a criar um novo
contêiner usando a imagem trzeci/emscripten
e executar o comando ./build.sh
.
build.sh
é um script de shell que você vai escrever em seguida. --rm
informa ao Docker para excluir o contêiner depois que ele terminar de ser executado. Assim, você não cria uma coleção de imagens de máquina desatualizadas com o tempo. -v $(pwd):/src
significa que você quer que o Docker "espelhe" o diretório atual ($(pwd)
) para /src
dentro do contêiner. Todas as mudanças feitas nos arquivos do diretório /src
dentro do
contêiner serão refletidas no projeto real. Esses diretórios espelhados
são chamados de "vinculações de montagem".
Vamos conferir 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 "============================================="
Há muito o que analisar aqui.
set -e
coloca o shell no modo "fail fast". Se algum comando no script
retornar um erro, todo o script será interrompido imediatamente. Isso pode ser muito útil, já que a última saída do script sempre será uma mensagem de sucesso ou o erro que causou a falha do build.
Com as instruções export
, você define os valores de algumas variáveis de ambiente. Eles permitem transmitir parâmetros adicionais de linha de comando para o compilador C (CFLAGS
), o compilador C++ (CXXFLAGS
) e o vinculador (LDFLAGS
). Todos recebem as configurações do otimizador via OPTIMIZE
para garantir que tudo seja otimizado da mesma forma. Há alguns valores possíveis para a variável OPTIMIZE
:
-O0
: não faça nenhuma otimização. Nenhum código inoperante é eliminado, e o Emscripten também não reduz o código JavaScript emitido. Bom para depuração.-O3
: otimiza de forma agressiva para melhorar o desempenho.-Os
: otimiza de forma agressiva o desempenho e o tamanho como um critério secundário.-Oz
: otimiza agressivamente para tamanho, sacrificando o desempenho se necessário.
Para a Web, recomendo principalmente o -Os
.
O comando emcc
tem várias opções próprias. O emcc é uma "substituição direta para compiladores como GCC ou clang". Portanto, todas as flags que você conhece do GCC provavelmente também serão implementadas pelo emcc. A flag -s
é especial porque permite configurar o Emscripten
especificamente. Todas as opções disponíveis podem ser encontradas no
settings.js
do Emscripten,
mas esse arquivo pode ser muito grande. Confira uma lista das flags do Emscripten
que considero mais importantes para desenvolvedores Web:
--bind
permite o uso do embind.- O
-s STRICT=1
não oferece mais suporte a todas as opções de build descontinuadas. Isso garante que seu código seja criado de maneira compatível com versões futuras. -s ALLOW_MEMORY_GROWTH=1
permite que a memória seja aumentada automaticamente, se necessário. No momento da gravação, o Emscripten aloca inicialmente 16 MB de memória. À medida que o código aloca partes da memória, essa opção decide se essas operações vão fazer com que todo o módulo wasm falhe quando a memória se esgotar ou se o código de ligação pode expandir a memória total para acomodar a alocação.-s MALLOC=...
escolhe qual implementação domalloc()
usar.emmalloc
é uma implementaçãomalloc()
pequena e rápida especificamente para Emscripten. A alternativa édlmalloc
, uma implementação completa demalloc()
. Só é necessário mudar paradlmalloc
se você estiver alocando muitos objetos pequenos com frequência ou se quiser usar linhas de execução.- O
-s EXPORT_ES6=1
vai transformar o código JavaScript em um módulo ES6 com uma exportação padrão que funciona com qualquer bundler. Também requer que-s MODULARIZE=1
seja definido.
As flags a seguir nem sempre são necessárias ou são úteis apenas para fins de depuração:
-s FILESYSTEM=0
é uma flag relacionada ao Emscripten e à capacidade dele de emular um sistema de arquivos para você quando seu código C/C++ usa operações de sistema de arquivos. Ele faz uma análise do código compilado para decidir se inclui ou não a emulação do sistema de arquivos no código de ligação. Às vezes, no entanto, essa análise pode estar errada, e você paga um valor considerável de 70 kB em código de ligação adicional para uma emulação de sistema de arquivos que talvez não precise. Com-s FILESYSTEM=0
, é possível forçar o Emscripten a não incluir esse código.-g4
fará com que o Emscripten inclua informações de depuração no.wasm
e também gere um arquivo de mapas de origem para o módulo wasm. Leia mais sobre depuração com Emscripten na seção de depuração.
Pronto! Para testar essa configuração, vamos criar um pequeno 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);
}
E um 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>
Confira um gist com todos os arquivos.
Para criar tudo, execute
$ npm install
$ npm run build
$ npm run serve
Ao navegar até localhost:8080, você verá a seguinte saída no console do DevTools:

Adicionar código C/C++ como uma dependência
Se quiser criar uma biblioteca C/C++ para seu web app, você precisa que o código dela faça parte do projeto. É possível adicionar o código ao repositório do projeto manualmente
ou usar o npm para gerenciar esses tipos de dependências também. Digamos que eu queira usar libvpx no meu webapp. libvpx é uma biblioteca C++ para codificar imagens com VP8, o codec usado em arquivos .webm
.
No entanto, a libvpx não está no npm e não tem um package.json
. Portanto, não é possível
instalá-la usando o npm diretamente.
Para sair desse dilema, há o
napa. Com ele, é possível instalar qualquer URL de
repositório git como uma dependência na pasta node_modules
.
Instale o napa como uma dependência:
$ npm install --save napa
e execute napa
como um script de instalação:
{
// ...
"scripts": {
"install": "napa",
// ...
},
"napa": {
"libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}
Ao executar npm install
, o napa clona o repositório libvpx do GitHub em node_modules
com o nome libvpx
.
Agora você pode estender o script de build para criar o libvpx. Ele usa configure
e make
para ser criado. Felizmente, o Emscripten pode ajudar a garantir que configure
e
make
usem o compilador do Emscripten. Para isso, há os comandos de wrapper
emconfigure
e 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 ...
Uma biblioteca C/C++ é dividida em duas partes: os cabeçalhos (tradicionalmente arquivos .h
ou .hpp
) que definem as estruturas de dados, classes, constantes etc. que uma biblioteca expõe e a biblioteca real (tradicionalmente arquivos .so
ou .a
). Para
usar a constante VPX_CODEC_ABI_VERSION
da biblioteca no seu código, inclua os arquivos de cabeçalho da biblioteca usando uma instrução #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;
}
O problema é que o compilador não sabe onde procurar vpxenc.h
.
É para isso que serve a flag -I
. Ele informa ao compilador quais diretórios verificar para arquivos de cabeçalho. Além disso, você também precisa fornecer ao compilador o
arquivo 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 ...
Se você executar npm run build
agora, vai notar que o processo cria um novo .js
e um novo arquivo .wasm
, e que a página de demonstração vai gerar a constante:

Você também vai notar que o processo de build leva muito tempo. O motivo para
tempos de build longos pode variar. No caso da libvpx, isso leva muito tempo porque
compila um codificador e um decodificador para VP8 e VP9 sempre que você executa
o comando de build, mesmo que os arquivos de origem não tenham sido alterados. Mesmo uma pequena mudança no seu my-module.cpp
vai levar muito tempo para ser criada. É muito útil manter os artefatos de build da libvpx depois que eles são criados pela primeira vez.
Uma maneira de fazer isso é usando variáveis de ambiente.
# ... 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 ...
Confira um gist com todos os arquivos.
O comando eval
permite definir variáveis de ambiente transmitindo parâmetros
ao script de build. O comando test
vai ignorar a criação do libvpx se
$SKIP_LIBVPX
estiver definido (com qualquer valor).
Agora você pode compilar o módulo, mas pule a recriação do libvpx:
$ npm run build:emscripten -- SKIP_LIBVPX=1
Personalizar o ambiente de build
Às vezes, as bibliotecas dependem de outras ferramentas para serem criadas. Se essas dependências estiverem faltando no ambiente de build fornecido pela imagem do Docker, adicione-as manualmente. Por exemplo, digamos que você também queira criar a
documentação do libvpx usando o doxygen. O Doxygen não está disponível no contêiner do Docker, mas é possível instalá-lo usando apt
.
Se você fizesse isso no seu build.sh
, teria que baixar e reinstalar
o doxygen sempre que quisesse criar sua biblioteca. Além de ser um desperdício, isso impede que você trabalhe no projeto off-line.
Nesse caso, faz sentido criar sua própria imagem do Docker. As imagens do Docker são criadas
escrevendo um Dockerfile
que descreve as etapas de build. Os Dockerfiles são bastante
poderosos e têm muitos
comandos, mas na maioria das
vezes, você pode usar apenas FROM
, RUN
e ADD
. Neste caso:
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
Com FROM
, você pode declarar qual imagem do Docker quer usar como ponto de partida. Escolhi trzeci/emscripten
como base, a imagem que você tem usado
o tempo todo. Com RUN
, você instrui o Docker a executar comandos de shell dentro do
contêiner. Todas as mudanças feitas por esses comandos no contêiner agora fazem parte
da imagem do Docker. Para garantir que a imagem do Docker foi criada e está
disponível antes de executar build.sh
, ajuste um pouco o 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",
// ...
},
// ...
}
Confira um gist com todos os arquivos.
Isso vai criar a imagem do Docker, mas apenas se ela ainda não tiver sido criada. Depois, tudo será executado como antes, mas agora o ambiente de build terá o comando doxygen
disponível, o que fará com que a documentação do libvpx também seja criada.
Conclusão
Não é surpreendente que o código C/C++ e o npm não sejam uma combinação natural, mas é possível fazer com que funcione de maneira confortável com algumas ferramentas adicionais e o isolamento fornecido pelo Docker. Essa configuração não funciona para todos os projetos, mas é um ponto de partida adequado que você pode ajustar de acordo com suas necessidades. Se você tiver sugestões de melhorias, compartilhe.
Apêndice: como usar camadas de imagens do Docker
Uma solução alternativa é encapsular mais desses problemas com o Docker e a abordagem inteligente de cache do Docker. O Docker executa Dockerfiles etapa por etapa e atribui o resultado de cada etapa a uma imagem própria. Essas imagens intermediárias são chamadas de "camadas". Se um comando em um Dockerfile não tiver sido alterado, o Docker não vai executar novamente essa etapa ao recriar o Dockerfile. Em vez disso, ele reutiliza a camada da última vez que a imagem foi criada.
Antes, era preciso fazer um esforço para não recriar o libvpx sempre que
você criava o app. Em vez disso, mova as instruções de criação do libvpx
do seu build.sh
para o Dockerfile
para usar o mecanismo de
armazenamento em cache do 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
Confira um gist com todos os arquivos.
É necessário instalar o git e clonar o libvpx manualmente, já que não há
montagens de vinculação ao executar docker build
. Como efeito colateral, não é mais necessário usar o
napa.