Ler blog em Português

Using ES2015 with Asset Pipeline on Ruby on Rails

Read in 6 minutes

JavaScript is all new. Until recently, we had no new features. The last significant update was back in 2009, with ES5‘s release. And you couldn’t use all features due to browser incompatibility.

To increase the compatibility level, we had to use things like es5-shim, which conditionally checked if a feature was available, adding a polyfill if the browser didn’t implement it.

And then the first pre-processors came in, like CoffeeScript. You could write different constructions, that were compiled to code that the browsers could actually understand.

Interestingly, Brendan Eich announced in 2009 a new JavaScript version called Harmony, which is now called ES20151. The first drafts were published in 2011, but the final specification was released in June of 2015.

ES2015 has so many new features:

Browsers are implementing ES2015 in a fast pace, but it’ll take some time until we can actually use it. Maybe years. Fortunately, we have Babel.js. We can use all these new features today without worrying with browser compatibility.

Babel.js2 is just a pre-processor. You write code that uses these new features (and more), which will be exported as code that browsers can understand, even those that don’t fully understand ES2015.

Using Babel.js with Asset Pipeline

There are several ways you can use to precompile your JavaScript code. Some people use Babel’s CLI. Some people prefer builders like Grunt or Gulp to automate the compilation process. But if you’re using Ruby on Rails you’re more likely to use Asset Pipeline for front-end assets compilation. And this is, in my opinion, the easiest way of using Babel, believe it or not.

Unfortunately there’s no built-in support on the stable release of Sprockets, so you’ll have to use a pre-release version.

The transpilation is performed by babel-schmooze-sprockets. It vendors several Babel extensions so that you can use Babel without having to deal with NPM on your application (you’ll still need Node.js though).

Update your Gemfile to include these dependencies.

source "https://rubygems.org"

gem "rails", "4.2.6"
gem "sqlite3"
gem "uglifier", ">= 1.3.0"

gem "sprockets", "~> 4.x"
gem "babel-schmooze-sprockets"

gem "turbolinks", "~> 5.x"
gem "jquery-rails"

That’s it! Now all .es6 files will be compiled using Babel. Create a file at app/assets/javascripts/hello.es6 with the following code:

class Hello {
  constructor() {
    alert("Hello!");
  }
}

new Hello();

Make sure you’re loading hello.es6 at app/assets/javascripts/application.js. You also need to load Babel helpers; they’ll be used to reduce the amount of generated code.

//= require babel
//= require_tree .
//= require_self

Now if you access the page on your browser, you’ll see an alert box like this:

Alert box - Hello

If you want the new module system, you still have some things to configure.

Using ES2015 modules

ES2015 introduced module support; instead of defining your code in the global scope, you can use a scope per file. Importing modules is pretty straightforward:

import Foo from "foo";

This will load Foo from foo.js. One single file can export several units (functions, objects, anything you want), and you don’t have to load everything at once, pretty much like Python.

Babel has no idea of how these modules should be exported, and by default, will use the CommonJS format, which can’t be used by the browser, so we’ll use another approach.

The easiest way is using AMD, and for this we’ll use almond. You can install almond with whatever you’re using for managing packages; in this article I’ll use http://rails-assets.org, a bower-to-rubygems converter. Update your Gemfile like the following:

source "https://rubygems.org"

gem "rails", "4.2.6"
gem "sqlite3"
gem "uglifier", ">= 1.3.0"

gem "sprockets", "~> 4.x"
gem "babel-schmooze-sprockets"

gem "turbolinks", "~> 5.x"
gem "jquery-rails"

source "https://rails-assets.org" do
  gem "rails-assets-almond"
end

Finally, update app/assets/javascripts/application.js so it loads almond.

//= require turbolinks
//= require almond
//= require jquery
//= require_tree .
//= require_self

Notice that Turbolinks must be loaded before Almond; this happens because Turbolinks is an anonymous AMD module. For more information, check the section “Almond.js gotcha”, later on this article.

Now you have to think about the execution process. Are you going to use some code dispatcher? Or execute code based on the view that is being rendered? Are you going to create your own execution mechanism? The answer depends on your own workflow, so I’m not going to give you too many alternatives here.

We’re going to create a boot script that uses the controller and action names to execute the JavaScript you need for that specific page. Just add the require call to your app/assets/javascripts/application.js file.

//= require turbolinks
//= require almond
//= require jquery
//= require_tree .
//= require_self

require(["application/boot"]);

You have to create app/assets/javascripts/application/boot.es6. I’ll listen to some events, like DOM’s ready and Turbolinks’ turbolinks:load.

import $ from "jquery";

function runner() {
  var path = $("body").data("route");

  // Load script for this page.
  // We should use System.import, but it's not worth the trouble, so
  // let's use almond's require instead.
  try {
    require([path], onload, null, true);
  } catch (error) {
    handleError(error);
  }
}

function onload(mod) {
  // Assign the default module.
  var Page = mod.default;

  // Instantiate the page, passing <body> as the root element.
  var page = new Page($(document.body));

  // Set up page and run scripts for it.
  if (page.setup) {
    page.setup();
  }

  page.run();
}

// Handles exception.
function handleError(error) {
  if (error.message.match(/undefined missing/)) {
    console.warn("missing module:", error.message.split(" ").pop());
  } else {
    throw error;
  }
}

$(window)
  .ready(runner)
  .on("turbolinks:load", runner);

This script needs a data-route property property on your <body> element. You can add something like the following to your layout file (e.g. app/views/layouts/application.html.erb). You can use the following helper method:

# app/helpers/application_helper.rb
module ApplicationHelper
  ACTION_ALIASES = {
    "update" => "edit",
    "create" => "new"
  }

  def js_route
    action_name = ACTION_ALIASES[controller.action_name] || controller.action_name
    controller_name = controller.class.name.underscore.gsub("_controller", "")

    "#{controller_name}/#{action_name}"
  end
end

And then:

<body data-route="application/pages/<%= js_route %>">

Now let’s create a class that will be executed when the template is rendered. We’ll use the site controller and home action as example. For this you’ll need to create the app/assets/javascripts/application/pages/site/home.es6 file.

export default class Home {
  constructor(root) {
    this.root = root;
  }

  setup() {
    // add event listeners
    console.log("-> setting up listeners and whatnot");
  }

  run() {
    // trigger initial action (e.g. perform http requests)
    console.log("-> perform initial actions");
  }
}

Note that we’re exporting the Home class as the default module. This is the module that will be used when you have something like import Home from 'application/pages/home';.

Also note that we’re defining the Home#constructor method; this is the method that is executed when the class is instantiated.

I said this before, but class definition is one of the things I like the most. Compare it with the constructor function form:

function Home(root) {
  this.root = root;
}

Home.prototype.setup = function() {
  // add listeners
};

Home.prototype.run = function() {
  // initial execution
};

They’re are similar, but using the class keyword makes closer to what we use in other languages. Here’s how you would write the same class in Ruby:

class Home
  def initialize(root)
    @root = root
  end

  def setup
  end

  def run
  end
end

ES2015 has other niceties. To discover what’s new, I recommend the Exploring ES6 book, which you can read for free.

Almond.js gotcha

There is one gotcha when using Almond; all modules must be explicitly named. This means that libraries like qunit won’t work out of the box because they’re anonymous modules. To solve this problem, you should load the library before loading Almond and then exporting the module yourself.

//= require qunit
//= require almond

define("qunit", function() {
  return QUnit;
});

The problem of doing this is that the library won’t detect AMD support and will export global variables, but I still prefer this behavior over compiling code with an optimizer like r.js or a library that integrates that into Rails.

Wrapping up

Using ES2015 today is a viable option. With Babel you can use all these new features without worrying with browser compatibility. The integration with Asset Pipeline make things easier, even for those that don’t fully grasp the Node.js ecosystem.

There’s a working repository available at Github.


  1. ES2015 is also known as ES.Next or ES6

  2. This project used to be called 6to5.js. Read more.