Introduction
Most of the current major browsers limit the number of simultaneous connections per hostname to six. This means that while six requests are being processed, additional requests for assets on a host will be queued by the browser. In the image below, the Chrome F12 developer tools network tab shows the timing for assets required by the welcome view
of the skeleton-navigation application.
As we can see, there are over 95 requests being made to load the first view. While the first few requests are being processed the others are waiting, ultimately taking almost 2.39s on a local machine.
In the past, the most common browser limit has been 2 connections. This may have been sufficient in the beginning of the web when most of the content was delivered in a single page load. However, it can soon become the bottleneck when building rich client applications with frameworks like Aurelia and others.
You may wonder: If this limit can have such a great impact on performance, then why don't browsers give us a higher limit? Most well-known browsers choose not to grant this wish in order to prevent the server from being overloaded by a small number of browsers. Such activity would be similar in nature to a DDOS attack.
Bundling
This connection limit will not cause slowness in our application if we can manage resources well enough to avoid it. When the page is first loaded, this is the initial request that returns HTML content. When the browser processes the HTML content, it spawns more requests to load resources like JS, CSS and images. It also executes JavaScript and sends AJAX requests to the server.
To make this process efficient, we need to compress the assets and make fewer (possibly less than 6) requests to load everything we need. Fortunately, static resources can be cached and only downloaded the first time. If they cause slowness, it happens on the first page load only and may be tolerable.
Bundling along with minification are techniques that can also be used to improve load time. Bundling and minification improve load time by reducing the number of requests to the server as well as reducing the size of requested assets such as views, view-models and CSS.
Bundling an Aurelia JSPM Application
We can use
Aurelia Bundler
to create a gulp task for bundling our JSPM app. Let's jump right into it. We will use the skeleton-navigation
as our app to bundle. If you don't have that set up. Follow
these steps
.
Now that we have our app running, let's start by installing aurelia-bundler
. To do so cd
into skeleton-navigation
and run the following command:
npm install -D aurelia-bundler
Now, let's create a bundle.js
file in build/tasks/bundle.js
as follows:
var gulp = require('gulp');
var bundle = require('aurelia-bundler').bundle;
var config = {
force: true,
baseURL: '.', // baseURL of the application
configPath: './config.js', // config.js file. Must be within `baseURL`
bundles: {
"dist/app-build": { // bundle name/path. Must be within `baseURL`. Final path is: `baseURL/dist/app-build.js`.
includes: [
'[*.js]',
'*.html!text',
'*.css!text'
],
options: {
inject: true,
minify: true
}
},
"dist/vendor-build": {
includes: [
'aurelia-bootstrapper',
'aurelia-fetch-client',
'aurelia-router',
'aurelia-animator-css',
'github:aurelia/templating-binding',
'github:aurelia/templating-resources',
'github:aurelia/templating-router',
'github:aurelia/loader-default',
'github:aurelia/history-browser',
'github:aurelia/logging-console',
'bootstrap/css/bootstrap.css!text'
],
options: {
inject: true,
minify: true
}
}
}
};
gulp.task('bundle', function() {
return bundle(config);
});
The bundle function returns a Promise for proper integration into async task engines like Gulp.
With that file in place, let's run the command below:
gulp bundle
Here are the things that should have happened after Gulp is finished executing the bundle task:
- A file,
dist/app-build.js
is created. - A file,
dist/vendor-build.js
is created. config.js
is updated.
Now, if we refresh/reload the app from the browser, we will see much less network traffic. This means that our app is properly bundled.
Just 9 requests tells the story. We have also managed to minimize the size from 1.2MB to just 773KB here.
Multiple Bundles
We can create as many bundles as we want. Here we have created two: one for our application code and another for Aurelia and third-party libraries.
We can create just a single bundle, if we want, that combines both application code and third-party libraries. The number of bundles we would like to have mostly depends on our application structure and the usage patterns of our app. For example, if our app was built in a modular fashion, such that it is a collection of child-app/sections, then a common
bundle for third-party libraries and a bundle per section
makes much more sense and performs better than a huge single bundle that needs to be loaded up front.
Bundling a JSPM v0.17 App
In a JSPM v0.17 style app, we have two separate config files: jspm.browser.js
and jspm.config.js
. In such case the configPath
in the bundle config should look like: configPath: ['./jspm.browser.js', './jspm.config.js']
. We also have to add another injectionConfigPath
to indicate which config file should host the bundle and depCache injection. Here is a typical bundle configuration for a JSPM v0.17
app.
var config = {
force: true,
baseURL: '.', // baseURL of the application
configPath: [ // SystemJS/JSPM configuration files
'./jspm.browser.js',
'./jspm.config.js'
],
injectionConfigPath: './jspm.config.js', // Configuration file path where bundle and depCache meta will be injected.
bundles: {
"dist/app-build": { // bundle name/path. Must be within `baseURL`. Output path will look like: `baseURL/dist/app-build.js`.
includes: [
'[*.js]',
'*.html!text',
'*.css!text'
],
options: {
inject: true,
minify: true
}
}
}
}
Duplicate Modules in Multiple Bundles
Creating multiple bundles requires us to be extra careful because multiple bundles may contain duplicate modules. Before explaining that, we need to understand how bundling works behind the scenes a bit. Let's consider the example modules A
and B
below:
import b from './b';
console.log('Hi, I am module A');
console.log('Hi, I am module B');
When we want to bundle a.js
, the bundler will analyze the source code of the module and find the dependencies by tracing the import
statements. In this case, the bundler will yield b.js
as the dependency of a.js
and ultimately place b.js
in the bundle.
Let us now take a closer look at the config
object. We will skip force
and packagePath
for the moment. bundles
is where we will focus now, specifically the includes
.
bundles: {
"dist/app-build": {
includes: [
'[*.js]',
'*.html!text',
'*.css!text'
],
Please pay attention to the pattern [*.js]
. The bundler supports some glob patterns like *.js
, */**/*.js
etc. *.js
here means, we are interested in bundling all the js
assets in the dist
folder (considering the path
in config.js
). So what does [*.js]
mean here? Well, as we know, the bundler will trace the module dependencies from the import statements. Lot's of our code refers to the modules of Aurelia
via import
statements. For example:
import {inject} from 'aurelia-framework';
import {HttpClient} from 'aurelia-fetch-client';
import 'fetch';
@inject(HttpClient)
export class Users{
heading = 'Github Users';
users = [];
constructor(http){
http.configure(config => {
config
.useStandardConfiguration()
.withBaseUrl('https://api.github.com/');
});
this.http = http;
}
activate(){
return this.http.fetch('users')
.then(response => response.json())
.then(users => this.users = users);
}
}
When the bundler analyzes this file it will find aurelia-framework
and aurelia-fetch-client
as it's dependencies and include them in the bundle. But the bundler does not stop there. It will recursively find the dependencies of aurelia-framework
and aurelia-fetch-client
and will go on until there is nothing left.
bundles: {
"dist/app-build": {
includes: [
'*.js',
'*.html!text',
'*.css!text'
],
Having *.js
in the above config will create a bundle containing lots of Aurelia
libraries including aurelia-framework
and aurelia-fetch-client
. If we consider the second bundle config dist/vendor-build
, we have 'aurelia-bootstrapper' and 'aurelia-fetch-client'. aurelia-bootstrapper
will yield aurelia-framework
. Ultimately, we will end up with duplicate modules in both the bundles.
Our goal is to create a bundle of our application code only. We have to somehow instruct the bundler not to recursively trace the dependencies. Guess what? [*.js]
is how we do it.
[*.js]
will exclude the dependencies of each module that the glob pattern *.js
yields. In the above case it will exclude aurelia-framework
, aurelia-fetch-client
and so on.
Bundle Configuration
Here is a typical bundle configuration in all its glory:
"dist/app-build": {
includes: [
'[*.js]',
'*.html!text',
'*.css!text',
'bootstrap/css/bootstrap.css!text'
],
excludes: [
'npm:core-js',
'github:jspm/nodelibs-process'
],
options: {
inject: true,
minify: true,
rev: true
}
}
- dist/app-build : This is the name of the bundle and also where the bundle file will be placed. The name of the bundle file will be
app-build.js
. As thebaseURL
forskeleton-navigation
pointed todist
folder, we named itdist/app-build
. - includes : We will specify all the modules/files that we want to include here. Since all our JavaScript is in the
dist
folder and we have thepath
rule configured inconfig.js
that points to thedist
folder. If we simply specify*
all ourjs
modules will be included. We can specify*/**/*
here if we want to include all the subfolders. *.html!text
: This includes all the templates/views in the bundle. The!text
tells the Bundler and Loader that these files will be bundled and loaded using thetext
plugin.*.css!text
: Like html templates, we are including all the css here. If you have previously usedplugin-css
, note that we are not using!css
here. The Aurelia Loader usestext
plugin for loading css to analyze and do other interesting things likescoping
etc.- excludes: This is where we specify what we want to exclude from the bundle. For example,
*
includes all the JS files in thedist
folder. For example, if for some reason we wantapp.js
to be excluded from the bundle, we would write:
excludes : [
'app'
]
Exclusion of files that are being used in the project but are not part of it (e.g. CDN URLs, URLs relative to the host, etc.) is done automatically. For bundling to work, do not add them to the excludes section. It will cause an error.
- inject: If set to
true
, this will inject the bundle inconfig.js
, so whenever the application needs a file within that bundle, the loader will load the entire bundle the first time. This is how we can achieve lazy bundle loading. For a large app with multiple sub sections, this will help us avoid loading everything upfront. - minify: As the name suggests, if this is set to
true
, the the source files will be minified as well. - rev: If this is set to
true
, an unique revision number will be appended to the bundle file name. - force : If this is set to
true
the task will overwrite any existing file/bundle with the same name. Set it to false if you are not sure about it. - packagePath : By default it is
'.'
, You can change this if yourpackage.json
file is somewhere else other than the base of your app.aurelia-bundler
uses this file to findconfig.js
,baseURL
, thejspm_packages
folder and other important project configuration.
Bundling HTML Imports
At this point, if you are thinking: "Well, this is all good but we have already developed an application that uses Polymer and HTML Imports
extensively. We want to bundle them as well." As you may already know, we have created a separate plugin
aurelia-html-import-template-loader
exclusively for this use case. We have bundling support for that too. This is how we can do it. It's actually a two part process. First let's install the aurelia-html-import-template-loader
plugin with the command below:
jspm install aurelia-html-import-template-loader
Now, let's open src/main.js
and add this line:
aurelia.use.plugin('aurelia-html-import-template-loader')
After the change main.js
should look like this:
import 'bootstrap';
export function configure(aurelia) {
aurelia.use
.standardConfiguration()
.developmentLogging();
aurelia.use.plugin('aurelia-html-import-template-loader')
aurelia.start().then(a => a.setRoot());
}
With this little change Aurelia Loader will now use HTML Imports
to load all the views. Now, back in our bundle task, we will add a config
like this:
"dist/view-bundle": {
htmlimports: true,
includes: 'dist/*.html',
options: {
inject: {
indexFile : 'index.html',
destFile : 'dest_index.html'
}
}
}
We will also change the first bundle a little bit to exclude all the html
and css
files. Finally our bundle.js
file should look like this:
var gulp = require('gulp');
var bundle = require('aurelia-bundler').bundle;
var config = {
force: true,
packagePath: '.',
bundles: {
"dist/app-build": {
includes: [
'[*.js]'
],
options: {
inject: true,
minify: true
}
},
"dist/aurelia": {
includes: [
'aurelia-bootstrapper',
'aurelia-fetch-client',
'aurelia-router',
'aurelia-animator-css',
'github:aurelia/templating-binding',
'github:aurelia/templating-resources',
'github:aurelia/templating-router',
'github:aurelia/loader-default',
'github:aurelia/history-browser',
'github:aurelia/logging-console'
],
options: {
inject: true,
minify: true
}
},
"dist/view-bundle": {
htmlimport: true,
includes: 'dist/*.html',
options: {
inject: {
indexFile : 'index.html',
destFile : 'dest_index.html'
}
}
}
}
};
We have changed the source code (src/main.js), so we need to rebuild our app. The command below should do that:
gulp serve
The serve
task is already configured in such a way that it runs the build
task first.
Now, let's run gulp bundle
from another console/tab. If we now refresh/reload our app from the browser, keeping the developer tools open, we should see the difference.
The order in which the tasks are run is important. The build
removes all the files in dist
folder. As a result, any bundle file in that folder will be deleted too. This is why we always have to run the gulp bundle
after the build
task is finished. If you are using watch
you will have to be extra careful here. Every change you make in the source file will trigger a build
task that clears the dist
folder and any bundles as well.
Let's examine the configuration one property at a time:
- dist/view-bundle : The name of the bundle file is
view-bundle.html
and will be placed indist
folder. - htmlimport : This is what makes it different from other bundles. If this is set to
true
the bundler will treat it as a html import based bundle and Aurelia loader will give it a different treatment as well. - includes: This is where we will specify what goes in the bundle. All the glob patterns are supported here including arrays of patterns and
!
based exclusion. For example:
includes : ['dist/**/*.html', '!dist/movie/*.html']
The above pattern will bundle all the views in dist
and its child folders except everything in the dist/movie
folder.
- options : if
inject
is set totrue
then a<link rel="import" href="path/of/bundle.html" >
will be injected in the body ofindex.html
. If you would like to keep yourindex.html
clean and create a separate index file then you have to setindexFile
anddestFile
.
indexFile: 'index.html',
destFile : 'dest_index.html'
Conclusion
In this article, you've learned both the why and how of bundling. We've covered the bundler
library, how to configure it for use with Gulp and demonstrated several different scenarios. To bundle your own app, we recommend that you begin with one of the configurations above and customize it. You may have a small app that makes sense as a single bundle or a larger one that can be broken down into features. Each application is different, but the bundler will help you to create the optimal deployment for your unique scenarios.