sábado, 9 de junio de 2012

RequireJS y módulos web

RequireJS (http://requirejs.org, npm:requirejs) es una librería para cargar módulos javascript. Veamos por qué resulta tan interesante esta librería según sus creadores (http://requirejs.org/docs/why.html)

El problema
  • Los sitios webs se están convirtiendo en aplicaciones web.
  • La complejidad del código crece junto con el sitio.
  • Ensamblar todos los módulos se vuelve cada vez más dificil.
  • El desarrollador quiere ficheros o módulos de javascript pequeños.
  • Para el despliegue queremos optimizar el código con pocas o una sola petición http.

La solución
Los desarrolladores web necesitamos una solución con:
  • algún tipo de #include/import/require.
  • la habilidad para cargar las dependencias anidadas.
  • que sea fácil de usar por un desarrollador y que además alguna herramienta pueda optimizar el despliegue.

APIs de carga de scripts
Lo primero es conseguir una API de carga de scripts. Algunas candidatas pueden ser:
  • Dojo: dojo.require("algun.modulo")
  • LABjs: $LAB.script("algun/modulo.js")
  • CommonJS: require("algun/modulo")

Todas ellas permiten cargar un módulo en algun/path/algun/modulo.js. Idealmente, elegiríamos la sintaxis CommonJS, ya que es la que se va a volver más común con el tiempo y queremos poder reutilizar código. 

También querríamos disponer de alguna sintaxis que permita cargar los ficheros de javascript de los que disponemos actualmente: un desarrollador no debería tener que reescribir todo su código javascript para obtener todos los beneficios de la carga dinámica de scripts.

Sin embargo, necesitamos algo que funcione bien en el navegador. La función require() de CommonJS es una tiene una llamada síncrona que espera que el módulo se obtenga inmediatamente. Esto es algo que no funciona bien con los navegadores.

Síncrono vs asíncrono
Este ejemplo debería ilustrar el problema básico para el navegador. Supongamos que tenemos un objeto Empleado y que queremos un objeto Gerente que derive de Empleado. Para este ejemplo, podríamos codificar algo parecido a esto con la API de carga de scripts:

var Empleado = require("types/Empleado");

function Gerente () {
  this.reportes = [];
}

//Error si la llamada es asíncrona
Gerente.prototype = new Empleado();
Como se indica en el comentario, si require() es asíncrono, este código no funcionará. Sin embargo, cargar scripts de manera síncrona puede acabar con el rendimiento del navegador. Por lo tanto, ¿qué se puede hacer?

Carga de scripts: XHR
Sin duda es tentador usar XMLHttpRequest (XHR) para la carga de scripts. Si se usase XHR, entonces podríamos revisar el código anterior: podríamos usar una expresión regular para encontrar llamadas a require(), asegurarnos de la carga de dichos scripts y luego usar eval() o etiquetas script en los que su cuerpo sería el texto del script cargado mediante XHR.

Usar eval() para evaluar es malo porque:

  • A algunos desarrolladores les han enseñado que eval() es malo
  • Algunos entornos no permiten eval()
  • Es muy difícil de depurar(). Firebug y el inspector de WebKit tienen una convención (//@sourceURL=) que permite dar un nombre al texto evaluado, pero no hay un soporte universal en todos los navegadores
  • el contexto de eval() es distinto entre los navegadores. Puedes usar execScript en IE para ayudar con este tema, pero esto implica gestionar más piezas distintas.

Usar tags script pasándoles el texto del fichero como el texto del cuerpo es malo porque:

  • Al depurar, el número de línea que recibes en un error no se corresponde con el del fichero original.

XHR tiene también problemas en peticiones cross-domain. Aunque algunos navegadores soportan ahora peticiones XHR cross-domain, el soporte sigue sin ser universal, y además los responsables de Internet Explorer decidieron exponer una API distinta para peticiones cross-domain, XDomainRequest. Más partes en movimiento y más cosas que pueden salir mal. En particular, necesitas asegurarte de no enviar cabeceras HTTP que no sean estándares o puede haber algunas peticiones "previas" para verificar que el acceso cross-domain está permitido.

Dojo ha usado con cargador basado XHR con eval() y, aunque funciona, ha sido una fuente de frustración para los desarrolladores. Dojo tiene un cargador cross-domain, pero requiere que los módulos sean modificados mediante un paso de construcción para usar una función "wrapper" o envolvente, de forma que los tags de script con src="" puedan usarse para cargar los módulos. Hay muchos casos límites y partes en movimiento que generan muchas cargas en en el desarrollador.

Si se ha de crear un cargador de scripts, se puede hacer mejor.

Carga de scripts: web workers
Los Web Workers pueden ser otra alternativa para cargar scripts, pero:
  • No tiene soporte en todos los navegadores
  • Es una API que funciona mediante el intercambio de mensajes y probablemente los scripts quieran interactuar con el DOM, por lo que que esto implicaría que el Web Worker se usaría para recuperar el texto del script pero luego se pasaría el texto a la ventana principal que a su vez usaría el eval() o una tag script para ejecutar el script. Esto presenta los problemas de XHR mencionados anteriormente.

Carga de scripts: document.write()
Se puede utilizar document.write() para cargar scripts: se pueden cargar script de otros dominios y se corresponde con el mecanismo habitual de los navegadores para consumir scripts, con lo que permite una depuración sencilla.

Sin embargo, como en el ejemplo de la sección "Síncrono vs asíncrono", no podremos ejecutar ese script directamente. Idealmente, conoceríamos las dependencias de require() antes de ejecutar el script, y nos aseguraríamos de que esas dependencias se cargasen antes. Pero en este caso no tenemos acceso al script antes de ejecutarlo.

Además, document.write() no funciona tras la carga de la página. Una forma fantástica para que se perciba rendimiento en un sitio web es cargar el código bajo demanda, a medida que el usuario lo necesite para su siguiente acción.

Finalmente, los scripts que se cargan mediante document.write() bloquean la renderización de la página. Esto es un gran obstáculo cuando se busca la mejor optimización de rendimiento.

Carga de scripts: head.appendChild(script)
Se pueden crear scripts bajo demanda y agregarlos a la cabecera de la página:

var head = document.getElementsByTagName('head')[0],
    script = document.createElement('script'); 

script.src = url;

head.appendChild(script);

En realidad, el caso real incluye algo más que lo que se muestra en el snippet, pero se trata de la idea básica. Esta aproximación tiene la ventaja sobre document.write() de que no bloqueará el renderizado de la página y funcionará tras la carga de la página. Sin embargo, sigue estando el problema de Sincrono vs Asíncrono: idealmente deberíamos conocer las dependencias de require antes de ejecutar el script y asegurarnos de que dichas dependencias se cargan antes.

Envolviendo las funciones
Hemos establecido que necesitamos conocer las dependencias y que hay que cargarlas antes de ejecutar nuestro script. La mejor forma para hacer esto es construir el nuestra API de carga de módulo con funciones que envuelvan el código. Esta es la manera:

define(
  //El nombre de este módulo 
  "types/Gerente",
 
  //El array de dependencias
  ["types/Employee"],
  
  //La función a ejecutar cuando se hayan cargado las dependencias. Los argumentos
  // de esta función es el array de dependencias indicado más arriba.
  function (Empleado) {

    function Gerente () {
      this.reportes = [];
    }

    //Esto ahora SI funciona
    Gerente.prototype = new Empleado();

    //devuelve la función de contrucción de Manager para que pueda ser usado por otros módulos.
    return Gerente;
  }
);

Y esta es la sintaxis usada por RequireJS. Hay también una sintaxis simplificada por si solo quieres cargar ficheros javascript que no definen módulos:

require(["algun/script.js"], function() {

  //Esta función se llama cuando se ha cargado algun/script.js

});

Este tipo de sintaxis se eligió por que es breve y permite al cargador utilizar el tipo de carga de head.appendChild(script).

La diferencia de la sintaxis normal de CommonJS se debe a la necesidad de funcionar bien en navegadores. Han habido sugerencias para que la sintaxis normal de CommonJS pudiera ser usada con el tipo de carga de head.appendChild(script) si un proceso del servidor transformase los módulos en un formato de transporte que tuviese una función envolvente.

Creo que es importante no forzar el uso de un proceso de servidor en tiempo de ejecución para transformar código:

  • la depuración se vuelve extraña, los números de línea no están sincronizados con la fuente puesto que el servidor injecta la función que envuelve al código.
  • implica más aparataje. El desarrollo de front-end debería ser posible solo con ficheros estáticos.


Podeis ver más detalles de las motivaciones de diseño y los casos de uso de este formato con funciones envolventes llamado Asynchronous Module Definition (AMD)  en esta página Why AMD? que espero traduciros pronto.


PD. Aquí tenéis una tabla comparativa relativamente actualizada y muy completa de cargadores de javascript: https://docs.google.com/spreadsheet/ccc?key=0Aqln2akPWiMIdERkY3J2OXdOUVJDTkNSQ2ZsV3hoWVE#gid=2


No hay comentarios:

Publicar un comentario