lunes, 28 de marzo de 2016

Prácticas Recomendadas para Proyectos con AngularJS 1.x Parte IV: Recomendaciones para Producción


Introducción

En posts anteriores se ha venido desarrollando una aplicación web con AngularJS 1.5, aplicando las recomendaciones que han sido aceptadas por el propio equipo de Google tanto en estructura del proyecto, secciones y rutas como el estilo de desarrollo de componentes como Directivas, Constantes, Controladores y Servicios.

En esta ocasión se van aplicar otras recomendaciones para generar la aplicación para publicar en un ambiente de producción.

Copia de las Vistas

Actualmente la única vista (archivo HTML) que se está copiando a la carpeta "dist" al ejecutar la tarea de Gulp "build" es "index.html". Para copiar los demás archivos HTML se creará una nueva tarea en "gulpfile.js", muy similar a la tarea ya existente "copy:assets".

gulp-paths.json
{
  "app": "./app/",
  "js": ["**/*.js"],
  "css": ["**/*.css"],
  "views": ["**/*.html", "!index.html"],
  "dist": "./dist/"
}

En la línea 5 se adiciona la entrada "views" con expresiones Glob que permiten encontrar todos los archivos HTML y otra que excluye el archivo "index.html" para no sobrescribir este archivo al copiar los otros HTML.

gulpfile.js
...
// Copies the HTML files into the dist folder
gulp.task('copy:html', function () {
  return gulp.src(paths.views, {cwd: paths.app})
    .pipe(gulp.dest(paths.dist));
});
...
// Production build
gulp.task('build', function (done) {
  runSequence('jshint', 'jscs', 'clean:dist', 'compress', 'copy:assets', 'copy:html', done);
});
...

Se definió la tarea "copy:html", copiando los archivos que se indican en "paths.views" hacia "dist" y se incluyó como parte la tarea "build".


Inyección Explícita de Depedencias

Si en este momento se intenta generar y ejecutar la aplicación para producción usando el comando "gulp server-dist" se debe ver un error como el siguiente en la consola del navegador web:

Uncaught Error: [$injector:modulerr] Failed to instantiate module weatherApp due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.home due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.core due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.blocks.router due to:
Error: [$injector:unpr] Unknown provider: t
http://errors.angularjs.org/1.5.0/$injector/unpr?p0=t
    at http://localhost:8080/js/vendor.min.js:4:27196
    at http://localhost:8080/js/vendor.min.js:5:15882
    at r (http://localhost:8080/js/vendor.min.js:5:14810)
    at i (http://localhost:8080/js/vendor.min.js:5:15110)
    at Object.s [as instantiate] (http://localhost:8080/js/vendor.min.js:5:15475)
    at i (http://localhost:8080/js/vendor.min.js:5:13419)
    at Object.provider (http://localhost:8080/js/vendor.min.js:5:13356)
    at r (http://localhost:8080/js/vendor.min.js:5:14198)
    at http://localhost:8080/js/vendor.min.js:5:14304
    at o (http://localhost:8080/js/vendor.min.js:4:27644)

Esto se debe a que todo el código JavaScript se está minificando y los nombres de las variables y parámetros se están acortando a una letra, incluyendo los que representan dependencias (como servicios por ejemplo), por lo que se trata de un error generado directamente por AngularJS y no por un error en la lógica de la aplicación.

Para solucionar este problema se incluirá el uso de "gulp-ng-annotate" minificar el código.

Como primer paso se debe instalar este plugin mediante npm:

npm install --save-dev gulp-ng-annotate

Luego se incluye su uso en la tarea de Gulp "compress" antes de aplicar el plugin "gulp-uglify" a los archivos JavaScript (recordando que en esta tarea también se minifican los archivos CSS):
...
// Compress into a single file the ones in between of blocks "build:xx" in index.html
gulp.task('compress', ['wiredep'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(plugins.useref({ searchPath: ['./', paths.app] }))
    .pipe(plugins.if('**/*.js', plugins.ngAnnotate()))
    .pipe(plugins.if('**/*.js', plugins.uglify({
      mangle: true
    }).on('error', plugins.util.log)))
    .pipe(plugins.if('**/*.css', plugins.cssnano()))
    .pipe(gulp.dest(paths.dist));
});
...

En la línea 5 de la tarea "compress" se incluyó el llamado "plugins.ngAnnotate()" justo antes de minificar el código JavaScript con "uglify".

Nota: Cabe recordar que no fue necesario incluir una variable manualmente para usar el plugin "gulp-ng-annotate" ya que al usar "gulp-load-plugins" todos los plugins que tengan el prefijo "gulp-" quedan disponibles en la variable "plugins".

Al ejecutar nuevamente la aplicación para producción podrá verse que ya funciona sin problemas.

Figura 1 - Aplicación con archivos para producción

Caché de Vistas (Template Cache)

Luego de navegar por las secciones "Home", "About" y consultar el clima en una ciudad en la figura 1 se puede ver señalado que se han realizado 13 peticiones HTTP, de las cuales 6 (casi la mitad) corresponden a la descarga de archivos HTML.

Localmente esto no parece ser un problema ya que la descarga se hace casi de inmediato y adicionalmente dependiendo del navegador y el servidor web usado es posible que estos archivos HTML sólo se descarguen una vez y luego se use una copia en la memoria caché del navegador web.

Sin embargo al tener la aplicación web en producción se tendrán variables adicionales como velocidad a Internet del usuario, capacidad y ancho de banda del servidor que expone la aplicación web, por lo que se debe disminuir lo más posible el número de llamados HTTP necesarios en la aplicación, en especial si se tienen páginas mucho más elaboradas que estas.

Para ello AngularJS dispone del servicio "$templateCache" el cual permite adicionar y tomar plantillas (templates) HTML para las vistas desde código JavaScript de una manera más rápida, sin embargo mantener código HTML dentro de código JavaScript hace que sea más difícil de entender y mantener.

Afortunadamente el plugin "gulp-angular-templatecache" permite tomar el contenido de los archivos HTML y crear automáticamente un archivo JavaScript con todas las vistas.

Antes de considerar esta opción, se debe tener en cuenta el número de secciones que tiene la aplicación, el tamaño de las vistas y la frecuencia con que los usuarios navegarán por la mayoría de dichas secciones ya que en caso de ser una aplicación de muchas secciones de las cuales cada usuario usará muy pocas, el archivo JavaScript generado con todas las vistas estaría descargando más datos de los que el usuario realmente necesita y no tener el ahorro de transferencia de datos esperado.

Primero se necesitará instalar "gulp-htmlmin" para minificar el código HTML (quitando espacios y saltos de línea innecesarios para los navegadores web), "gulp-angular-templatecache" para generar el archivo "templates.js" con las vistas HTML y "gulp-concat" para concatenar el contenido de dicho archivo en el archivo JavaScript final de la aplicación "scripts.min.js".

npm install gulp-htmlmin gulp-angular-templatecache gulp-concat --save-dev

gulp-paths.json
{
  "app": "./app/",
  "js": ["**/*.js"],
  "jsMin": "js/scripts.min.js",
  "css": ["**/*.css"],
  "views": ["**/*.html", "!index.html"],
  "templatesCache": "templates.js",
  "dist": "./dist/"
}

gulpfile.js
...
gulp.task('templates:build', function () {
  return gulp.src(paths.views, {cwd: paths.app})
    .pipe(plugins.htmlmin({collapseWhitespace: true}))
    .pipe(plugins.angularTemplatecache({
      module: 'weatherApp',
      moduleSystem: 'IIFE'
    }))
    .pipe(plugins.uglify({
      mangle: true
    }))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:concat', ['templates:build'], function () {
  return gulp.src([paths.jsMin, paths.templatesCache], {cwd: paths.dist})
    .pipe(plugins.concat(paths.jsMin))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:clean', ['templates:concat'], function () {
  return del(paths.dist + paths.templatesCache);
});
...
// Production build
gulp.task('build', function (done) {
  runSequence('jshint', 'jscs', 'clean:dist', 'compress', 'templates:clean', 'copy:assets', done);
});
...
  • Tarea copy:html: Dado que ahora en el código de producción tomará las vistas HTML de cache no se requiere copiar los archivos HTML a la carpeta "dist", por lo que la tarea "copy:html" debe borrarse.
  • Tarea templates:build: Genera el archivo "templates.js" (nombre por defecto) dentro de la carpeta "dist" (paths.dist) con el HTML minificado, indicando que hará parte del módulo principal de la aplicación (weatherApp) y que se usará Immediately Invoked Function Expression (IIFE), por lo cual también puede minificarse el código JavaScript generado usando "gulp-uglify".

    El archivo (sin minificar) debe verse de manera similar al siguiente:
    (function(){angular.module("weatherApp").run(["$templateCache", function($templateCache) {$templateCache.put("about/about.html","<wa-section-header wa-title=\"\'About this Site\'\" wa-subtitle=\"\'This site is just a demonstration for AngularJS 1.5 with an organized project structure using Gulp.\'\" class=\"about-header\"></wa-section-header>");
    $templateCache.put("home/home.html","<wa-section-header wa-title=\"\'Available Cities\'\" wa-subtitle=\"\'Select the city to check the current weather.\'\"></wa-section-header><div class=\"list-group\"><a ng-repeat=\"city in vm.cities | orderBy:\'name\'\" ui-sref=\"city({ cityId: city.id })\" class=\"list-group-item\">{{ city.name }} - {{city.country}}</a></div>");
    $templateCache.put("layout/footer.html","<footer class=\"footer\"><div class=\"container\"><p class=\"navbar-text\">Copyright &copy; 2016</p></div></footer>");
    $templateCache.put("layout/menu.html","<nav class=\"navbar navbar-inverse navbar-fixed-top\"><div class=\"container\"><div class=\"navbar-header\"><button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#navbar\" aria-expanded=\"false\" aria-controls=\"navbar\"><span class=\"sr-only\">Toggle navigation</span> <span class=\"icon-bar\"></span> <span class=\"icon-bar\"></span> <span class=\"icon-bar\"></span></button> <a class=\"navbar-brand\" href=\"#\">Weather Web App</a></div><div id=\"navbar\" class=\"collapse navbar-collapse\"><ul class=\"nav navbar-nav\"><li ui-sref-active=\"active\"><a ui-sref=\"home\">Home</a></li><li ui-sref-active=\"active\"><a ui-sref=\"about\">About</a></li></ul></div></div></nav>");
    $templateCache.put("home/city/city.html","<wa-section-header wa-title=\"vm.cityWeather.name\" wa-subtitle=\"vm.cityWeather.country\"></wa-section-header><div id=\"currentWeather\" ng-show=\"vm.cityWeather.image\"><img id=\"weatherImage\" ng-src=\"{{vm.cityWeather.image}}\"><br><strong>Weather</strong>: {{vm.cityWeather.description}}<br><strong>Temperature</strong>: {{vm.cityWeather.temperature}} &deg;C</div><br>Weather data from: <a href=\"http://openweathermap.org\" target=\"_blank\">OpenWeatherMap</a>");
    $templateCache.put("widgets/wa-section-header/wa-section-header.html","<div class=\"jumbotron text-center\"><h1>{{title}}</h1><p ng-show=\"subtitle\">{{subtitle}}</p></div>");}]);})();
    
  • Tarea templates:concat: Se toma el contenido de "templates.js" (paths.templatesCache) y se concatena al final de "dist/js/scripts.min.js" (paths.jsMin). A diferencia de otras tareas de Gulp definidas en este archivo, esta tiene como directorio actual (Current Working Directory - cwd) "./dist/" ya que los archivos con los que se trabajará en están en dicha carpeta.
  • Tarea templates:clean: Al finalizar "templates:concat" se borra el archivo "dist/templates.js".
  • Tarea build: Se borra de la lista la tarea "copy:html" y se adiciona "templates:clean"; las dependencias declaradas entre las tareas "templates:xyz" permiten que sólo sea necesario indicar esta última.
Ejecutando nuevamente la aplicación (comando gulp server-dist) podrá verse que la cantidad de peticiones HTTP se reduce a la mitad haciendo la misma navegación que el ejemplo de la figura 1.

Figura 2 - Aplicación con archivos para producción con TemplateCache


Deshabilitar Información de Debug

Siguiendo una de las recomendaciones que se hace en la documentación oficial de AngularJS para poner las aplicaciones en producción es deshabilitar la información de debug que el framework adiciona a varios de sus componentes, ya que mucha de esta información sólo es usada para pruebas, lo cual puede ayudar a mejorar el rendimiento de la aplicación.

Recordando el contenido del archivo "app.config.js", allí se tienen dos condiciones que permiten habilitar o deshabilitar esta información:

app/app.config.js
(function() {
  'use strict';

  angular
    .module('weatherApp')
    .config(configure);

  /* @ngInject */
  function configure($compileProvider, $logProvider, $httpProvider) {
    // Replaced by Gulp build task
    $compileProvider.debugInfoEnabled('@@debugInfoEnabled' !== 'false');
    $logProvider.debugEnabled('@@debugLogEnabled' !== 'false');

    $httpProvider.interceptors.push('HttpInterceptor');
  }
})();

  • Línea 11: Condición para deshabilitar la información de debug en la aplicación.
  • Línea 12: Condición para deshabilitar la salida de debug en la consola del navegador enviada mediante "$log.debug()". Es común que en las aplicaciones se envíe mucha información a la consola sólo para ver su resultado en el navegador durante el desarrollo (por ejemplo respuestas de llamados a servicios web), pero dicha información es innecesaria en producción así que de esta manera puede deshabilitar esta salida sin tener que modificar directamente el código de la aplicación.


Las cadenas "@@debugInfoEnabled" y "@@debugLogEnabled" pueden ser reemplazadas usando el plugin "gulp-replace-task":

npm install gulp-replace-task --save-dev

gulpfile.js
...
// Compress into a single file the ones in between of blocks "build:xx" in index.html
gulp.task('compress', ['wiredep'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(plugins.useref({ searchPath: ['./', paths.app] }))
    .pipe(plugins.if('*/scripts.min.js', plugins.replaceTask({
      patterns: [
        {
          match: 'debugInfoEnabled',
          replacement: 'false'
        },
        {
          match: 'debugLogEnabled',
          replacement: 'false'
        }
      ]
    })))
    .pipe(plugins.if('**/*.js', plugins.ngAnnotate()))
    .pipe(plugins.if('**/*.js', plugins.uglify({
      mangle: true
    }).on('error', plugins.util.log)))
    .pipe(plugins.if('**/*.css', plugins.cssnano()))
    .pipe(gulp.dest(paths.dist));
});
...

En este caso se adicionó que al momento de procesar el archivo con el código JavaScript de la aplicación (scripts.min.js) se busque en este las cadenas mencionadas y se reemplacen por "false" para que se cumpla la condición indicada en "app.config.js".

Conclusiones

En este momento ya se tiene una aplicación web desarrollada con AngularJS lista para desplegarse en cualquier servidor web (Apache, http://nginx.org/, ExpressJS, o inclusive contenedores como Tomcat), bien sea con los archivos HTML copiados normalmente o incluidos en el "$templateCache".

Cabe anotar que para aplicar las recomendaciones indicadas no se requirió modificar el código de la aplicación como tal (a excepción de las dos líneas de código que de hecho ya estaban en "app.config.js"), sino que fueron tareas de Gulp modificadas o adicionadas, con lo cual se comprueba la versatilidad y conveniencia de esta herramienta de construcción de proyectos.

En un próximo post se concluirá esta serie con el tema de las pruebas para la aplicación. El proyecto de la aplicación se puede descargar desde: https://github.com/guillermo-varela/angularjs-demo/tree/part-iv

Referencias

https://github.com/johnpapa/angular-styleguide/tree/master/a1
https://docs.angularjs.org/guide/production
https://docs.angularjs.org/api/ng/provider/$logProvider
https://www.npmjs.com/package/gulp-ng-annotate
https://docs.angularjs.org/api/ng/service/$templateCache

1 comentario: