Como agrupar recursos que não são JavaScript

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

Suponha que você esteja 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 muitas vezes eles são acoplados logicamente a componentes reutilizáveis. Por exemplo, uma folha de estilo para um menu suspenso personalizado vinculado à parte JavaScript, imagens de ícones vinculadas a um componente da barra de ferramentas ou um módulo WebAssembly vinculado à cola JavaScript. Nesses casos, é mais conveniente fazer referência aos recursos diretamente dos módulos JavaScript e carregá-los dinamicamente quando (ou se) o componente correspondente for carregado.

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

No entanto, a maioria dos grandes projetos tem sistemas de build que realizam outras otimizações e reorganização de conteúdo, por exemplo, agrupamento e minificação. Eles não podem executar o código e prever o resultado da execução, nem percorrer todos os literais de string possíveis em JavaScript e fazer suposições sobre se é um URL de recurso ou não. Como fazer com que eles "vejam" esses recursos dinâmicos carregados por componentes JavaScript e os incluam no build?

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 build, copia para o destino final, executa otimizações aplicáveis ao tipo do recurso e retorna o URL final a ser usado durante o tempo de execução.

Os benefícios dessa abordagem: a reutilização da 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 build.

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

Padrão universal para navegadores e agrupadores

Se você estiver trabalhando em um componente reutilizável, ele precisará funcionar 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 em módulos JavaScript:

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

Esse padrão pode ser detectado de forma estática 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 dividir isso. O construtor new URL(...) usa um URL relativo como o primeiro argumento e o resolve em 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. Portanto, o primeiro argumento pode ser qualquer caminho relativo a ele.

Ela tem compensações semelhantes à importação dinâmica. Embora seja possível usar import(...) com expressões arbitrárias, como import(someUrl), os agrupadores dão um tratamento especial a um padrão com URL estático import('./some-static-url.js') como uma forma de pré-processar uma dependência conhecida no momento da compilação, mas dividi-la em seu próprio bloco carregado dinamicamente.

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

URLs relativos ambíguos

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

Isso acontece porque, ao contrário das instruções de importação, todas as solicitações dinâmicas são resolvidas em relação ao 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 em relação ao documento. Como resultado, fetch('./module.wasm') tentaria carregar http://example.com/module.wasm em vez da http://example.com/src/module.wasm pretendida e falharia (ou, pior, carregaria silenciosamente um recurso diferente do pretendido).

Ao agrupar 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 os loaders.

Substitua fetch('./module.wasm') por fetch(new URL('./module.wasm', import.meta.url)) e ele vai carregar o módulo WebAssembly esperado, além de dar aos bundlers uma maneira de encontrar esses caminhos relativos durante o build.

Suporte a ferramentas

Empacotadores

Os bundlers abaixo já oferecem suporte ao esquema new URL:

WebAssembly

Ao trabalhar com o WebAssembly, geralmente você não carrega o módulo Wasm manualmente, mas importa a cola JavaScript emitida pelo conjunto de ferramentas. As toolchains a seguir podem emitir o padrão new URL(...) descrito para você.

C/C++ usando o Emscripten

Ao usar o Emscripten, é possível solicitar que ele emita cola 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 vai usar o padrão new URL(..., import.meta.url), para que os bundlers possam encontrar o arquivo Wasm associado automaticamente.

Também é possível usar essa opção com linhas de execução do WebAssembly adicionando uma flag -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.

Rust usando wasm-pack / wasm-bindgen

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

Por padrão, ele vai emitir um módulo JavaScript que depende da proposta de integração do ESM do WebAssembly. No momento da escrita, essa proposta ainda é experimental, e a saída só vai funcionar quando agrupada com o Webpack.

Em vez disso, peça para o wasm-pack emitir um módulo ES6 compatível com o navegador usando --target web:

$ wasm-pack build --target web

A saída vai 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 linhas de execução do WebAssembly com Rust, a história é um pouco mais complicada. Confira a seção correspondente do guia para saber mais.

A versão curta é 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. A cola JavaScript usada pelo wasm-bindgen-rayon também inclui o padrão new URL(...), e os workers também podem ser descobertos e incluídos pelos bundlers.

Recursos futuros

import.meta.resolve

Uma chamada import.meta.resolve(...) dedicada é uma possível melhoria futura. Isso permitiria resolver especificadores em relação 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 integraria melhor aos mapas de importação e aos solucionadores personalizados, porque passaria pelo mesmo sistema de resolução de módulos que import. Também seria um indicador mais forte para bundlers, porque é uma sintaxe estática que 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 sem resposta sobre como ele funciona na Web.

Importar declarações

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

foo.json:

{ "answer": 42 }

main.mjs:

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

Elas também podem ser usadas por agrupadores e substituir os casos de uso atualmente cobertos pelo padrão new URL, mas os tipos em importações são adicionados caso a caso. Por enquanto, eles abrangem apenas JSON, com módulos CSS em breve, mas outros tipos de recursos ainda vão precisar de uma solução mais genérica.

Confira a explicação do recurso do v8.dev para saber mais.

Conclusão

Como você pode ver, há várias maneiras de incluir recursos que não são JavaScript na Web, mas eles têm várias desvantagens e não funcionam em várias cadeias de ferramentas. Propostas futuras podem nos permitir importar esses ativos 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, vários bundlers e toolchains do WebAssembly.