Skip to content

2019_11 Pasando de sprockets a webpacker con Rails 6

Vladimir Támara Patiño edited this page Jan 25, 2021 · 2 revisions

La función de webpacker y sprockets es la misma: preparar y desplegar junto con cada página HTML de una aplicación los recursos javascript, CSS e imagenes que se requieran.

La necesidad de cambiar de sprockets (o tubería de recursos de Rails) a webpacker, en lo que a Javascript respecta, se debe a los módulos Javascript que no pueden manejarse con sprockets sino con webpack que viene convirtiendose en la solución estándar para implementar módulos en navegadores .

sprockets y la tubería de recursos es histórica de Rails, pero webpacker es una gema que usa la herramienta Javascript webpack, que es muy común en el mundo Javascript por optimizar en tiempo de despliegue (o compilación) las diversas fuentes y módulos Javascript que requiera una aplicación y que realiza las traducciones necesarias.

Para evitar por un tiempo la migración a módulos en Javascript es posible seguir usando sprockets, e incluso hay un servicio gratuito que convierten en línea algunos paquetes para bower a gemas: https://rails-assets.org/. Sin embargo notamos que el equipo de desarrollo de Rails y en general las aplicaciones sobre Rails están migrando buena parte de sus Javascript a módulos Javascript que se distribuyen vía npm y webpacker y están reservando sprockets más para distribución de imágenes y hojas de estilo (aunque eso también lo podría hacer webpack).

A continuación incluimos explicaciones que pueden ayudar en el proceso. Recomendmos tambień otros recursos como:

1. ¿Cómo es el sistema de módulos en Javascript?

Es un poco caótico debido a que hubo varias convenciones no oficiales hasta el 2015 (CommonJS y AMD), varias implementaciones y finalmente con ES2016 hubo una especificación estándar que ha sido paulatinamente adoptada por los navegadores. Por lo mismo en npm (repositorio oficial de paquetes Javascript) se encuentran paquetes que usan las diversas convenciones para módulos, por lo que antes de usar alguno debe analizarse si podrá ser cargado con webpack.

Además de esas dificultades, los navegadores han implementado el sistema de módulos estándar separado del ambiente global de ejecución Javascript, por lo que (1) aunque el navegador soporte módulos estándar desde la consola no puede usar import, (2) pueden ser dificiles de depurar o usar. Finalmente mezclar diversas formas de usar módulos puede generar fallas con webpack (como sus desarrolladores reconocen e.g https://github.com/webpack/webpack.js.org/issues/552 ), y hemos evidenciado diversas fallas al mezclar módulos manejados con webpacker y librerías Javascript manejadas con sprockets.

1.1 Módulos estándar o ESM

Como se explica en https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules en la especificación y convención actual los módulos deberían estar en archivos con extensión .js o .mjs y deberían ser servidos por el servidor web con el tipo MIME application/javascript. Deben cargarse desde el HTML con <script type='module' src='modulo.mjs'> y mediante un servidor (no pueden cargarse directo de un archivo del computador donde corre el navegador). Se ejecutarán después de que esté desplegado el HTML (así que no se necesita $.ready()).

Un módulo puede exportar funciones así como variables (let y var), constantes (const) y clases (class) bien cuando se definen o bien posteriormente con

  export {mivar, mifun, miconst, miclase}

Un módulo puede importar de otro módulo mediante

import {mivar, mifun, miconst, miclase} from './ruta/a/modulo'

Un módulo también puede exportar lo que importa de otros módulos.

Opcionalmente un módulo puede renombrar lo que exporta o lo que importa.

Son ideales los módulos que exportan una sola clase que encapsula todo lo necesario y que sirve como espacio de nombres.

Aún más reciente es la posibilidad de cargar módulos dinamicamente mediante la función import, que retorna una promesa, cuyo uso, tomado de la misma referencia de MDN, se ejemplifica a continuación:

import('./modules/square.mjs').then((Module) => {
    let square1 = new Module.Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, 'blue');
    square1.draw();
    square1.reportArea();
    square1.reportPerimeter();
  })
});

1.2 CommonJS

Es el sistema empleado por node.js donde los módulos se cargan de forma sincrona.

Para poderlo usar en un navegador, se requiere una herramienta que agrupe los diversos módulos en uno sólo. Justamente webpacker puede hacerlo, un ejemplo mínimo puede verse en https://github.com/webpack/webpack/tree/master/examples/commonjs

1.3 AMD

Los módulos se cargan de manera asincrona como es más típico en navegadores. Un ejemplo de una definición de un módulo:

define([], function() {

  return {
    hello: function() {
      console.log('hello');
    },
    goodbye: function() {
      console.log('goodbye');
    }
  };
});

Y de su uso:

define(['myModule', 'myOtherModule'], function(myModule, myOtherModule) {
  console.log(myModule.hello());
});

1.4 UMD

Permite definir módulos que se usen como CommonJS o como AMD para operar en clientes y en servidores.

Un ejemplo

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
      // AMD
    define(['myModule', 'myOtherModule'], factory);
  } else if (typeof exports === 'object') {
      // CommonJS
    module.exports = factory(require('myModule'), require('myOtherModule'));
  } else {
    // Browser globals (Note: root is window)
    root.returnExports = factory(root.myModule, root.myOtherModule);
  }
}(this, function (myModule, myOtherModule) {
  // Methods
  function notHelloOrGoodbye(){}; // A private method
  function hello(){}; // A public method because it's returned (see below)
  function goodbye(){}; // A public method because it's returned (see below)

  // Exposed public methods
  return {
      hello: hello,
      goodbye: goodbye
  }
}));

1.5 Patrones de módulos usando el alcance limitado de una función como espacio de nombres

Permiten definir cómo módulos en el sentido de que aislan el espacio de nombres empleando el alcance local de una función.

1.5.1 Clausura anonima

function () {
  // We keep these variables private inside this closure scope
  
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item}, 0);
    
      return 'Your average grade is ' + total / myGrades.length + '.';
  }

  var failing = function(){
    var failingGrades = myGrades.filter(function(item) {
      return item < 70;});
      
    return 'You failed ' + failingGrades.length + ' times.';
  }

  console.log(failing());

}());

// ‘You failed 2 times.’

1.5.2 Importando un global

(function (globalVariable) {

  // Keep this variables private inside this closure scope
  var privateFunction = function() {
    console.log('Shhhh, this is private!');
  }

  // Expose the below methods via the globalVariable interface while
  // hiding the implementation of the method within the 
  // function() block

  globalVariable.each = function(collection, iterator) {
    if (Array.isArray(collection)) {
      for (var i = 0; i < collection.length; i++) {
        iterator(collection[i], i, collection);
      }
    } else {
      for (var key in collection) {
        iterator(collection[key], key, collection);
      }
    }
  };

  globalVariable.filter = function(collection, test) {
    var filtered = [];
    globalVariable.each(collection, function(item) {
      if (test(item)) {
        filtered.push(item);
      }
    });
    return filtered;
  };

  globalVariable.map = function(collection, iterator) {
    var mapped = [];
    globalUtils.each(collection, function(value, key, collection) {
      mapped.push(iterator(value));
    });
    return mapped;
  };

  globalVariable.reduce = function(collection, iterator, accumulator) {
    var startingValueMissing = accumulator === undefined;

    globalVariable.each(collection, function(item) {
      if(startingValueMissing) {
        accumulator = item;
        startingValueMissing = false;
      } else {
        accumulator = iterator(accumulator, item);
      }
    });

    return accumulator;

  };

 }(globalVariable));

1.5.3 Interfaz a un objeto

var myGradesCalculate = (function () {
    
  // Keep this variable private inside this closure scope
  var myGrades = [93, 95, 88, 0, 55, 91];

  // Expose these functions via an interface while hiding
  // the implementation of the module within the function() block

  return {
    average: function() {
      var total = myGrades.reduce(function(accumulator, item) {
        return accumulator + item;
        }, 0);
        
      return'Your average grade is ' + total / myGrades.length + '.';
    },

    failing: function() {
      var failingGrades = myGrades.filter(function(item) {
          return item < 70;
        });

      return 'You failed ' + failingGrades.length + ' times.';
    }
  }
})();

myGradesCalculate.failing(); // 'You failed 2 times.' 
myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'

1.5.4 Patrón de módulo que revela

var myGradesCalculate = (function () {
    
  // Keep this variable private inside this closure scope
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item;
      }, 0);
      
    return'Your average grade is ' + total / myGrades.length + '.';
  };

  var failing = function() {
    var failingGrades = myGrades.filter(function(item) {
        return item < 70;
      });

    return 'You failed ' + failingGrades.length + ' times.';
  };

  // Explicitly reveal public pointers to the private functions 
  // that we want to reveal publicly

  return {
    average: average,
    failing: failing
  }
})();

myGradesCalculate.failing(); // 'You failed 2 times.' 
myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'

2. ¿Cómo operan webpacker y webpack?

webpack es una aplicación que compila módulos Javascript con las traducciones previas que cada uno requiera (por ejemplo Coffescript o de versiones más recientes de Javascript a unas más clásicas y soportadas) y que genera un archivo con todo incluido apto para transmitir al navegador.

webpacker es una gema que maneja la configuración de webpack en una aplicación rails. Como convención por omisión configura webpack para leer módulos Javascript de app/javascript/packs y de node_modules, funciones que deben ejecutarse en el ambiente global de app/javascript y que escribe el archivo o archivos resultantes en public/packs/js/ (dejando un archivo de resumen public/packs/manifest.js).

Por omisión webpacker puede manejar ambientes de desarrollo, pruebas y producción y el modo de ejecución lo lee de la variable NODE_ENV. Provee ayudadores para incluir recursos compilados con webpack en las vistas como javascript_pack_tag, stylesheet_pack_tag, favicon_pack_tag, image_pack_tag, los cuales generaran elementos HTML que referencian rutas de la forma /packs/ webpacker configura webpack con babel y cargadores de CSS que pueden convertir de SASS (por si se quisieran transmitir CSS con webpack en lugar de sprockets).

Los paquetes npm deben estar en el directorio node_modules y allí los pondrá yarn.

El soporte para webpacker en motores es apenas incipiente y el camino que han seguido los desarrolladores de rails, ha sido publicar paquetes npm como @rails/actioncable, @rails/activestorage, @rails/ujs y @rails/webpacker, que proceden de las gemas actioncable, activestorage, rails y webpacker respectivamente y por lo mismo cambian de versión de forma simultanea gemas y paquetes npm.

3. Un ejemplo mínimo con Action Text

En Rails 6 la forma por omisión de emplear el editor enriquecido trix es mediante webpacker. A continuación pasos para lograr su operación:

  • Asegure que webpacker y yarn operan. Como se describe en https://github.com/pasosdeJesus/sip/wiki/Actualizaci%C3%B3n-a-Rails-6-en-6-pasos:

    doas pkg_add install bash
    ftp -o- https://yarnpkg.com/install.sh | bash 
    . $HOME/.profile
    CXX=c++ bin/rails webpacker:install
    

    Que agregará los siguientes directorios y archivos:

    Archivo Descripción
    config/webpacker.yml Cofiguración de webpacker
    config/webpack/development.js Configuración de webpack en sitio de desarrolo
    config/webpack/environment.js Configuración de webpack común
    config/webpack/production.js Configuración de webpack en sitio de producción
    config/webpack/test.js Configuración de webpack en sitio de prueba
    postcss.config.js Configuración del cargador PostCSS
    babel.config.js Configuración de paquete babel usado para soportar ES-6
    .browserslistrc Lista navegadores objetivo de la aplicación
    app/javascript Directorio donde pueden ubicar su aplicación JS o packs que requiera
    app/javascript/packs/application.js Referencia packs de webpacker por desplegar
    packages.json Declara paquetes javascript requeridos y versión de cada. Inicia con paquete webpacker. Podrá actualizar versión de todos con CXX=c++ yarn upgrade. Se agregan paquetes con yarn add paquete y se eliminan con yarn remove paquete
    node_modules Directorio donde quedan los paquetes JS declarados en packages.json y descargados con CXX=c++ yarn install
  • Como se explica en las guías de rails (https://edgeguides.rubyonrails.org/action_text_overview.html), ejecute: rails action_text:install y corra migraciones bin/rails db:migrate. Con esto se realizarán entre otros los siguientes cambios:

    • En packages.json al diccionario del atributo dependencies se añadirá "trix": "^1.0.0". Recuerde ejecutar CXX=c++ yarn install que descargará la versión 1.0.0 en node_modules/trix/
    • En app/javascript/packs/application.js se agregará código para cargar módulos (en notación de CommonJS)
      require("trix")
      require("@rails/actiontext")
      

    puede ver más sobre el sistema de módulos en ES6 en https://hacks.mozilla.org/2015/08/es6-in-depth-modules/ y sobre módulos com CommonJS en https://blog.risingstack.com/node-js-at-scale-module-system-commonjs-require/.

    • Se creará el archivo app/assets/stylesheets/actiontext.scss que permite sobrecargar estilo por omisión de trix. Que se carga mediante sprockets con:
//= require trix/dist/trix
  • Como el CSS por omisión del paquete Javascript trix, se carga con sprockets, es necesario agregar en config/initializers/assets.rb: Rails.application.config.assets.paths << Rails.root.join('node_modules')
  • En su archivo de layout, digamos app/layouts/application.html.erb recuerde cargar la aplicación javascript y los packs compilados con webpack con:
<%= javascript_pack_tag 'application' %>
  • Si está montando la aplicación en una ruta diferente a /, recomendamos que aliste un enlace packs en su punto de montaje que apunte a public/packs. Por ejemplo si la monta en miorg/miap ejecute:
cd public/miorg/miapp
ln -s ../../packs .

4. Consideraciones para migrar de sprockets a webpacker

Migrar una aplicación o un motor de sprockets a webpacker no es trivial, pues es importante (1) entender la operación de sprockets, de webpacker y de webpack (que a su vez requiere entender módulos de Javascript), (2) debe analizarse de forma particular cada gema con Javascript de la cual dependa la aplicación para realizar el cambio de la misma y (3) debe migrarse el Javascript de la aplicación a módulos.

Ver por ejemplo https://github.com/pasosdeJesus/sip/wiki/Actualizaci%C3%B3n-de-sip-2.0b6-a-sip-2.0b7

4.1 Arranque de la aplicación

Tenga en cuenta que antes iniciar la aplicación en modo desarrollo, ahora es preferible ejecutar:

RAILS_ENV=development bin/rails assets:precompile --trace

de no hacerse es posible que la aplicación se congele al cargarla en modo desarrollo tipicamente después de presentar:

bin/rails s ...
...
formato_fecha:
yyyy-mm-dd
 relative_url_root:
/

Nos ha ocurrido cuando hemos tenido problemas de dependencias, por ejemplo cuando en package.json hemos agregado bootstrap y boostrap-datepicker pero en app/javascript/packs/application.js hemos incluido import 'bootstrap-datepicker' sin haber incluido antes import 'bootstrap'.

En tales bloqueos no se logra detener con Control-C sino que debe matarse el proceso con señal 9. Por ejemplo puede buscar la instancia de puma que corre en puerto 2400 con

ps ax | grep 2400

y si el número del proceso es 54837 puede matarlo con:

kill -9 54837

4.2 Ambiente en navegador

De lo que hemos experimentando notamos que si javascript/packs/application.js usa import de ES6 la aplicación corre en el ambiente de ese archivo. Los script de los HTML se cargan en el mismo ambiente, así como los recursos servidos por sprockets.

Si se hace otro archivo javascript/packs/modulo2.js por lo visto se carga en un ambiente diferente.

4.3 Paquetes que deben estar en el ambiente global

4.3.1 jquery

En general los nuevos Javascript evitan jQuery y es recomendable ir convirtiendo lo que falte a Javascript plano (también llamado Javascript vainilla).

En el transito, para tener disponible jQuery en el ambiente global (cargado con sprockets) y en todo módulo (cargado con webpacker) nos ha funcionado una solución que mezcla https://inopinatus.org/2019/09/14/webpacker-jquery-and-jquery-plugins/ con https://github.com/rails/webpacker/blob/master/docs/webpack.md:

$ yarn add expose-loader
$ CXX=c++ yarn install

Y en config/webpack/environment.js :

const { environment } = require('@rails/webpacker')
...

const webpack = require('webpack')

environment.plugins.prepend(
  'Provide',
  new webpack.ProvidePlugin({
   $: 'jquery',
    jQuery: 'jquery',
    jquery: 'jquery',
    Popper: ['popper.js', 'default'],
  })
)

environment.loaders.append('expose', {
    test: require.resolve('jquery'),
    use: [
          { loader: 'expose-loader', options: '$' },
          { loader: 'expose-loader', options: 'jQuery' }
        ]
})
      
...
module.exports = environment

4.3.2 tinycolors2

Además de agregarlo con yarn add tinycolor2 exponerlo globalment desde config/webpack/environment.js:

const webpack = require('webpack') 

environment.plugins.prepend( 
  'Provide',
  new webpack.ProvidePlugin({ 
    ...
    tinycolor: 'tinycolor2'
  })
)      

environment.loaders.append('expose', {
    test: require.resolve('jquery'),
    use: [
...          { loader: 'expose-loader', options: 'tinycolor' }
        ]
})
Clone this wiki locally