EcmaScript Modules na prática

Fala turma, para reforçar os conhecimentos que tive estudando EcmaScript modules, resolvi fazer um pequeno monorepo para praticar. Vamos conseguir ver na prática como funciona o suporte do NodeJS e dos browsers para o ESM, incluindo utilizar o ESM com o CommonJS, e também como utilizar o ESM com o TypeScript.

Exemplo 1: "type": "module"

Vamos começar com o exemplo mais utilizado. Eu criei uma pasta chamada esm-node-type-module para esse primeiro exemplo, dentro dela vamos criar um arquivo package.json com o seguinte conteúdo:

{
  "name": "esm-node-type-module",
  "version": "1.0.0",
  "description": "Exemplo de uso do ESM com o type module",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "author": "Caio Fuzatto",
  "license": "MIT"
}

Depois vamos criar os arquivos index.js e module1.js com o seguinte conteúdo:

// index.js
import { test1 } from './module1.js';

test1();

Uma das primeiras coisas que vamos notar é a extensão do arquivo deve ser especificada no import, diferente do "ESM transpilado" que conseguia inferir a extensão do arquivo. Expliquei tudo sobre isso no post Entendendo EcmaScript Modules.

// module1.js
export const test1 = () => {
  console.log('Estamos dentro de um módulo ESM');
}

Agora vamos rodar o comando yarn start e ver o resultado:

Erro ao tentar carregar um ESM com o NodeJS

Opa, erro ao carregar o módulo. Mas felizmente o NodeJS nos dá duas dicas do que fazer, nesse primeiro exemplo vamos utilizar a primeira dica e adicionar "type": "module" no nosso package.json. Agora é só rodar novamente e ver o resultado:

$ node index.js
Estamos dentro de um módulo ESM

Compatibilidade com o CommonJS

O NodeJS também trouxe um suporte aos módulos ESM com o CommonJS, ou seja, podemos utilizar o ESM com o CommonJS. Para isso vamos criar um arquivo module2.cjs com o seguinte conteúdo:

const test2 = async () => {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  console.log("Estamos dentro de um módulo CommonJS");
};

module.exports = { test2 };

Observe que a extensão do arquivo é .cjs, isso é necessário para o NodeJS entender que esse arquivo é um módulo CommonJS.

E agora vamos importar esse módulo no nosso index.js:

import { test1 } from './module1.js';
import { test2 } from './module2.cjs';

test1();
await test2();

Fiz uma função async para você ver que o ESM suporta o uso de await no top-level de módulos. Incrível né?

Agora vamos rodar o comando yarn start e ver o resultado, que após 2 segundos será o seguinte:

$ node index.js
Estamos dentro de um módulo ESM
Estamos dentro de um módulo CommonJS

Exemplo 2: Extensão .mjs

Vamos agora para o segundo exemplo, que é o uso da extensão .mjs. Como já estamos familiarizados, vamos direto agora em?

Para isso vamos criar uma pasta chamada esm-node-mjs e dentro dela vamos criar um arquivo package.json com o seguinte conteúdo:

{
  "name": "esm-node-mjs",
  "version": "1.0.0",
  "description": "Exemplo de uso do ESM com a extensão .mjs",
  "main": "index.mjs",
  "scripts": {
    "start": "node index.mjs"
  },
  "author": "Caio Fuzatto",
  "license": "MIT"
}

Observe que nesse exemplo já estamos utilizando a extensão .mjs na propriedade main e no script start.

Agora vamos copiar os arquivos index.js e module1.js do exemplo anterior e renomear para index.mjs e module1.mjs respectivamente. Já o module2.cjs vamos renomear para module2.js. Além disso é importante ajustar os imports na index né? Confira como ficou:

import { test1 } from './module1.mjs';
import { test2 } from './module2.js';

test1();
await test2();

Percebeu que inverteu o jogo? Agora os módulos ESM precisam ter a extensão .mjs e os CommonJS utilizam a .js, enquanto no outro exemplo era o contrário. Agora vamos rodar o comando yarn start e temos o mesmo resultado:

$ node index.mjs
Estamos dentro de um módulo ESM
Estamos dentro de um módulo CommonJS

Exemplo 3: ESM no frontend

Vamos agora para o terceiro exemplo, que é o uso do ESM no frontend. Para isso vamos criar uma pasta chamada esm-frontend e dentro dela vamos criar um arquivo app.js com o seguinte conteúdo:

import { test1 } from './module1.js';

document.getElementById('content').textContent = await test1();

Bem semelhante aos exemplos de NodeJS, a diferença aqui é que estamos utilizando o document para alterar o conteúdo de um elemento HTML.

Agora vamos criar nosso módulo, para isso vamos criar um arquivo module1.js com o seguinte conteúdo:

export const test1 = async () => {
  console.log("Estamos dentro de um módulo ESM | ", new Date().toISOString());
  await new Promise((resolve) => setTimeout(resolve, 2000));
  console.log("Conteúdo carregou | ", new Date().toISOString());
  return "Fui gerado por um módulo ESM";
};

E por fim, a página HTML que vai carregar o nosso módulo. Para isso vamos criar um arquivo index.html com o seguinte conteúdo:

<!DOCTYPE html>
<head>
  <title>ESM no Frontend</title>
</head>
<body>
  <div id="content">Carregando...</div>
  <script type="module" src="app.js"></script>
</body>

Observe que estamos utilizando o atributo type="module" para indicar que o arquivo app.js é um módulo ESM.

Para testar (e não tomar erro de CORS) vou utilizar a lib HTTP-SERVE. Você pode rodar npx http-serve dentro da pasta esm-frontend e acessar a URL http://localhost:8080 para ver o resultado:

ESM no Frontend

Exemplo 4: ESM nativo e Typescript

Vamos agora para o quarto exemplo, que é o uso do ESM nativo com o TypeScript. Para isso vamos criar uma pasta chamada esm-typescript e dentro dela vamos criar um arquivo package.json com o seguinte conteúdo:

{
  "name": "esm-node-type-module",
  "version": "1.0.0",
  "description": "Exemplo de uso do ESM com o type module",
  "main": "index.js",
  "license": "MIT",
  "author": "Caio Fuzatto",
  "scripts": {
    "start": "ts-node-esm index.ts"
  },
  "type": "module",
  "devDependencies": {
    "ts-node": "^10.9.1",
    "typescript": "^5.2.2"
  }
}

Observe que estamos utilizando o ts-node-esm para rodar o TypeScript com o ESM nativo. Você pode ver mais sobre no Github do ts-node

Agora vamos criar nosso arquivo module1.ts com o seguinte conteúdo:

export const test1 = async () => {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  console.log("Estamos dentro de um módulo ESM nativo no Typescript");
};

Até aqui tudo ok né? Agora vamos criar o index.ts com o seguinte conteúdo:

import { test1 } from './module1.js';

await test1();

Observe que estamos importando o módulo module1.js e não module1.ts. Isto é porque o TypeScript é transpilado para JavaScript, e o Node.js vai executar o código JavaScript transpilado, não o código TypeScript original.

Agora vamos rodar o comando yarn start e ver o resultado:

$ ts-node-esm index.ts
Estamos dentro de um módulo ESM nativo no Typescript

Conclusão

Bom pessoal, espero que tenham gostado do conteúdo. Conseguimos ver várias formas de utilizar o ESM nativo do NodeJS e dos browsers, e também como utilizar o ESM com o CommonJS. Vale sempre a pena avaliar a situação do projeto para escolher qual desses caminhos seguir, se precisar de ajuda da uma olhada no meu post Entendendo o EcmaScript Modules. Você pode ver o código completo no Github. Até a próxima!