As vezes que o desenvolvimento JavaScript se torna cada vez mais comum, os namespaces e dependências se tornam muito mais difíceis de lidar. Diferentes soluções foram desenvolvidas para lidar com este problema na forma de sistemas de módulos. Neste post, vamos explorar as diferentes soluções atualmente empregadas pelos desenvolvedores e os problemas que eles tentam resolver. Leia em!
Introdução: Porque são necessários módulos JavaScript?
Se você está familiarizado com outras plataformas de desenvolvimento, você provavelmente tem alguma noção dos conceitos de encapsulamento e dependência. Diferentes partes de software são geralmente desenvolvidas isoladamente até que algum requisito precise ser satisfeito por uma parte de software previamente existente. No momento em que outro software é trazido para o projeto é criada uma dependência entre ele e o novo pedaço de código. Como esses softwares precisam trabalhar juntos, é importante que não surjam conflitos entre eles. Isto pode parecer trivial, mas sem algum tipo de encapsulamento, é uma questão de tempo até que dois módulos entrem em conflito um com o outro. Esta é uma das razões pelas quais os elementos das bibliotecas em C geralmente levam um prefixo:
#ifndef MYLIB_INIT_H#define MYLIB_INIT_Henum mylib_init_code { mylib_init_code_success, mylib_init_code_error};enum mylib_init_code mylib_init(void);// (...)#endif //MYLIB_INIT_H
O encapsulamento é essencial para prevenir conflitos e facilitar o desenvolvimento.
Quando se trata de dependências, no desenvolvimento tradicional em JavaScript do lado do cliente, elas estão implícitas. Em outras palavras, é tarefa do desenvolvedor garantir que as dependências sejam satisfeitas no momento em que qualquer bloco de código é executado. Os desenvolvedores também precisam garantir que as dependências sejam satisfeitas na ordem certa (um requisito de certas bibliotecas).
O exemplo seguinte faz parte dos exemplos do Backbone.js. Scripts são carregados manualmente na ordem correta:
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <title>Backbone.js Todos</title> <link rel="stylesheet" href="todos.css"/> </head> <body> <script src="../../test/vendor/json2.js"></script> <script src="../../test/vendor/jquery.js"></script> <script src="../../test/vendor/underscore.js"></script> <script src="../../backbone.js"></script> <script src="../backbone.localStorage.js"></script> <script src="todos.js"></script> </body> <!-- (...) --></html>
Como o desenvolvimento JavaScript fica cada vez mais complexo, o gerenciamento de dependências pode se tornar incômodo. A refatoração também é prejudicada: onde as dependências mais recentes devem ser colocadas para manter a ordem correta da cadeia de carga?
JavaScript module systems try to deal with these problems and others. Eles nasceram da necessidade de acomodar o sempre crescente cenário JavaScript. Vamos ver o que as diferentes soluções trazem à tabela.
Uma Solução Ad Hoc: O Padrão do Módulo Revelador
A maioria dos sistemas de módulos são relativamente recentes. Antes de estarem disponíveis, um padrão de programação particular começou a ser usado cada vez mais em código JavaScript: o padrão de módulo revelador.
var myRevealingModule = (function () { var privateVar = "Ben Cherry", publicVar = "Hey there!"; function privateFunction() { console.log( "Name:" + privateVar ); } function publicSetName( strName ) { privateVar = strName; } function publicGetName() { privateFunction(); } // Reveal public pointers to // private functions and properties return { setName: publicSetName, greeting: publicVar, getName: publicGetName };})();myRevealingModule.setName( "Paul Kinlan" );
Este exemplo foi retirado do livro Addy Osmani’s JavaScript Design Patterns.
Escopos JavaScript (pelo menos até à aparência de let
no ES2015) funcionam ao nível da função. Em outras palavras, qualquer ligação que seja declarada dentro de uma função não pode escapar ao seu escopo. É por esta razão que o padrão revelador do módulo se baseia em funções para encapsular conteúdos privados (como muitos outros padrões JavaScript).
No exemplo acima, os símbolos públicos são expostos no dicionário retornado. Todas as outras declarações são protegidas pelo escopo da função que as encapsula. Não é necessário usar var
e uma chamada imediata à função que envolve o escopo privado; uma função nomeada também pode ser usada para módulos.
Este padrão está em uso há bastante tempo em projetos JavaScript e lida bastante bem com a matéria de encapsulamento. Ele não faz muito sobre a questão das dependências. Sistemas de módulos adequados tentam lidar com este problema também. Outra limitação está no fato de que a inclusão de outros módulos não pode ser feita na mesma fonte (a menos que se use eval
).
Pros
- Simples o suficiente para ser implementado em qualquer lugar (sem bibliotecas, sem suporte a linguagem necessária).
- Múltiplos módulos podem ser definidos em um único arquivo.
Cons
- Não há como importar programmaticamente módulos (exceto usando
eval
). - Dependências precisam ser tratadas manualmente.
- Carregamento assíncrono de módulos não é possível.
- Dependências circulares podem ser problemáticas.
- Dificuldade de análise para analisadores de código estático.
CommonJS
CommonJS é um projeto que visa definir uma série de especificações para ajudar no desenvolvimento de aplicações JavaScript do lado do servidor. Uma das áreas que a equipa do CommonJS tenta abordar é a dos módulos. Os desenvolvedores do Node.js originalmente pretendiam seguir a especificação do CommonJS, mas mais tarde decidiram contra ela. Quando se trata de módulos, a implementação do Node.js é muito influenciada por ela:
// In circle.jsconst PI = Math.PI;exports.area = (r) => PI * r * r;exports.circumference = (r) => 2 * PI * r;// In some fileconst circle = require('./circle.js');console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);
Uma noite na Joyent, quando eu mencionei estar um pouco frustrado por algum pedido ridículo de uma funcionalidade que eu sabia ser uma péssima idéia, ele me disse: “Esqueça o CommonJS. Está morto. Nós somos JavaScript do lado do servidor”. – O criador do NPM Isaac Z. Schlueter citando o criador do Node.js Ryan Dahl
Há abstrações no topo do sistema de módulos do Node.js na forma de bibliotecas que fazem a ponte entre os módulos do Node.js e o CommonJS. Para os propósitos deste post, mostraremos apenas as características básicas que são em sua maioria as mesmas.
Nos módulos do Node.js e CommonJS existem essencialmente dois elementos para interagir com o sistema de módulos: require
e exports
. require
é uma função que pode ser usada para importar símbolos de outro módulo para o escopo atual. O parâmetro passado para require
é o id do módulo. Na implementação do Nó, é o nome do módulo dentro do diretório node_modules
(ou, se não estiver dentro desse diretório, o caminho para ele). exports
é um objeto especial: tudo que for colocado nele será exportado como um elemento público. Nomes de campos são preservados. Uma diferença peculiar entre Node e CommonJS surge na forma do objeto module.exports
. No Nó, module.exports
é o verdadeiro objeto especial que é exportado, enquanto exports
é apenas uma variável que é ligada por padrão a module.exports
. O CommonJS, por outro lado, não tem nenhum objeto module.exports
. A implicação prática é que no Node não é possível exportar um objeto totalmente pré-construído sem passar por module.exports
:
// This won't work, replacing exports entirely breaks the binding to// modules.exports.exports = (width) => { return { area: () => width * width };}// This works as expected.module.exports = (width) => { return { area: () => width * width };}
Módulos do CommonJS foram projetados tendo em mente o desenvolvimento do servidor. Naturalmente, a API é síncrona. Em outras palavras, os módulos são carregados no momento e na ordem em que são requeridos dentro de um arquivo fonte.
Pros
- Simples: um desenvolvedor pode entender o conceito sem olhar para os documentos.
- Gestão de dependência é integrada: módulos requerem outros módulos e são carregados na ordem necessária.
-
require
pode ser chamado em qualquer lugar: módulos podem ser carregados programmaticamente. - Dependências circulares são suportadas.
Cons
- A API síncrona não a torna adequada para certos usos (lado do cliente).
- Um arquivo por módulo.
- Navegadores requerem uma biblioteca de carregadores ou transpiling.
- Sem função construtora para módulos (Node suporta isto no entanto).
- Dificilmente analisável para analisadores de código estático.
Implementações
Já falamos sobre uma implementação (em forma parcial): Node.js.
Para o cliente, existem actualmente duas opções populares: webpack e browserify. Browserify foi explicitamente desenvolvido para analisar as definições dos módulos do tipo Node (muitos pacotes de Node funcionam fora da caixa com ele!) e juntar seu código mais o código desses módulos em um único arquivo que carrega todas as dependências. O Webpack, por outro lado, foi desenvolvido para lidar com a criação de pipelines complexos de transformações de código fonte antes da publicação. Isto inclui o empacotamento de módulos CommonJS.
Asynchronous Module Definition (AMD)
AMD nasceu de um grupo de desenvolvedores que estavam descontentes com a direção adotada pelo CommonJS. Na verdade, a AMD foi separada da CommonJS no início do seu desenvolvimento. A principal diferença entre AMD e CommonJS está em seu suporte ao carregamento assíncrono de módulos.
//Calling define with a dependency array and a factory functiondefine(, function (dep1, dep2) { //Define the module value by returning a value. return function () {};});// Or:define(function (require) { var dep1 = require('dep1'), dep2 = require('dep2'); return function () {};});
O carregamento assíncrono é possível usando o tradicional idioma de fechamento do JavaScript: uma função é chamada quando os módulos solicitados são terminados de carregar. As definições dos módulos e a importação de um módulo são realizadas através da mesma função: quando um módulo é definido, suas dependências são explicitadas. Portanto, um carregador AMD pode ter uma imagem completa do gráfico de dependência para um determinado projeto em tempo de execução. As bibliotecas que não dependem uma da outra para o carregamento podem, portanto, ser carregadas ao mesmo tempo. Isto é particularmente importante para navegadores, onde os tempos de inicialização são essenciais para uma boa experiência do usuário.
Pros
- Carregamento assíncrono (melhores tempos de inicialização).
- Dependências circulares são suportadas.
- Compatibilidade para
require
eexports
. - Gestão de dependência totalmente integrada.
- Módulos podem ser divididos em múltiplos arquivos se necessário.
- Funções de construção são suportadas.
- Suporte de plugin (passos de carregamento personalizados).
Cons
- Sensivelmente mais complexo sintaticamente.
- Bibliotecas de carregamento são necessárias a menos que sejam transpostas.
- Dificilmente analisáveis para analisadores de código estático.
Implementações
Atualmente, as implementações mais populares da AMD são necessárias.js e Dojo.
Usar require.js é bastante simples: inclua a biblioteca em seu arquivo HTML e use o atributo data-main
para dizer ao require.js qual módulo deve ser carregado primeiro. Dojo tem uma configuração similar.
ES2015 Módulos
Felizmente, a equipe do ECMA por trás da padronização do JavaScript decidiu resolver a questão dos módulos. O resultado pode ser visto no último lançamento do padrão JavaScript: ECMAScript 2015 (anteriormente conhecido como ECMAScript 6). O resultado é sintaticamente agradável e compatível com os modos de operação síncrono e assíncrono.
//------ lib.js ------export const sqrt = Math.sqrt;export function square(x) { return x * x;}export function diag(x, y) { return sqrt(square(x) + square(y));}//------ main.js ------import { square, diag } from 'lib';console.log(square(11)); // 121console.log(diag(4, 3)); // 5
>
Exemplo retirado do blog Axel Rauschmayer
A diretiva import
pode ser usada para trazer módulos para o namespace. Esta diretiva, em contraste com require
e define
não é dinâmica (ou seja, não pode ser chamada em nenhum lugar). A diretiva export
, por outro lado, pode ser usada para explicitamente tornar elementos públicos.
A natureza estática da diretiva import
e export
permite aos analisadores estáticos construir uma árvore completa de dependências sem código em execução. O ES2015 suporta carga dinâmica de módulos:
System.import('some_module') .then(some_module => { // Use some_module }) .catch(error => { // ... });
Na verdade, o ES2015 especifica apenas a sintaxe para os carregadores de módulos dinâmicos e estáticos. Na prática, as implementações do ES2015 não são necessárias para fazer nada depois de analisar estas diretivas. Carregadores de módulo como System.js ainda são necessários até que a próxima especificação ECMAScript seja lançada.
Esta solução, em virtude de ser integrada com a linguagem, permite que os tempos de execução escolham a melhor estratégia de carregamento de módulos. Em outras palavras, quando o carregamento assíncrono dá benefícios, ele pode ser usado pelo tempo de execução.
Pros
- Suportado para carregamento assíncrono e assíncrono.
- Sintacticamente simples.
- Suporte para ferramentas de análise estática.
- Integrado com a linguagem (eventualmente suportado em qualquer lugar, sem necessidade de bibliotecas).
- Dependências circulares suportadas.
Cons
- Sem suporte em qualquer lugar.
Implementações
Felizmente, nenhum dos principais módulos JavaScript suporta os módulos ES2015 em seus ramos estáveis atuais. Isto significa que não há suporte para Firefox, Chrome, ou Node.js. Felizmente, muitos transpilers suportam módulos e um polifill também está disponível. Atualmente, o ES2015 preset para Babel pode lidar com módulos sem problemas.
A solução All-in-One: System.js
>
Você pode se encontrar tentando se afastar do código legado usando um sistema de módulos. Ou você pode querer ter certeza que o que quer que aconteça, a solução que você escolheu ainda vai funcionar. Enter System.js: um carregador de módulos universal que suporta os módulos CommonJS, AMD, e ES2015. Ele pode trabalhar em conjunto com transpilers como Babel ou Traceur e pode suportar ambientes Node e IE8+. Usando-o é uma questão de carregar System.js em seu código e depois apontá-lo para o seu URL base:
<script src="system.js"></script> <script> // set our baseURL reference path System.config({ baseURL: '/app', // or 'traceur' or 'typescript' transpiler: 'babel', // or traceurOptions or typescriptOptions babelOptions: { } }); // loads /app/main.js System.import('main.js'); </script>
As System.js faz todo o trabalho on-the-fly, usando módulos ES2015 deve geralmente ser deixado para um transpiler durante a etapa de construção no modo de produção. Quando não estiver em modo de produção, System.js pode chamar o transpiler para você, fornecendo uma transição sem problemas entre ambientes de produção e depuração.
Aside: O que usamos no Auth0
No Auth0, usamos muito JavaScript. Para o nosso código do lado do servidor, nós usamos módulos CommonJS estilo Node.js. Para certos códigos do lado do cliente, nós preferimos AMD. Para a nossa biblioteca de Bloqueio sem senha baseada em React-based Passwordless, optamos pelos módulos ES2015.
Como o que você vê? Inscreva-se e comece a usar Auth0 em seus projetos hoje.
Você é um desenvolvedor e gosta do nosso código? Se sim, candidate-se agora para uma posição de engenharia. Nós temos uma equipe incrível!
Conclusão
Construir módulos e lidar com dependências foi complicado no passado. Soluções mais recentes, sob a forma de bibliotecas ou módulos ES2015, tiraram a maior parte da dor. Se você está olhando para começar um novo módulo ou projeto, ES2015 é o caminho certo a seguir. Será sempre suportado e o suporte actual utilizando transpilers e polyfills é excelente. Por outro lado, se preferir aderir ao código simples do ES5, a divisão habitual entre AMD para o cliente e CommonJS/Node para o servidor continua a ser a escolha habitual. Não se esqueça de nos deixar a sua opinião na secção de comentários abaixo. Hack on!