一款使用Vue JS,Webpack&Material Design开发的渐进式web应用(PWA)[Part 3]


PART III
这篇文章是A Progressive Web Application with Vue JS, Webpack & Material Design系列的其中一篇,旨在使用VueJS,Webpack和Material Design从头搭建一个基础但功能完整的PWA。如果你还没有看过之前的文章,可以通过以下链接访问:

代码在GitHub开源:
https://github.com/charlesBochet/vueJSPwa

如果你已经完成了第一部分的教程: 我更新了Cropchat的源码,在代码中使用了全新的vue init pwa命令。如果你克隆并使用了该仓库的代码,我强烈建议你拉一遍仓库的代码。

下面是我们的任务清单:

checkList

在本次教程中,我们将给Cropchat应用增加一个超棒的新功能:离线模式

简单来说,我们会学习以下内容:

  • Service Worker 以及它是如何提供离线体验的;
  • 如何缓存应用的App shell(core js and css);
  • 如何缓存应用的HTTP请求(external assets, cat pictures, …);
  • 如何缓存Firebase数据。

[PART 3]使用Service Workers实现离线功能

offline
不依赖网络连接是PWA的关键特性之一。 我们希望持续地为用户提供类app的体验,而不是当没有网络或网络速度很慢的时候出现连接断开或页面冻结的情况。

Google nonexistent connectivity
Chrome通常在弱网或无网络情况下的表现

PWA本质上是运行在webview中的一个网页应用。然而,受益于浏览器和操作系统的发展,Service Workers 使得网页的离线模式变得可能。

Service Workers简介

不同于网页代码,Service Workers是运行在用户浏览器内部的JS代码。即使当你的应用关闭或处于后台时,Service Workers依旧在运行,并且表现出一些类app的特性如缓存(使用Cache Storage API 和 Fetch API),推送通知(使用Push API)及后台同步数据等。
如果你想了解更多关于Service Workers的信息,请参见Google documentation
备注:渐进式应用不一定非要在所有手机上实现离线模式:iOS暂不支持Service Workers。这就是为什么叫PWA为渐进式的原因:它可以运行在任何设备上,而在支持其新特性的设备上表现出更优秀的用户体验。

Service Workers是如何工作的?

你可以把Service Workers看做是一个代理。应用发出的每个HTTP请求都会触发fetch事件。该事件将会被Service Workers捕捉并选择从缓存中取回结果或请求服务器得到结果
你所需要做的只是在有网络的时候将数据缓存下来。那么Service Workers是如何知道什么时候应该缓存什么时候应该从服务器请求数据呢?这完全取决于你:你可以在缓存策略(caching strategies) 中精确控制它:缓存优先(cache first,用于缓存App shell文件),网络优先(network first,用于缓存远程的资源或数据)...

下图便是叫做网络优先(network first)的缓存策略:
network first

还有很多别的缓存策略,可以参考Google Offline Coobook

Service Workers库和VueJS

Service Workers Libraries and VueJS
我在写这篇文章的时候尝试了几种实现缓存的方式:

  • 参考谷歌POC手动实现Service Workers
  • 在缓存静态资源时使用sw-precache库,在应用运行时使用sw-toolbox和sw-precache一同实现缓存
  • 使用谷歌开发的workbox插件

我的建议是使用sw-precache插件:自己写一套Server Workers方法缓慢并且重复造轮子,而Workbox plugin虽然也不错但目前它还不支持应用运行时的缓存机制[现在它已经支持该功能了,可以试试]。

第一步:使用sw-precache缓存静态资源

sw-precache可以缓存由App Shell提供的一些静态资源。根据谷歌App Shell Model描述,App Shell指的是“提供最基本的用户界面所需的HTML,CSS,JS文件”。
这样一来,无论网络状况如何,应用的核心资源总是会被加载并渲染出页面。这被称为pre-caching
在这里我们将会使用sw-precache-webpack-plugin。先安装一下:

npm install sw-precache-webpack-plugin --save

配置sw-precache

事实上,最新的VueJS cli已经集成了sw-precache。如果你用的是最新的VueJS Cli,你甚至不必再特地安装sw-precache

修改文件dist/webpack.prod.conf.js中的配置:

var SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin')

...

var webpackConfig = merge(baseWebpackConfig, {
  ...
  plugins: [
    ...
    // service worker caching
    new SWPrecacheWebpackPlugin({
      cacheId: 'my-vue-app',
      filename: 'service-worker.js',
      staticFileGlobs: ['dist/**/*.{js,html,css}'],
      minify: true,
      stripPrefix: 'dist/'
    })
  ]
})

index.html<body>标签中添加如下代码,这样Webpack将会加载sw-precache插件:

<%= htmlWebpackPlugin.options.serviceWorkerLoader %>

以生产模式构建一遍应用:

npm run build

缓存应用的App Shell就是这么简单

sw-precache缓存机制

当应用在构建的时候,sw-precach-webpack-plugin生成了一个service-worker.js文件。该文件位于dist/service-worker.js,将在浏览器首次运行应用的时候被加载和执行。其代码如下:

"use strict";

var precacheConfig = [
  ["index.html", "218187414cc275eaa7bb37f098e0fc92"],
  ["static/css/app.d1a62a5e00430713ca6ccd67cb5fd580.css", "d1a62a5e00430713ca6ccd67cb5fd580"],
  ["static/js/app.04ef0c51d385926ea560.js", "13cfbbcfd7077380247bbbb6d9731463"],
  ["static/js/manifest.2d92f59992387f01763b.js", "00451931f39fbee842952a5d349f22a6"],
  ["static/js/vendor.0140506021c3a9c89dd2.js", "27f61c490b1b8af1d621b16afdd174bb"]
];
...
urlsToCacheKeys = new Map(precacheConfig.map(function (e) {
  var t = e[0], n = e[1], r = new URL(t, self.location), a = createCacheKey(r, hashParamName, n, !1);
  return [r.toString(), a]
}));

self.addEventListener("install", function (e) {
  ...
});

self.addEventListener("activate", function (e) {
  ...
})

self.addEventListener("fetch", function (e) {
  if ("GET" === e.request.method) {
    var t, n = stripIgnoredUrlParameters(e.request.url, ignoreUrlParametersMatching);
    t = urlsToCacheKeys.has(n);
    t || (n = addDirectoryIndex(n, "index.html"), t = urlsToCacheKeys.has(n));
    t && e.respondWith(caches.open(cacheName).then(function (e) {
      return e.match(urlsToCacheKeys.get(n)).then(function (e) {
        if (e)return e;
        throw Error("The cached response that was expected is missing.")
      })
    }).catch(function (t) {
      return console.warn('Couldn\'t serve response for "%s" from cache: %O', e.request.url, t), fetch(e.request)
    }))
  }
});

测试离线模式

你可能注意到了我并没有关注webpack.dev.conf.js这个文件,原因是Service Workers会在生产环境中生效,而在应用的开发过程中我们可能不希望*.js*.css文件被缓存下来。
我们以生产模式构建应用来测试离线的效果:

npm run build

为了模拟真实的HTTP请求和Cropchat的构建文件,你可以使用超赞的serve tool:(需要node>6.9.0)(译注:出于安全考虑Service workers只能由HTTPS承载,localhost是个例外,因此局域网的访问可能不太奏效。参见MDN Service worker

sudo npm install -g serve

之后运行起来:

serve dist/

访问http://localhost:3000(不一定是3000这个端口)就可以看到效果了。
F12打开Chrome控制台,在Application中可以看到Service Workers已经安装了:
Chrome Developer Tools: our Service Worker is installed

ApplicationService Workers菜单下,你可以看到应用已经安装并运行了Service Workers。Chrome Developer Tools是一款非常强大的调试软件,如果你想了解更多关于Service Workers的调试技巧,快来Progressive Web App section看看吧!

现在让我们测试一下离线模式的效果:

  • 返回到Service Workers菜单,激活(勾选)Offline模式;
  • Network选项下可以看到Offline模式也被激活(勾选)了;
  • Network选项下勾选Disable cache选项;
  • 刷新页面。

有无Service Workers时的表现

由于Service Workers在后台运行并缓存了一些资源,即使在没有网络的情况下应用依旧可以运行。

(关闭并重启调试工具)在Cache Storage中可以看到静态资源已经被缓存了:

Chrome Developer Tools: Cache Storage

但是还有如下的资源没有被正确地缓存下来:

我们将在接下来的部分解决这些问题。

第二步:使用sw-toolbox缓存HTTP请求

Service Workers同样可以帮助我们缓存HTTP请求,它叫做runtime caching

缓存外部的Material Design Lite资源

同样的,我们不会自己造轮子去实现Service Workers,而是使用一个非常棒的插件叫做sw-toolbox,它已经集成到了sw-precache-webpack-plugin中。不需要安装额外的插件,这就是VueJS和webpack组合的神奇之处。

更新build/webpack.prod.conf.js中的代码:

var SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin')

...

var webpackConfig = merge(baseWebpackConfig, {
  ...
  plugins: [
    ...
    // service worker caching
    new SWPrecacheWebpackPlugin({
      cacheId: 'my-vue-app',
      filename: 'service-worker.js',
      staticFileGlobs: ['dist/**/*.{js,html,css}'],
      minify: true,
      stripPrefix: 'dist/',
      runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.googleapis\.com\//,
        handler: 'cacheFirst'
      },
      {
        urlPattern: /^https:\/\/fonts\.gstatic\.com\//,
        handler: 'cacheFirst'
      },
      {
        urlPattern: /^https:\/\/code\.getmdl\.io\//,
        handler: 'cacheFirst'
      }]
    })
  ]
})

再次构建项目并启动服务。
你需要注销Service Workers并关闭Chrome...(Service Workers的生命周期现在还有点问题)。
现在可以看到使用runtime caching缓存的效果了:
Service Worker with runtime caching

缓存应用中的猫咪图片

即将大功告成了。接下来要做的便是缓存应用中的猫咪图片。在这个应用中猫咪图片的地址都从Cat API中请求而来:
Chrome Developer Tools: Network tab gives us CropChat images urls list

更新webpack.prod.conf.js中的代码:

runtimeCaching: [
   {
      urlPattern: /^https:\/\/thecatapi\.com\/api\/images\/get\.php\?id/,
      handler: 'cacheFirst'
   },
   {
      urlPattern: /^https:\/\/(\d+)\.media\.tumblr\.com\//,
      handler: 'cacheFirst'
   },
   {
      urlPattern: /^http:\/\/(\d+)\.media\.tumblr\.com\//,
      handler: 'cacheFirst'
   },
   ...
]

重新构建应用并启动服务(可能需要注销Service Workers并重启Chrome),激活offline模式:
CropChat is now available offline!

在Cache Storage中可以看到猫咪的图片已经被缓存了:

Chrome Developer Tools: CropChat Cat Images are now cached.

第三步:使用localstorage api缓存Firebase WebSocket

???我(作者)骗了你
应用还没有实现在线功能,即使在有正常的网络连接的情况下你也不会看到应用的内容。
因为Firebase流并没有被缓存。究其原因,是因为Firebase基于WebSockets实现而**Service Workers并不能处理WebSockets**。
于是我们需要自己实现Firebase的缓存了。对于网络优先的情况下我的缓存策略是这样的:

  • 如果用户可以访问网络(使用navigator.onLine检测),那么将Firebase Websocket的返回值缓存到浏览器的local storage中,then hook on Firebase WebSocket(???当请求返回的数据更新的时候也将其更新到local storage中的意思?);
  • 否则从local storage中取到缓存的数据。

要实现这个,在HomeView.vue中更新如下代码:

<script>
  export default {
    methods: {
      displayDetails (id) {
        this.$router.push({name: 'detail', params: { id: id }})
      },
      getCats () {
        if (navigator.onLine) {
          this.saveCatsToCache()
          return this.$root.cat
        } else {
          return JSON.parse(localStorage.getItem('cats'))
        }
      },
      saveCatsToCache () {
        this.$root.$firebaseRefs.cat.orderByChild('created_at').once('value', (snapchot) => {
          let cachedCats = []
          snapchot.forEach((catSnapchot) => {
            let cachedCat = catSnapchot.val()
            cachedCat['.key'] = catSnapchot.key
            cachedCats.push(cachedCat)
          })
          localStorage.setItem('cats', JSON.stringify(cachedCats))
        })
      }
    },
    mounted () {
      this.saveCatsToCache()
    }
  }
</script>

<template>部分,v-for循环遍历的数据中使用getCats()获取到的数据。
再次构建应用并启动服务,现在有网络的情况下加载它然后尝试在离线(无网络)情况下访问它。

总结

巴拉巴拉。在本章中我们学习了:

  • 使用sw-precache缓存App Shell文件
  • 使用se-toolboxruntimeCaching缓存外部资源
  • 使用浏览器的local storage缓存Firebase websockets

现在我们的任务清单变成了下面这样:
Progressive Web App checklist

Part 4中我们将会解决剩余的问题。