Workbox in Ionic and Lazy Loading Modules

Published: December 17, 2017  •  Updated: December 18, 2017  •  ionic, pwa

A couple of weeks ago I presented in this blog post a way to replace the sw-toolbox service worker library, that the Ionic starter templates uses by default, with Workbox. In addition the blog post described a way to create assets with hashed filenames.

Unfortunately the solution for hashing filenames I presented in the blog does not work for Ionic apps that use lazy loading. Lazy loading is a way to split an application into multiple modules and only load them on demand. The benefit of this is that the initial application bundle is smaller and reduces the startup time of the app.

In this blog post we revisit the Workbox integration and I show you a way how to create hashed asset filenames that work with lazy loaded modules.


Setup

First we create an example application that uses lazy loading. The tabs template is a good starting point because we can split the different tabs into their own modules.

ionic start workboxlazy tabs

This creates an application with three tabs (Home, About and Contact). Next we change the About and Contact tab to lazy loaded modules. We leave the Home page in the initial application bundle because it's the page that the application initially displays and it would not benefit of lazy loading.

To create a lazy loaded module we need to create a module file. In the src/pages/about folder create a new file about.module.ts with this content:

import {NgModule} from '@angular/core';
import {IonicPageModule} from 'ionic-angular';
import {AboutPage} from "./about";

@NgModule({
  declarations: [AboutPage],
  imports: [IonicPageModule.forChild(AboutPage)],
})
export class AboutModule {
}

src/pages/about/about.module.ts

Similar to the src/app/app.module.ts this module describes declarations, imports and providers for a specific module.
Next we have to add the @IonicPage decorator to the page class

import {Component} from '@angular/core';
import {IonicPage} from 'ionic-angular';

@IonicPage()
@Component({
 selector: 'page-about',
 templateUrl: 'about.html'
})
export class AboutPage {

src/pages/about/about.ts

This enables deep linking and allows the application to reference the page by name.

Do the same for the contact page. Add a module file (src/pages/contact/contact.module.ts) and add the @IonicPage decorator to the page class.

Next we remove the references to the About and Contact page in src/app/app.module.ts. You need to remove them from the declarations and entryComponents section.

And finally we need to change the references to the pages in src/pages/tabs.ts. We need to reference the About and Contact page by name because they live in a different module than the tabs component. The reference to the HomePage stays the same because it's part of the main application module.

import {Component} from '@angular/core';
import {HomePage} from '../home/home';

@Component({
 templateUrl: 'tabs.html'
})
export class TabsPage {
 tab1Root = HomePage;
 tab2Root = 'AboutPage';
 tab3Root = 'ContactPage';
}

src/pages/tabs/tabs.ts

Now you can start the application with ionic serve.

When you tap the first time on the About and Contact tab and observe the network requests in the browser developer tools you see that the application loads the module bundles 0.js and 1.js. lazy loaded modules

A problem with lazy loaded modules is that users may notice a short delay between tapping and display the page on the screen, because the browser has to download and parse the bundle.

To mitigate this delay you can enable preloading in your application. Preloading eagerly fetches all the lazy loaded modules after the initial application bundle is download and started. This way you have a smaller initial application bundle but still the benefit that the modules are instantaneous available when the user request them.

To enable preloading change the IonicModule import in src/app/app.module.ts to this.

IonicModule.forRoot(MyApp, {
      preloadModules: true
})

src/app/app.module.ts

When you observe the network requests again you see that the application downloads the initial application bundle (main.js), then presents the Home page and after that downloads the modules (0.js and 1.js). Tapping on the Contact and About tab no longer results in additional network requests. preloading


Migrating to Workbox

Next we replace the sw-toolbox service worker library with Workbox. These changes are described in the previous blog post and don't differ if your application uses lazy loaded modules or not. So I will only summarize the changes here.

npm uninstall sw-toolbox
npm uninstall @ionic-native/core
npm uninstall @ionic-native/splash-screen
npm uninstall @ionic-native/status-bar

npm install workbox-cli -D
npm install workbox-sw	

Open src/index.html and remove the script tag that loads cordova.js and uncomment the code that loads the service worker.

Remove references to Ionic native in src/app/app.module.ts and src/app/app.component.ts. Create a new file copy.config.js with this content:

module.exports = {
  copyWorkbox: {
    src: ['./node_modules/workbox-sw/build/importScripts/workbox-sw.prod.v2.1.2.js'],
    dest: '{{WWW}}'
  }
}

copy.config.js

Add a config option to package.json and configure the custom copy configuration

"config": {
 "ionic_copy": "./copy.config.js",
},

package.json

Create a new file workbox-cli-config.js and add this code

module.exports = {
 "dontCacheBustUrlsMatching": new RegExp('.+'),
 "maximumFileSizeToCacheInBytes": "5MB",
 "globDirectory": "www/",
 "globPatterns": [
   "assets/fonts/*.woff2",
   "build/**/*.css",
  "build/**/*.js",
   "index.html",
   "manifest.json"
 ],
 "swSrc": "src/service-worker.js",
 "swDest": "www/service-worker.js"
};

workbox-cli-config.js

Open src/service-worker.js and overwrite the existing code with the following Workbox code.

importScripts('workbox-sw.prod.v2.1.2.js');

const workboxSW = new self.WorkboxSW();
workboxSW.precache([]);

src/service-worker.js

Add a dist task in package.json that creates a production build of our app and adds the resources to the precache method.

"dist": "shx rm -rf www && npm run build --prod && workbox inject:manifest",

I also added a command that deletes the www folder before it starts the build. Make sure that you install the shx library: npm install shx -D. Ionic app scripts provides the clean task (npm run clean) but that only deletes the www/build folder and I prefer to start from an empty directory when I do a production build.

And finally we add a web server to our project so we can test the production build.

npm install http-server -D

The following task starts the web server, uses the www folder as root and opens the browser

"open": "http-server www -o -a localhost -p 1234"

To check if everything is set up correctly start a production build with npm run dist. Check www/service-worker.js if all resources that make the application are listed as parameters to the precache call.

When you start the application with npm run open it should work as usual. Also check if the app still works when the browser no longer has a connection to the server. You can either stop the web server or check the Offline check box in the Network tab of the browser developer tools.

When you observe the network requests you encounter one problem with the ionicons.woff2 file. The service worker caches this resource with the name ionicons.woff2

{
 "url": "assets/fonts/ionicons.woff2"
},

but the browser tries to fetch it with the name ionicons.woff2?v=3.0.0-alpha.3.

Because the names do not match the service worker does not find it in the cache and requests the resource from the server. To solve that we need to precache the resource with the name the browser requests it. Add a second call to the precache method. Don't overwrite the precache([]) line because the inject job of the Worbbox CLI looks for this code to inject the resources.

workboxSW.precache([]);
workboxSW.precache([{
   "url": "assets/fonts/ionicons.woff2?v=3.0.0-alpha.3"
 }
]);

src/service-worker.js


Hashed Filenames

In this section we revisit the task of generating filenames with hashes. In the previous blog post I implemented this with a combination of the tools hashmark and map-replace. Unfortunately this setup does not work with lazy loaded modules. We could easily rename the modules (0.js, 1.js, ...) with hashmark but the problem is replacing the references to these files.

Replacing the reference to main.js is easy because the file is referenced in index.html and map-replace can do that. But the lazy loaded module filenames are backed into the vendor.js code. With some clever scripts we could replace the filename in this file but there is a much easier way. We tell Webpack to produce hashed file names.

First we create a new config file webpack.config.js with this content:

const useDefaultConfig = require('@ionic/app-scripts/config/webpack.config.js');
const ManifestPlugin = require('webpack-manifest-plugin');
useDefaultConfig.prod.output.filename = '[name].[chunkhash:10].js';
useDefaultConfig.prod.plugins.push(new ManifestPlugin({
  fileName: '../../build-manifest.json'
}));
module.exports = useDefaultConfig;

webpack.config.js

This configuration depends on the ManifestPlugin that we need to install:
npm install webpack-manifest-plugin -D

The script imports the default webpack configuration from Ionic app script and changes the output filename for the production build. The parameter [chunkhash:10] tells Webpack to compute a hash based of the file content and add it to the filename. :10 limits the hash code to 10 characters. With the ManifestPlugin Webpack exports the mapping old->new filename into the file build-manifest.json. We need this information for replacing the references in the index.html page.

To activate this Webpack configuration we need to add it to the config section of the package.json file.

"config": {
...
 "ionic_webpack": "./webpack.config.js"
},

package.json

When you start a production build with npm run build --prod or npm run dist you see all the revisioned files in www/build.

There are two things left to do. When you list the files in the www/build folder you see a few files without a hash in their name (main.css and polyfills.js) and the script and link tag in www/index.html still point to the unhashed filenames.

For hashing the leftovers and changing the references in index.html I wrote the following script.

const fs = require('fs');
const path = require('path');
const revHash = require('rev-hash');

const manifestJSON = require('./build-manifest.json');

const wwwDir = path.resolve(__dirname, 'www');
const buildDir = path.resolve(wwwDir, 'build');
const indexPath = path.join(wwwDir, 'index.html');

const hashedRegex = new RegExp('.+\.[a-f0-9]{10}\..+');
const ignoreFiles = [];

fs.readdirSync(buildDir).forEach(file => {

 if (!hashedRegex.test(file) && !ignoreFiles.includes(file)) {
  const filePath = path.join(buildDir, file); 
  const fileHash = revHash(fs.readFileSync(filePath));

  const lastDotPos = file.lastIndexOf('.');

  const newFileName = `${file.substring(0, lastDotPos)}.${fileHash}${file.substring(lastDotPos)}`;  
  const newFilePath = path.join(buildDir, newFileName);
  fs.renameSync(filePath, newFilePath);
  manifestJSON[file] = newFileName;
 }

});

let indexContent = fs.readFileSync(indexPath).toString('utf8');

Object.keys(manifestJSON).forEach(key => {
indexContent = indexContent.replace(key, manifestJSON[key]);
});

fs.writeFileSync(indexPath, indexContent);
fs.unlinkSync('./build-manifest.json');

cache-busting.js

The script depends on the rev-hash library: npm install rev-hash -D
The script looks for files in the www/build folder without a hash in their name, computes a hash from the content and adds the hash to the filename.

After that the script opens the index.html file and replaces the references to the assets. For this task it reads the build-manifest.json file from the Webpack build with the old->new filenames mapping.

To call the script we add it to our dist task

"dist": "shx rm -rf www && npm run build --prod && node ./cache-busting.js && workbox inject:manifest"

package.json

When you build the application with npm run dist all the assets in www/build should now have a hash in their filename and all the references in www/index.html should point to the correct file.


Server Configuration

Because a service worker still uses the disk/http cache of the browser it's important that the web server not accidentally serves the index.html with an expiration header far in the future. This would delay updates for the app until the cache expires.

The other resources in our app can be served with a long expiration date.

I usually use Nginx for serving my webpages. A Nginx configuration for this application could look like this.

    location /workbox {
      alias /opt/demo/workbox;
      expires -1;

      gzip on;
      gzip_buffers 16 8k;
      gzip_comp_level 1;
      gzip_http_version 1.1;
      gzip_min_length 10;
      gzip_types text/plain text/css application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript image/x-icon application/vnd.ms-fontobject font/opentype application/x-font-ttf application/pdf application/json;
      gzip_vary on;
      gzip_proxied any;
      gzip_static on;

      location /workbox/assets {
         expires 30d;
      }

      location /workbox/build {
         expires max;
      }
    }

This configuration serves everything in the build folder with an expiration date of 20 years in the future. All these assets are revisioned and the names change each time we create a new version of our app.

Assets in the assets folder have an expiry date of 30 days in the future. These files are mostly static resources (web fonts, images) and may never change but because the filenames are not revisioned we should not serve them with an expiration date far in the future because that would make an update very difficult.

Compressing

Another important aspect of serving resources is saving bandwidth. Fortunately a webserver like Nginx handles that transparently with the proper configuration. When a client sends the header Accept-Encoding: gzip the server knows that the client is capable of handling compressed responses and compresses the response on the fly before it sends it back. In Nginx you enable this with the gzip directives.

When you maintain very popular website and your server has to handle a lot of requests it can make sense to precompress the resources. Instead of compressing the responses on the fly you compress the files in your build process and the web server no longer has to do that for each request. He can simply read the compressed file from the filesystem and send it back to the client. This can save some significant cpu time.

With Nginx you enable this with the gzip_static on; directive. When a client (that supports gzip) requests a file filename.css, Nginx looks for a file with the name filename.css.gz and when it exists it serves the content of this file to the client. When it does not exist Nginx compresses the content on the fly before it sends it back.

There are many ways how to produce precompressed assets. A simple way is the gzip-all npm package. First install it: npm install gzip-all -D
then add a task that compresses all files in the www and www/build folder.

"compress": "gzip-all \"www/*.*\" && gzip-all \"www/build/*.*\"",

package.json

and then call the task from the dist task

"dist": "shx rm -rf www && npm run build --prod && node ./cache-busting.js && workbox inject:manifest && npm run compress",

package.json

When everything works you should find all the ressources in the www and www/build folder in an uncompressed and compressed form (ending with .gz).

This concludes our journey into the process of creating a build system for an Ionic app with lazy loaded modules, a Workbox service worker and revisioned and precompressed asset files.

You find the complete project on GitHub and you can access the production build of the app here:
https://demo.rasc.ch/workbox/index.html
served by Nginx with the presented configuration.