Wednesday, 19 July, 2017 UTC


Summary

In the previous article, we attempted to use service workers, and it was a success. We were also able to use it to cache some assets for our app. What we are yet to see is how service workers and caches work together to give users a fantastic offline experience. This is what we will be digging in this part.
Browsers have a storage called Cache Storage which we saw while debugging in the previous chapter. When we mention caching, we are not just referring to that storage but to whatever in-browser persistence mechanism that we employ to keep hold of data for offline use.
In summary, we are going to:
  • Explore the unofficial standard caching strategies.
  • Explore the available storage we can use for caching.
  • Build a demo to show trending projects on Github.
  • Implement caching on the demo for offline experience.
Caching Strategies
Just like every other theory in software engineering, it just takes some time to put some patterns together and make those patterns popular for solving certain kind of problems. Caching for PWA is no difference. Jake Archibald had compiled a lot of these patterns and we will just throw more lights on the most used ones:

Cache on Install

This is the pattern we saw in the previous post where you cache all the items needed for an app shell to be displayed:
self.addEventListener('install', function(e) {
  console.log('[ServiceWorker] Install');
  e.waitUntil(
    caches.open(cacheName).then(function(cache) {
      console.log('[ServiceWorker] Caching app shell');
      return cache.addAll(filesToCache);
    })
  );
});
The items cached here include your HTML templates, CSS files, JavaScript, fonts, few images.

Cashe on Network Response

This strategy checks if a network request has been previously cached and updates the page with the cache. If the cache is not available, it goes straight to the network to fetch the item. When the network request returns successfully, it updates the page and caches the item returned:
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(cacheName).then(function(cache) {
      return cache.match(event.request).then(function (response) {
        return response || fetch(event.request).then(function(response) {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});
This strategy is mostly applied to frequently updating contents like feeds.

Cache, Falling Back To network

This strategy is more like "it's either it's cached, or it isn't." When items are not in the cache, we don't try to cache, rather we just fetch it from the network:
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});

Cache, Then network

This request fires both arequest to the cache and another to the network. If an item exists in the cache, that's fine, serve it to the page. When the network request comes back overwrite the page's content with what you got from the network:
const networkReturned = false;
if ('caches' in window) {
  caches.match(app.apiURL).then(function(response) {
    if (response) {
      response.json().then(function(trends) {
        console.log('From cache...')
        if(!networkReturned) {
          app.updateTrends(trends);
        }
      });
    }
  });
}

fetch(app.apiURL)
.then(response => response.json())
.then(function(trends) {
  console.log('From server...')
  networkReturned = true;
  app.updateTrends(trends.items)
}).catch(function(err) {
  // Error
});
In rare cases, the cache will come back before the network. Meaning that if the network returns with data, the cache data will be replaced with the network data. In, rare cases, I mentioned. It's the web; anything goes, hence, network data might come back before cached data. That's why you need a flag like networkReturned in the code sample above to keep things in check.
Storage Techniques
There are two recommended data persistence storage for service workers -- Cache Storage and Index DB (IDB).
  • Cache Storage: Sometimes, in the past, we relied on AppCache for caching. This wasn't flexible because we needed an API that was more interactive. The Cache API, baked right in service workers allows you to persist data more interactively. The good thing about this API is that it's both available in service workers and also in your web pages. We have seen this storage in action already.
  • Index DB: Index DB is an asynchronous data storage solution. It's API is love-level and quite a pain to work with, but wrapper libraries like localForage simplify the API with a localStorage-like interface.
Service worker supports these two storage options. The question becomes, when should we use which? According to Addy Osmani's post:
For URL addressable resources, use the Cache API (part of Service Worker). For all other data, use IndexedDB (with a Promises wrapper).
SW Precache
Now that we have discussed caching strategies and data storage techniques. It's time to put it them to use. Before doing that, it would be nice to meet SW Precahce from Google.
This tool goes the extra mile to simplify the repeated pattern we have discussed into just a configuration object. Hence, all you need do is define you cacheable items in an array.
Let's add precache and use it to generate our service-worker.js automatically. Start with adding a package.json file to the pwa-demo project folder:
npm init -y
Install sw-precache:
npm install --save-dev sw-precache
Create a configuration file:
// ./tools/precache.js

const name = 'scotchPWA-v1'
module.exports = {
  staticFileGlobs: [
    './index.html',
    './images/*.{png,svg,gif,jpg}',
    './fonts/**/*.{woff,woff2}',
    './js/*.js',
    './css/*.css',
    'https://fonts.googleapis.com/icon?family=Material+Icons'
  ],
  stripPrefix: '.'
};
staticFileGlobs takes an array of file patterns which we want to cache. This is simpler because we don't have to list all the files, but the patterns.
Add a script to generate the service worker file to the package.json:
"scripts": {
    "sw": "sw-precache --config=tools/precache.js --verbose"
  },
Generate a service worker file by running:
npm run sw
This should generate a ./service-worker.js. A close look into that file should feel familiar.
Complete Demo
Let's flesh out the app first before we can see how to use what we have learned to take the app offline.
Back to the app.js file; listen to the page loaded event and try to get a list of GitHub trending projects:
(function() {
  const app = {
    apiURL: `https://api.github.com/search/repositories?q=created:%22${dates.startDate()}+..+${dates.endDate()}%22%20language:javascript&sort=stars&order=desc`
  }

  app.getTrends = function() {
    fetch(app.apiURL)
    .then(response => response.json())
    .then(function(trends) {
      console.log('From server...')
      app.updateTrends(trends.items)
    }).catch(function(err) {
      // Error
    });
  }

  document.addEventListener('DOMContentLoaded', function() {
    app.getTrends()
  })

  if ('serviceWorker' in navigator) {
    navigator.serviceWorker
     .register('/service-worker.js')
     .then(function() { 
        console.log('Service Worker Registered'); 
      });
  }
})()
When the page is ready, we call the getTrends method which uses the API URL to fetch trending GitHub projects. This is decided by sorting them with the stars in descending order.
Notice the dates in the API URL string. Here is how they were constructed:
Date.prototype.yyyymmdd = function() {
  // getMonth is zero based,
  // so we increment by 1
  let mm = this.getMonth() + 1;
  let dd = this.getDate();

  return [this.getFullYear(),
          (mm>9 ? '' : '0') + mm,
          (dd>9 ? '' : '0') + dd
        ].join('-');
};

const dates = {
  startDate: function() {
     const startDate = new Date();
     startDate.setDate(startDate.getDate() - 7);
     return startDate.yyyymmdd();
   },
   endDate: function() {
     const endDate = new Date();
     return endDate.yyyymmdd();
   }
 }
yyyymmdd just helps us format the date in a way that the GitHub API wants it (yyyy-mm-dd).
When getTrends fetches data, it calls updateTrends with the fetched items. Le's see what that method does:
app.updateTrends = function(trends) {
 const trendsRow = document.querySelector('.trends');
  for(let i = 0; i < trends.length; i++) {
    const trend = trends[i];
    trendsRow.appendChild(app.createCard(trend));
  }
}
It iterates over all the items from the request and uses createCard to build a DOM template and appends this template to the .trends element:
 <!-- ./index.html -->

<div class="row trends">
 <!-- append here -->
</div>
The createCard method just clones a template from the DOM as found in the index.html file. It used this cloned template to build a card:
const app = {
  apiURL: `...`,
  cardTemplate: document.querySelector('.card-template')
}

app.createCard = function(trend) {
  const card = app.cardTemplate.cloneNode(true);
  card.classList.remove('card-template')
  card.querySelector('.card-title').textContent = trend.full_name
  card.querySelector('.card-lang').textContent = trend.language
  card.querySelector('.card-stars').textContent = trend.stargazers_count
  card.querySelector('.card-forks').textContent = trend.forks
  card.querySelector('.card-link').setAttribute('href', trend.html_url)
  card.querySelector('.card-link').setAttribute('target', '_blank')
  return card;
}
The following is the HTML content from the index.html from which the card is cloned and created:
<div class="row trends">
  <divclass="col s12 m4 card-template">
    <div class="card horizontal">
      <div class="card-stacked">
        <div class="card-content white-text">
          <span class="card-title">Card Title</span>
          <div class="card-sub grey-text text-lighten-2">
            <i class="material-icons">info</i><span class="card-lang"> JavaScript</span>
            <i class="material-icons">star</i><span class="card-stars"> 299</span>
            <i class="material-icons">assessment</i><span class="card-forks"> 100</span>
          </div>
          <p>A set of best practices for JavaScript projects</p>
        </div>
        <div class="card-action">
          <a href="#" class="card-link">Visit Repo</a>
        </div>
      </div>
    </div>
  </div>
</div>
Runtime Caching
Runtime caching are those dynamic items that you need to serve from the cache for using during the app usage. Not the app shell now, but the actual content that the user will consume.
We need to tell service worker which resource we at runtime in the precache configuration:
// ./tools/precache.js
const name = 'scotchPWA-v1'
module.exports = {
  staticFileGlobs: [
    // ...
  ],
  stripPrefix: '.',
  // Run time cache
  runtimeCaching: [{
    urlPattern: /https:\/\/api\.github\.com\/search\/repositories/,
    handler: 'networkFirst',
    options: {
      cache: {
        name: name
      }
    }
  }]
};
We define a URL pattern that when matched, we intend to cache it's payload. The pattern matches all GitHub Search API requests. This will cache the app but does not mean our request will not be sent to the network. We need to implement one of the caching strategy, "Cache, Then network."
With that strategy, when a request is made, we first serve the cache version and update with network if there is connectivity:
app.getTrends = function() {
 const networkReturned = false;
  if ('caches' in window) {
    caches.match(app.apiURL).then(function(response) {
      if (response) {
        response.json().then(function(trends) {
          console.log('From cache...')
          if(!networkReturned) {
            app.updateTrends(trends);
          }
        });
      }
    });
  }

  fetch(app.apiURL)
  .then(response => response.json())
  .then(function(trends) {
    console.log('From server...')
    app.updateTrends(trends.items)
    networkReturned = true;
  }).catch(function(err) {
    // Error
  });
}
Update the version in precache.js and regenerate the service work:
const name = 'scotchPWA-v2'
npm run sw
When you run the app, try to refresh it again, pull out the debug console and take the app offline. After doing, so refresh again and see some magic:
Refreshing
Once the user starts sensing that the app is behaving like a mobile app, she might want to update contents manually when there is a better connection. You need to give them the chance to refresh. Let's add an event to the refresh button and call getTrends when it's called:
document.addEventListener('DOMContentLoaded', function() {
 app.getTrends()

 // Event listener for refresh button
 const refreshButton = document.querySelector('.refresh');
 refreshButton.addEventListener('click', app.getTrends)
})
What Next?
Hope this one was not such a mouthful? In summary, you now know how to build apps that work offline or flaky connections; welcome to the gang. We are done with the boring parts of service workers, in the next (and last) post, we will discuss what's fun about this technology. Some of which include push notifications, home screen icons, and splash screen.