Rodrigo Rosenfeld Rosas

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

Fri, 26 Feb 2016 17:25:00 +0000 (last 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:

1require 'json'
2
3WEBPACK_MAPPING = "#{Rails.root}/app/resources/webpack-assets.json"
4
5module ApplicationHelper
6
7 def webpack_resource_js_path(resource_name)
8 webpack_resource_path resource_name, 'js'
9 end
10
11 def webpack_resource_css_path(resource_name)
12 webpack_resource_path resource_name, 'css'
13 end
14
15 def webpack_stylesheet_link_tag(resource_name)
16 stylesheet_link_tag webpack_resource_css_path(resource_name)
17 end
18
19 private
20
21 def webpack_resource_path(resource_name, type)
22 webpack_mapping[resource_name][type]
23 end
24
25 def webpack_mapping
26 @webpack_mapping ||= JSON.parse File.read WEBPACK_MAPPING
27 end
28end

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

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

/common/_webpack_boot.html.erb:

1<script type="text/javascript">
2 function webpackJsonx(module, exports, __webpack_require__) {
3 var load = function() {
4 if (window.VENDORS_LOADED)
5 return webpackJsonp(module, exports, __webpack_require__);
6 setTimeout(load, 10);
7 }
8 load();
9 }
10</script>
11<!--[if lte IE 8]>
12<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/es5-shim/4.5.5/es5-shim.js"></script>
13<![endif]-->

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

lib/tasks/webpack.rake:

1namespace :webpack do
2 webpack_deps = ['resources:sprites', 'js:routes', 'webpack:generate_settings_js',
3 'webpack:install']
4
5 desc 'build webpack resources'
6 task build: webpack_deps do
7 puts 'building webpack resources...'
8 system('cd app/resources && PROD=1 node_modules/.bin/webpack --bail > /dev/null 2>&1') or
9 raise 'webpack build failed'
10 puts 'resources successfully built'
11 end
12
13 desc 'webpack watch'
14 task watch: webpack_deps do
15 system 'cd app/resources && node_modules/.bin/webpack -w'
16 end
17
18 task :install do
19 system 'cd app/resources && npm install >/dev/null 2>&1 && node_modules/.bin/bower install >/dev/null 2>&1' or
20 puts 'webpack install failed'
21 end
22
23 task :generate_settings_js do
24 require 'erb'
25 require 'fileutils'
26 FileUtils.mkdir_p 'app/resources/src/js/app'
27 File.write 'app/resources/src/js/app/settings.js',
28 ERB.new(File.read 'app/assets/javascripts/app/settings.js.erb').result(binding)
29 end
30end
31
32Rake::Task['assets:precompile'].enhance ['webpack:build']
33

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

lib/tasks/sprites.rake:

1namespace :resources do
2 desc 'generate theme sprites'
3 task :sprites do
4 `front-end/generate-sprites.rb`
5 end
6
7 # TODO: Fix the need for this in Capistrano
8 task :generate_fake_manifest do
9 `touch public/assets/manifest.txt`
10 end
11end
12
13Rake::Task['assets:precompile'].enhance ['resources:sprites', 'resources:generate_fake_manifest']

front-end/generate-sprites.rb:

1#!/usr/bin/env ruby
2
3require_relative 'sprite_generator'
4
5THEMES = ['uk', 'default']
6
7THEMES.each{|t| SpriteGenerator.generate t }

sprite_generator.rb:

1require 'fileutils'
2
3class SpriteGenerator
4 def self.generate(theme)
5 new(theme).generate
6 end
7
8 def initialize(theme)
9 @theme = theme
10 end
11
12 def generate
13 create_sprite
14 compute_size_and_offset
15 FileUtils.rm_rf css_output_path
16 FileUtils.mkdir_p css_output_path
17 generate_css
18 end
19
20 private
21
22 def create_sprite
23 FileUtils.rm_rf output_path
24 FileUtils.mkdir_p output_path
25 `convert -background transparent -append #{theme_path}/*.png #{output_path}/#{sprite_filename}`
26 end
27
28 def theme_path
29 @theme_path ||= "front-end/resources/images/#{@theme}/theme"
30 end
31
32 def output_path
33 @output_path ||= "public/assets/#{@theme}"
34 end
35
36 def sprite_filename
37 @sprite_filename ||= "theme-#{checksum}.png"
38 end
39
40 def checksum
41 @checksum ||= `cat #{theme_path}/*.png|md5sum`.match(/(.*?)\s/)[1]
42 end
43
44 def compute_size_and_offset
45 dimensions = `identify -format "%wx%h,%t\\n" #{theme_path}/*.png`
46 @image_props = []
47 offset = 0
48 dimensions.split("\n").each do |d|
49 m = d.match /(\d+)x(\d+),(.*)/
50 w, h, name = m[1..-1]
51 @image_props << (prop = [w.to_i, h = h.to_i, name, offset])
52 @sort_ascending = prop if name == 'sort-ascending' # special behavior
53 @sort_desc = prop if name == 'sort-descending' # special behavior
54 offset += h
55 end
56 end
57
58 def css_output_path
59 @css_output_path ||= "app/assets/stylesheets/themes/#{@theme}"
60 end
61
62 def generate_css
63 sp = @sort_ascending
64 common_rules = [
65 @image_props.map{|(w, h, name, offset)| ".theme-#{name}"}.join(', '),
66 ', a.sort.ascending:after, a.sort.descending:after {',
67 " background-image: url(/assets/#{@theme}/#{sprite_filename});",
68 ' background-repeat: no-repeat;',
69 ' display: inline-block;',
70 ' border: 0;',
71 ' background-color: transparent;',
72 '}',
73 @image_props.map{|(w, h, name, offset)| "button.theme-#{name}"}.join(', '),
74 '{',
75 " cursor: pointer;",
76 ' outline: none;',
77 '}',
78 @image_props.map{|(w, h, name, offset)| ".theme-#{name}.disabled"}.join(', '),
79 '{',
80 " -webkit-filter: grayscale(100%);",
81 ' filter: grayscale(100%);',
82 '}',
83 ].join "\n"
84 content = @image_props.map do |(w, h, name, offset)|
85 [
86 ".theme-#{name} {",
87 " height: #{h}px;",
88 " width: #{w}px;",
89 " background-position: 0 -#{offset}px;",
90 "}",
91 ].join "\n"
92 end.join("\n")
93 File.write "#{css_output_path}/theme.css", "#{common_rules}\n\n#{content}"
94 end
95end

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.

Powered by Disqus