Rodrigo Rosenfeld Rosas

Improving SPA loading time with webpack (and why Sprockets is in your way)

Fri, 26 Feb 2016 17:25:00 +0000 (Updated at Sat, 27 Feb 2016 11:10:00 +0000)

This should be seen as a 3-parts series and it was previously published as all those articles bundled together but the article became too long. I've published the server-side framework agnostic part here and that part itself require some background on how scripts can be loaded and the trade-offs for each approach here.

This article will focus on how to implement those techniques in Rails and how it compares to Sprockets, the de facto solution, or the Rails Assets Pipeline.

Switching away from Sprockets

As I mention in the other related article, I realized that for our already optimized application (as far as Sprockets allows it to be) to take one step further we'd have to introduce code splitting and only load the code required for the initial state initially.

I could have implemented code splitting on my own, since Sprockets doesn't support it out of the box, but by that time I was already feeling Sprockets was in my way for a long time for many other reasons, like lack of source-maps or ES6/Babel support and bad integration with the npm packages system and the Node.js community as a whole. About a month after I realized I should be replacing it with a better build system and started to study webpack I read the rails-assets.org team announcing they would stop supporting that effort by the end of 2017, which confirmed I was in the right direction as their team came to the same conclusion as I that it wasn't the best approach for integrating with the JS ecosystem and also because I would no longer be able to count on rails-assets.org for the bower integration after 2018 (and that integration was never perfect anyway).

I'd love to be able to tell you how to migrate from Sprockets to Webpack in baby steps but after thinking about it for a long time I couldn't figure out some way to do it gradually. It took me about a week to finish the migration of several sources and libraries to webpack and fortunately I had a calm week after our last deploy that would allow me to make this change happen. Before that I have invested another week or two investigating about webpack and other alternatives to be sure this was the right direction for me to take. If your application is big and has lots of modules be warned that the transition to webpack is not a trivial one. But it's not hard either, but you need some time available to perform it and no other development should take place during the transition to avoid many conflicts which would take even more time to resolve.

However, I can recommend that the first step would be to make your libraries available through webpack, so that you can get used to it at the same time you can get rid of the rails-assets.org gems by replacing them with npm or bower packages since this can be done in parallel with other activities and with baby steps. At least, this is what I did and it took me about 2 days to move away from rails-assets.org gems to webpack managed libraries.

Webpack drawbacks when compared to Sprockets

There are basically 3 points where Sprockets is better than the Webpack approach:

1 - Sprockets supports persistent caching when compiling assets, which allows faster deploy times when you just change a few assets; 2 - Requests to the document will block until all changed assets compilation has finished. Even though the watch mode of webpack is pretty fast (assuming uglify is not enabled in development mode), it may take 2 or 3 seconds to update the bundles after some file is changed. If you try to refresh a page just after making the change, it's possible it won't load the latest changes, while Sprockets will block the request until the generated assets are updated, which is nicer than checking the console to see if the compilation has finished; 3 - Any errors in the assets are better displayed when loading the document due to the great integration sprockets has with Rails;

On the other side, Sprockets has so many drawbacks that I won't list all of them here to not repeat myself. Just read the remaining of this article and the other mentioned ones. Just to name a few: lack of support for code splitting, source-maps, ES6/Babel, NPM/Bower integration (with regards to evaluating requires). Integration with several client-side test frameworks can also be made much easier with webpack, by specifying all dependencies in a separate webpack configuration without having to export anything to the global context... It also allows your front-end code to be managed independently, without any dependencies on Rails which may be desired for some teams where the front-end team would prefer to work independently from the back-end team.

Having said that, by no means I regret moving from Sprockets to Webpack. After the first week I created this Rails app to replace a Grails app I inherited, I decided to switch from ActiveRecord to Sequel. I was already a Sequel fan but Arel had just arrived to AR by that time and I decided to give it a try but gave up after one week. Replacing AR with Sequel was the best decision I took for this project and I think moving from Sprockets to Webpack will prove to be the second best choice I've made for this project.

Integration Webpack with Rails

Follow the instructions described in this other generic article about Webpack and then proceed with these instructions.

Webpack will generate a webpack-assets.json file due to the assets-webpack-plugin, which allows us to get the generated bundle full name with the chunk hash included so that we can use it to pass to the script src attribute.

I do that by adding some methods to application_helper.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
require 'json'

WEBPACK_MAPPING = "#{Rails.root}/app/resources/webpack-assets.json"

module ApplicationHelper

  def webpack_resource_js_path(resource_name)
    webpack_resource_path resource_name, 'js'
  end

  def webpack_resource_css_path(resource_name)
    webpack_resource_path resource_name, 'css'
  end

  def webpack_stylesheet_link_tag(resource_name)
    stylesheet_link_tag webpack_resource_css_path(resource_name)
  end

  private

  def webpack_resource_path(resource_name, type)
    webpack_mapping[resource_name][type]
  end

  def webpack_mapping
    @webpack_mapping ||= JSON.parse File.read WEBPACK_MAPPING
  end
end

Then, it's used like this in the page:

1
2
3
4
5
6
  <%= webpack_stylesheet_link_tag "app/theme-#{@theme}" %>
  <%= render partial: '/common/webpack_boot' %>
  <%= javascript_include_tag webpack_resource_js_path('vendor'),
        defer: 'defer', async: 'async', crossorigin: 'anonymous' %>
  <% script = webpack_resource_js_path(current_user.internal? ? 'app/internal' : 'app/client') %>
  <%= javascript_include_tag script, defer: 'defer', async: 'async', crossorigin: 'anonymous' %>

/common/_webpack_boot.html.erb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<script type="text/javascript">
  function webpackJsonx(module, exports, __webpack_require__) {
    var load = function() {
      if (window.VENDORS_LOADED)
        return webpackJsonp(module, exports, __webpack_require__);
      setTimeout(load, 10);
    }
    load();
  }
</script>
<!--[if lte IE 8]>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/es5-shim/4.5.5/es5-shim.js"></script>
<![endif]-->

I've also enhanced the assets:precompile task so that you don't have to change your deploy
scripts:

lib/tasks/webpack.rake:

@@@ ruby
namespace :webpack do
  webpack_deps = ['resources:sprites', 'js:routes', 'webpack:generate_settings_js',
    'webpack:install']

  desc 'build webpack resources'
  task build: webpack_deps do
    puts 'building webpack resources...'
    system('cd app/resources && PROD=1 node_modules/.bin/webpack --bail > /dev/null 2>&1') or
      raise 'webpack build failed'
    puts 'resources successfully built'
  end

  desc 'webpack watch'
  task watch: webpack_deps do
    system 'cd app/resources && node_modules/.bin/webpack -w'
  end

  task :install do
    system 'cd app/resources && npm install >/dev/null 2>&1 && node_modules/.bin/bower install >/dev/null 2>&1' or
      puts 'webpack install failed'
  end

  task :generate_settings_js do
    require 'erb'
    require 'fileutils'
    FileUtils.mkdir_p 'app/resources/src/js/app'
    File.write 'app/resources/src/js/app/settings.js',
      ERB.new(File.read 'app/assets/javascripts/app/settings.js.erb').result(binding)
  end
end

Rake::Task['assets:precompile'].enhance ['webpack:build']

I've also moved the sprites generation from compass to a custom script I created:

lib/tasks/sprites.rake:

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace :resources do
  desc 'generate theme sprites'
  task :sprites do
    `front-end/generate-sprites.rb`
  end

  # TODO: Fix the need for this in Capistrano
  task :generate_fake_manifest do
    `touch public/assets/manifest.txt`
  end
end

Rake::Task['assets:precompile'].enhance ['resources:sprites', 'resources:generate_fake_manifest']

front-end/generate-sprites.rb:

1
2
3
4
5
6
7
#!/usr/bin/env ruby

require_relative 'sprite_generator'

THEMES = ['uk', 'default']

THEMES.each{|t| SpriteGenerator.generate t }

sprite_generator.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
require 'fileutils'

class SpriteGenerator
  def self.generate(theme)
    new(theme).generate
  end

  def initialize(theme)
    @theme = theme
  end

  def generate
    create_sprite
    compute_size_and_offset
    FileUtils.rm_rf css_output_path
    FileUtils.mkdir_p css_output_path
    generate_css
  end

  private

  def create_sprite
    FileUtils.rm_rf output_path
    FileUtils.mkdir_p output_path
    `convert -background transparent -append #{theme_path}/*.png #{output_path}/#{sprite_filename}`
  end

  def theme_path
    @theme_path ||= "front-end/resources/images/#{@theme}/theme"
  end

  def output_path
    @output_path ||= "public/assets/#{@theme}"
  end

  def sprite_filename
    @sprite_filename ||= "theme-#{checksum}.png"
  end

  def checksum
    @checksum ||= `cat #{theme_path}/*.png|md5sum`.match(/(.*?)\s/)[1]
  end

  def compute_size_and_offset
    dimensions = `identify -format "%wx%h,%t\\n" #{theme_path}/*.png`
    @image_props = []
    offset = 0
    dimensions.split("\n").each do |d|
      m = d.match /(\d+)x(\d+),(.*)/
      w, h, name = m[1..-1]
      @image_props << (prop = [w.to_i, h = h.to_i, name, offset])
      @sort_ascending = prop if name == 'sort-ascending' # special behavior
      @sort_desc = prop if name == 'sort-descending' # special behavior
      offset += h
    end
  end

  def css_output_path
    @css_output_path ||= "app/assets/stylesheets/themes/#{@theme}"
  end

  def generate_css
    sp = @sort_ascending
    common_rules = [
      @image_props.map{|(w, h, name, offset)| ".theme-#{name}"}.join(', '),
      ', a.sort.ascending:after, a.sort.descending:after {',
      "  background-image: url(/assets/#{@theme}/#{sprite_filename});",
      '  background-repeat: no-repeat;',
      '  display: inline-block;',
      '  border: 0;',
      '  background-color: transparent;',
      '}',
      @image_props.map{|(w, h, name, offset)| "button.theme-#{name}"}.join(', '),
      '{',
      "  cursor: pointer;",
      '  outline: none;',
      '}',
      @image_props.map{|(w, h, name, offset)| ".theme-#{name}.disabled"}.join(', '),
      '{',
      "  -webkit-filter: grayscale(100%);",
      '  filter: grayscale(100%);',
      '}',
    ].join "\n"
    content = @image_props.map do |(w, h, name, offset)|
      [
        ".theme-#{name} {",
        "  height: #{h}px;",
        "  width: #{w}px;",
        "  background-position: 0 -#{offset}px;",
        "}",
      ].join "\n"
    end.join("\n")
    File.write "#{css_output_path}/theme.css", "#{common_rules}\n\n#{content}"
  end
end

Final notes

You can find some numbers on how this set up improved the loading time of our application in the generic webpack article "Some numbers" section.

Even though it may require a lot of effort to migrate from Sprockets to Webpack, there are tons of advantages of doing so, including performance improvements for loading your application faster and additional features support, like source-maps, much easier integration with NPM and bower packages, support for more compilers/transpilers and ability to move your front-end code to a separate project. And it's also a much more easily customizable solution, allowing you to easily change the build configuration by using regular JavaScript in the Node.js environment.

If you want to take your loading time performance to the next level, then I'd say moving out from Sprockets is a must and webpack is the only solution I was able to find in my research that will allow you to do that.

comments powered byDisqus