A Simple, Understandable Production Ready Frontend Project Setup

I have low tolerance for complexity, I also like to understand the tools I use.

That's why over the years I've developed project setups myself after seeing towers of complex "boilerplate" fell apart once I tried to do some customization, or worse, for no apparent reason other tan bitrot or setting up the project on another machine.

Before the setup I will show you here I used to use requirejs as module system and the lowest common denominator of js features for development, the release step involved just minifying the module hierarchy.

With browsers keeping themselves up to date automatically and many "advanced" js features being available on them I started new projects by using some advanced features during development that are supported by the browsers I use to develop (Firefox and Chrome) and then only for the release I transpile those features to ES5.

I see a point in the near future where if we can target Chrome-based Edge and evergreen Firefox, Safari and Chrome, I can skip the tranpilation step and ship ES6 directly.

Here's the setup I use for my projects as an attempt to show a simpler alternative to current practices, or maybe to show how all the parts fit together by making them ourselves.

Initial Setup

Let's start by creating the folder for our project:

mkdir myapp
cd myapp

Let's create the basic structure:

mkdir js css lib img

Folder usage:

js
Our Javascript code
css
CSS files
lib
Libraries we use
img
Images, you can call it assets and put fonts and other things there too

Let's create our index.html with the following content:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1, maximum-scale=1.0, user-scalable=no">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
    <title>My App</title>
    <script src="./lib/deps.js"></script>
    <script type="module" src="./js/app.js"></script>
    <link rel="stylesheet" href="css/bootstrap.css" media="all"/>
    <link rel="shortcut icon" href="img/favicon.png">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

The file already gives you some ideas of the next steps, you may have noticed that the script tag has type="module" in it, that's because (new Date()).getFullYear() >= 2019 and we are going to use Javascript Modules supported by all evergreen browsers and easily translatable to old javascript syntax with a single command.

Let's create a file at js/app.js with the following content:

function main() {
  document.getElementById('app').innerHTML = 'Hello World';
}

// call main when the page completed loading
window.addEventListener('load', main);

Now we can try that it works by starting a server that will serve the files, I use python's builtin HTTP server when I'm starting, you can use others:

If you have python 2.x installed:

python -m SimpleHTTPServer

If you have python 3.x installed:

python3 -m http.server

Now open your browser at http://localhost:8000/

Let's see how to use modules, let's create a module at js/util.js and use it from js/app.js, write the following in js/util.js.

function byId(id) {
  return document.getElementById(id);
}

function setNodeText(node, text) {
  node.innerText = text;
}

export {byId, setNodeText};

And change js/app.js to use our new util module:

import {byId, setNodeText} from './util.js';

function main() {
  let node = byId('app');
  setNodeText(node, 'Hello World!');
}

window.addEventListener('load', main);

You can read more about import and export syntax on the MDN page about import and the MDN page about export.

Now let's add bootstrap to it:

wget https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css -O css/bootstrap.css

Yes, I just downloaded it to the css folder, I don't change my deps so often, when I do I want to do it manually and check that everything works, I also want my workflow to work when I don't have an internet connection and be fully versioned at every point so I can checkout any commit and dependencies will be at the right version.

Building a Release

To build a release we need to bundle all our modules together and minify it, to bundle them we are going to use rollup from the command line, first we need to install it if we don't have it:

npm install -g rollup

Then to use it:

# remove dist if it existed
rm -rf dist
# create it again
mkdir dist
# bundle app.js and all it's dependencies into dist/bundle.js
rollup js/app.js --file dist/bundle.js --format iife -n app

Note

This post assumes you have node.js and npm installed.

You can open dist/bundle.js and confirm that all our code is there as a single vanilla js file.

Now we want to minify it, for that we will use uglify-es, first we need to install it:

npm install -g uglify-es

Then we can use it:

uglifyjs dist/bundle.js -m -o dist/bundle.min.js

You can open dist/bundle.min.js and confirm that it's our code but minified.

What if we want to use new js features?

One option is to just use the features available on our minimum common denominator browser (which if we target Edge and evergreen Firefox and Chrome it's a lot!) and our build step stays as is (es modules -> rollup -> uglify-es), the alternative if we are targeting older browsers (I hope it's just IE 11) is to transpile our new javascript into an older version like ES5, we can achieve that with babel, first we install it:

npm install babel-cli babel-preset-es2015

We put a file at the root of our project called .babelrc to tell babel what options we want to use:

{"presets": ["babel-preset-es2015"]}

And then use it:

babel dist/bundle.js --minified -o dist/bundle.babel.js

Let's modify our code a little to see the transpiling in action, change the main function in js/app.js to look like this:

function main() {
  let node = byId('app'),
    items = [1, 2, 3, 4];

  setNodeText(node, items.map(v => '#' + v));
}

We are using arrow functions in items.map, now let's do the build:

rm -rf dist
mkdir dist
rollup js/app.js --file dist/bundle.js --format iife -n app
babel dist/bundle.js --minified -o dist/bundle.babel.js
# notice that we are now minifying the output of babel
uglifyjs dist/bundle.babel.js -m -o dist/bundle.min.js

If you check dist/bundle.babel.js you will notice that the let turned into var and the arrow function turned into a anonymous function.

You will also notice that the output from babel is already sort of minified, we can let it as is or pass it through uglify, that in my experience makes the output even smaller. In my case the outputs here are 312 bytes for babel and 231 for uglify.

There's a final step in the build process, if you remember our script tag said type="module" and our build is no longer using modules, so we need to get rid of it, let's do that:

sed 's/type="module" //g' index.html > dist/index.html

Done, we just remove type="module" in index.html and put the result in dist/index.html

So, now we have a production ready build process in 5 commands, let's work with dependencies.

Let's try using react, it will be similar with vue (even simpler), but I want to show how to use react without fancy things:

wget https://unpkg.com/react@16.8.6/umd/react.production.min.js -O lib/react.js
wget https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js -O lib/react-dom.js

Yes, I just downloaded the minified versions from cdnjs.

Now let's bundle all our dependencies:

cat lib/react.js lib/react-dom.js > lib/deps.js

If you check index.html is already loading lib/deps.js before our app.js so it will just work.

Our build needs an extra step, that is: copying the css, libs and images into the build directory:

mkdir -p dist/lib dist/css
cp lib/deps.js dist/lib/
cp css/bootstrap.css dist/css/
cp -r img dist/

Let's use react without a build step (aka no JSX), change js/app.js to look like this:

import {byId} from './util.js';
import {render, div, span, button} from './dom.js';

// global state
const STATE = {counter: 0};

function counterClicked(state) {
  state.counter += 1;
  doRender();
}

function renderRoot(state) {
  return div(
    {},
    span({}, 'Counter: ', state.counter),
    button({onClick: _e => counterClicked(state)}, 'Increment')
  );
}

let rootNode;
function doRender() {
  render(renderRoot(STATE), rootNode);
}

function main() {
  rootNode = byId('app');
  doRender();
}

window.addEventListener('load', main);

Here I'm doing a simplification with doRender calls, event handling and state management to avoid introducing any other library and make the example more complex, what you use for state management is up to you.

The important part is the renderRoot, it's all vanilla js calls, I don't have to learn a new syntax or mix two different syntaxes, I don't need a compiler running, I can use everything I know about javascript for my render code.

Let's see the "magic" behind the dom.js module:

/*globals React, ReactDOM*/
const c = React.createFactory.bind(React),
  div = c('div'),
  span = c('span'),
  button = c('button'),
  render = ReactDOM.render;

export {render, div, span, button};

Yep, that's all, of course in my real dom.js module I have all the HTML and SVG tags instead of just 3.

Now you may want to automate this instead of copying and pasting the commands, I have a Makefile that does the job for me, you can put it in shell scripts or whatever works for you, here's a Makefile for this project:

setup:
        npm install rollup uglify-es babel-cli babel-preset-es2015
        mkdir -p js lib css img
        wget https://unpkg.com/react@16.8.6/umd/react.production.min.js -O lib/react.js
        wget https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js -O lib/react-dom.js
        cat lib/react.js lib/react-dom.js > lib/deps.js
        wget https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css -O css/bootstrap.css

build:
        rm -rf dist
        mkdir -p dist/lib dist/css dist/js
        sed 's/type="module" //g' index.html > dist/index.html
        rollup js/app.js --file dist/bundle.js --format iife -n app
        babel dist/bundle.js --minified -o dist/bundle.babel.js
        uglifyjs dist/bundle.babel.js -m -o dist/bundle.min.js
        cp dist/bundle.min.js dist/js/app.js
        cp lib/deps.js dist/lib/
        cp css/bootstrap.css dist/css/
        cp -r img dist/

serve:
        python3 -m http.server

The "workflow" is, run make setup only once, change and reload as needed, when you want to ship, run make build, you can do cd dist; make serve and opening http://localhost:8000/ to check that it works.

To deploy remove dist/bundle*.js and copy the dist folder to its destination.

You may think that I'm lying and telling you something I don't use, well, this is the same workflow I use to build 4 apps at instadeq.

The main differences are:

  • I have a target in the makefile called setup-dev that fetches the non minified versions of the libraries to diagnose errors easily and be able to step into library code.
  • I have some extra dependencies

That's all, well... I also use eslint and prettier, but that's in my editor config and not in the project.

But.. live reloading?

I used to have it, basically by storing the current state in the window object, reloading the script tag and restoring the state if it was in the window object at startup (it was less than 30 lines of code), now I develop in a notebook style so I create an example of the thing I want to implement and just reload, the reload is fast and the example is in the initial state I want, no need to reimplement live reloading yet.

So there it is, a simple, modern, understandable and production ready setup for your frontends.

Comentarios

Comments powered by Disqus