DEV Community

Cover image for Making webpack bundled application display loading progress bar.
Dmitriy Vatsuro
Dmitriy Vatsuro

Posted on • Edited on

Making webpack bundled application display loading progress bar.

When the size of my single page application (SPA) got big enough to notice blank page before the app gets fully initialized, I decided to add a progress bar which would display the actual state of application loading. And I want to share my experience I got trying to implement this.

In this article I used create-react-app to generate demo app to work with. If you have different boilerplate the output it produces might be slightly different but do not worry the idea remains the same.

Formulation of the problem

Let's take a look at index.html after webpack has compiled the app.

<head>
  ...
  <link href="/static/css/main.ab7136cd.chunk.css" rel="stylesheet">
</head>
<body>
  ...
  <script src="/static/js/runtime-main.c94b6d8d.js"/>
  <script src="/static/js/2.33779fb9.chunk.js"/>
  <script src="/static/js/main.2aab9359.chunk.js"/>
</body>
Enter fullscreen mode Exit fullscreen mode

Here are CSS files and JS files HtmlWebpackPlugin has injected, let’s call them assets. In order to get a current value of application loading progress we have to divide sum of assets that have already been loaded by total size of all assets.

progress = sum (loaded assets) / total size of assets
Enter fullscreen mode Exit fullscreen mode

And there is no way to get information from the browser how many script files have been loaded and what are their sizes. I need to modify the output of HtmlWebpackPlugin the way I will know the size of each asset.

All I need to do is:

  • Get the size of each asset after compilation and inject this information into index.html
  • Write tiny JS bootloader which will use the prepared data from the previous step and load the assets in correct order and update the progress

The only way I could think of how to implement this is by writing a webpack plugin. Here is a very helpful webpack documentation and basic webpack plugin architecture. Let’s get to it.

Getting and injecting assets meta data

Somehow, I need to get information about which assets will be injected into index.html and get its sizes. Poking around in the source codes of webpack and HtmlWebpackPlugin, I found out that webpack compilation instance has a property assets: CompilationAssets

type CompilationAssets = {
  [assetName: string]: Source
}
Enter fullscreen mode Exit fullscreen mode

I can get the size of an asset by its name.

compilation.assets['static/js/main.2aab9359.chunk.js'].size();
Enter fullscreen mode Exit fullscreen mode

and HtmlWebpackPlugin has a hook beforeAssetTagGeneration. First argument of the hook has a property assets: Assets

type Assets = {
  publicPath: string,
  js: string[],
  css: string[],
  manifest?: string,
  favicon?: string
}
Enter fullscreen mode Exit fullscreen mode

Fields js, css contain absolute paths to the files HtmlWebpackPlugin will inject into index.html. This is exactly what I need to create assets meta data in format that is convenient to use in bootloader. Here is the code:

const htmlAssets = {
  js: [],
  css: []
};

compiler.hooks.thisCompilation.tap('BootloaderPlugin', (compilation) => {
  const hooks = this.htmlWebpackPlugin.getHooks(compilation);
  hooks.beforeAssetTagGeneration.tap('BootloaderPlugin', ({assets}) => {
    const collectFunc = (src, result) => {
      const scriptName = src.replace(assets.publicPath, '');
      const asset = compilation.assets[scriptName];
      // add asset record to the result
      result.push({
        file: src,
        size: asset.size()
      })
    }
    assets.js.forEach(src => collectFunc(src, htmlAssets.js));
    assets.css.forEach(src => collectFunc(src, htmlAssets.css));
  });
  hooks.alterAssetTags.tap('BootloaderPlugin', ({assetTags}) => {
    // remove all scripts and styles
    assetTags.scripts = [];
    assetTags.styles = [];
    // transform the result into javascript code and inject it
    assetTags.scripts.unshift({
      tagName: 'script',
      innerHTML: `window.$bootloader=${JSON.stringify(htmlAssets)};`,
      closeTag: true
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

And the result this code produces is:

<script>
window.$bootloader = {
  js: [{
      file: "/static/js/runtime-main.c94b6d8d.js",
      size: 2368
    }, {
      file: "/static/js/2.33779fb9.chunk.js",
      size: 131272
    }, {
      file: "/static/js/main.2aab9359.chunk.js",
      size: 1242
    }
  ],
  css: [{
      file: "/static/css/main.ab7136cd.chunk.css",
      size: 943
    }
  ]
}
</script>
Enter fullscreen mode Exit fullscreen mode

This code declares variable $bootloader in global scope so I can use it in the bootloader.

Bootloader

There are two strategies how to load the files. The first one is to use native script/link html element mechanism. Browsers help to track the loading of scripts add css, onload and onerror events are there for that purpose.
Pros

  • Nothing changes in the application, works like a charm.

Cons

  • script, link html elements don't have onprogress event, in other words onload event triggers only when script has been completely downloaded. Therefore the smooth movement of the progress bar will depend on how many files you have and what size they are.

The second strategy is to use XMLHttpRequest.

downloadFileWithXHR(url)
  .then(blob => {
    const tag = document.createElement("script");
    tag.type = "text/javascript";
    tag.src = URL.createObjectURL(blob); //<- here is the trick
    document.head.appendChild(tag);
  });
Enter fullscreen mode Exit fullscreen mode

URL.createObjectURL gets Blob and creates url like blob:http://localhost:5000/0ba54ca4-2251-4d67-aa65-b3899c61c2f8 and everything works fine. But the first problem I encountered with is the browser could not find source maps. That is because the original filename is /static/js/main.2aab9359.chunk.js has base url /static/js/ and the last line in the file is

//# sourceMappingURL=main.2aab9359.chunk.js.map
Enter fullscreen mode Exit fullscreen mode

That means the source map file url is /static/js/main.2aab9359.chunk.js.map but the browser tries to get /main.2aab9359.chunk.js.map because the base url has become /.
Pros

  • Constantly triggers progress event when downloading file which causes the progress bar moving smoothly.

Cons

  • Does not support source maps or you have to move them into the root of the homepage.
  • All paths in the code have to be relative to the root of the homepage.

I have implemented both types of the bootloaders but in production use only the first one.

Bootloader compilation and injection

I want my bootloader compile by the same compilation process as the whole application code.

compiler.hooks.entryOption.tap('BootloaderPlugin', (context) => {
  compiler.hooks.make.tapAsync('BootloaderPlugin', (compilation, callback) => {
    const entry = SingleEntryPlugin.createDependency('./src/bootloader.js', 'bootloader');
    compilation.addEntry(context, entry, 'bootloader', callback);
  });
});
Enter fullscreen mode Exit fullscreen mode

The code creates and adds a new entry named bootloader with entry point ./src/bootloader.js that means the bootloader will have its own webpack runtime. Also webpack will try to split the bootloader code into several chunks, most likely webpack runtime and main code, and I do not need it because I want to keep my bootloader as small as possible and in one file.

compilation.hooks.afterOptimizeChunks.tap('BootloaderPlugin', () => {
  const entrypoint = compilation.entrypoints.get('bootloader');
  if (entrypoint) {
    const newChunk = compilation.addChunk('bootloader');
    for (const chunk of Array.from(entrypoint.chunks)) {
      if (chunk === newChunk) continue;
      // move all modules to new chunk
      for (const module of chunk.getModules()) {
        chunk.moveModule(module, newChunk);
      }
      // delete empty chunk
      entrypoint.removeChunk(chunk);
      const index = compilation.chunks.indexOf(chunk);
      if (index > -1) {
        compilation.chunks.splice(index, 1);
      }
      compilation.namedChunks.delete(chunk.name);
    }
    entrypoint.pushChunk(newChunk);
    entrypoint.setRuntimeChunk(newChunk);
  }
});
Enter fullscreen mode Exit fullscreen mode

Here I tap into afterOptimizeChunks hook and make all optimizations I need. First I create a new chunk named bootloader but most likely it was created when I added bootloader entry and therefore webpack will just return an existing one. Next I iterate over all bootloader chunks and move all modules from them to the new chunk and then remove now empty chunks. Eventually all modules will be in one chunk including webpack runtime code. It will keep the bootloader size about 4Kb.

Now I need to replace the application assets in index.html with bootloader's ones.

const hooks = this.htmlWebpackPlugin.getHooks(compilation);
hooks.alterAssetTags.tap('BootloaderPlugin', ({assetTags}) => {
  const entrypoint = compilation.entrypoints.get('bootloader');
  if (entrypoint) {
    const bootloaderFiles = entrypoint.getFiles();
    assetTags.scripts = assetTags.scripts
        .filter(tag => this.isBootloaderScript(tag, bootloaderFiles))
        .map(tag => this.inlineScript(publicPath, compilation.assets, tag));
    assetTags.styles = assetTags.styles
        .filter(tag => this.isBootloaderStyle(tag, bootloaderFiles))
        .map(tag => this.inlineStyle(publicPath, compilation.assets, tag));
    // removing bootloader files from assets so webpack will not emit them
    bootloaderFiles.forEach(filename => delete compilation.assets[filename]);
  }
});
Enter fullscreen mode Exit fullscreen mode

As the bootloader now loads all the application assets itself I don't need HtmlWebpackPlugin inject them in index.html so I filter them out and leave only bootloader files. Also I decided to build in the bootloader assets into index.html.

Splash screen

Here you can do everything imagination is capable of. I just decided to cover the app root DOM node with splash screen with logo and progress bar.

<body>
  <div id="root"></div>
  <div id="bootloader-container">
    <div class="logo">AWE <span>SOME</span> APP</div>
    <progress id="progressbar" value="0" max="1"/>
  </div>
</body>
Enter fullscreen mode Exit fullscreen mode

And just remove bootloader-container when application gets ready.

useEffect(() => {
  const container = document.getElementById("bootloader-container");
  container?.parentNode?.removeChild(container);
}, [])
Enter fullscreen mode Exit fullscreen mode

This side effect hook is just a componentDidMount.

Result

Here is the bootloader based on script/link onload event.

Tag bootloader

After loading 3 small files, the progress bar freezes and waits until the last largest file is loaded. If your application has more files about the same size the movement of the progress bar will be more even.

This is how XHR bootloader works like.

XHR bootloader

It works much nicer but as I said before has its shortcomings.

Source code is available at:

Please comment if you have any feedback or suggestions

Top comments (0)