Enfrentamiento de sistemas de módulos de JavaScript: CommonJS vs AMD vs ES2015

A medida que el desarrollo de JavaScript se vuelve más y más común, los espacios de nombres y las dependencias se vuelven mucho más difíciles de manejar. Se han desarrollado diferentes soluciones para tratar este problema en forma de sistemas de módulos. En este post, exploraremos las diferentes soluciones empleadas actualmente por los desarrolladores y los problemas que intentan resolver. Siga leyendo!

Introducción: ¿Por qué son necesarios los módulos de JavaScript?

Si estás familiarizado con otras plataformas de desarrollo, probablemente tengas alguna noción de los conceptos de encapsulación y dependencia. Las diferentes piezas de software suelen desarrollarse de forma aislada hasta que algún requisito debe ser satisfecho por una pieza de software previamente existente. En el momento en que esa otra pieza de software se incorpora al proyecto, se crea una dependencia entre ella y la nueva pieza de código. Dado que estas piezas de software tienen que funcionar juntas, es importante que no surjan conflictos entre ellas. Esto puede parecer trivial, pero sin algún tipo de encapsulación, es cuestión de tiempo que dos módulos entren en conflicto. Esta es una de las razones por las que los elementos de las bibliotecas de C suelen llevar un prefijo:

#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

La encapsulación es esencial para evitar conflictos y facilitar el desarrollo.

Cuando se trata de dependencias, en el desarrollo tradicional de JavaScript del lado del cliente, son implícitas. En otras palabras, es tarea del desarrollador asegurarse de que las dependencias se satisfacen en el momento en que se ejecuta cualquier bloque de código. Los desarrolladores también tienen que asegurarse de que las dependencias se satisfacen en el orden correcto (un requisito de ciertas bibliotecas).

El siguiente ejemplo es parte de los ejemplos de Backbone.js. Los scripts se cargan manualmente en el orden correcto:

<!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>

A medida que el desarrollo de JavaScript se vuelve más y más complejo, la gestión de dependencias puede volverse engorrosa. La refactorización también se ve perjudicada: ¿dónde deben colocarse las nuevas dependencias para mantener el orden correcto de la cadena de carga?

Los sistemas de módulos de JavaScript intentan solucionar estos problemas y otros. Nacieron de la necesidad de acomodar el siempre creciente panorama de JavaScript. Veamos qué aportan las diferentes soluciones.

Una solución ad hoc: El patrón de módulo revelador

La mayoría de los sistemas de módulos son relativamente recientes. Antes de que estuvieran disponibles, un patrón de programación particular comenzó a utilizarse en más y más código JavaScript: el patrón 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 ejemplo fue tomado del libro JavaScript Design Patterns de Addy Osmani.

Los ámbitos de JavaScript (al menos hasta la aparición de let en ES2015) funcionan a nivel de función. En otras palabras, cualquier vinculación que se declare dentro de una función no puede escapar de su ámbito. Es, por esta razón, que el patrón de módulo revelador se basa en funciones para encapsular contenidos privados (como muchos otros patrones de JavaScript).

En el ejemplo anterior, los símbolos públicos están expuestos en el diccionario devuelto. Todas las demás declaraciones están protegidas por el ámbito de la función que las encierra. No es necesario utilizar var y una llamada inmediata a la función que encierra el ámbito privado; también se puede utilizar una función con nombre para los módulos.

Este patrón se ha utilizado durante bastante tiempo en los proyectos de JavaScript y trata bastante bien el asunto de la encapsulación. No hace mucho con el tema de las dependencias. Los sistemas de módulos apropiados intentan lidiar con este problema también. Otra limitación radica en el hecho de que la inclusión de otros módulos no se puede hacer en el mismo código fuente (a menos que se utilice eval).

Pros

  • Suficientemente simple para ser implementado en cualquier lugar (sin bibliotecas, sin soporte de lenguaje necesario).
  • Se pueden definir múltiples módulos en un solo archivo.

Cons

  • No hay forma de importar módulos mediante programación (excepto usando eval).
  • Las dependencias deben ser manejadas manualmente.
  • La carga asíncrona de módulos no es posible.
  • Las dependencias circulares pueden ser problemáticas.
  • Difícil de analizar para los analizadores de código estático.

CommonJS

CommonJS es un proyecto que pretende definir una serie de especificaciones para ayudar en el desarrollo de aplicaciones JavaScript del lado del servidor. Una de las áreas que el equipo de CommonJS intenta abordar son los módulos. Los desarrolladores de Node.js tenían originalmente la intención de seguir la especificación CommonJS, pero más tarde decidieron no hacerlo. En lo que respecta a los módulos, la implementación de Node.js está muy influenciada por ella:

// 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)}`);

Una tarde en Joyent, cuando mencioné que estaba un poco frustrado por alguna petición ridícula de una característica que sabía que era una idea terrible, me dijo: «Olvida CommonJS. Está muerto. Somos JavaScript del lado del servidor». – Isaac Z. Schlueter, creador de NPM, citando a Ryan Dahl, creador de Node.js

Hay abstracciones en la parte superior del sistema de módulos de Node.js en forma de bibliotecas que tienden un puente entre los módulos de Node.js y CommonJS. Para los propósitos de este post, sólo mostraremos las características básicas que son en su mayoría las mismas.

En ambos módulos de Node y CommonJS hay esencialmente dos elementos para interactuar con el sistema de módulos: require y exports. require es una función que se puede utilizar para importar símbolos de otro módulo al ámbito actual. El parámetro que se pasa a require es el id del módulo. En la implementación de Node, es el nombre del módulo dentro del directorio node_modules (o, si no está dentro de ese directorio, la ruta hacia él). exports es un objeto especial: cualquier cosa que se ponga en él se exportará como elemento público. Los nombres de los campos se conservan. Una diferencia peculiar entre Node y CommonJS surge en la forma del objeto module.exports. En Node, module.exports es el verdadero objeto especial que se exporta, mientras que exports es sólo una variable que se vincula por defecto a module.exports. CommonJS, por otro lado, no tiene ningún objeto module.exports. La implicación práctica es que en Node no es posible exportar un objeto completamente pre-construido sin pasar 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 };}

Los módulos de CommonJS fueron diseñados pensando en el desarrollo de servidores. Naturalmente, la API es sincrónica. Es decir, los módulos se cargan en el momento y en el orden en que se requieren dentro de un archivo fuente.

Pros

  • Simple: un desarrollador puede captar el concepto sin mirar los docs.
  • La gestión de dependencias está integrada: los módulos requieren otros módulos y se cargan en el orden necesario.
  • requireSe puede llamar a cualquier parte: los módulos se pueden cargar mediante programación.
  • Se soportan las dependencias circulares.

Cons

  • La API sincrónica hace que no sea adecuado para ciertos usos (del lado del cliente).
  • Un archivo por módulo.
  • Los navegadores requieren una biblioteca de carga o transpilación.
  • No hay función constructora para los módulos (aunque Node lo soporta).
  • Difícil de analizar para los analizadores de código estático.

Implementaciones

Ya hemos hablado de una implementación (de forma parcial): Node.js.

Módulos JavaScript de Node.js

Para el cliente, actualmente hay dos opciones populares: webpack y browserify. Browserify fue desarrollado explícitamente para analizar las definiciones de los módulos de Node (¡muchos paquetes de Node funcionan con él!) y agrupar tu código más el código de esos módulos en un solo archivo que lleva todas las dependencias. Webpack, por otro lado, fue desarrollado para manejar la creación de complejos pipelines de transformaciones de código fuente antes de la publicación. Esto incluye la agrupación de módulos de CommonJS.

Definición de módulos asíncronos (AMD)

AMD nació de un grupo de desarrolladores que estaban descontentos con la dirección adoptada por CommonJS. De hecho, AMD se separó de CommonJS al principio de su desarrollo. La principal diferencia entre AMD y CommonJS radica en su soporte para la carga asíncrona 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 () {};});

La carga asíncrona es posible mediante el uso del lenguaje de cierre tradicional de JavaScript: se llama a una función cuando los módulos solicitados terminan de cargarse. Las definiciones de módulos y la importación de un módulo se llevan a cabo mediante la misma función: cuando se define un módulo, sus dependencias se hacen explícitas. Por lo tanto, un cargador de AMD puede tener una imagen completa del gráfico de dependencias para un proyecto dado en tiempo de ejecución. Así, las bibliotecas que no dependen unas de otras para cargarse pueden cargarse al mismo tiempo. Esto es particularmente importante para los navegadores, donde los tiempos de inicio son esenciales para una buena experiencia de usuario.

Pros

  • Carga asíncrona (mejores tiempos de inicio).
  • Se soportan las dependencias circulares.
  • Compatibilidad para require y exports.
  • Gestión de dependencias totalmente integrada.
  • Los módulos pueden dividirse en múltiples archivos si es necesario.
  • Se soportan funciones de constructor.
  • Soporte de plugins (pasos de carga personalizados).

Contra

  • Ligeramente más complejo sintácticamente.
  • Se requieren bibliotecas de carga a menos que se transpile.
  • Difícil de analizar para los analizadores de código estático.

Implementaciones

Actualmente, las implementaciones más populares de AMD son require.js y Dojo.

Require.js para módulos de JavaScript

Usar require.js es bastante sencillo: incluya la biblioteca en su archivo HTML y utilice el atributo data-main para indicar a require.js qué módulo debe cargarse primero. Dojo tiene una configuración similar.

ES2015 Modules

Afortunadamente, el equipo de la ECMA que está detrás de la estandarización de JavaScript decidió abordar el tema de los módulos. El resultado puede verse en la última versión del estándar de JavaScript: ECMAScript 2015 (anteriormente conocido como ECMAScript 6). El resultado es sintácticamente agradable y compatible con los modos de funcionamiento síncrono y así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

Ejemplo tomado del blog de Axel Rauschmayer

La directiva importpuede utilizarse para introducir módulos en el espacio de nombres. Esta directiva, en contraste con require y define no es dinámica (es decir, no puede ser llamada en cualquier lugar). La directiva export, por otro lado, puede utilizarse para hacer explícitamente públicos los elementos.

La naturaleza estática de las directivas import y export permite a los analizadores estáticos construir un árbol completo de dependencias sin ejecutar el código. ES2015 soporta la carga dinámica de módulos:

System.import('some_module') .then(some_module => { // Use some_module }) .catch(error => { // ... });

En realidad, ES2015 sólo especifica la sintaxis para los cargadores de módulos dinámicos y estáticos. En la práctica, las implementaciones de ES2015 no tienen que hacer nada después de analizar estas directivas. Los cargadores de módulos como System.js siguen siendo necesarios hasta que se publique la próxima especificación de ECMAScript.

Esta solución, al estar integrada en el lenguaje, permite a los tiempos de ejecución elegir la mejor estrategia de carga para los módulos. En otras palabras, cuando la carga asíncrona da beneficios, puede ser utilizada por el tiempo de ejecución.

Pros

  • Soporta la carga síncrona y asíncrona.
  • Sintácticamente simple.
  • Soporta las herramientas de análisis estático.
  • Integrado con el lenguaje (eventualmente soportado en todas partes, sin necesidad de bibliotecas).
  • Soporte de dependencias circulares.

Cons

  • Todavía no está soportado en todas partes.

Implementaciones

Desgraciadamente, ninguno de los principales tiempos de ejecución de JavaScript soporta los módulos ES2015 en sus ramas estables actuales. Esto significa que no hay soporte para Firefox, Chrome o Node.js. Afortunadamente, muchos transpiladores sí son compatibles con los módulos y también hay un polyfill disponible. Actualmente, el preset de ES2015 para Babel puede manejar módulos sin problemas.

Babel para módulos JavaScript

La solución todo en uno: System.js

Puede que se encuentre tratando de alejarse del código heredado utilizando un sistema de módulos. O puede querer asegurarse de que, pase lo que pase, la solución que eligió seguirá funcionando. Entra en System.js: un cargador de módulos universal que soporta módulos CommonJS, AMD y ES2015. Puede trabajar en conjunto con transpiladores como Babel o Traceur y puede soportar entornos Node e IE8+. Usarlo es cuestión de cargar System.js en tu código y luego apuntarlo a tu 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>

Como System.js hace todo el trabajo sobre la marcha, el uso de módulos ES2015 debe dejarse generalmente a un transpilador durante el paso de construcción en modo de producción. Cuando no está en modo de producción, System.js puede llamar al transpilador por ti, proporcionando una transición perfecta entre los entornos de producción y depuración.

Aparte: Lo que usamos en Auth0

En Auth0, usamos mucho JavaScript. Para nuestro código del lado del servidor, utilizamos módulos Node.js al estilo de CommonJS. Para cierto código del lado del cliente, preferimos AMD. Para nuestra biblioteca de bloqueo sin contraseña basada en React, hemos optado por módulos ES2015.

¿Le gusta lo que ve? Regístrate y empieza a usar Auth0 en tus proyectos hoy mismo.

¿Eres un desarrollador y te gusta nuestro código? Si es así, solicite un puesto de ingeniero ahora. Tenemos un equipo increíble.

Conclusión

Construir módulos y manejar dependencias era engorroso en el pasado. Las nuevas soluciones, en forma de bibliotecas o módulos ES2015, han eliminado la mayor parte del dolor. Si estás pensando en comenzar un nuevo módulo o proyecto, ES2015 es el camino correcto a seguir. Siempre tendrá soporte y el soporte actual mediante transpilers y polyfills es excelente. Por otro lado, si prefieres quedarte con el código ES5 simple, la división habitual entre AMD para el cliente y CommonJS/Node para el servidor sigue siendo la opción habitual. No olvides dejarnos tu opinión en la sección de comentarios más abajo. Hack on!

Deja un comentario