Rodrigo Rosenfeld Rosas
Improving SPA loading time with webpack (and why Sprockets is in your way)
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 | require 'json' |
2 | |
3 | WEBPACK_MAPPING = "#{Rails.root}/app/resources/webpack-assets.json" |
4 | |
5 | module 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 |
28 | end |
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:
1 | namespace :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 |
30 | end |
31 | |
32 | Rake::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:
1 | namespace :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 |
11 | end |
12 | |
13 | Rake::Task['assets:precompile'].enhance ['resources:sprites', 'resources:generate_fake_manifest'] |
front-end/generate-sprites.rb:
1 | #!/usr/bin/env ruby |
2 | |
3 | require_relative 'sprite_generator' |
4 | |
5 | THEMES = ['uk', 'default'] |
6 | |
7 | THEMES.each{|t| SpriteGenerator.generate t } |
sprite_generator.rb:
1 | require 'fileutils' |
2 | |
3 | class 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 |
95 | 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.