Como agrupar recursos que não são JavaScript

Saiba como importar e agrupar vários tipos de recursos do JavaScript.

Imagine que você está trabalhando em um app da Web. Nesse caso, é provável que você precise lidar não apenas com módulos JavaScript, mas também com todos os tipos de outros recursos: Web Workers (que também são JavaScript, mas não fazem parte do gráfico do módulo normal), imagens, folhas de estilo, fontes, módulos WebAssembly e outros.

É possível incluir referências a alguns desses recursos diretamente no HTML, mas eles são frequentemente acoplados logicamente a componentes reutilizáveis. Por exemplo, uma folha de estilo para um menu suspenso personalizado vinculado à parte JavaScript, imagens de ícone vinculadas a um componente da barra de ferramentas ou módulo WebAssembly vinculado ao agrupador do JavaScript. Nesses casos, é mais conveniente fazer referência aos recursos diretamente nos módulos JavaScript e carregá-los dinamicamente quando (ou se) o componente correspondente for carregado.

Gráfico que visualiza vários tipos de recursos importados para JS.

No entanto, a maioria dos projetos grandes tem sistemas de compilação que realizam otimizações adicionais e reorganização do conteúdo, por exemplo, agrupamento e minificação. Eles não podem executar o código e prever qual será o resultado da execução, nem podem percorrer todos os literais de string possíveis em JavaScript e adivinhar se é ou não um URL de recurso. Como é possível fazê-los "ver" os recursos dinâmicos carregados por componentes JavaScript e incluí-los na criação?

Importações personalizadas em bundlers

Uma abordagem comum é reutilizar a sintaxe de importação estática. Em alguns bundlers, ele pode detectar automaticamente o formato pela extensão do arquivo, enquanto outros permitem que os plug-ins usem um esquema de URL personalizado, como no exemplo a seguir:

// regular JavaScript import
import { loadImg } from './utils.js';

// special "URL imports" for assets
import imageUrl from 'asset-url:./image.png';
import wasmUrl from 'asset-url:./module.wasm';
import workerUrl from 'js-url:./worker.js';

loadImg(imageUrl);
WebAssembly.instantiateStreaming(fetch(wasmUrl));
new Worker(workerUrl);

Quando um plug-in do bundler encontra uma importação com uma extensão que reconhece ou um esquema personalizado explícito (asset-url: e js-url: no exemplo acima), ele adiciona o recurso referenciado ao gráfico de compilação, copia-o para o destino final, executa as otimizações aplicáveis ao tipo do recurso e retorna o URL final a ser usado durante o tempo de execução.

O benefício dessa abordagem: reutilizar a sintaxe de importação do JavaScript garante que todos os URLs sejam estáticos e relativos ao arquivo atual, o que facilita a localização dessas dependências para o sistema de compilação.

No entanto, ele tem uma desvantagem significativa: esse código não pode funcionar diretamente no navegador, já que o navegador não sabe como lidar com esses esquemas ou extensões de importação personalizados. Isso pode ser bom se você controlar todo o código e depender de um bundler para desenvolvimento, mas é cada vez mais comum usar módulos JavaScript diretamente no navegador, pelo menos durante o desenvolvimento, para reduzir o atrito. Alguém que trabalha em uma pequena demonstração pode nem precisar de um bundler, mesmo em produção.

Padrão universal para navegadores e bundlers

Ao trabalhar em um componente reutilizável, é recomendável que ele funcione em qualquer ambiente, seja usado diretamente no navegador ou pré-criado como parte de um app maior. A maioria dos bundlers modernos permite isso aceitando o seguinte padrão nos módulos JavaScript:

new URL('./relative-path', import.meta.url)

Esse padrão pode ser detectado estaticamente pelas ferramentas, quase como se fosse uma sintaxe especial, mas é uma expressão JavaScript válida que também funciona diretamente no navegador.

Ao usar esse padrão, o exemplo acima pode ser reescrito como:

// regular JavaScript import
import { loadImg } from './utils.js';

loadImg(new URL('./image.png', import.meta.url));
WebAssembly.instantiateStreaming(
  fetch(new URL('./module.wasm', import.meta.url)),
  { /* … */ }
);
new Worker(new URL('./worker.js', import.meta.url));

Como funciona? Vamos separar. O construtor new URL(...) usa um URL relativo como o primeiro argumento e o resolve em relação a um URL absoluto fornecido como o segundo argumento. No nosso caso, o segundo argumento é import.meta.url, que fornece o URL do módulo JavaScript atual. Assim, o primeiro argumento pode ser qualquer caminho relativo a ele.

As vantagens são semelhantes às da importação dinâmica. Embora seja possível usar import(...) com expressões arbitrárias como import(someUrl), os bundlers oferecem tratamento especial a um padrão com URL estático import('./some-static-url.js') como uma maneira de pré-processar uma dependência conhecida no momento da compilação e ainda dividi-la em seu próprio fragmento que é carregado dinamicamente.

Da mesma forma, é possível usar new URL(...) com expressões arbitrárias como new URL(relativeUrl, customAbsoluteBase), mas o padrão new URL('...', import.meta.url) é um sinal claro para os bundlers pré-processarem e incluirem uma dependência com o JavaScript principal.

URLs relativos ambíguos

Você pode estar se perguntando: por que os bundlers não detectam outros padrões comuns, por exemplo, fetch('./module.wasm') sem os wrappers new URL?

Isso porque, ao contrário das instruções de importação, todas as solicitações dinâmicas são resolvidas em relação ao próprio documento e não ao arquivo JavaScript atual. Digamos que você tenha a seguinte estrutura:

  • index.html:
    html <script src="src/main.js" type="module"></script>
  • src/
    • main.js
    • module.wasm

Se você quiser carregar module.wasm de main.js, pode ser tentador usar um caminho relativo, como fetch('./module.wasm').

No entanto, fetch não sabe o URL do arquivo JavaScript em que é executado. Em vez disso, ele resolve URLs relativos ao documento. Como resultado, o fetch('./module.wasm') acaba tentando carregar http://example.com/module.wasm em vez do http://example.com/src/module.wasm pretendido e falha (ou, pior, carrega silenciosamente um recurso diferente do que você pretendia).

Ao envolver o URL relativo em new URL('...', import.meta.url), você pode evitar esse problema e garantir que qualquer URL fornecido seja resolvido em relação ao URL do módulo JavaScript atual (import.meta.url) antes de ser transmitido para qualquer carregador.

Substitua fetch('./module.wasm') por fetch(new URL('./module.wasm', import.meta.url)) para carregar o módulo WebAssembly esperado, além de oferecer aos bundlers uma maneira de encontrar esses caminhos relativos durante o tempo de compilação.

Suporte a ferramentas

Compactadores

Os bundlers a seguir já são compatíveis com o esquema new URL:

WebAssembly

Ao trabalhar com o WebAssembly, você normalmente não carregará o módulo Wasm manualmente. Em vez disso, importará o cola JavaScript emitido pelo conjunto de ferramentas. Os conjuntos de ferramentas a seguir podem emitir internamente o padrão new URL(...) descrito.

C/C++ via Emscripten

Ao usar o Emscripten, é possível solicitar que ele emita um agrupador de JavaScript como um módulo ES6 em vez de um script normal usando uma das seguintes opções:

$ emcc input.cpp -o output.mjs
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6

Ao usar essa opção, a saída utiliza o padrão new URL(..., import.meta.url) em segundo plano para que os bundlers possam encontrar o arquivo Wasm associado automaticamente.

Você também pode usar essa opção com as linhas de execução do WebAssembly adicionando uma sinalização -pthread:

$ emcc input.cpp -o output.mjs -pthread
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread

Nesse caso, o Web Worker gerado será incluído da mesma maneira e também poderá ser descoberto por bundlers e navegadores.

Ferrugem via wasm-pack / wasm-bindgen

O Wasm-pack, o principal conjunto de ferramentas Rust para WebAssembly, também tem vários modos de saída.

Por padrão, ele emite um módulo JavaScript que depende da proposta de integração do WebAssembly ESM (link em inglês). No momento, esta proposta ainda é experimental e a saída funcionará somente quando for empacotada com o Webpack.

Em vez disso, é possível solicitar que o Wasm-pack emita um módulo ES6 compatível com navegador via --target web:

$ wasm-pack build --target web

A saída usará o padrão new URL(..., import.meta.url) descrito, e o arquivo Wasm também será descoberto automaticamente pelos bundlers.

Se você quiser usar as linhas de execução do WebAssembly com o Rust, a história é um pouco mais complicada. Confira a seção correspondente do guia para saber mais.

A versão resumida é que não é possível usar APIs de linha de execução arbitrárias, mas se você usar o Rayon, poderá combiná-lo com o adaptador Wasm-bindgen-rayon para que ele possa gerar workers na Web. O cola JavaScript usado pelo wasm-bindgen-rayon também inclui o padrão new URL(...) em segundo plano. Portanto, os workers serão detectáveis e incluídos pelos bundlers.

Recursos futuros

import.meta.resolve

Uma chamada import.meta.resolve(...) dedicada é uma possível melhoria. Isso permitiria resolver especificadores relativamente ao módulo atual de maneira mais direta, sem parâmetros extras:

new URL('...', import.meta.url)
await import.meta.resolve('...')

Ele também se integra melhor a mapas de importação e resolvedores personalizados, porque passa pelo mesmo sistema de resolução de módulo que import. Também seria um indicador mais forte para os bundlers, já que a sintaxe estática não depende de APIs de ambiente de execução, como URL.

O import.meta.resolve já foi implementado como um experimento no Node.js, mas ainda há algumas perguntas não resolvidas sobre como ele deve funcionar na Web.

Declarações de importação

As declarações de importação são um novo recurso que permite importar tipos diferentes dos módulos ECMAScript. Por enquanto, eles estão limitados ao JSON:

foo.json:

{ "answer": 42 }

main.mjs:

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

Eles também podem ser usados por bundlers e substituir os casos de uso atualmente cobertos pelo padrão new URL, mas os tipos nas declarações de importação são adicionados de acordo com cada caso. Por enquanto, eles só abrangem JSON, com módulos CSS em breve. No entanto, outros tipos de recursos ainda exigirão uma solução mais genérica.

Confira a explicação de recursos v8.dev para saber mais sobre ele.

Conclusão

Como você pode ver, há diversas maneiras de incluir recursos não JavaScript na Web, mas elas têm desvantagens e não funcionam em vários conjuntos de ferramentas. Propostas futuras podem permitir a importação desses recursos com sintaxe especializada, mas ainda não chegamos lá.

Até lá, o padrão new URL(..., import.meta.url) é a solução mais promissora que já funciona em navegadores e em vários bundlers e conjuntos de ferramentas WebAssembly.