Code-splitting and Lazy-loading with Bazel

Greg Magolan
7 min readSep 10, 2018

--

In March, I wrote about an unreleased experimental feature in the rules_nodejs rollup_bundle Bazel rule which allowed for code-splitting using the recently added code-splitting feature in rollup. I’m happy to announce that this feature has now matured and has been released in rules_nodejs 0.12.3.

In this post we’ll go over how the new code-splitting feature of rollup_bundle works. We’ll also take a look at how this new feature is used in the canonical angular-bazel-example to setup lazy loaded routes in an Angular application.

Bazel is a Google open-source project. It is a fast, scalable, multi-language build system that is used to build nearly all of Google’s applications.

What is code-splitting?

Code-splitting allows you to split application bundle into multiple chunks. This approach can reduce the load time of your application significantly by only loading the chunks that are required for your application to start and allowing the remaining chunks to be loaded later, either lazily, when the user navigates to a route for which they are needed, or eagerly, in the background before they are needed.

Documentation

The Bazel documentation for the rollup_bundle rule can be found here: https://bazelbuild.github.io/rules_nodejs/rollup/rollup_bundle.html#rollup_bundle

The canonical angular-bazel-example, which demonstrates how to use the rollup_bundle rule to setup lazy loaded Angular routes, is found here: https://github.com/alexeagle/angular-bazel-example

Configuration

To enable code-splitting in the rollup_bundle rule, you simply specify one or more additional_entry_points in the rule.

rollup_bundle(
name = "bundle",
entry_point = "src/main",
additional_entry_points = [
"src/foo",
"src/bar",
],
deps = ["//src"],
)

The entry_point should be the main entry point to your application and additional_entry_points are the additional entry points that rollup will turn into separate entry chunks. Rollup will analyze all off the entry points specified and create an entry chunk for each one as well as one or more common chunks depending on how much shared code there is between the entry points.

The output of the rollup_bundle rule with code-splitting enabled is a number of folders which contain different variants of the code-split bundles in CommonJS format. The folder names start with the target name. For example, if the target name is bundle then the folders outputted would be:

  • bundle.cs.es6 (es6)
  • bundle.cs (es5 downlevelled from es6 output)
  • bundle.cs.min (es5 minified with uglify)
  • bundle.cs.min_debug (es5 minified with uglify with debug flags turned on)

The es6 variant is outputted by rollup and the remaining variants are derived from that output. Each variant will contain the entry point chunks, one or more common chunks and the corresponding source maps. In the above configuration, with the entry points src/main, src/foo and src/bar, the following files would be outputted to each variant folder:

  • main.js
  • main.js.map
  • foo.js
  • foo.js.map
  • bar.js
  • bar.js.map
  • chunk-<hash>.js
  • chunk-<hash>.js.map

The number of common chunk files, which are named chunk-<hash>.js, is variable and they are uniquely named with a hash of their content.

In addition to the code-split bundles, the rollup_bundle rule will also output a corresponding SystemJS boilerplate & bootstrap file for each variant. These files configure the SystemJS map of entry points and bootstraps the main entry point. They are also named after the target name:

  • bundle.es6.js
  • bundle.js
  • bundle.min.js
  • bundle.min_debug.js

These scripts are optional to use as you can configure SystemJS yourself or use another loader in the browser. They are convenient as single scripts that can be loaded to configure SystemJS and bootstrap your code-split application:

<html>
<head>...</head>
<body>
...
<!-- Note: system.js must be loaded before bundle.min.js -->
<script src="https://cdnjs.cloudflare.com/.../system.js"></script>
<script src="/bundle.min.js"></script>
</body>
</html>

If the main entry_point is src/main and the additional_entry_points are src/foo and src/bar as shows above, then bundle.min.js will look like so:

// SystemJS boilerplate/bootstrap for code-split rollup_bundle.
// GENERATED BY Bazel
(function(global) {
System.config({
packages: {
'': {
map: {
'./main': 'bundle.cs.min/main',
'./foo': 'bundle.cs.min/foo',
'./bar': 'bundle.cs.min/bar'},
defaultExtension: 'js',
},
}
});
System.import('main').catch(function(err) {
console.error(err);
});
})(this);

Lazy-loading with Angular

The canonical angular-bazel-example shows how to use rollup_bundle to generate code-split bundles that can be lazy-loaded using the Angular router.

For an Angular application, the additional_entry_points for the rollup_bundle rule are the ngfactory files of the modules corresponding to the lazy-loaded routes. In angular-bazel-example, these are src/hello-world/hello-world.module.ngfactory and src/todos/todos.module.ngfactory:

rollup_bundle(
name = "bundle",
additional_entry_points = [
"src/hello-world/hello-world.module.ngfactory",
"src/todos/todos.module.ngfactory",
],
entry_point = "src/main",
deps = ["//src"],
)

The main entry point src/main contains the Angular bootstrap code:

import {enableProdMode} from '@angular/core';
import {platformBrowser} from '@angular/platform-browser';
import {AppModuleNgFactory} from './app.module.ngfactory';
enableProdMode();
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

The production server is started with yarn serve-prod. When this is run, Bazel will build the code-split bundles and run the production server, which listens on port 8080.

history-server listening on port 8080; Ctrl+C to stop

The index.html file served in angular-bazel-example is /src/index.html. This html file is common in development and production modes and looks as follows:

<!doctype html>
<html>
<head>
<title>Angular Bazel Example</title>
<base href="/">
</head>
<body>
<app-component></app-component>
<script src="/zone.min.js"></script>
<script src="/system.js"></script>
<script src="/bundle.min.js"></script>
</body>
</html>

Notice that we load /bundle.min.js, which contains the SystemJS boilerplate and bootstrap for the application:

// SystemJS boilerplate/bootstrap for code-split rollup_bundle.
// GENERATED BY Bazel
(function(global) {
System.config({
packages: {
'': {
map: {
"./main": "bundle.cs.min/main",
"./hello-world/hello-world.module.ngfactory": "bundle.cs.min/hello-world.module.ngfactory",
"./todos/todos.module.ngfactory": "bundle.cs.min/todos.module.ngfactory",
},
defaultExtension: 'js',
},
}
});
System.import('main').catch(function(err) {
console.error(err);
});
})(this);

After starting the production server, when you navigate to http://localhost:8080 you’ll see that the browser first loads zone.min.js and system.js. These scripts are required for the application to run. It then loads the bundle.min.js script which contains the SystemJS boilerplate and bootstrap code. The System.import(‘main’) line in bundle.min.js will cause the main entry point chunk, main.js, to be loaded. The main entry point will load the two common chunks that it requires (chunk-d4b9cc84.js and chunk-acf34f7e.js). Angular will then be bootstrapped in main.js and the Angular router will the lazy load the / route, which loads the hello-world.module.ngfactory.js entry chunk and the common chunk (chunk-8d93dd18.js1) that it requires.

Note: In an real application you wouldn’t want to lazy load your main route (/) but this is done here for demonstration purposes.

At this point the application has everything is needs to run the main page, but it hasn’t yet loaded the chunks for the /todos route. Only when you click on the Todos link does Angular request the todos.module.ngfactory.js script required for that route. At this point all common chunks have already been loaded so only the todos.module.ngfactory.js script is required for the /todos route. Once the todos entry script is loaded, Angular changes the current route to /todos and the Todos page is displayed.

Note: The the behavior is different if you manually change the URL to http://localhost:8080/todos instead of clicking on the link which changes the route in Angular. In this case, the browser treats this as a full reload instead of an Angular route change.

Not yet supported in development mode

Code-splitting is not yet available in development mode using Bazel. The development server rule, ts_devserver, in rules_typescript does not yet support code-splitting and does not serve a code-split bundle.

Development mode requires a fast RTT (round-trip-time) from when a developer makes a change to a source file to when the application is reloaded in the browser. The RTT should be short and proportional to the size of the change. Our intent is that the median RTT is less than two seconds, even for a large application. ts_devserver uses in-memory, on-the-fly bundling to generate the development bundle in order to accomplish this. Getting code-splitting to be fast and proportional to the size of the change in development mode is challenging and we have not developed a solution to this problem to date.

angular-bazel-example shows how to setup an Angular application with Bazel which has a production build that makes use of code-splitting and lazy-loading and development build which does not.

Bazel vs. Rollup vs. Webpack

Bazel is a multi-language and multi-tool build system that can work with both rollup and webpack. An action in Bazel can run rollup, uglify, webpack, the typescript compiler or any other tool you would like it to using custom rules.

Webpack could be used very effectively with Bazel to bundle applications and perform code-splitting. With the webpack DLL plugin, webpack could even be used with Bazel to allow for the time to generate a production bundle to be proportional to the size of the change since the last build. This is something we are already thinking about at Angular labs.

Bazel vs. Angular CLI

Right now you can either use Bazel to build your Angular application or the Angular CLI. The two don’t work together very well.

At Angular labs, we are currently laying down the groundwork for the future so we can use Bazel under the hood of the Angular CLI. Angular CLI users will get all the benefits of the Angular CLI combined with the build performance improvements of Bazel. Stay tuned for more on this in the future.

--

--