July 18, 2018
Imagine you’re on a long trip and want to pass the time doing something pleasant. Or you’re on a business trip, visiting your client’s offices. You pull up your favorite mobile app or one that you need to do business (one app could be both of those, though) and… it fails for good, refusing to work properly due to the Internet connection being spotty. So, you either do overtime waiting for websites to load, decide to give it a try another time, or at least get frustrated with the whole idea of websites.
The answer to these problem is a Progressive Web App, available for both Android and iOS users. They offer some truly remarkable possibilities, but in my opinion, the biggest of those is their capacity for offline support. Your app can work just like a native app, provided you “install” it, that is visit the website first before going offline. The result? Once you download the PWA to your device, you can consume content within the app without an Internet connection. Vue.js App ring any bells?
So, let’s elaborate a little on this awesome feature in terms of static data storage for PWAs. I’ll outline how to introduce such support to an already existing application. Why? Well, wouldn’t you like to help your users never see the downasaur again?
The first step to entering the offline support phase is saving the app in browser storage. To do it, you need to store the static files necessary to run your application. How, you ask? Let me guide you through the steps.
Adding files to the cache is possible once the SW installevent is launched. Actually, SW doesn’t put a static file in the cache, but rather a response to the specified request.
That's why in the context of caching, I’m going to use the terms responses and files interchangeably. First, you need to declare these requests and include the absolute path to the files you want to store. If you want to write it manually, the code will look something like this:
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('your-cache-name').then(function(cache) {
return cache.addAll([
// your list of cache keys to store in cache
'main.bundle.js',
'vendor.bundle.js',
// etc.
])
})
);
});
Let me explain what the code stands for:
install
event fired on our self
(window
) object.event.waitUntil
function. That is crucial, because the code opening and saving your files in the cache uses asynchronous methods.caches
object called simply open
, with a defined parameter of your store’s name your-cache-name
This will return the object with your cache's store where you can add files (paths for requests) using the method cache.addAll
, with an array of paths to the files as a parameter.If you already have the SW file, you need to run it. There is a simple script to register the SW only in the browser or a device that supports navigation.serviceWorker
:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.catch(function(err) {
console.info('Service workers are not supported.');
});
}
The Update Process
The troublesome part of the cached data is defining when it should be updated. Although there are a handful of ways to handle this problem that you can find in books and articles, I’ll just go ahead and remove one hurdle for you by showing you an actually working solution. It’s inspired by sw-precache library and serves to cache static files by the SW.
The actions of adding and removing cache will be separated from events delivered by the SW: install
and activate
. It can’t happen simultaneously because as long as the new SW is inactive, the old data from the cache will still be used by the browser. Interrupting it would result in crashing your app.
However, when the activate
event is fired, you new data is saved in the cache and a new SW is launched. Now, it’s time to throw away everything you no longer need and clean up your cache.
The solution requires files to have checksums/hashes/any other value in the filename to differentiate individual file versions. If your files are missing this value, modify your builder. In webpack, you can add [chunkhash]
to the filename in output
, in Angular 6+ you can do it by adding the outputHashing
param to the configuration in angular.json.
Later on, this value will be the only thing to distinguish between the old and new SWs. The new SW will be installed and activated only when the SW file is different. If you already have the files well prepared, you can get down to writing the SW code. Create a file called sw-template.js
which will later be used to generate the final SW file.
Let’s go back to the install
event and add these lines of code to the template:
self.addEventListener('install', event => {
event.waitUntil(
caches.open($CACHE_STORE)
.then(cache => {
return cache.addAll($FILES);
})
.then(() => {
return self.skipWaiting();
})
);
});
Have you noticed any changes?
Now, I’m no longer using static strings to declare the name of the cache store and the list of files. You see $CACHE_STORE
and $FILES
variables instead.
It's because you’re going to declare them in a later step of generating the SW file for your app. Moreover, there’s one function called at the end: self.skipWaiting()
. It's needed to force the SW to change the stage from installation to activation. So, as you can see, the SW won't go forward until all of files are saved in the cache.
In some situations, your app will require all the files to work. In others, however, only a few of your files will be crucial for the app, leaving others to be loaded in the background. How to do that? Simply by splitting your $FILES
array into two different arrays, i.e. $FILES_IMPORTANT
and $FILES_ADDITIONAL
. Furthermore, you should apply slight changes to a block of storing files in cache:
.then(function (cache) {
cache.addAll($FILES_ADDITIONAL);
return cache.addAll($FILES_IMPORTANT);
})
The additional files won't block SW going to the next stage and you can now activate
event.
self.addEventListener('activate', event => {
event.waitUntil(
caches.open($CACHE_STORE)
.then(cache => {
return cache.keys()
.then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
return $FILES.indexOf(cacheName) === -1;
}).map(cacheName => {
return caches.delete(cacheName);
})
);
})
.then(() => {
return self.clients.claim();
});
})
);
});
As I mentioned before, the best place to clean your cache is listener for the event activate
. Let's have a look at the process. You already know the event.waitUntil
and the caches.open($CACHE_STORE)
functions. cache.keys
returns all keys of resources stored in the cache. So now you can filter this list by getting only the items from outside of your array of files and then delete them by cache.delete function. Last instruction here is fired to force all clients to use new SW immediately. Otherwise, it won't happen until the next load.
You’re already prepared to store everything you need in the browser cache. But is the browser intelligent enough to see through cache and omit sending requests? Not really. That's why the SW delivers a hook for another event called fetch. It’s fired whenever the browser sends a request and is used to respond to requests with your stored files.
self.addEventListener('fetch', event => {
if (event.request.method === 'GET') {
let url = event.request.url.indexOf(self.location.origin) !== -1 ?
event.request.url.split(`${self.location.origin}/`)[1] :
event.request.url;
let isFileCached = $FILES.indexOf(url) !== -1;
if (isFileCached) {
event.respondWith(
caches.open($CACHE_STORE)
.then(cache => {
return cache.match(url)
.then(response => {
if (response) {
return response;
}
throw Error('There is not response for such request', url);
});
})
.catch(error => {
return fetch(event.request);
})
);
}
}
});
This time, the code is a bit longer. Let’s have a closer look at what happens:
fetch
event. We want to respond only to GET
requests, so there is an if instruction.isFiledCached
flag returns true, you can call event.respondWith()
Thanks to this method, you will be able to customize the action of responding to a request.caches.open($CACHE_STORE)
and find the proper file by using the cache.match(url)
method, where url
is your modified (or not) URL from the request.catch ()
method from Promise and then simply leave them to the browser by calling the fetch(event.request)
This is one of many strategies for serving resources from the cache. It’s called "Cache falling back to the network" in the offline cookbook. Such an approach offers high performance while loading the application because usually a response from the browser cache is faster than one from a server.
The browser cache is available without an Internet connection, and you have your static files stored in the browser cache with the logic to update and fetch them when needed. Looks like you’re prepared to support your app in offline mode. But wait! You still need to prepare redirect rules for SPA, so whenever a user wants to navigate to /app/page
you need to tell the browser “do not load /app/page.html
, load /index.html
.” Let’s put a few more lines into the handler of the fetch
event.
self.addEventListener('fetch', event => {
...
let isFileCached = $FILES.indexOf(url) !== -1;
// If the request wasn't found in the array of files and this request has navigation mode and there is defined navigation fallback
// then navigation fallback url is picked instead of real request url
// and is checked if this url should be in the cache
if (!isFileCached && event.request.mode === 'navigate' && $NAVIGATION_FALLBACK) {
url = $NAVIGATION_FALLBACK;
isFileCached = $FILES.indexOf(url) !== -1;
}
if (isFileCached) {
...
});
Just between the declaration of isFileCached
and the if statement, I put another if statement for redirection. This instruction will be fired if the request isn't found in the array of files, the request has a navigation mode, and you have declared the $NAVIGATION_FALLBACK
constant variable before. This condition is important here because it’s necessary in the situation where you want to redirect to a proper route.
If every condition is fulfilled then the browser should be redirected to our $NAVIGATION_FALLBACK
That's why I set url to the navigation fallback value and then checked whether it should be included in the cache. If everything goes properly, then our SPA should open even without an active Internet connection. Ta-dam!
Alright, so I prepared a kind of template for caching static files by the SW. If your values are constant then you can just input proper values to the variables: $FILES
(or $FILES_IMPORTANT
and $FILES_ADDITIONAL
), $CACHE_STORE
, and $NAVIGATION_FALLBACK
, and change them manually when you want to update the SW. Otherwise, you can generate values for these variables based on your built application. Actually $CACHE_STORE
and $NAVIGATION_FALLBACK
will be constant, but to keep it in one place we will generate them inside the node.js script. Here you are:
The first lines are for the definitions of the libraries you want to use. Further down, you have some methods:
readArgv()
to read flags from the command line,removeFile(path)
to remove a file in the specific path,isDirectory(path)
to check whether a file in the specific path is a directory,getFiles(source)
to get all the files in the source without directories,arrayToString(array)
to convert array to a readable string for the js file,generateSWFile()
to generate the SW file with all the data.The most important method here is, obviously, generateSWFile()
, so I will briefly describe what it’s all about. First, you need to save all the flags to an argv
constant variable. Then, remove the old SW file if it exists. The next step involves saving the template to the template constant variable, getting all the internal files and declaring an array of URLs to external files which you want to store in the browser cache. When you have both arrays, you can convert them to strings looking like a readable array for a js file ('[file1, file2, ..., fileN]')
. Now, it's time to prepare all the content for the SW file, first the variables and then the template. Finally, you can write the file, save it, and you’re ready to go.
Creating a basic SW file to store static resources in the browser cache is only the first step of adding offline support to your application. You can extend this solution by introducing new features or modifying existing ones. In case of any doubts, feel free to use my code available in the repository.
Remember that solutions presented herein don’t provide you with a ready library to store static files. If you want to have one, I’d recommend using the more advanced sw-precache.
The next step along the way to achieving full offline support is, well, quite a broad subject, and includes handling dynamic data without an Internet connection. To see how to store, update, and synchronize data between your application and a server, along with with some use cases and solutions, read the second part of my article about PWA going offline.
React Native Mobile Development Flutter Ionic Cross-platform development
Cross-Platform App Development 101: What Is It and How Does It Work?