Universal React with Rails: Part III
Building Universal app
It’s been awhile since I wrote my previous post about Rails API. Meantime a lot of things happened in JavaScript world: ES6 was officially approved and renamed to ES2015, “isomorphic” javascript (almost) became “universal”, one of the flux frameworks (which I’ve started to use in my apps) was deprecated in favour of new one (which we will use in the next post).
There is a great quote by Addy Osmani:
First do it, then do it right, then do it better.
Our goal is to learn how to build modern javascript apps and efficiently manage their state. But before do complex things, we should learn basics. So our starting point will be super simple app, which doesn’t interact with API and have only static data inside React components. In other words we will build static javascript application with server rendering using React.
:before
Here is the list of stuff you need to be familiar with to feel comfortable while reading this post:
- Basic React components
- React-router 1.0.0 API
- ES6 (ES2015) syntax (deeper).
- Express 4.x API
- Gulp 3.x API
- Webpack API
- Jade API
Also take a look at these posts, we will use them later.
- How to avoid
../../../../this/crazy/requires
in Node.js - How to handle long-term caching of static assets with Webpack
You can return to these links anytime, if you’ll stuck somewhere down the road.
The app
We gonna build small site with 2 pages: Main and About. Nothing special, except that it will be universal.
Project structure
I always keep in mind that there can be multiple bundles in app (public site and admin area for example), so some modules will be split on two logical parts:
- reusable abstraction (it will be shared between all bundles)
- bundle-specific stuff.
This is how it usually looks like in my projects.
|-- /app <---- App container
|------ /assets <---- Shared assets (css, fonts etc.)
|------ /bundles <---- Bundles
|---------- /[bundle 1]
|---------- /[bundle 2]
|------ /errors <---- Server errors layouts
|------ /libs <---- Shared libs
|
|-- /build <---- Build configs
|-- /config <---- Application configs
|-- /public <---- Public assets
|-- /server <---- Express middlewares
|-- .dotfiles <---- Dotfiles
|-- gulpfile.babel.js <---- Gulpfile
|-- package.json <---- You know what it is
|-- server.[bundle].js <---- Bundle's Express server
|-- server.dev.js <---- Webpack dev server
|-- server.js <---- Reusable part for Express servers
Shared code base
Let’s start with the shared code, which will be the same on the server and on the client. Actually, it’s super simple React components with super simple react-router setup.
First of all we’ll define routes:
import React from 'react';
import { Route } from 'react-router';
import App from '../layouts/Layout';
import Main from '../components/Main';
import About from '../components/About';
import NotFound from '../components/NotFound';
export default (
<Route name="app" component={App}>
<Route name="main" path="/" component={Main} />
<Route name="about" path="/about" component={About} />
<Route name="not-found" path="*" component={NotFound} />
</Route>
);
And this is Layout component:
/* app/bundles/app/layouts/Layout.jsx */
import React from 'react';
import Header from '../components/Header';
export default class Layout extends React.Component {
constructor(props, context) {
super(props, context);
}
render() {
return (
<section id="layout">
<Header />
{/* In react-router 1.0.0 there is no `RouteHandler` */}
{/* It's just `this.props.children` instead */}
{this.props.children}
</section>
);
}
}
Main & About components are trivial — you can find them here.
Our main goal — make it universal. Let’s do it.
Server side rendering
When request from the browser gets to our app, it will be handled by Express — Node.js web app framework. Express server doesn’t know anything about React, it just takes request, parses it and passes it with response object to middleware function. This middleware takes care of the rest: initializes React app, renders initial html and sends it to browser (by response object from Express) with client’s javascript app on the board.
Express server:
/* server.app.js */
// This is entry point of our `app` bundle.
// And here we collect bundle specific stuff
// to pass it to reusable abstraction part.
// We can add `server.admin.js` bundle etc.
// We gonna use ES6 / ES7 syntax in our modules
// So we need to transform it to ES5 with every `require` from here on
require('babel/register')({
extensions: ['.js', '.jsx'],
stage : 0
});
// Middleware function of `app` bundle
var initter = require('./app/bundles/app/initters/server');
// Bundle settings
var config = require('./config/server.app');
// Starting express server
require('./server')(initter, config);
/* server.js */
// This is shared between all bundles
import express from 'express';
import parser from 'body-parser';
import cookies from 'cookie-parser';
import path from 'path';
export default (initter, config) => {
// Defining some globals
global.__CLIENT__ = false;
global.__SERVER__ = true;
global.__DEV__ = config.env !== 'production';
// Initializing app
const app = express();
// Parsing json bodies
app.use(parser.json());
// Parsing urlencoded bodies
app.use(parser.urlencoded({ extended: true }));
// Parsing cookies
app.use(cookies());
// Serving static files from `public` dir
app.use(express.static(path.join(__dirname, 'public')));
// Here we are!
// Transferring incoming requests to middleware function of the bundle
app.use('/', initter);
// Setting up port to listen
app.set('port', config.appPort);
// And listening it
app.listen(app.get('port'), function() {
console.log(`=> 🚀 Express ${config.bundle} ${config.env} server is running on port ${this.address().port}`);
});
}
Config for app bundle:
/* config/server.app.js */
// Importing shared config part
import config from './server';
// Defining name of the bundle
config.bundle = 'app';
// Defining port of bundle
// In production we will store this in APP_PORT environment variable
// In development it will be 3500
config.appPort = process.env.APP_PORT || 3500;
export default config;
/* config/server.js */
// This is shared between all bundles
let config = {};
// Defining environment
config.env = process.env.NODE_ENV || 'development';
// Defining port for webpack dev server (details below)
config.devPort = 3001;
export default config;
Express did all the routine job and passed request to bundle’s middleware (I call it initter), where all magic is gonna happen.
Middleware function in Express is function with following signature:
function middleware(req, res, next) {
res.send("Hello, world!")
}
This is entry point of React app, and it also will be split on abstraction / bundle-specific parts:
/* app/bundles/app/initters/server.jsx */
// Here we collect bundle specific stuff
// to pass it to shared initter with bundle `params`
import initter from 'app/libs/initters/server';
import getAsset from 'app/libs/getAsset';
import config from 'config/server.app';
import routes from '../routes/routes';
import Head from '../layouts/Head';
export default (req, res, next) => {
const { bundle } = config;
const params = {
// Name of the bundle
bundle,
// Routes
routes,
// <head> template
Head,
// Variables for Jade template
// Here we store paths to compiled assets to inject them in html
// Explore `getAsset` helper in `app/libs` folder
locals: {
jsAsset : getAsset(bundle, 'js'),
cssAsset : getAsset(bundle, 'css'),
vendorAsset: getAsset('vendor', 'js')
}
};
// Initializing app
initter(req, res, next, params);
}
/* app/libs/initters/server.jsx */
// This initter is shared between all bundles
import React from 'react';
import Router from 'react-router';
import Location from 'react-router/lib/Location';
import serialize from 'serialize-javascript';
import jade from 'jade';
export default (req, res, next, params) => {
// Storing params in variables
const { routes, bundle, locals, Head } = params;
// Creating location object for the server router
const location = new Location(req.path, req.query);
// Running the router
Router.run(routes, location, (error, initialState, transition) => {
// If something went wrong, responding with 500
if (error) return res.status(500).send(error);
try {
// Rendering <head> tag
// Using `renderToStaticMarkup` here,
// because we don't need React ids on these nodes
locals.head = React.renderToStaticMarkup(
<Head cssAsset={locals.cssAsset} />
);
// Rendering app
locals.body = React.renderToString(
<Router location={location} {...initialState} />
);
// Storing webpack chunks in variable
// to expose it as global var in html for production bundles
// It's related to long-term caching of assets (details below)
const chunks = __DEV__ ? {} : require('public/assets/chunk-manifest.json');
locals.chunks = serialize(chunks);
// Defining path to jade layout
const layout = `${process.cwd()}/app/bundles/${bundle}/layouts/Layout.jade`;
// Compiling initial html
const html = jade.compileFile(layout, { pretty: false })(locals);
// 😽💨
res.send(html);
} catch (err) {
// If something went wrong, responding with 500
res.status(500).send(err.stack);
}
});
}
Jade template is pretty straightforward:
doctype html
html
// Injecting `locals.head`
!= head
body
// Injecting `locals.body`
#app!= body
// Injecting `locals.chunks` global for webpack
script.
window.__CHUNKS__ = !{chunks};
// Injecting js assets
script(src="#{vendorAsset}")
script(src="#{jsAsset}")
At this point initial html was rendered and sent to browser with client’s javascript app.
Client entry point
Client’s initter is much easier.
/* app/bundles/app/initters/client.jsx */
// Here we collect bundle specific stuff, same as for server
// Babel polyfill, required for some of the ES6/ES7 features
import polyfill from 'babel/polyfill';
import initter from 'app/libs/initters/client';
import routes from '../routes/routes';
const params = { routes };
export default initter(params);
/* app/libs/initters/client.jsx */
// This initter is shared between all bundles
import React from 'react';
import Router from 'react-router';
import BrowserHistory from 'react-router/lib/BrowserHistory';
export default (params) => {
const { routes } = params;
// Creating history object for the client router
const history = new BrowserHistory();
// Creating app container
const AppContainer = (
<Router history={history} children={routes} />
);
// Selecting DOM container for app
const appDOMNode = document.getElementById('app');
// Flushing application to DOM
React.render(AppContainer, appDOMNode);
}
Next thing we need to learn is how to build the client bundles and how to setup production / development environments with gulp & webpack.
Gulp, Webpack & hot reloading
Building process is the biggest pain in the ass in javascript world, so get ready. We’re gonna setup two different environments: development & production. In the end there will be available 3 console commands:
-
npm start or gulp start:dev
Runs development web server with hot reloading.
-
npm run prod or gulp start:prod
Runs local server with production ready bundles to check how it works.
-
npm run build or just gulp
Compiles production builds.
Key tools we’re gonna use are Gulp and Webpack. Here is the gulpfile:
/* gulpfile.babel.js */
import gulp from 'gulp';
import webpack from 'webpack';
import eslint from 'eslint/lib/cli';
import run from 'run-sequence';
import gutil from 'gulp-util';
import { exec } from 'child_process';
import del from 'del';
import gulpConfig from './build/gulp.config';
// Task name to compile production assets
const prodBuildTask = 'build';
// Task name to start dev server
const startDevTask = 'start:dev';
// Task name to start local server with production assets
const startProdTask = 'start:prod';
// Defining environment
const isDevBuild = process.argv.indexOf(startDevTask) !== -1;
// Defining default task
const startTask = isDevBuild ? startDevTask : prodBuildTask;
// Importing gulp config (it's function, returns an object)
const config = gulpConfig(isDevBuild);
/* Run tasks */
// Using `run-sequence` plugin to group [async tasks] in [sync groups of async tasks]
// 1 group: Cleaning public folder and linting scripts
// 2 group: Compiling assets and coping static stuff (fonts, favicon etc.) to public folder
// 3 group: Starting local Express servers
gulp.task('default', [startTask]);
gulp.task(prodBuildTask, done => {
run(['clean', 'lint'], ['bundle', 'copy'], done);
});
gulp.task(startDevTask, done => {
run(['clean', 'lint'], ['bundle', 'copy'], ['server'], done);
});
gulp.task(startProdTask, done => {
run(['clean', 'lint'], ['bundle', 'copy'], ['server'], done);
});
/* Node servers starter */
// Helper to start Express servers from gulp
const startServer = (serverPath, done) => {
// Defining production environment variable
const prodFlag = !isDevBuild ? 'NODE_ENV=production' : '';
// Starting the server
const server = exec(`NODE_PATH=. ${prodFlag} node ${serverPath}`);
// Handling messages from server
server.stdout.on('data', data => {
// Checking if it's a message from webpack dev server
// that initial compile is finished
if (done && data === 'Webpack: Done!') {
// Notifying gulp that assets are compiled, this task is done
done();
} else {
// Just printing output from server to console
gutil.log(data.trim());
}
});
// If there is an error - printing output to console and doing the BEEP
server.stderr.on('data', data => {
gutil.log(gutil.colors.red(data.trim()));
gutil.beep();
});
};
/* Build bundles */
gulp.task('bundle', done => {
if (isDevBuild) {
// Starting webpack dev server
startServer('server.dev.js', done);
} else {
// Just compiling assets
webpack(config.webpack).run(done);
}
});
/* Start express servers */
gulp.task('server', done => {
const servers = config.server.paths;
let queue = servers.length;
servers.forEach(server => {
startServer(server);
if (--queue === 0) done();
});
});
/* Copy files to `public` */
gulp.task('copy', done => {
const files = config.copy.files;
let queue = files.length;
files.forEach(file => {
const from = config.copy.from + file[0];
const to = config.copy.to + (file[1] || file[0]);
exec(`cp -R ${from} ${to}`, err => {
if (err) {
gutil.log(gutil.colors.red(err));
gutil.beep();
}
if (--queue === 0) done();
});
});
});
/* Lint scripts */
gulp.task('lint', done => {
eslint.execute('--ext .js,.jsx .');
done();
});
/* Clean up public before build */
gulp.task('clean', done => {
del(['./public/**/*'], done);
});
/* build/gulp.config.js */
import webpackDevConfig from './webpack.config.dev';
import webpackProdConfig from './webpack.config.prod';
const _app = './app';
const _public = './public';
const _assets = `${_app}/assets`;
export default (isDevBuild) => {
return {
webpack: isDevBuild ? webpackDevConfig : webpackProdConfig,
server: {
paths: ['./server.app.js']
},
copy: {
from : _assets,
files: [
[ '/tinies/favicon.ico', '/' ],
[ '/tinies/robots.txt', '/' ]
],
to: _public
}
};
}
For each environment there will be its own setup.
Production
First let’s define requirements for production build:
- Asset name should contain identifier for long-term caching purposes. We should include hash of the asset file in URL, thus when we’ll update the app, visitor’s browser will download updated asset, rather than using old one from cache. More detailed explanation on subject — in this medium post.
- Our app and vendor’s modules should be split in separated chunks. Every time we update the app, whole client’s asset is changed. So visitor have to re-download it, incl. vendor’s modules, that hasn’t been changed. If we will split client’s bundle in two chunks — app and vendor’s stuff — users won’t need to re-download vendor’s modules, because it’s in different chunk and vendor’s chunk is cached by the browser.
- CSS asset should be extracted out of javascript bundle and placed in head tag above initial html. If we won’t do this, visitors will see un-styled content for a moment, because initial html from server is placed above the javascript assets. You will notice this in development mode.
- Assets files should be minified & gzipped. So we will be serving smaller-sized assets to client.
For webpack compiler we have file with 2 end points:
/* build/bundles/app.js */
import scripts from '../../app/bundles/app/initters/client.jsx';
import styles from '../../app/bundles/app/layouts/Layout.styl';
Here is webpack production config:
/* build/webpack.config.prod.js */
import webpack from 'webpack';
import Extract from 'extract-text-webpack-plugin';
import Gzip from 'compression-webpack-plugin';
import Manifest from 'webpack-manifest-plugin';
import ChunkManifest from 'chunk-manifest-webpack-plugin';
import path from 'path';
export default {
// Defining entry point
entry: {
// Bundle's entry points
app: './build/bundles/app.js',
// List of vendor's modules
// To extract them to separate chunk
vendor: ['react', 'react-router']
},
// Defining output params
output: {
path : './public/assets',
filename : '[name]-[chunkhash].js',
chunkFilename: '[name]-[chunkhash].js'
},
// Defining resolver params
// On the server we use NODE_PATH=.
// On the client we use resolve.alias
resolve: {
alias: {
'app' : path.join(process.cwd(), 'app'),
'config': path.join(process.cwd(), 'config'),
'public': path.join(process.cwd(), 'public')
},
extensions: ['', '.js', '.jsx']
},
devtool : false,
debug : false,
progress: true,
node : {
fs: 'empty'
},
plugins: [
// Extracting css
new Extract('[name]-[chunkhash].css'),
// Extracting vendor libs to separate chunk
new webpack.optimize.CommonsChunkPlugin({
name : 'vendor',
chunks : ['app'],
filename : 'vendor-[chunkhash].js',
minChunks: Infinity
}),
// Defining some globals
new webpack.DefinePlugin({
__CLIENT__ : true,
__SERVER__ : false,
__DEV__ : false,
__DEVTOOLS__ : false,
'process.env': {
'NODE_ENV': JSON.stringify('production')
}
}),
// Avoiding modules duplication
new webpack.optimize.DedupePlugin(),
// Extracting chunks filenames to json file
// Required to resolve assets filenames with hashes on server
// See `getAsset` helper in `app/libs/getAsset.js`
new Manifest(),
// Extracting chunks internal ids to json file
// Required to keep vendor's chunkhash unchanged
new ChunkManifest({
filename : 'chunk-manifest.json',
manifestVariable: '__CHUNKS__'
}),
// Also required to keep vendor's chunkhash unchanged
new webpack.optimize.OccurenceOrderPlugin(),
// Minifying js assets
new webpack.optimize.UglifyJsPlugin({
compress: {
'warnings' : false,
'drop_debugger': true,
'drop_console' : true,
'pure_funcs' : ['console.log']
}
}),
// Gzipping assets
new Gzip({
asset : '{file}.gz',
algorithm: 'gzip',
regExp : /\.js$|\.css$/
})
],
// Defining loaders
module: {
noParse: /\.min\.js$/,
loaders: [
{ test : /\.jsx?$/, loader: 'babel?stage=0', exclude: /node_modules/ },
{
test : /\.styl$/,
loader: Extract.extract('style', 'css!autoprefixer?{browsers:["last 2 version"], cascade:false}!stylus')
},
{
test : /\.css$/,
loader: Extract.extract('style', 'css!autoprefixer?{browsers:["last 2 version"], cascade:false}')
}
]
}
}
npm run prod and we have compiled assets in _public/assets_
folder ready to deploy.
Development
Requirements for development build:
- Local dev server should be hot reloadable. Webpage should automatically reflect all changes in js / css files on file save.
We don’t care about long-term caching, css extraction and assets minification in dev environment.
Hot reloadable dev server
The main idea of hot reloadable dev server, is that webpack is starting his own express server on some port (devPort in our config) and serving all assets from there with polling script on top:
<script src="[http://lvh.me:3001/assets/app.js](http://lvh.me:3001/assets/app.js)"></script>
Under the hood it observes the assets files, if it’s changed, it recompiles assets and sends changes to client. So you don’t need to reload page on every change. Here is how to set this up for React application:
/* build/webpack.config.dev.js */
import webpack from 'webpack';
import path from 'path';
import appConfig from '../config/server.app';
export default {
// Defining entry points for webpack dev server
entry: {
app: [
`webpack-dev-server/client?http://lvh.me:${appConfig.devPort}`,
'webpack/hot/only-dev-server',
'./build/bundles/app.js'
],
vendor: ['react', 'react-router']
},
// Defining output for webpack dev server
output: {
path : path.join(process.cwd(), 'public', 'assets'),
filename : '[name].js',
publicPath: `http://lvh.me:${appConfig.devPort}/assets`
},
resolve: {
alias: {
'app' : path.join(process.cwd(), 'app'),
'config': path.join(process.cwd(), 'config'),
'public': path.join(process.cwd(), 'public')
},
extensions: ['', '.js', '.jsx']
},
// Source maps are slow, eval is fast
devtool : '#eval',
debug : true,
progress: true,
node : {
fs: 'empty'
},
plugins: [
// Enabling dev server
new webpack.HotModuleReplacementPlugin(),
// Don't update if there was error
new webpack.NoErrorsPlugin(),
new webpack.optimize.CommonsChunkPlugin({
name : 'vendor',
chunks : ['app'],
filename : 'vendor.js',
minChunks: Infinity
}),
new webpack.DefinePlugin({
__CLIENT__ : true,
__SERVER__ : false,
__DEV__ : true,
__DEVTOOLS__ : true,
'process.env': {
'NODE_ENV': JSON.stringify('development')
}
})
],
module: {
noParse: /\.min\.js$/,
loaders: [
// Notice `react-hot` loader, required for React components hot reloading
{ test : /\.jsx?$/, loaders: ['react-hot', 'babel?stage=0'], exclude: /node_modules/ },
{
test : /\.styl$/,
loader: 'style!css!autoprefixer?{browsers:["last 2 version"], cascade:false}!stylus'
},
{
test : /\.css$/,
loader: 'style!css!autoprefixer?{browsers:["last 2 version"], cascade:false}'
}
]
}
}
And here is webpack development server, which we start by npm start (before express server of our app):
/* server.dev.js */
require('babel/register')({
extensions: ['.js', '.jsx'],
stage : 0
});
var webpack = require('webpack');
var WebpackDevServer = require('webpack-dev-server');
var webpackConfig = require('./build/webpack.config.dev');
var serverConfig = require('./config/server');
var initialCompile = true;
var compiler = webpack(webpackConfig);
var devServer = new WebpackDevServer(compiler, {
contentBase : 'http://lvh.me:' + serverConfig.devPort,
publicPath : webpackConfig.output.publicPath,
hot : true,
inline : true,
historyApiFallback: true,
quiet : false,
noInfo : false,
lazy : false,
stats : {
colors : true,
hash : false,
version : false,
chunks : false,
children: false
}
});
devServer.listen(serverConfig.devPort, 'localhost', function(err) {
if (err) console.error(err);
console.log('=> 🔥 Webpack development server is running on port %s', serverConfig.devPort);
});
// This part will notify gulp that assets are compiled
// Gulp will start application Express server then
// (and it'll pick up assets filenames from json manifest to inject them in html)
compiler.plugin('done', function() {
if (initialCompile) {
initialCompile = false;
process.stdout.write('Webpack: Done!');
}
});
Now we can run npm start and open** http://lvh.me** in browser. Every time we make a changes in js or css files, page will be automatically updated by webpack dev server.
Conclusion
We’ve learned how to build simple universal javascript app using React (Dude! It’s not simple at all!). But this is only beginning. Real-world apps is much more complex, so here is the questions we have to answer next:
- How to manage state in our app?
- How to prefetch data on the server, so we can render html with data from API and serve it to client?
- How to secure it?
- How to deploy it to production server?
Stay tuned to get the answers!
Part I: Planning the application
Part III: Building Universal app