Integrating Webpack into a Legacy Rails Codebase

Introduction
Recently, as part of a development effort at my company, we needed to figure out a migration path from some legacy Rails applications (and legacy Sprockets implementations) and extremely legacy Javascript code into a sleeker ES2015 codebase with React/Redux and some other niceties. A lot of the gems that provide React/Redux integration into a Rails codebase were not compatible with the version of Rails we are running, plus we wanted cleaner integration with ES2015, testing via mocha/chai/enzyme and some other features.
We were also concerned about anything that could potentially introduce new infrastructure dependencies, so we needed to figure out a solution that provided the best possible developer experience and productivity improvements without sacrificing too much fluidity from either Rails or React/Redux.
The Inspiration
The overall inspiration for this approach came from Phoenix, something that I’ve written quite a lot about at this point. Instead of rolling their own asset pipeline, the Phoenix team decided to stand on the shoulders of giants and use an existing solution, brunch.io, instead of creating a special Elixir-based pipeline.
From this idea, we asked ourselves if, instead of introducing a new gem that we may have to wait for lag time behind new releases and gem updates, we could do the same: take an existing Node.js-based asset compilation pipeline and integrate it into our Rails development workflow by standing it in the middle of our Rails application. The end result was something that we’re really happy with, even if it’s still not perfect. We have the latest version of Webpack integrated with our legacy Rails/Sprockets asset pipeline and have lost very little in the translation (although you do need a few extra shells open to have it do things for you automatically!).
Why Webpack?
Anyone familiar with Phoenix may be asking why, if inspired by Phoenix, we decided not to use brunch.io. Part of it was that some of our R&D efforts into single-page applications led us to choose React/Redux as our technology of choice, and Webpack seemed to have a lot of traction there. In addition, a lot of tutorials out there were built around using Webpack, so it was comfortable to us and we decided to stick with it. That being said, there are some choices that we did not choose (Ember.js, Angular/Angular2, and Brunch.io to name a few) and the choice was a combination of effort to learn, ease of use/reuse, and general community support.
To note, I’m actually a big fan of Brunch.io and Ember.js, so this is absolutely not a condemnation of either (nor of Angular, but I have significantly less experience with that so it’s hard to say much of anything about it). We chose React/Redux based on the general level of knowledge of our team and the specific needs of upcoming projects.
How Did We Integrate?
This is where things get a little more fun! We started off by deciding that, since we had legacy javascript and coffeescript code hanging around, we didn’t want it too closely tied to our Rails application and application structure. We started off by picking a new directory at the project’s root (in our case, we chose web). Then, we created a package.json file and webpack.config.js file at the Rails project root.
From there, we installed the packages we wanted to use specifically for our application using npm install — save-dev [packages]. We installed webpack, babel, and babel presets for react and es2015 at a minimum to start, but we also integrated a few other packages (mocha/chai/enzyme/chai-enzyme for testing purposes, react/redux/react-redux for components and state management, immutable for code sanity, axios and redux-promise for ajax requests and management into Redux, material-ui for some sweet Material design components, etc).
Next, we set up our standard project structure. We’re keeping to a pretty standard React/Redux structure overall for sake of simplicity:
[rails root]
|--- [rails directories]
|--- web/
|--- app/
|--- actions/
|--- types.js <-- Stores our action types
|--- components/
|--- sample.js <-- A sample component
|--- lib/
|--- store.js <-- Sets up the React/Redux store
|--- reducers/
|--- index.js <-- The combined reducers
|--- app.js <-- Our app entry point
|--- test/
|--- actions/
|--- components/
|--- sample_test.js <-- Our tests for the sample component
|--- lib/
|--- reducers/
|--- test_helper.js <-- Our setup file for our tests
|--- .babelrc
|--- .eslintignore
|--- .eslintrc.json
app.js is our glue portion of our application and handles rendering into the application itself. Similarly, test_helper.js is the glue portion of our tests for our React components. Let’s take a look at app.js to understand a little bit about how it would integrate with our Rails application:
Diving Into app.js
Instead of pulling this apart line-by-line, I’ve added some comments to each of the major sections of code explaining what we’re doing in each piece. We’re doing some material-ui-specific code as well as React/Redux-specific code, so you may not need all of this for whatever application you’re building.
// Required imports from NPM packages
import $ from 'jquery';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
// Local imports
import ThemeWrapper from './components/theme_wrapper';
import SampleComponent from './components/sample';
import { store } from './lib/store';
// Needed for material-ui components
import injectTapEventPlugin from 'react-tap-event-plugin';
injectTapEventPlugin();
// Helper function to render a React component to a specific
// element on the page with a set of starting properties.
const render = (ComponentClass, renderTarget, props) => {
// Make sure the target element exists before attempting to
// render to it.
if ($(renderTarget)) {
ReactDOM.render(
<Provider store={store}>
<ThemeWrapper>
<ComponentClass {...props} />
</ThemeWrapper>
</Provider>,
document.querySelector(renderTarget)
);
}
};
$(() => {
// Sets up outside integration into the app
// This allows non-React components to integrate
// with React components via the Redux store to
// minimize impure interactions.
if (window) {
window.dispatch = store.dispatch;
}// Render components conditionally
// Right now these are hard-coded, but we could
// theoretically expose this into the window
// object too to allow us to render/mount components
// from inside of our Rails templates.
render(SampleComponent, '#sample-component');
});
Now that we’ve figured that out, let’s take it a step further and dive into our webpack configuration file, webpack.config.js.
Diving Into webpack.config.js
To really understand how this is going to hook into the standard Rails asset pipeline, we need to look at the webpack configuration file to see how everything hooks up. We start off with a few requires at the top:
var webpack = require('webpack');
var CopyWebpackPlugin = require('copy-webpack-plugin');
var path = require('path');Webpack gives us the ability to provide plugins for Webpack. The CopyWebpackPlugin will copy any image assets (you could expand this to include other directories as well) from our pseudo-SPA’s assets/images directory into Rails’ assets/images directory.
We set up our plugins as mentioned above here, in the plugins key of our module.exports:
module.exports = {
plugins: [
new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
}),This provides jquery to all of our ES2015 files.
new CopyWebpackPlugin([
{ context: './web/app/assets/images', from: '**/*', to: './app/assets/images' },
]),
],
As mentioned before, this will set up webpack to copy image assets from the Webpack app directory into our Rails asset pipeline.
entry: {
app: './web/app/app.js',
},Next, we tell webpack that one of the entry points for our app (which we’ve named app) points to the file we discussed previously.
output: {
filename: './javascripts/[name].bundle.js',
path: './app/assets/',
publicPath: '/assets/',
},In the above, we will build a .bundle.js file for every entry point. We will make our main assets directory the same as our Rails assets directory and point our file creation specifically to the javascripts directory/
module: {
loaders: [
{
test: /\.css$/,
loaders: [
'style',
'css',
],
},
{
test: /\.js?$/,
exclude: /(node_modules)/,
loader: 'babel',
query: {
presets: [
'react',
'es2015',
],
},
},
{
test: /\.scss$/,
loaders: [
'style',
'css',
'sass?includePaths[]=./node_modules/foundation-sites/scss/' +
'&includePaths[]=./node_modules/motion-ui/src',
],
},
{ test: /.html$/, loader: 'file-loader' },
{
test: /\.(jpg|png|woff|woff2|eot|ttf|svg)$/,
loader: 'url-loader?limit=100000',
},
],
},Next, we have a bunch of add-ons for loading HTML files, images, scss files, js files, and css files into Webpack. We also tell Webpack to use Babel with the react/es2015 presets to make sure everything works.
externals: {
'react/addons': true,
'react/lib/ExecutionEnvironment': true,
'react/lib/ReactContext': true,
},
};Finally, we set up a couple of Webpack externals for our tests. Next, let’s look at a few of the dotfiles living in our web directory to see what other setups might be required.
Diving Into The Dotfiles
.babelrc
.babelrc is just a small file containing the following:
{
"presets": ["es2015", "react"]
}This just tells our app that everything under these directories needs to be included by babel and the es2015 and react transpilers. (This will make JSX work on your test_helper file.)
.eslintignore
The ES linter configuration package we’re using, Airbnb, REALLY doesn’t like chai’s describe(‘description’, () => {}) syntax, so I have just told it to ignore linting in the whole test directory. This one is 100% optional and up to your own choice.
test/**/*.js
.eslintrc.json
This just tells eslint that we’re using the airbnb linting configuration. Feel free to tweak this as necessary for your own needs/wants!
{
"extends": "airbnb"
}Diving Into package.json
In package.json (I won’t list the whole thing here because it’s pretty big, but the scripts portion of it is worth a look), I set up some commands that developers can run to improve quality of life.
"scripts": {
"start": "webpack --watch",
"build": "webpack",
"watch": "webpack --watch",
"test": "mocha --compilers js:babel-core/register --require ./web/test/test_helper.js --recursive ./web/test -R nyan",
"test:watch": "npm run test -- --watch"
},start sets up a webpack watcher that builds whenever changes are made (this is the same as the watch command; this just allows for developers to run npm start instead of npm run watch).
build just does a one-time webpack build.
test runs all of the tests for our ES side of our app. As a bonus, it outputs in Nyan Cat format!
test:watch sets up a guard-like test watcher that runs tests whenever they’re modified. The only gotcha to this is that test:watch can only watch files it already knows about, so if you write a new file with new tests in it, you have to restart test:watch for it to start watching them!
Modifying Application.js
We also had to modify [root]/app/assets/javascripts/application.js to include our bundle file. Right now, we only have app.bundle.js (the one app entry listing), so in application.js we just need to add:
//= require app.bundle
And voila! We now have our React/Redux components ready to be integrated into our legacy Rails application without worrying about additional infrastructure of gem dependencies!
End Result
We now have a way of introducing more modern Javascript code and SASS styling into our application in a way that doesn’t fundamentally alter too much of our deployment process for our legacy applications. If you’re staring down needing to migrate your application from an old jquery soup/coffeescript nightmare into something cleaner and more modern, this is a good way to introduce progressive modifications without needing to change anything in one day to a single page app or rewrite your entire application!
That being said, this system isn’t PERFECT, yet. We’re happy with it, overall, especially given that exposing the dispatch function to the Window namespace allows us to interact with our React components via Redux, and our React components can interact with the rest of the application via jquery if necessary. We still get our tests, ES2015, React/Redux, and whatever other NPM packages are out there (that work with webpack, anyways). We also get the ability to potentially introduce something like Typescript or Flow into our application without worrying about ANOTHER gem or system dependency (especially one that may not work at all with other gems for getting modern JS into your ecosystem).
Downsides to this approach so far are that you’re still dealing with giant node_modules directories (which aren’t checked into git, at least) and the install process to get started takes a little while. It also requires node and NPM versions that are pretty recent (I used node.js v5.6.x and npm v3.6.x) As I mentioned previously, it’s not perfect, but it’s pretty good for what we need and I hope that this information can help out anyone else in this same pickle.
If you ended up following this, please reach out to me on twitter (@diamondgfx)! I’d love to hear about your experiences with this!