Steal like a dev: create-react-app

If you're a React developer you've most likely used create-react-app once or twice to bootstrap a starter project. Yet this approach hides all the underneath configurations, which is something that I was getting increasingly curious about.

Article title and React logo
GitHub logo GitHub repo

So, let's copy-paste CRA's generated application code but write all the React + Webpack + Babel 7 + Service Worker configs from scratch, while trying to mimic the end result as closely as possible.

Copy-pasting these configs together with the source-code of CRA will not work out of the box. There is one very tiny modification I had to make in order to have everything working as it should. I detail it in the Webpack configs section.

# Benchmarks

First phase of this project was to have a really good look at what CRA is doing and how, since we want to 'steal' it's behaviour as much as we can. So here's the list I put up before writing any code:

With this list in mind let's start coding!

# Babel config

I'll start with this one since it's the smallest and most straight-forward of them all.

// .babelrc
{
  "presets": [
    "@babel/preset-react",
    [
      "@babel/preset-env",
      {
        "targets": {
          "browsers": "last 2 versions"
        },
        "modules": false
      }
    ]
  ]
}

We use the @babel/preset-react to transform JSX code to valid JavaScript syntax. Then, we use the @babel/preset-env to transpile our code to a format understood by last 2 versions of all browsers. You might also notice the "modules": false line. That's to tell Babel we don't want to transform different module types (like UMD or ESModules), so it should leave them as is.

# Webpack configs

With Babel done, we arrive at the mighty Webpack 👑

It's clear from the beginning that we want to have 2 different configs: one for development and one for production. So let's create a webpack.config.js file in the root of our project, and then 2 more files: webpack.development.js and webpack.production.js in a new folder which we'll call build-utils.

Webpack config project structure
Webpack config project structure

webpack.config.js contains all the common config rules between both scenarious. It will merge itself with either the development or production config depending on the type of build we create, thus obtaining the final configuration.

But enough talk, let's look at the code starting with the main config file:

// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const webpackMerge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ResourcesManifestPlugin = require("resources-manifest-webpack-plugin");

function modeConfig(env)  {
  return require(`${path.resolve(__dirname, 'build-utils')}/webpack.${env}`)(env);
}

module.exports = ({mode} = {mode: 'production'}) => {
  return webpackMerge(
    {
      mode,
      entry: './src/index.js',
      optimization: {
        splitChunks: { chunks: 'all' }
      },
      module: {
        rules: [
          {
            test: /\.jsx?$/,
            use: ['babel-loader']
          },
          {
            test: /\.(png|jpe?g|gif|svg|webp)$/,
            use: [
              {
                loader: 'file-loader',
                options: {
                  name: 'static/media/[name].[contenthash].[ext]'
                }
              }
            ]
          }
        ]
      },
      devServer: {
        historyApiFallback: true,
        contentBase: path.join(__dirname, 'public')
      },
      plugins: [
        new HtmlWebpackPlugin({
          template: 'public/index.html',
          inject: 'body',
          minify: {
            html5: true,
            removeComments: true,
            collapseWhitespace: true
          },
          templateParameters: {
            PUBLIC_URL: ''
          }
        }),
        new ResourcesManifestPlugin({
          TO_CACHE: /.+\.(js|css|png|jpe?g|gif|svg|webp)$/
        }, 'public/service-worker.js'),
        new webpack.ProgressPlugin()
      ]
    },
    modeConfig(mode)
  );
};

Let's disect the most important parts of the code above, starting with the modeConfig function on line 8. Thats what we're using to dynamically ask for either the development or production config based on the env parameter. Further down the file (lines 29-34) is the file-loader config telling Webpack to create those assets into the static/media folder.

The HTMLWebpackPlugin (lines 44-55) handles everything regarding the final index.html, from compiling the template CRA generates, to inserting the CSS & JS files and even minifiying it to squeeze as much performance as possible.

I also initialized the PUBLIC_URL template parameter (line 53) to an empty string, because I'll be serving this app from the root of the server.

And here the only change from the original application code: I modified the syntax for template parameters inside index.html so that it will be compatible with HTMLWebpackPlugin.

CRA template parameters syntax
Modified template parameters syntax
Original (left) and modified (right) template parameters syntax inside index.html

Moving on to the webpack.development.js config, where just 3 things differ:

// webpack.development.js
const path = require('path');

module.exports = () => ({
  devtool: 'source-map',
  output: {
    path: path.resolve(__dirname, '../build'),
    publicPath: '/',
    filename: 'static/js/[name].[hash].js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
});
Wait, why are you using [hash] instead of a [contenthash] for filenames?

Good question 😅! The thing is that running it in development mode + [contenthash] throws an error. So I had to settle for the simpler [hash] in the development build.

And finally the webpack.production.js config:

// webpack.production.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = () => ({
  devtool: 'none',
  output: {
    path: path.resolve(__dirname, '../build'),
    publicPath: '/',
    filename: 'static/js/[name].[contenthash].js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader']
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'static/css/[name].[contenthash].css'
    }),
    new CopyWebpackPlugin([
      {
        from: 'public/',
        to: '.',
        ignore: ['service-worker.js']
      }
    ]),
    new OptimizeCSSAssetsPlugin()
  ]
});

This one is similar to the development one, except we do things the "opposite" way:

And voilà, that's all for the Webpack configs!

# Offline-first service worker

CRA creates a fully functioning, offline-first service worker which we can enable or disable from index.js. It uses Workbox behind the scenes, but to keep the challenge fair we'll have to write it ourselves!

The tricky part is knowing which assets to cache, since they change hashes every time their content gets updated...

Thankfully I've stumbled upon this problem before so I've already got a custom Webpack plugin to help with this: resources-manifest-webpack-plugin.

npm install resources-manifest-webpack-plugin --save-dev

It looks at all the files passing through the build process and generates a resource-manifest.json with the names of the files we want to cache. Configuring it is as easy as writing a regular expression. You might have seen it in action in webpack.config.js (lines 61-63) but just to be sure you didn't miss it I'll add it here as well:

new ResourcesManifestPlugin({
    TO_CACHE: /.+\.(js|css|png|jpe?g|gif|svg|webp)$/
}, 'public/service-worker.js'),

With the above config, running the production build will output this resource-manifest.json:

{
  "TO_CACHE": [
      "static/media/logo.5d5d9eefa31e5e13a6610d9fa7a283bb.svg",
      "static/css/main.e838c23d408701d90175.css",
      "static/js/main.b7943dcd1063c87c4534.js",
      "static/js/vendors~main.48d846f49fa5931ca2df.js"
  ]
}

Perfect! Now let's write our service-worker.js file. Notice this is different from the already existing serviceWorker.js which just handles the registration and un-registration of our service worker. We don't touch that one!

const VERSION = 1;
const CACHE_NAME = `stolen-cra-cache-${VERSION}`;
const BUILD_FOLDER = '';
const PRECACHE_MANIFEST = `${BUILD_FOLDER}/resources-manifest.json`;

self.addEventListener('install', event => {
    event.waitUntil(
        new Promise(resolve => {
            caches
                .open(CACHE_NAME)
                .then(cache => {
                    return fetch(PRECACHE_MANIFEST)
                        .then(resp => resp.json())
                        .then(jsonResp => {
                            return cache.addAll(['/', ...jsonResp.TO_CACHE.map(name => `${BUILD_FOLDER}/${name}`)]);
                        })
                        .then(resolve);
                })
                .catch(err => console.error('SW errors', err));
        })
    );
});

self.addEventListener('activate', function onActivate(event) {
    event.waitUntil(
        caches.keys().then(keys => {
            return keys.filter(key => key !== CACHE_NAME).forEach(key => caches.delete(key));
        })
    );
});

self.addEventListener('fetch', function onFetch(event) {
    if (event.request.url.indexOf(location.origin) === 0) {
        event.respondWith(cacheOrNetwork(event));
    }
});

function cacheOrNetwork(event) {
    const clonedRequest = event.request.clone();
    return caches.match(event.request).then(resp => resp || fetch(clonedRequest));
}

It might seem complicated but the underlying logic is actually simple:

  1. on install (lines 6-22) we fetch resource-manifest.json, go through it's contents and then cache all the files listed there
  2. on activate (lines 24-30) we delete all previous caches to free up some space for our users
  3. and finally, on every fetch request (lines 32-36) we try and get that resource from the cache first. If not, we fallback to the network.

See that VERSION variable declared on line 1? The `resources-manifest-webpack-plugin` looks for that exact variable and increments it on every build. This small change in the service worker's is required so that the browser realises something changed, and installs the newest version.

# Final results

We did it!!! We have the exact sizes of production JavaScript & CSS, the exact folder structure and asset loading patters, even the exact offline-first functionality! 🥂

The only differences are that CRA creates source-maps on the build step too, which seems kinda weird and unnecesary, plus it has 3 JavaScript files in the final bundle, but the total size is the same as our two :)

PS: here's the GitHub repo with the full source code in case you want to take it for a spin!

PPS: and the package.json so you have all the code needed to run this on your device:

{
    "name": "steal-like-a-dev-cra",
    "version": "1.0.0",
    "author": {
        "name": "Alexandru Pavaloi",
        "email": "pava@iampava.com",
        "url": "https://iampava.com"
    },
    "scripts": {
        "start": "rm -rf build/ && webpack-dev-server --env.mode development --hot",
        "build": "rm -rf build/ && webpack --env.mode production"
    },
    "dependencies": {
        "react": "^16.8.6",
        "react-dom": "^16.8.6"
    },
    "devDependencies": {
        "@babel/core": "^7.4.4",
        "@babel/preset-env": "^7.4.4",
        "@babel/preset-react": "^7.0.0",
        "babel-loader": "^8.0.5",
        "copy-webpack-plugin": "^5.0.3",
        "css-loader": "^2.1.1",
        "file-loader": "^3.0.1",
        "html-webpack-plugin": "^3.2.0",
        "mini-css-extract-plugin": "^0.6.0",
        "optimize-css-assets-webpack-plugin": "^5.0.1",
        "resources-manifest-webpack-plugin": "^3.0.0",
        "style-loader": "^0.23.1",
        "webpack": "^4.30.0",
        "webpack-cli": "^3.3.1",
        "webpack-dev-server": "^3.3.1",
        "webpack-merge": "^4.2.1"
    }
}
Portrait of Pava
hey there!

I am Pava, a front end developer, speaker and trainer located in Iasi, Romania. If you enjoyed this, maybe we can work together?