Enabling Modern JavaScript in Rails with Webpack(er)
When I joined Caviar in 2016, I was fully bought into React and all the good stuff around building modular systems. jQuery was no longer…
When I joined Caviar in 2016, I was fully bought into React and all the good stuff around building modular systems. jQuery was no longer the cool kid around the block. NPM was the place to go to get any missing dependencies. ES6 was starting to gain more traction as compilers like Babel helped older browsers support the new syntax.
I was thrilled to hear Caviar used React! I could see myself building components with Promises and that cool new import
syntax. Having only worked with Node, it was easy to find a solution that was related to JavaScript. Wanted ES6 on old browsers or a linter that checked your code as you worked? No problem, Webpack’s got your back. That was before I knew anything about Rails conventions.
JavaScript: Rails’ neglected sibling
Rails, on the other hand, is commonly known for its preference for convention over configuration. That meant it had strong opinions on how JavaScript should be handled. Specifically, Rails had its own asset packaging library called Sprockets, which was not pre-configured to resolve NPM dependencies. Instead, jQuery and a small number of popular JavaScript libraries wrapped by Gems are available. On top of that, the language that shipped with Rails was CoffeeScript, which was losing favor over the rising popularity of ES6 syntax.
The Rails setup of frontend assets certainly worked, but coming from a Node setup, the architecture felt old and pieces didn’t seem like they came together very well. Path resolution specifically was hard to configure and there were some pretty weird ways of including dependencies. Since node_modules
wasn’t configured out of the box, if you wanted to include a vendor dependency, you would have to look for a Gem, or download the dependency manually and place it in the vendor/assets/javascripts
directory. The vendor libraries could then be included into the base application.js
manifest file via Sprockets’ special directive processor, e.g. //=require jquery
. This works fine for traditional dependencies instantiated in the global context of the browser, but things get uglier if you need to pre-process your code or use CommonJS. The syntax for requiring modules becomes bloated since you need to include the module via Sprockets in addition to JavaScript require
.
Sprockets is capable of pre-processing JS code by reading the extension from left to right. Therefore, if you wanted to process some Rails environment, then process CoffeeScript into plain JS, you’d have an extension like *.js.coffee.erb
. On top of that, in order to support React, we used react-rails
which allows processing of React files by appending *.js.jsx
, so now you have *.js.jsx.coffee
(luckily we didn’t compile Rails data often in our JavaScript). But wait, there’s more! If you wanted to use CommonJS require syntax, you had to install yet another Gem called sprockets-commonjs which requires you to add the *.module*
extension for a file to be handled with Javascript require(…) syntax. This meant that nearly all our React files ended in *.module.js.jsx.coffee
😰.
Working within the guidelines of Rails Asset Pipeline made implementing a modular codebase difficult since it restricted developers to old monolithic dependencies. Using jQuery is generally frowned upon in React, but there wasn’t really any way we could sanely polyfill Promises or fetch. This limited us to using outdated coding methods that we would then have to refactor in the future. Not having NPM when working with a view library like React is a huge crutch when the common practice is to essentially “build your own framework” via the NPM dependencies available.
The missing component to the formula was a build tool like Webpack. Webpack is notoriously hard to configure, but once it’s been setup it is the most versatile asset packaging library available in the JavaScript community. Through Webpack loaders and plugins you can enable tree-shaking, code splitting, hot module reloading, ES6 compilation, and code linting to name a few. The problem was there were no conventional way to setup Webpack with Rails. We considered libraries like react_on_rails
, but setting it up required significant refactoring that we could not afford.
Enter Webpacker
Fortunately, the Rails community had also identified the need for Webpack. They introduced the Webpacker
gem, which was compatible for Rails 4.2+. Out of the box, Webpacker generates configurations that applies the best practices for development, staging, and production environments. This worked immediately with our deploy pipeline since it was preconfigured to follow Rails conventions. Specifically, it hooks up the build process to rake asset:precompile
. This saved us an immense amount of time fumbling with configuration files, allowing us to focus our attention on refactoring and migrating our code.
The Migration Process
Ideally, it would have been nice to have blocked off a significant chunk of time to work on the migration. In reality, we still had lots of features to ship and it’s not always easy to justify spending time on a project that doesn’t immediately pay dividends to the company’s success. Nevertheless, I believed it was important to keep our tech debt in check and progress towards a modern stack. Therefore, we took an incremental approach in our migration, loading two application bundles for a period of time. To save time, we focused on a pure refactor, settling on less than ideal changes on the first pass so that we didn’t blow up any particular pull request.
We began to move the obvious modular components first; in our codebase it was the React components. Since new code was being added regularly, it was important not to disrupt the workflow of other engineers. Even though Webpacker’s default Javascript directory was located in app/javascript
, I re-configured Webpack to treat the old path, app/assets/javascripts
, as the root via resolve.modules
property. Now the relative paths used in our files were valid, but we had to make some adjustments to handle the long file extensions.
To handle our file extensions, Webpack needs to treat the long extensions as a single extension. I appended all the different extension combinations into the resolve.extensions
array. Next, I had to make some adjustments to the existing loaders. Webpacker ships with loaders that compile Coffeescript and React via Babel, but not with the two combined nor the ability to process our special file extensions. To enable this, I added the following loader.
module.exports = {
test: /(\.module)?\.js(\.jsx)?\.coffee$/,
exclude: /node_modules/,
use: ['babel-loader', 'coffee-loader']
}
If a file’s extensions matches the regex specified in the test
attribute, it will apply the loaders specified in use
from left to right.
Migrating older Javascript assets that are executed on page load is fairly straightforward. You can specify an entire directory to be used as a base context to resolve paths using require.context
. Looping through each path in the context and resolving the file will instantiate the code.
const context = require.context(‘global’);
context.keys().forEach(context);
Once path resolution is handled properly through Webpack configs, it was then safe to migrate the files that were successfully compiled by Webpack over to the conventional app/javascript
directory. Migrating the files and renaming the extensions were left as final steps to reduce confusion for other engineers.
Finally, we reinstalled our vender dependencies using Yarn instead of Rails bundler or through vendor directories. Since dependencies are typically instantiated on the global context using Sprockets (whereas Webpack will only include dependencies you explicitly inject) you will need to go through each file to require
dependencies the module uses. Unless you have a script to inject dependencies, this can be quite time consuming. As an alternative, the ProvidePlugin
can be used to require dependencies whenever a certain variable such a moment
or React
is found. The drawback to this is that you are introducing magic to the codebase instead of being explicit which dependencies are used within any given file.
Immediate Improvements
Once migration is complete, there are some quick wins to be gained. Webpack’s production configuration will automatically minify your application bundle and apply hashes to the names of your assets to invalidate cached assets in browsers. You can take this a step further by setting up a separate chunk for your vendor and entry bundle, since the vendor file changes infrequently.
With Webpack, you can also setup linting and ES6 compilation with loaders. Webpacker already ships with ES6, so you can begin using the new syntax immediately. It should be noted that you may need to include presets
to enable React compilation. ESLint is also as simple as installing and configuring the eslint-loader
.
If you feel like your bundle is getting too big, you can setup BundleAnalyzer
to visualize the dependencies you are pulling in. This can help you identify duplicate dependencies or find dependencies you split into smaller chunks to be dynamically imported when you need them.
Webpacker also ships with webpack-dev-server
, which is a node server that compiles bundles as you develop. Not only is this a useful tool for debugging code, you can also enable hot module replacement to boost productivity. Hot module replacement will reload any front end code changes without refreshing your page, saving the developers the trouble of making round trips to the server. Caviar uses react-rails
to mount components from multiple elements, which made enabling HMR especially tricky since there is no single application container component. Instead of setting up react-hot-loader
, you can enable React hot loading using react-rails-ujs
’ mount method.
function initReactRailsContext() {
const componentRequireContext = require.context(‘containers’, true);
ReactRailsUJS.useContext(componentRequireContext);
// convert the component names passed into react_component
// from pascal-case to snake-case for the file names to be resolved
const originalGetConstructor = ReactRailsUJS.getConstructor;
ReactRailsUJS.getConstructor = function (reqctx) {
return originalGetConstructor(_.snakeCase(reqctx));
};
return componentRequireContext;
}
const componentRequireContext = initReactRailsContext();
if (module.hot) {
module.hot.accept(componentRequireContext.id, () => {
initReactRailsContext();
ReactRailsUJS.mountComponents();
});
}
Without the proper tools, it can be difficult to keep up with the constantly evolving world of frontend development. Webpack has given Caviar the ability to be more productive and effective at building our product, allowing our engineers to build the best food ordering platform. Since migrating to Webpack, we have been able to decaffeinate our code and move to ES6+ syntax. In addition, we’ve also been able to integrate libraries such as Enzyme to our testing suite, a reputable testing library that would have been difficult to install through Rails asset pipeline. Although there are other ways to accomplish the tasks mentioned through Rails or other build tools, I have found Webpack to be the one stop shop for addressing a variety of frontend needs.
With Webpack in place, we can take advantage of the myriad of tools available on NPM. Our team is now focused on reducing the application bundles we serve to our clients. As mentioned above, we’re actively looking into dynamically serving assets on the component level using react-loadable. Though not discussed in this article, Webpack is also capable of bundling CSS assets. Using tools like PurifyCSS, it is possible to eliminate unused CSS generated by frameworks like Bootstrap. All of these efforts are in service to building a performant and delightful web experience for our users.
Feeling hungry? Visit trycaviar.com and order food from the best restaurants around your area.