Workshop: Building Offline-First Web Apps with Service Workers for your Android mobile phone

von Merle Ehm (Kommentare: 0)

In this workshop we will transform a typical server-depending web app into a nice and fluffy app which can be run on disconnected devices. The project will be a simple shopping list app. Items can be added or removed.

The offline capabilities will be handled by the Service Worker API. A Service Worker can act like a proxy. Every request passes the Service Worker. The worker decides whether to load the content from the cache or to initiate a request to get changes from server.

On the other side, create or delete actions during connection lost must be send to the server when the device gets reconnected to the internet. This will be done by the Background Sync. It extends the Service Worker API with an "onsync" event.

STEP 1: Offline first!

Let us start to put all files from the shopping list into the cache. The app can then be loaded even in offline mode.

A Service Worker runs on a different thread than the main JavaScript. Thereby the whole code for the Service Worker must be placed into a separate JavaScript file. This file will be used to register the Service Worker in the main JavaScript Code:

# main.js

navigator.serviceWorker.register('./sw.js')
    .then(registration => {
        registration.update();
    })
    .catch(console.error);

Now the Service Worker is registered but it is doing nothing at the moment. A Service Worker gets active only on special events.

The first event to implement is the "install" event. We will put all files from the shopping list app into the cache during the installation of the Service Worker. This is done by the first page load. Even the "items.json" file itself, which contains the list data, will be placed into the cache.

# sw.js

const CACHE = 'shopping-list';

self.addEventListener('install', function(evt) {    
    evt.waitUntil(
        caches.open(CACHE)
            .then(cache => cache.addAll([
                './', 
                './index.html', 
                './style/main.css', 
                './script/app.js', 
                './items.json', 
                './manifest.json'
            ]))
            .catch(console.error)
    );
});

Then we implement the "fetch" event. It is triggered on every request from the shopping list app after the first page load when the Service Worker gets installed. In this first step we are going to respond every request with the cached data excepting POST requests.

# sw.js

self.addEventListener('fetch', function(evt) {    

    if(evt.request.method != 'POST') {

        evt.respondWith(
            caches.open(CACHE)
                .then(cache => cache.match(evt.request))
                .catch(console.error)
        );
    }
});

If you start the app right now, the Service Worker downloads all files belonging to the shopping list app into the cache. If you reload the page, you cann see in the Chrome Developer Tools that all following requests will be served by the cache.

The app should now be runnable even when the device lost connection to the internet. Try it out!

STEP 2: Updateing the cache!

So far so good. But what happens if you change any file on the server side? These changes will never be downloaded by the browser. We have to take care to send a server request by ourselves to load the changed data from the server. Therefore the asynchronous request must be wrapped into a "waitUntil" callback function call to keep the Service Worker active during the long chain of asynchronous operations.

The change detection will be done by a comparing the "Etag" header. If the "Etag" of the cached file is different to the header of the new downloaded file, the latter is put into the cache to replace the obsolete file.

# sw.js

...

evt.waitUntil(

    caches.open(CACHE).then(cache => {
        return Promise.all([
            cache.match(evt.request),
            fetch(evt.request)
        ])
        .then(responses => {

            const cachedResponse = responses[0],
                fetchedResponse = responses[1];

            if(cachedResponse.headers.get('ETag') !== fetchedResponse.headers.get('ETag')) {
                return cache.put(evt.request, fetchedResponse.clone())
                    .then(() => self.clients.matchAll())
                    .then(clients => {
                        clients.forEach(client => {
                            client.postMessage({type: 'refreshList', url: fetchedResponse.url});
                        });
                    })
                    .catch(console.error);

            }

        }).catch(console.error)

    })
);

After the changes are loaded from server and put into the cache, we want to refresh the shopping list on the UI to make the latest changes adhoc visible to the user. For that purpose the Service Worker sends a refresh message to all clients (clients = registered browser windows or tabs). This message can be received in the main JavaScript file by registering an "onmessage" handler.

# main.js

const CACHE = 'shopping-list';

navigator.serviceWorker.onmessage = function(evt) {

    const message = evt.data;

    if (message.type === 'refreshList') {
        caches.open(CACHE)
            .then(cache => cache.match(message.url))
            .then(response => response.json().then(json => {
                list = json;
                renderList(json);
            }))
            .catch(console.error);

    }

};

The main JavaScript file is also able to access the cache. So if the message is from type "refreshList", the new inserted shopping list items will be read from the cache and rendered on the UI. Violá!

STEP3: Background Sync!

The last missing key feature would be to cache the actions for creation or deletion of sopping list items during connection loss. These actions should then be automatically processed when the device reconnects to the internet. This will be done by implementing the "sync" event in the Service Worker.

# sw.js

self.addEventListener('sync', function(event) {

    if (event.tag == 'syncShoppingItems') {
        event.waitUntil(syncItems());
    }
});

The main work of the event handler is outsourced into the function "syncItems()". This function looks for local changes to the shopping list and initiates creation or delete requests to sync them with the server. On the other side it downloads the current shopping list items. The whole server communication is now moved from the main JavaScript file into the Service Worker. The clue for this is the behaviour of the "waitUntil" function: If "syncItems()" returns a rejected promise the function will be called another time when the device gets connection to the internet. But how does the Service Worker get notice of local changes done by creating or deleting shopping list items? The solution is to use a kind of local data storage like IndexedDB, which is supported by Service Worker. Keep in mind that a Service Worker runs in a separate thread with no DOM access and even no access to LocalStorage or SessionStorage. Last but not least we need to register the background sync in the main JavaScript file. The background sync is now initiated by page reload and by clicking on the reload button on the UI.

# main.js

navigator.serviceWorker.register('./sw.js')
    .then(() => navigator.serviceWorker.ready)
    .then(registration => {

        registration.update();

        document.getElementById('reload').addEventListener('click', () => {
            registration.sync.register('syncShoppingItems');
        }, false);

        return registration.sync.register('syncShoppingItems');
    })
    .catch(console.error);

Due to the usage of a local database, new created shopping list items can be saved permanently even when the browser is closed. This completes the app for an effective offline experience.

Conclusion

The Service Worker API offers more control and more possibilities in comparison to the old Application Cache API. The background sync is a new feature which was not available using the old API. But the new API seems to be a bit complex. You need some time to get used to. It's too bad that Service Worker are not supported on iOS. Therefore it is not ready for use in a productive platform independent mobile app.

Source code

You can view and download the source code on https://github.com/ergovia-mobile/ws-service-worker.

Zurück