PWA

850 阅读15分钟

Web App Manifest

manifest是一种简单的json数据风格的配置文件,通过对其相应的属性进行配置,从而实现自定义启动画面、app默认URL、设置界面颜色、设置桌面图标等等。

添加方式

在根html中利用link标签引入manifest.json

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

这里需要注意的是href引入的根路径为该html文件,如上引入方式即manifest.json与html文件同级

manifest.json属性

name

name: {string},用来描述应用的名称,会显示在各类提示的标题位置和启动画面中。

short_name

short_name: {string},用来描述应用的短名字。当应用的名字过长,在桌面图标上无法全部显示时,会用short_name中定义的来显示。

start_url

start_url: {string},用来描述当用户从设备的主屏幕点击图标进入时,进入的第一个url。

  • 如果设置为空字符串,则会以manifest.js的地址做为URL
  • 如果设置的URL打开失败,则和正常显示的网页打开错误的样式一下(可以通过后面讲的ServiceWorker改善)
  • 如果设置的URL与当前的项目不在一个域下,也不能正常显示
  • start_url 必须在scope的作用域范围内
  • 如果 start_url 是相对地址,那么根路径基于manifest的路径
  • 如果 start_url 为绝对地址,则该地址将永远以 / 作为根路径

icons

icons: {Array.},用来设置Web App的图标集合。

  • src: {string},图标的地址
  • type {string},图标的 mime 类型,可以不填写。这个字段会让浏览器不使用定义类型外的图标
  • sizes {string},图标的大小,用来表示widthxheight,单位为px,如果图标要适配多个尺寸,则第个尺寸间用空格分割,如12x12 24x24 100x100

background_color

background_color: {Color},值为CSS的颜色值,用来设置Web App启动画面的背景颜色。

theme_color

theme_color: {Color},定义和background_color一样的CSS颜色值,用于显示Web App的系统背景色,显示在banner位置。

display

display: {string},用来指定 Web App 从主屏幕点击启动后的显示类型

显示类型 描述
fullscreen 应用的显示界面将占满整个屏幕
standalone 浏览器相关UI(如导航栏、工具栏等)将会被隐藏
minimal-ui 显示形式与standalone类似,浏览器相关UI会最小化为一个按钮,不同浏览器在实现上略有不同
browser 浏览器模式,与普通网页在浏览器中打开的显示一致

支持性

iOS对pwa的支持性较低,由于safari其自身支持添加到主屏幕功能,若要让safari上的表现形式与安卓一致,需要通过meta/link标签声明一些私有属性

<!-- 指定桌面 icon -->
<link rel="apple-touch-icon" href="/images/logo.png" >
<!-- 指定应用名称 -->
<meta name="apple-mobile-web-app-title" content="myapp" >
<!-- 是否隐藏 Safari 地址栏等  -->
<meta name="apple-mobile-app-capable" content="yes" >
<!-- 修改 iOS 状态栏颜色 -->
<meta name="apple-mobile-web-app-status-bar-style" content="black" >

Service Workers

目的

  • 实现离线优先。
  • 允许新 Service Worker 自行做好运行准备,无需中断当前的 Service Worker。
  • 确保整个过程中作用域页面由同一个 Service Worker(或者没有 Service Worker)控制。
  • 确保每次只运行网站的一个版本。

最后一点非常重要。如果没有 Service Worker,用户可以将一个标签加载到您的网站,稍后打开另一个标签。这会导致同时运行网站的两个版本。有时候这样做没什么问题,但如果您正在处理存储,那么,出现两个标签很容易会让您的操作中断,因为它们的共享的存储空间管理机制大相径庭。这可能会导致错误,更糟糕的情况是导致数据丢失。

能力

  • 网络代理
  • 转发请求
  • 伪造响应
  • 离线缓存

PWA就是使用其拦截和处理网络请求的能力,去实现一个离线应用。

特征

  • 无法操作DOM

  • 只能在HTTPS或localhost环境下运行

  • 可以拦截全站请求从而控制你的应用

  • 与主线程独立不会被阻塞(不要再应用加载时注册sw)

  • 完全异步,无法使用XHR和localStorage

  • 一旦被 install,就永远存在,除非被 uninstall或者dev模式手动删除

  • 独立上下文

  • 响应推送

  • 后台同步

注册

页面在首次打开的时候就进行缓存sw的资源,因为sw内预缓存资源是需要下载的,sw线程一旦在首次打开时下载资源,将会占用主线程的带宽,以及加剧对cpu和内存的使用,而且Service worker 启动之前,它必须先向浏览器 UI 线程申请分派一个线程,再回到 IO 线程继续执行 service worker 线程的启动流程,并且在随后多次在ui线程和io线程之间切换,所以在启动过程中会存在一定的性能开销,在手机端尤其严重。 因此需要将sw的注册绑定在页面的load事件下,即在页面加载完毕后再执行sw相关操作,而在注册之前一般对浏览器的支持程度判定。

// CODELAB: Register service worker.
if('serviceWorker' in navigator) {
  window.addEventListener('load',() => {
    navigator.serviceWorker.register("/sw.js",{ scope: "/static"})
      .then((reg) => {
      console.log("Service Workder registered",reg)
    })
      .catch((e) => {
      console.log("Service Workder register failed",e)
    })
  })
} else console.log("The browser doesn't support Service Worker")

在这里navigator.serviceWorker作为一个ServiceWorkerContainer具有一系列的API可以实现Service Worker的控制。

ServiceWorkerContainer.register()

register是我们最常用的方法,用给定的scriptURL创建或者更新一个ServiceWorkerRegistration

register 方法接受两个参数,第一个是 Service Worker文件的路径,这个文件路径是相对于 Origin ,而不是当前 JS 文件的目录的;第二个参数是 Serivce Worker 的配置项,可选填,其中比较重要的是scope 属性,它是用来声明其作用域,且该scope只能指向同级域或子域(除服务端设置service-worker-allowed外)。

生命周期

我们可以看到生命周期分为这么几个状态 安装中, 安装后, 激活中, 激活后, 废弃

  • 安装( installing ):这个状态发生在 Service Worker 注册之后,表示开始安装,触发 install 事件回调指定一些静态资源进行离线缓存。

install 事件回调中有两个方法:

  • event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。
  • self.skipWaiting()self 是当前 context 的 global 变量,执行该方法表示强制当前处在 waiting 状态的 Service Worker 进入 activate 状态。
  • 安装后( installed ):Service Worker 已经完成了安装,并且等待其他的 Service Worker 线程被关闭。
  • 激活( activating ):在这个状态下没有被其他的 Service Worker 控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联缓存的旧缓存资源,等待新的 Service Worker 线程被激活。

activate 回调中有两个方法:

  • event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。
  • self.clients.claim():在 activate 事件回调中执行该方法表示取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制着页面,之后会被停止。
  • 激活后( activated ):在这个状态会处理 activate 事件回调 (提供了更新缓存策略的机会)。并可以处理功能性的事件 fetch (请求)sync (后台同步)push (推送)
  • 废弃状态 ( redundant ):这个状态表示一个 Service Worker 的生命周期结束。

这里特别说明一下,进入废弃 (redundant) 状态的原因可能为这几种:

  • 安装 (install) 失败
  • 激活 (activating) 失败
  • 新版本的 Service Worker 替换了它并成为激活状态

Service Worker支持的所有事件

  • install:Service Worker 安装成功后被触发的事件,在事件处理函数中可以添加需要缓存的文件。
  • activate:当 Service Worker 安装完成后并进入激活状态,会触发 activate 事件。通过监听 activate 事件你可以做一些预处理,如对旧版本的更新、对无用缓存的清理等。
  • message:Service Worker 运行于独立 context 中,无法直接访问当前页面主线程的 DOM 等信息,但是通过 postMessage API,可以实现他们之间的消息传递,这样主线程就可以接受 Service Worker 的指令操作 DOM。

Service Worker 有几个重要的功能性的的事件,这些功能性的事件支撑和实现了 Service Worker 的特性。

  • fetch (请求):当浏览器在当前指定的 scope 下发起请求时,会触发 fetch 事件,并得到传有 response 参数的回调函数,回调中就可以做各种代理缓存的事情了。
  • push (推送):push 事件是为推送准备的。通过 PUSH API,当订阅了推送服务后,可以使用推送方式唤醒 Service Worker 以响应来自系统消息传递服务的消息,即使用户已经关闭了页面。
  • sync (后台同步):sync 事件由 background sync (后台同步)发出。background sync 配合 Service Worker 推出的 API,用于为 Service Worker 提供一个可以实现注册和监听同步处理的方法。但它还不在 W3C Web API 标准中。在 Chrome 中这也只是一个实验性功能,需要访问 chrome://flags/#enable-experimental-web-platform-features ,开启该功能,然后重启生效。
//sw.js
//监听install事件
self.addEventListener('install', (evt) => {
  //此时sw状态为installed
  evt.waitUntil(	//waitUntil为等待执行
    caches.open(CACHE_NAME).then((cache) => {  //CACHE_NAME为浏览器即将开启的缓存名字
      return cache.addAll(FILES_TO_CACHE)  //FILES_TO_CACHE为包含文件名字符串的数组
    })
  )
  self.skipWaiting();		//在新旧sw交替时,新sw跳过waiting状态,直接进入activate状态
});

self.addEventListener('activate', (evt) => {
  //此时sw状态为activated
  evt.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(keyList.map((key) => {
        if (key !== CACHE_NAME) {
          return caches.delete(key)		//释放不相干资源,仅保留当前缓存资源
        }
      }))
    })
  )
  self.clients.claim();		//在首次sw安装时接管client控制权,否则在首次安装sw时需要手动刷新sw才能拦截请求等
});

self.addEventListener('fetch', (evt) => {
  // 只对同源的资源走 sw,cdn 上的资源利用 http 缓存策略
  if (new URL(evt.request.url).origin !== self.origin) {
    return;
  }
  if (evt.request.url.includes('/api/movies')) {	//对特定url的请求缓存
    evt.respondWith(	//respondWith为响应api
      fetchAndCache(evt.request)
      .catch(function () {
        return caches.match(evt.request);
      })
    );
    return;
  }
  evt.respondWith(	
    fetch(evt.request)
    	.then((res) => {
        	console.log(res)			//进入成功回调,打印接口响应
      })
      .catch(() => {  //cache进入fetch失败回调,即离线回调
        return caches.open(CACHE_NAME)  //通过缓存对象caches的全局变量打开对应缓存
          .then((cache) => {
            return cache.match('offline.html')		//返回个性化离线页面
          })
      })
  )
});

/**
 * 请求并缓存内容
 *
 * @param {Request} req request
 * @return {Promise}
 */
function fetchAndCache(req) {
  return fetch(req)
    .then(function (res) {
    saveToCache(req, res.clone());
    return res;
  });
}

/**
 * 缓存到 cacheStorage 里
 *
 * @param {Request} req 请求对象
 * @param {Response} res 响应对象
 */
function saveToCache(req, res) {
    return caches
        .open(CACHE_NAME)
        .then(cache => cache.put(req, res));
}

缓存策略

缓存级别

http 缓存、ManifestService Worker 三种缓存都使用时, 会以 service worker 优先, 因为sw 把请求拦截了, 优先做处理,如果缓存库里有, 就直接返回, 没有就走正常请求。

然后就到了Manifest 层,Manifest缓存里有的话, 就直接取,没有的话就去请求。

然后会到HTTP 缓存里面取, 没有的话,就发请求去获取, 服务端根据HTTPetag或者Modified Time , 返回304 或者 200 + 数据内容。

缓存方式

静态
self.addEventListener('install', (evt) => {
  //此时sw状态为installed
  evt.waitUntil(	//waitUntil为等待执行
    caches.open(CACHE_NAME).then((cache) => {  //CACHE_NAME为浏览器即将开启的缓存名字
      return cache.addAll(FILES_TO_CACHE)  //FILES_TO_CACHE为包含文件名字符串的数组
    })
  )
  self.skipWaiting();		//在新旧sw交替时,新sw跳过waiting状态,直接进入activate状态
});

install事件中通过全局变量cachesopen方法打开缓存后,在then回调中参数cache的的addAll函数中传递需要缓存的文件名数组对象。

动态
self.addEventListener('fetch', (evt) => {
  if (evt.request.url.includes('/forecast')) {	//缓存特定url的请求
    evt.respondWith(
      caches.open(DATA_CACHE_NAME)	//打开缓存
        .then((cache) => {
          return fetch(evt.request)	//发出请求
            .then((response) => {
              if (response.status === 200) {	//判断响应状态码
                cache.put(evt.request.url, response.clone())	//通过put函数缓存
              }
              return response
            })
            .catch((error) => {
              return cache.match(evt.request)
            })
        })
    )
    return
  }
});

fetch事件中打开缓存后,发出请求,若响应成功通过cacheput函数缓存动态数据。

response.clone() 用于创建可单独读取的额外副本。

缓存模式

Cache only(纯缓存)

适合于: 您认为属于该“版本”网站静态内容的任何资源。您应在安装事件中缓存这些资源,以便您可以依靠它们。

self.addEventListener('fetch', function(event) {
  // If a match isn't found in the cache, the response will look like a connection error
  event.respondWith(caches.match(event.request));
});
Network Only(纯网络)

适合于: 没有相应离线资源的对象,如 analytics pings、non-GET 请求。

self.addEventListener('fetch', function(event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which will result in default browser behaviour
});
Cache First(缓存优先)

适合于: 如果您以离线优先的方式进行构建,这将是您处理大多数请求的方式。 根据传入请求而定,其他模式会有例外。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});

其针对缓存中的资源为您提供“仅缓存”行为,而对于未缓存的资源则提供“仅网络”行为(其包含所有 non-GET 请求,因为它们无法缓存)。

Cache & network race(缓存网络先到先得)

适合于: 小型资源,可用于改善磁盘访问缓慢的设备的性能。

在硬盘较旧、具有病毒扫描程序且互联网连接很快这几种情形相结合的情况下,从网络获取资源比访问磁盘更快。不过,如果在用户设备上具有相关内容时访问网络会浪费流量,请记住这一点。

// Promise.race is no good to us because it rejects if a promise rejects before fulfilling.Let's make a proper race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map(p => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach(p => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b))
      .catch(() => reject(Error("All failed")));
  });
};

self.addEventListener('fetch', function(event) {
  event.respondWith(
    promiseAny([
      caches.match(event.request),
      fetch(event.request)
    ])
  );
});

Network First(网络优先)

适合于: 快速修复(在该“版本”的网站外部)频繁更新的资源。 例如,文章、头像、社交媒体时间表、游戏排行榜。

这意味着您为在线用户提供最新内容,但离线用户会获得较旧的缓存版本。 如果网络请求成功,您可能需要更新缓存条目。

不过,此方法存在缺陷。如果用户的网络时断时续或很慢,他们只有在网络出现故障后才能获得已存在于设备上的完全可接受的内容。这需要花很长的时间,并且会导致令人失望的用户体验。 请查看下一个模式,缓存然后访问网络,以获得更好的解决方案。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request);
    })
  );
});

Cache then network(先缓存再网络)

适合于: 频繁更新的内容。例如,文章、社交媒体时间表、游戏排行榜。

这需要页面进行两次请求,一次是请求缓存,另一次是请求访问网络。 该想法是首先显示缓存的数据,然后在网络数据到达时更新页面。

有时候,当新数据(例如,游戏排行榜)到达时,您可以只替换当前数据,但是具有较大的内容时将导致数据中断。从根本上讲,不要使用户正在读取或交互的内容“消失”。

Twitter 在旧内容上添加新内容,并调整滚动位置,以便用户不会感觉到间断。 这是可能的,因为 Twitter 通常会保持使内容最具线性特性的顺序。 我为 trained-to-thrill 复制了此模式,以尽快获取屏幕上的内容,但当它出现时仍会显示最新内容。

页面中的代码:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json').then(function(response) {
  return response.json();
}).then(function(data) {
  networkDataReceived = true;
  updatePage();
});

// fetch cached data
caches.match('/data.json').then(function(response) {
  if (!response) throw Error("No data");
  return response.json();
}).then(function(data) {
  // don't overwrite newer network data
  if (!networkDataReceived) {
    updatePage(data);
  }
}).catch(function() {
  // we didn't get cached data, the network is our last hope:
  return networkUpdate;
}).catch(showErrorMessage).then(stopSpinner);

ServiceWorker 中的代码:

我们始终访问网络并随时更新缓存。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function(cache) {
      return fetch(event.request).then(function(response) {
        cache.put(event.request, response.clone());
        return response;
      });
    })
  );
});

Generic fallback(常规回退)

如果您未能从缓存和/或网络提供一些资源,您可能需要提供一个常规回退。

适合于: 次要图像,如头像、失败的 POST 请求、“Unavailable while offline”页面。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    // Try the cache
    caches.match(event.request).then(function(response) {
      // Fall back to network
      return response || fetch(event.request);
    }).catch(function() {
      // If both fail, show a generic fallback:
      return caches.match('/offline.html');
      // However, in reality you'd have many different
      // fallbacks, depending on URL & headers.
      // Eg, a fallback silhouette image for avatars.
    })
  );
});

如果您的页面正在发布电子邮件,您的 ServiceWorker 可能回退以在 IDB 的发件箱中存储电子邮件并进行响应,让用户知道发送失败,但数据已成功保存。

ServiceWorker-side templating(模版渲染)

适合于: 无法缓存其服务器响应的页面。

在服务器上渲染页面可提高速度,但这意味着会包括在缓存中没有意义的状态数据,例如,“Logged in as…”。如果您的页面由 ServiceWorker 控制,您可能会转而选择请求 JSON 数据和一个模板,并进行渲染。

importScripts('templating-engine.js');

self.addEventListener('fetch', function(event) {
  var requestURL = new URL(event.request);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function(response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function(response) {
        return response.json();
      })
    ]).then(function(responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html'
        }
      });
    })
  );
});