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>
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
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
}
I can get the size of an asset by its name.
compilation.assets['static/js/main.2aab9359.chunk.js'].size();
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
}
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
});
});
});
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>
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 haveonprogress
event, in other wordsonload
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);
});
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
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);
});
});
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);
}
});
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]);
}
});
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>
And just remove bootloader-container
when application gets ready.
useEffect(() => {
const container = document.getElementById("bootloader-container");
container?.parentNode?.removeChild(container);
}, [])
This side effect hook is just a componentDidMount
.
Result
Here is the bootloader based on script/link
onload
event.
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.
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)