15 minute read

Static single page applications

In a recent project we decided to use Firebase and React to quickly build a product without having to manage an infrastructure or a server. Single page applications (SPA) are versatile enough to work with any static hosting system, all you need is to deploy your index.html and the static files generated by webpack and you’re done.

While this approach works nicely for developers, allowing them to iterate quickly, the experience for the user might not be optimal. SPA are often big in file size and require some time to parse and render the desired page leaving the user staring at a blank page.

Performances

There are many steps in optimizing an application, like correctly configuring the cache, code splitting to deliver only the smallest amount of code needed, minification, image optimization and so on… If you’re using react boilerplate or create-react-app many of the optimization techniques are already set up for you, but what’s missing is pre-rendering.

When using a server you can rely on server-side rendering to generate the initial HTML of your app. The idea is simple, once a user hits your server, e.g. for the /help page, the server can run React in a node.js process and generate the same HTML that React would generate when running client side. The browser receiving the page can render it immediately without having to wait for React and all the other dependencies to load, generate the virtual DOM and apply it to the page.

When you’re not using a server, you loose the ability to render on a user request, but you can still generate your pages before deploying to your hosting provider.

Pre-rendering

Some tools allow you to pre-render static pages

  • static-site-generator-webpack-plugin is a webpack plugin that generates static pages from your universal application. It runs purely on node.js so you might have to provide a global window or document if your app requires so.
  • react-snapshot is the recommended tool from create-react-app and runs your pages inside a fake browser, jsdom
  • prerender-spa-plugin webpack plugin that uses Phantom.JS.
  • chrome-render render any web page inside chrome headless browser, only works in node 7+.

Generating your pages in a fake browser environment might work for you, but you might find that the setup necessary to get the desired result is complex and error prone. An alternative approach is to use an actual browser to render the page and output HTML.

As of Chrome 59 you can run an actual Chrome browser in a headless environment, without a visible UI.

The following code shows how to pre-render pages with Chrome headless:

// Chrome launcher allows you to run an instance of chrome
const chromeLauncher = require('chrome-launcher');
// Chrome interface gives you tools to interact with chrome
const chromeInterface = require('chrome-remote-interface');
const fs = require('fs');

// Page to be statically pre-rendered
const SOURCE_PAGE = 'http://localhost:3000/help';
// Output page
const OUTPUT_PAGE = './dist/help.html';

// This is the bulk of the code
// Launch Chrome headless and connect the debugging interface
// Wait for the page load and inject a script to return the generated DOM
chain(
  () => launchChrome(SOURCE_PAGE),
  connectDebuggingInterface
)
.then(([chrome, client]) => {
  const { Page, Runtime } = client;
  return chain(
    () => Page.enable(),
    () => Page.loadEventFired(),
    () => Runtime.evaluate(expression(extractOuterHTML)),
    (result) => extractHtml(result),
    (html) => savePage(OUTPUT_PAGE, html),
    (path) => console.log(`Page ${SOURCE_PAGE} saved at ${OUTPUT_PAGE}`),

    () => client.close(),
    () => chrome.kill()
  );
});

function launchChrome(url) {
  return chromeLauncher.launch({
    startingUrl: url,
    chromeFlags: [
      '--disable-gpu',
      '--headless',
    ]
  });
}

function connectDebuggingInterface(chrome) {
  return chromeInterface({ port: chrome.port });
}

function extractHtml(evaluatedCode) {
  return `<!doctype html>${evaluatedCode.result.value}`;
}

function expression(code) {
  return { expression: `(${code})()` };
}

function extractOuterHTML() {
  return document.documentElement.outerHTML;
}

function savePage(destination, html) {
  return new Promise((resolve, reject) => {
    fs.writeFile(destination, html, (err) => {
      if (err) reject(err);
      else resolve();
    });
  });
}

// This is just a utility to run promises sequentially
// Resolves with an array of the input promises resolutions
function chain(...actions) {
  const results = [];
  return actions.reduce((promise, fn, index) => {
    return promise.then((previous) => {
      if (index > 0) {
        results[index - 1] = previous;
      }
      return fn(previous);
    });
  }, Promise.resolve())
  .then((last) => {
    results.push(last);
    return results;
  });
}

The code above is available as an npm module, prerender-chrome-headless.

You can use it as

const prerender = require('prerender-chrome-headless');

// Page to be statically pre-rendered
const SOURCE_PAGE = 'http://localhost:3000/help';

render(SOURCE_PAGE).then((html) => {
  fs.writeFileSync(destination, html);
});

Continuous integration

The script above works on a machine with Chrome installed. Most CI environments allows you to install external packages.

Here is what you have to do to get Chrome headless working on Travis

# The default at the time of writing this blog post is Ubuntu `precise`
# Chrome addon is only available on trusty+ or OSX
dist: trusty

# This will install Chrome stable (which already supports headless)
addons:
  chrome: stable

before_install:
  # Needed by `chrome-launcher`
  - export LIGHTHOUSE_CHROMIUM_PATH=google-chrome-stable

install:
  # Run the script created above
  - node generate_static_page.js

Leave a Comment

Your email address will not be published. Required fields are marked *

Loading...