Upgrading to

@JSConf.CN Shanghai July 2017

Progressive Web Apps

"Talking in Chinese.

  Slides are in English."

Evan You's Pattern

Speaker

Attendee

Alibaba Trip

Ele.me

Attendee

Wepiao.com

A Story of Web Apps

Upgrading to PWA or

A Web App

Let's get started at

Githuber.js

https://huangxuan.me/githuber.js/

  • API Data => UI

  • JavaScript Framework

  • Client-side Routing

  • Single-Page App

  • Transpiler

  • Bundler

  • API Data => UI

  • JavaScript Framework

  • Client-side Routing

  • Single-Page App

  • Transpiler

  • Bundler

Githuber.js

https://huangxuan.me/githuber.js/

In Browsers

Runtime & Entry

Network

Code via HTTP

Hard Dependencies

🎆

We won on desktop!

A Standalone Web App

Add some native flavours

iOS 1.1.3

2008-01-15 

Added section on specifying a web clip icon.

iOS 2.1.0

2008-09-09 

Bookmarked home screen apps open in full screen

<!-- Add to homescreen for Chrome on Android -->
<meta name="mobile-web-app-capable" content="yes">
<mate name="theme-color" content="#000000">

<!-- Add to homescreen for Safari on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Lighten">

<!-- Tile icon for Win8 (144x144 + tile color) -->
<meta name="msapplication-TileImage" content="images/touch/ms-touch-icon-144x144-precomposed.png">
<meta name="msapplication-TileColor" content="#3372DF">

<!-- Icons for iOS and Android Chrome M31~M38 -->
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="images/touch/apple-touch-icon-144x144-precomposed.png">
<link rel="apple-touch-icon-precomposed" sizes="114x114" href="images/touch/apple-touch-icon-114x114-precomposed.png">
<link rel="apple-touch-icon-precomposed" sizes="72x72" href="images/touch/apple-touch-icon-72x72-precomposed.png">
<link rel="apple-touch-icon-precomposed" href="images/touch/apple-touch-icon-57x57-precomposed.png">

<!-- Generic Icon -->
<link rel="shortcut icon" href="images/touch/touch-icon-57x57.png">

<!-- Chrome Add to Homescreen -->
<link rel="shortcut icon" sizes="196x196" href="images/touch/touch-icon-196x196.png">
{
  "name": "Githuber.JS",
  "short_name": "Githuber.JS",
  "icons": [{
      "src": "logo-512x512.png",
      "type": "image/png",
      "sizes": "512x512"
    }],
  "start_url": "./",
  "display": "standalone",
  "orientation": "portrait",
  "theme_color": "#f36a84",
  "background_color": "#ffffff"
}

Web App Manifest

<link rel="manifest" href="/manifest.json">

An Installable Web App

Network As Progressive Enhancement

// Somewhere in your javascript
var localServer = google.gears.factory.create("localserver");
var store = localServer.createManagedStore(STORE_NAME);
store.manifestUrl = "manifest.json"

Google Gears (2007)

{
  "betaManifestVersion": 1,
  "version":  "1.0",
  "entries": [ 
    { "url":  "index.html"},
    { "url":  "main.js"}
  ]
}

App Cache (2011)

<html manifest="cache.appcache">
CACHE MANIFEST

CACHE:
style/default.css
images/sound-icon.png
images/background.png

NETWORK:
comm.cgi

HTTP caching Race Condition

Unrecoverable Errors (Cache the manifest)

Success only If ALL resources are cached

Not a Progressive Enhancement

Caching Dynamic Contents needs hack & re-architecture

Full of Assumption

The Cache is organized by "Manifest"

Manifest changes trigger ALL cached files redownload.

NO clean up mechanism for cache

Deficit of Flexibility

Unprogrammable

Very Limited Fallback/Routing

Network

<===    HTTP    ===>

Download & Run on the fly

Cache Storage

Network

Introducing Service Worker

A Client-side JavaScript Proxy

Service Worker

||

v

<===    HTTP    ===>

// register Service Worker in index.html
if('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('/sw.js')
    .then( registration => {
      console.log('Service Worker Registered');
    })
    .catch( error => {
      console.log('Registration failed with' + error);
    })
}

Register

HTTPS as Prerequisite

Service Worker Pitfalls

/* sw.js */
self.onfetch = (e) => {
  e.respondWith(new Response('Hello World from SW!'))
}

Say "Hello World"

😱😱😱

↕︎

Register
Error
Deleted
Idle
Running

↕︎

↕︎

⤵︎

Service Worker LifeCycle

⤵︎
Activate
Install

↕︎

Register
Error
Deleted
Idle
Running

↕︎

↕︎

⤵︎

LifeCycle Events

⤵︎
Install

Activate

ExtendableEvent

// IDL
interface ExtendableEvent : Event {
  void waitUntil(Promise<any> f);
};
// sw.js
self.oninstall = (e) => {
  e.waitUntil(promiseA)
}
self.onactivate = (e) => {
  e.waitUntil(promiseB)
}

↕︎

Register
Install
Error
Deleted
Idle
Activate
Running

↕︎

↕︎

⤵︎

Functional Events

Fetch
Push
Sync

 

⤵︎

↕︎

Register
Install
Error
Deleted
Idle
Activate
Running

↕︎

↕︎

⤵︎

Inherited Events

Fetch
Push
Sync

 

⤵︎

Message

👪

const CACHE_NAMESPACE = 'githuber.js.dev-'
const PRECACHE = CACHE_NAMESPACE + 'precache'
const PRECACHE_LIST = [
  './',
  './static/js/bundle.js',
]
self.oninstall = (e) => {
  e.waitUntil(
    caches.open(PRECACHE)
    .then(cache => cache.addAll(PRECACHE_LIST))
  )
}

Pre-cache on install

const CACHE_NAMESPACE = 'githuber.js.dev-'
const PRECACHE = CACHE_NAMESPACE + 'precache'
const PRECACHE_LIST = [
  './',
  './static/js/bundle.js',
]
self.oninstall = (e) => {
  e.waitUntil(
    caches.open(PRECACHE)
    .then(cache => cache.addAll(PRECACHE_LIST))
  )
}

Pre-cache on install

Cache Storage

Network

Cache.addAll(requests)

Service Worker

||

v

<===>

"Origin Storage"

Service Worker Pitfalls

self.onfetch = (e) => {
  // suppose we have offline.html in caches
  // match offline.html in all cache opened in caches
  const sorry = caches.match("offline.html")

  // if the fetched reject, we return the sorry Response.
  e.respondWith(
    fetched.catch(_ => sorry)
  )
}

Custom Offline Page

self.onfetch = (e) => {
  const fetched = fetch(e.request)
  // match offline.html in all cache opened in caches
  const sorry = caches.match("offline.html")

  // if the fetched reject, we return the sorry Response.
  e.respondWith(
    fetched.catch(_ => sorry)
  )
}

Custom Offline Page

self.onfetch = (e) => {
  // Cuz we are a SPA using History API, 
  // we need "rewrite" navigation requests to root route.
  let url = rewriteUrl(e);
  //
  const cached = caches.match(url) 

  e.respondWith(
    cached
      .then(resp => resp || fetch(url))
      .catch(_ => {/* eat any errors */})
  )
}

Serve the app from cache

self.onfetch = (e) => {
  // Cuz we are a SPA using History API, 
  // we need "rewrite" navigation requests to root route.
  let url = rewriteUrl(e);
  // match url in all cache opened in caches
  const cached = caches.match(url) 

  e.respondWith(
    cached
      .then(resp => resp || fetch(url))
      .catch(_ => {/* eat any errors */})
  )
}

Serve the app from cache

self.onfetch = (e) => {
  // Cuz we are a SPA using History API, 
  // we need "rewrite" navigation requests to root route.
  let url = rewriteUrl(e);
  // match url in all cache opened in caches
  const cached = caches.match(url) 

  e.respondWith(
    cached
      .then(resp => resp || fetch(url))
      .catch(_ => {/* eat any errors */})
  )
}

Serve the app from cache

Cache Storage

Network

How can SW be started before

navigations occur?

Service Worker

||

v

<===    HTTP    ===>

  • Web Worker

  • SharedWorker 

  • Chrome Background Pages

  • Chrome Event Pages

  • !App Cache

IS DEAD?!

WHAT IF

An Evergreen Web App

Update your installable web app

By default, SW is for SECOND load

Service Worker Pitfalls

self.onactivate = (e) => {
  // Clients.claim() let SW control the page in the first load
  clients.claim()
}

By default, A page's fetches won't  

go through SW unless the page request itself went through SW.

Override with clients.claim()

Considered Update if Byte-different

Expire automatically (no dead-code)

/**
 * In production,
 * We could introduce a build process to automatically 
 * cause byte-diffs by 
 * (1) injecting versioned assets manifest
 * (2) increasing/generating VERSION by diffing all assets
 */
const VERSION = "0"
const VERSION = "1"

By default, new SW won't take over until app RESTARTS

 

Service Worker Pitfalls

self.oninstall = (e) => {
  e.waitUntil(
    caches.open(PRECACHE)
    .then(cache => cache.addAll(PRECACHE_LIST))
    .then(self.skipWaiting())
    .catch(err => console.log(err))
  )
}

By default, new SW will wait until

the old one being abandoned

Override with skipWaiting()

SkipWaiting() means new SW controls a old version page!

Service Worker Pitfalls

Quick Update = skipWaiting() + Refresh

Prompt users to refresh

Prompt users to refresh

But what if they refuse to???

Quick Update = skipWaiting() + Refresh

Solution.1

Forced Refreshing after skipWaiting()

// broadcasting clients to do window.location.reload() 
self.clients.matchAll().then(clients => {
  clients.forEach(client => {
    client.postMessage(REFRESH_MSG)
  })
})

// new API: client.navigate
self.clients.matchAll().then(clients => {
  clients.forEach(client => {
    client.navigate(REFRESH_URL)
  })
})

Solution.2

SkipWaiting-then-Refresh on interaction via PostMessage()

// registration.waiting.postMessage()
self.onmessage = (e) => {
  switch (e.data.command) {
    case "SKIP_WAITING_AND_RELOAD_ALL_CLIENTS_TO_ROOT":
      self.skipWaiting()
        .then(_ => reloadAllClients("/"))
        .catch(err => console.log(err))
      break;
  }
}

Work with HTTP Cache

Service Worker Pitfalls

Cache Storage

Network

Service Worker

<==>

A

v

HTTP Disk Cache

<==>

<==>

||

cache-control:max-age=315360000
GET /bundle.js
self.onfetch = (e) => {
  // Fetch API may support cache mode in the future:

  let fetched = fetch(e.request, {cache: 'reload'}) 
  let fetched = fetch(e.request, {cache: 'no-cache'})  

  // Now you can only use cache-busting query to workaround.
  // but would also break revalidation depends on your CDN 
  let fetched = fetch(`${e.request.url}?${Date.now()}`)

  e.respondWith(fetched)
}

Cache-busting Query

Modern Best Practice

// long-term caching
cache-control:max-age=315360000
// Could be injected in build process
const PRECACHE_LIST = [
  './',
  './script-f93bca2c.js',
  './styles-a837cb1e.css',
  './cats-0e9a2ef4.jpg'
]
self.oninstall = (e) => {
  e.waitUntil(
    caches.open(PRECACHE)
    .then(cache => cache.addAll(PRECACHE_LIST))
  )
}

Cache Migration

Service Worker Pitfalls

A Simple Migration Example

// sw.js
const PRECACHE = "precache" + VERSION
const RUNTIME = "runtime" + VERSION
const expectedCaches = [PRECACHE, RUNTIME]

self.onactivate = (e) => {
  e.waitUntil(
    caches.keys().then(cacheNames => Promise.all(
      cacheNames
        .filter(cacheName => cacheName.startsWith(CACHE_NAMESPACE)) 
        .filter(cacheName => !expectedCaches.includes(cacheName))    
        .map(cacheName => caches.delete(cacheName))
    ))
  )
}

👋 Service Worker

SW-Precache

Service Worker Libraries

// sw-precache-config.js
module.exports = {
  staticFileGlobs: [
    'app/css/**.css',
    'app/**.html',
    'app/images/**.*',
    'app/js/**.js'
  ]
};
$ sw-precache --config=path/to/sw-precache-config.js
// sw.js
var precacheConfig = [
  ["js/a.js", "3cb4f0"], 
  ["css/b.css", "c5a951"]
]

// urlToCacheKeys
Map(2) {
  "http.../js/a.js" => "http.../js/a.js?_sw-precache=3cb4f0", 
  "http.../css/b.js" => "http.../css/b.css?_sw-precache=c5a951"
}

collect version at build time Incremental Update Mechanism

// webpack.config.js
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
module.exports = {
  plugins: [
    new SWPrecacheWebpackPlugin({
      // assets already hashed by webpack aren't concerned to be stale
      dontCacheBustUrlsMatching: /\.\w{8}\./,  
      filename: 'service-worker.js',
      minify: true,
      navigateFallback: PUBLIC_PATH + 'index.html',
      staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/],
    }),
  ],
};

Can be used with Gulp/Grunt as well !

An Offline-1st Web App 

Offline is not an Error Condition

RWD      Media Query        "Mobile First"

PWA      Service Worker    "Offline First"

Ajax       XHR                          "Async First"

Buzzword    Key Technology              The Architecture

// here, we hard-code the online/offline logics
// In production, we can expose callbacks to subscribers
function updateOnlineStatus(event) {
  if(navigator.onLine){
    document.body.classList.remove('app-offline')
  }else{
    document.body.classList.add('app-offline');
    createSnackbar({ message: "you are offline." })
  }
}

window.addEventListener('online',  updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// sw.js
self.onfetch = (e) => {
  // ...
  if(url.includes('api.github.com')){
    e.respondWith(networkFirst(url));
    return;
  } 
  if(url.includes('githubusercontent.com')){
    e.respondWith(staleWhileRevalidate(url));
    return;
  }
  if(PRECACHE_ABS_LIST.includes(url)){
    e.respondWith(cacheOnly(url));
    return;
  }
  // default: Network Only
}

Runtime Caching

// sw.js
self.onfetch = (e) => {
  // ...
  if(url.includes('api.github.com')){
    e.respondWith(networkFirst(url));
    return;
  } 
  if(url.includes('githubusercontent.com')){
    e.respondWith(staleWhileRevalidate(url));
    return;
  }
  if(PRECACHE_ABS_LIST.includes(url)){
    e.respondWith(cacheOnly(url));
    return;
  }
  // default: Network Only
}

Runtime Caching

// sw.js
self.onfetch = (e) => {
  // ...
  if(url.includes('api.github.com')){
    e.respondWith(networkFirst(url));
    return;
  } 
  if(url.includes('githubusercontent.com')){
    e.respondWith(staleWhileRevalidate(url));
    return;
  }
  if(PRECACHE_ABS_LIST.includes(url)){
    e.respondWith(cacheOnly(url));
    return;
  }
  // default: Network Only
}

Runtime Caching

Cache Replacement 

Policies

Service Worker Pitfalls

// sw.js
function replaceRuntimeCache(MAX_ENTRIES){
  caches.open(RUNTIME)
    .then(cache => {
      cache.keys()
        .then(entries => {
          // FIFO queue
          if(entries.length > MAX_ENTRIES) {
            cache.delete(entries[0])
          } 
        })
    })
}

Naive FIFO Replacement

SW-Toolbox

Service Worker Libraries

// sw.js
importScripts('sw-toolbox.js')
// sw.js
importScripts('sw-toolbox.js')

toolbox.router.get('/(.*)');

Express-style Routing

// sw.js
importScripts('sw-toolbox.js')

toolbox.router.get('/(.*)', global.toolbox.cacheFirst);

5 Caching Strategies

  • CacheOnly
  • CacheFirst
  • Fastest (Stale-while-Revalidate)
  • NetworkOnly
  • NetworkFirst
// sw.js
importScripts('sw-toolbox.js')

toolbox.router.get('/(.*)', global.toolbox.cacheFirst, {
  cache: {
   name: 'products',
   maxEntries: 12,

  },
  origin: /\.products\.com$/
});

LRU via IDB

// sw.js
importScripts('sw-toolbox.js')

toolbox.router.get('/(.*)', global.toolbox.cacheFirst, {
  cache: {
   name: 'products',
   maxEntries: 12,
   maxAgeSeconds: 86400  // 24hr
  },
  origin: /\.products\.com$/
});

Time-aware LRU via IDB

// sw-precache-config.js
module.exports = {
  // ...
  runtimeCaching: [{
    urlPattern: /this\\.is\\.a\\.regex/,
    handler: 'networkFirst'
  }]
};

// sw.js with sw-toolbox imported
toolbox.precache([
  "./index.a35bc762.js",
  "./style.5217a6fb.css"
])

sw-precache ❤️ sw-toolbox

Workbox

Service Worker Libraries

// User.jsx
export default class User extends Component {
  // ...
  openDedicatedCache(data){
    caches.open(`${data.login}.githuber.js`)
      .then(cache => cache.addAll([
        `https://api.github.com/users/${data.login}`,
        data.avatar_url
      ]))
      .then(_ => {
        this.setState({
          cached: true
        })
      }) 
  }
}

Cache on demand

// Home.jsx
export default class Home extends Component {
  // ...
  inspectCache(data){
    if(!window.caches) return;  // PE

    caches.keys()
      .then(cacheNames => cacheNames.filter(
        cacheName => cacheName.endsWith('githuber.js')))
      .then(cacheNames => Promise.all(
        cacheNames.map(
          cacheName => this.mapCacheNameToData(cacheName))))
      .then(cached => this.setState({cached: cached}))
  }
}

Cache-aware UI

A Streaming Web App

Gradually load and install

Mobile is still slow

on-demand software?

  • HTTP Roundtrips

  • Deep Dependency Graph

  • JavaScript Startup Costs

  • Monolithic Bundle

  • Push/Preload initial route

  • Render initial route

  • Pre-cache future routes

  • Lazy-load future routes

You know Dependency Graph better than browsers do

First Visit

Criteria

<1.7s

<5s

Repeat Visit

Criteria

∞→0s

<2s

A Progressive Web App 

The best of both worlds

Reliable

Fast

Engaging

Samsung Internet DeX / Chromebook / Win10

A JavaScript Web App 

Integrated with JS frameworks

Any Web App/Site

PWA?    SPA?    APP? 

"Remember, this is for everyone. The name isn’t."

@phae

“Progressive Web Apps”

is just The Web 

Service Worker

WASM

Fetch

React

Web GL

CSS

Promise

HTTPS

HTTP2.0

Web Payment

Device API

Web Socket

Web Push Protocol

Indexed DB

ExtendableEvent

Cache Storage

RWD

ES.Next

Web VR/AR

Vue

Angular

Web Components

Houdini

Notification

Next.js

Webpack

Babel

The Web

The end and the beginning

Tons of "Hybridzation"

"It's about 'Anyone, at any time, can publish anything from anywhere.' That is the stuff of revolutions, and our evolution as a species."

@brianleroux

"Progressive Web Apps:
Escaping Tabs Without Losing Our Soul"

@slightlylate

Let's make the Web

great again!

@huxpro

@JSConf.CN Shanghai July 2017

Thank You