网站 PWA 化开发记录

最近,研究了 PWA,将本站进行了 PWA 化,感觉不错,在此分享一下心得。

添加到桌面图标:

启动页面:

全屏显示:

前言

近年来,Web 应用在整个软件与互联网行业承载的责任越来越重,软件复杂度和维护成本越来越高,Web 技术,尤其是 Web 客户端技术,迎来了爆发式的发展。

包括但不限于基于 Node.js 的前端工程化方案;诸如 Webpack、Rollup 这样的打包工具;Babel、PostCSS 这样的转译工具;TypeScript、Elm 这样转译至 JavaScript 的编程语言;React、Angular、Vue 这样面向现代 web 应用需求的前端框架及其生态,也涌现出了像同构 JavaScript与通用 JavaScript 应用这样将服务器端渲染(Server-side Rendering)与单页面应用模型(Single-page App)结合的 web 应用架构方式,可以说是百花齐放。

但是,Web 应用在移动时代并没有达到其在桌面设备上流行的程度。究其原因,尽管上述的各种方案已经充分利用了现有的 JavaScript 计算能力、CSS 布局能力、HTTP 缓存与浏览器 API 对当代基于 Ajax 与响应式设计的 web 应用模型的性能与体验带来了工程角度的巨大突破,我们仍然无法在不借助原生程序辅助浏览器的前提下突破 web 平台本身对 web 应用固有的桎梏:客户端软件(即网页)需要下载所带来的网络延迟;与 Web 应用依赖浏览器作为入口所带来的体验问题。

在桌面设备上,由于网络条件稳定,屏幕尺寸充分,交互方式趋向于多任务,这两点造成的负面影响对比 web 应用免于安装、随叫随到、无需更新等优点,瑕不掩瑜。但是在移动时代,脆弱的网络连接与全新的人机交互方式使得这两个问题被无限放大,严重制约了 web 应用在移动平台的发展。在用户眼里,原生应用不会出现「白屏」,清一色都摆在主屏幕上;而 web 应用则是浏览器这个应用中的应用,使用起来并不方便,而且加载也比原生应用要慢。

什么是 PWA

Progressive Web App, 中文名叫做渐进式网页应用,简称 PWA,是提升 Web App 的体验的一种新方法,能给用户原生应用的体验。

PWA 以及构成 PWA 的一系列关键技术的出现,终于让我们看到了彻底解决这两个平台级别问题的曙光:能够显著提高应用加载速度、甚至让 web 应用可以在离线环境使用的 Service Worker 与 Cache Storage;用于描述 web 应用元数据(metadata)、让 web 应用能够像原生应用一样被添加到主屏、全屏执行的 Web App Manifest;以及进一步提高 web 应用与操作系统集成能力,让 web 应用能在未被激活时发起推送通知的 Push API 与 Notification API 等等。

PWA 可以将 Web 和 App 各自的优势融合在一起:渐进式、可响应、可离线、实现类似 App 的交互、即时更新、安全、可以被搜索引擎检索、可推送、可安装、可链接。

PWA 的技术要点

其实,PWA 并不是单独的一门技术,其中每一项功能的实现都是多种技术的组合。

桌面访问:Service Worker + Web App Manifest

这是 PWA 中最炫酷的功能了,添加桌面图标。这个功能就算没有PWA也是可以实现的,但是目前添加桌面图标指示简单的打开网页而已,在获得HTML、JavaScript和CSS之前,页面将会呈现一段时间的空白时间,但是WebApp Manifest功能可以在这一空白时间内补上一开屏画面,从而提高用户等待期间的体验。目前该功能还是比较简陋的,对于一些稍微高级一点的需求就无法支持了,比如背景图片,动画。

缓存机制:Service Worker + CacheStorage

在这之前所有的离线功能是交给 Application Cache 和 IndexedDB,IndexedDB 是一个优秀的API,但是 Application Cache 自诞生初期就被吐槽,其对于缓存的控制不灵活一直为人所诟病,所以更多用户选择,将静态资源响应头的 max-age 和 expire 字段设置成最大,使得缓存永不过期来达到离线效果。但是自从有了 CacheStorage 之后,“麻麻再也不用担心控制缓存的能力了”。因为 CacheStorage + Service Worker 简直就是控制缓存的最佳组合,CacheStorage 可以将静态资源批量或者单个导入,在 Service Worker 中对指定地址的静态资源进行监听,当 Service Worker 接收 fetch 事件时,开发者可以按照具体的业务需求,来制定是否使用或者如何使用缓存的策略。

推送通知:Push + Notification

Notification和Push功能是绑定使用的,开发者可以在 inspector 下使用模拟推送来调试通知功能。

推送是一个很有价值接口,并且依赖于 Service Worker 线程,所以是否能够推送成功,全都仰仗ServiceWorker线程是否存在,之前曾经提到过,Service Worker 是运行在单独线程,在浏览器线程退出之后,Service Worker 线程也会关闭,Service Worker 线程在网页关闭后,如果内存足够,也不会被销毁。

几种技术的优缺点

Native APP

Native APP 由于天生就基于操作系统(Android、iOS),因此具备很多优点:

  • 相比于其它模式,提供最佳的用户体验,最优质的用户界面,最华丽的交互
  • 针对不同平台提供不同体验
  • 可节省带宽成本,打开速度更快
  • 功能最为强大,特别是在与系统交互中,几乎所有功能都能实现

Native APP 用起来很流畅,但是也有其天然的基因缺陷:

  • 门槛高,原生开发人才稀缺,至少比前端和后端少,开发环境昂贵
  • 无法跨平台,开发的成本比较大,各个系统独立开发
  • 发布成本高,版本更新需要将新版本上传到不同的应用商店,导致更新缓慢,软件上线需要审核
  • 维持多个版本、多个系统的成本比较高,而且必须做兼容
  • 用户 80% 的时间被 Top3 的超级 App 占据,对于站点来说,应用分发的性价比也越来越不划算
  • 要使用它,首先还需要下载几十兆上百着兆的安装包,即使是偶尔需要使用一下下

Hybrid APP

混合模式移动应用,介于Web App、Native App这两者之间的App开发技术,兼具“Native App良好交互体验的优势”和“Web App跨平台开发的优势”(百度百科解释)

主要的原理是,由Native通过JSBridge等方法提供统一的API,然后用Html+Css实现界面,JS来写逻辑,调用API,最终的页面在Webview中显示,这种模式下,Android、iOS的API一般有一致性,Hybrid App所以有跨平台效果。

  • 优点:开发和发布都比较方便,效率介于Native App、Web App之间
  • 缺点:学习范围较广,需要原生配合
  • 应用技术:PhoneGap,AppCan,Wex5,APICloud,H5+(HBuilder)等

Web App

web网页开发成本低,网站更新时上传最新的资源到服务器即可,用手机带的浏览器打开就可以使用,具有很多优点:

  • 可以跨平台,调试方便
  • 无需安装,不会占用手机内存,而且更新速度最快
  • 不存在多版本问题,维护成本低
  • 临时入口,可以随意嵌入

但是除了体验上比 Native App 还是差一些,还有一些明显的缺点:

  • 手机桌面入口不够便捷,想要进入一个页面必须要记住它的 url 或者加入书签
  • 依赖于网络,没网络就没响应,不具备离线能力,第一次访问页面速度慢,耗费流量
  • 不像APP一样能进行消息推送
  • 受限于手机和浏览器性能,用户体验相较于其他模式最差
  • 功能受限,大量移动端功能无法实现

Web App 与 WAP 的区别:

说到 Web App 不少人会联想到 WAP,或者有人认为,WAP 就是 Web App,其实不然。

Web App 与 WAP 最直接的区别就是功能层面。WAP更侧重使用网页技术在移动端做展示,包括文字、媒体文件等。而Web App更侧重“功能”,是使用网页技术实现的App。总的来说,Web App就是运行于网络和标准浏览器上,基于网页技术开发实现特定功能的应用。

PWA

一个 PWA 应用首先是一个网页, 可以通过 Web 技术编写出一个网页应用。随后添加上 App Manifest 和 Service Worker 来实现 PWA 的安装和离线等功能。

解决了以下一些问题:

  • 可以添加至主屏幕,点击主屏幕图标可以实现启动动画以及隐藏地址栏
  • 实现离线缓存功能,能够在各种网络环境下使用,包括网络差和断网条件下
  • 实现了消息推送
  • 其本质是一个网页,没有原生app的各种启动条件,快速响应用户指令

但仍然存在一些问题:

  • 支持率不高:现在ios手机端不支持pwa,IE也暂时不支持
  • Chrome在中国桌面版占有率还是不错的,安卓移动端上的占有率却很低
  • 各大厂商还未明确支持pwa
  • 依赖的GCM服务在国内无法使用
  • 微信小程序的竞争

尽管有上述的一些缺点,PWA技术仍然有很多可以使用的点。

  • service worker 技术实现离线缓存,可以将一些不经常更改的静态文件放到缓存中,提升用户体验
  • service worker 实现消息推送,使用浏览器推送功能,吸引用户
  • 渐进式开发,尽管一些浏览器暂时不支持,可以利用上述技术给使用支持浏览器的用户带来更好的体验

小程序

微信小程序和PWA(Progressive Web App)是目前移动端以及前端受关注度较高的两项技术。小程序自去年公测以来,国内很多公司均投入到小程序的开发中,今日头条、携程、摩拜单车等小程序纷纷在第一时间发布。从实际效果来看,小程序的用户体验普遍受到了好评,并且它可以在微信上安装、卸载和离线使用。PWA则是在传统Web应用的基础上,通过完善Web应用的一些能力,比如离线使用、后台加载、添加到主屏和消息推送等,达到用户体验提升和性能优化。两者的达到的效果相似,但实现技术上略有不同。

Web App Manifest

Web App Manifest,即通过一个清单文件向浏览器暴露 web 应用的元数据,包括名字、icon 的 URL 等,以备浏览器使用,比如在添加至主屏或推送通知时暴露给操作系统,从而增强 web 应用与操作系统的集成能力。

Manifest 的前世今生

在 Web App Manifest 诞生之前,其实已经有类似的解决方案出现,各大浏览器和私有平台通过 <meta>和<link> 标签为自己定制专有的桌面应用:

<!-- 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">
<!-- Add to homescreen for Chrome on Android -->
<meta name="mobile-web-app-capable" content="yes">
<mate name="theme-color" content="#000000">
<!-- 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">
<!-- Icon for Android Chrome, recommended -->
<link rel="shortcut icon" sizes="196x196" href="images/touch/touch-icon-196x196.png">
<!-- 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">
<!-- Generic Icon -->
<link rel="shortcut icon" href="images/touch/touch-icon-57x57.png">

但是,很快地头部也被这些私有定制填满,这种做法并不优雅,分散又重复的元数据定义多余且难以维持同步,与 html 耦合在一起也加重了浏览器检查元数据未来变动的成本。与此同时,社区里开始出现使用 manifest 文件以中心化地描述元数据的方案,比如 Chrome Extension、 Chrome Hosted Web Apps (2010)Firefox OS App Manifest (2011) 使用 JSON;CordovaWindows Pinned Site 使用 XML。

2013 年,W3C WebApps 工作组开始对基于 JSON 的 Manifest 进行标准化,于同年年底发布第一份公开 Working Draft,并逐渐演化成为今天的 W3C Web App Manifest。

诸如 name、icons、display 都是我们比较熟悉的,而大部分新增的成员则为 web 应用带来了一系列以前 web 应用想做却做不到(或在之前只能靠 hack)的新特性。

作为 PWA 的「户口本」,承载着 web 应用与操作系统集成能力的重任,Web App Manifest 还将在日后不断扩展,以满足 web 应用高速演化的需要。

Manifest 的使用

首先,需要在站点根目录下创建一个 mainfest.json 文件,然后在网页头部引用:

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

manifest.json 的结构

接下来,就可以开始我们的 mainfest.json 编写,主要结构如下:

{
  "name": "小昱个人网站",
  "short_name": "昱之家",
  "display": "fullscreen",
  "background_color": "#fff",
  "description": "不忘初心 方得始终",
  "start_url": "/",
  "theme_color": "#333",
  "icons": [
    {
	    "src": "icon/lowres.webp",
	    "sizes": "48x48",
	    "type": "image/webp"
	  },
	  {
	    "src": "icon/lowres",
	    "sizes": "48x48"
	  },
	  {
	    "src": "icon/hd_hi.ico",
	    "sizes": "72x72 96x96 128x128 256x256"
	  },
	  {
	    "src": "icon/hd_hi.svg",
	    "sizes": "72x72"
	  }
  ],
 	"related_applications": [{
		"platform": "web",
		"url": "https://www.xiaoyulive.top"
 	}]
}

以上就创建了一个基础的清单文件,里面的各个字段作用其实已经很清楚了。

Manifest 清单选项

以下详细讲解一些字段的使用

基础配置

  • name 为应用程序提供一个名称。
  • short_name 为应用程序提供一个简短易读的名称。
  • description 提供有关Web应用程序的一般描述。
  • start_url 指定用户从设备启动应用程序时加载的URL。
  • background_color 为web应用程序预定义的背景颜色。
  • theme_color 定义应用程序的默认主题颜色。

display

定义开发人员对Web应用程序的首选显示模式。包括以下选项:

显示模式 描述
fullscreen 全屏显示, 所有可用的显示区域都被使用, 并且不显示状态栏。
standalone 让这个应用看起来像一个独立的应用程序,包括具有不同的窗口,在应用程序启动器中拥有自己的图标等。这个模式中,用户代理将移除用于控制导航的UI元素,但是可以包括其他UI元素,例如状态栏。
minimal-ui 该应用程序将看起来像一个独立的应用程序,但会有浏览器地址栏。 样式因浏览器而异。
browser 该应用程序在传统的浏览器标签或新窗口中打开,具体实现取决于浏览器和平台。 这是默认的设置。

icons

指定可在各种环境中用作应用程序图标的图像对象数组。 例如,它们可以用来在其他应用程序列表中表示Web应用程序,或者将Web应用程序与OS的任务切换器和/或系统偏好集成在一起。

图像对象可能包含以下值:

字段 描述
sizes 包含空格分隔的图像尺寸的字符串。
src 图像文件的路径。 如果src是一个相对URL,则基本URL将是manifest的URL。
type 提示图像的媒体类型。此字段的目的是允许用户代理快速忽略不支持的媒体类型的图像。

更多详细的清单选项参见:https://developer.mozilla.org/zh-CN/docs/Web/Manifest

Service Worker 介绍

Service Worker 是 Chrome 团队提出和力推的一个 WEB API,用于给 web 应用提供高级的可持续的后台处理能力。

Service Worker 是一个单独的后台线程,不依赖于某一个 WebView,它是一个 Proxy,用于监听以及管理服务的请求以及返回。

Service Workers 就像介于服务器和网页之间的拦截器,能够拦截进出的HTTP 请求,从而完全控制你的网站。

sw 有以下这些特点:

  • 在页面中注册并安装成功后,运行于浏览器后台,不受页面刷新的影响,可以监听和截拦作用域范围内所有页面的 HTTP 请求。
  • 网站必须使用 HTTPS。除了使用本地开发环境调试时(如域名使用 localhost)
  • 运行于浏览器后台,可以控制打开的作用域范围下所有的页面请求
  • 单独的作用域范围,单独的运行环境和执行线程
  • 不能操作页面 DOM。但可以通过事件机制来处理
  • 事件驱动型服务线程

为什么要求网站必须是HTTPS的,大概是因为service worker权限太大能拦截所有页面的请求吧,如果http的网站安装service worker很容易被攻击

浏览器支持情况详见: https://caniuse.com/#feat=serviceworkers

service worker 生命周期

ServiceWorker是运行在单独线程,在浏览器进程退出之后,ServiceWorker线程也会关闭。再次打开浏览器之后,可以通过Wake Up机制唤醒ServiceWorker线程。

PWA网页和浏览器生命周期一致,浏览器进程杀掉网页也销毁了。ServiceWorker进程在网页关闭后,如果内存足够,也不会被销毁。

当用户首次导航至 URL 时,服务器会返回响应的网页。

  1. 当你调用 register() 函数时, Service Worker 开始下载。
  2. 在注册过程中,浏览器会下载、解析并执行 Service Worker ()。如果在此步骤中出现任何错误,register() 返回的 promise 都会执行 reject 操作,并且 Service Worker 会被废弃。
  3. 一旦 Service Worker 成功执行了,install 事件就会激活。
  4. 安装完成,Service Worker 便会激活,并控制在其范围内的一切。如果生命周期中的所有事件都成功了,Service Worker 便已准备就绪,随时可以使用了!

输入 chrome://serviceworker-internals 来了解当前浏览器中所有已安装Service Worker的详细情况

HTTP 缓存与 service worker 缓存

HTTP 缓存

Web 服务器可以使用 Expires 首部来通知 Web 客户端,它可以使用资源的当前副本,直到指定的“过期时间”。反过来,浏览器可以缓存此资源,并且只有在有效期满后才会再次检查新版本。 使用 HTTP 缓存意味着你要依赖服务器来告诉你何时缓存资源和何时过期。

service worker 缓存

打开调试工具,可以在 Application > Cache Storage 中看到缓存下来的数据:

Service Workers 的强大在于它们拦截 HTTP 请求的能力。

进入任何传入的 HTTP 请求,并决定想要如何响应。在你的 Service Worker 中,可以编写逻辑来决定想要缓存的资源,以及需要满足什么条件和资源需要缓存多久。一切尽归你掌控!

使用 service worker 缓存

在应用入口加入 sw 的注册代码:

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello Caching World!</title>
  </head>
  <body>
    <script>
      // 注册 service worker
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function (registration) {
          // 注册成功
          console.log('ServiceWorker registration successful with scope: ', registration.scope);
        }).catch(function (err) {
          // 注册失败 :(
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

Service Worker 的注册路径决定了其 scope 默认作用页面的范围。

如果 service-worker.js 是在 /sw/ 页面路径下,这使得该 Service Worker 默认只会收到 页面 /sw/ 路径下的 fetch 事件。

如果存放在网站的根路径下,则将会收到该网站的所有 fetch 事件。

如果希望改变它的作用域,可在第二个参数设置 scope 范围。示例中将其改为了根目录,即对整个站点生效。

service-worker.js

var cacheName = 'helloWorld';     // 缓存的名称
// install 事件,它发生在浏览器安装并注册 Service Worker 时
self.addEventListener('install', event => {
/* event.waitUtil 用于在安装成功之前执行一些预装逻辑
 但是建议只做一些轻量级和非常重要资源的缓存,减少安装失败的概率
 安装成功后 ServiceWorker 状态会从 installing 变为 installed */
  event.waitUntil(
    caches.open(cacheName)
    .then(cache => cache.addAll([    // 如果所有的文件都成功缓存了,便会安装完成。如果任何文件下载失败了,那么安装过程也会随之失败。
      '/js/script.js',
      '/images/hello.png'
    ]))
  );
});
/**
为 fetch 事件添加一个事件监听器。接下来,使用 caches.match() 函数来检查传入的请求 URL 是否匹配当前缓存中存在的任何内容。如果存在的话,返回缓存的资源。
如果资源并不存在于缓存当中,通过网络来获取资源,并将获取到的资源添加到缓存中。
*/
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request)
    .then(function (response) {
      if (response) {
        return response;
      }
      var requestToCache = event.request.clone();
      return fetch(requestToCache).then(
        function (response) {
          if (!response || response.status !== 200) {
            return response;
          }
          var responseToCache = response.clone();
          caches.open(cacheName)
            .then(function (cache) {
              cache.put(requestToCache, responseToCache);
            });
          return response;
        }
      )
  	})
  )
});

为什么用 request.clone() 和 response.clone() ?

需要这么做是因为request和response是一个流,它只能消耗一次。因为我们已经通过缓存消耗了一次,然后发起 HTTP 请求还要再消耗一次,所以我们需要在此时克隆请求。

一个简单的示例

Google 的一个示例项目可以演示sw离线缓存的功能。

打开此网址,打开调试工具

  1. 勾选可以模拟网站离线情况,勾选后network会有一个黄色警告图标,该网站已经离线。此时刷新页面,页面仍然能够正常显示
  2. 当前service worker的scope。它能够拦截https://googlechrome.github.io/samples/service-worker/basic/index.html下的请求,同样也能够拦截https://googlechrome.github.io/samples/service-worker/basic/*/*.html下的请求

调试面板具体代表的什么参看 https://x5.tencent.com/tbs/guide/serviceworker.html 的第三部分

Service Worker 开发

引入 cache-polyfill

在我们开始写码之前

这个项目地址拿到 cache-polyfill。

这个 polyfill 支持 CacheStorage.match,Cache.add 和 Cache.addAll,而现在 Chrome M40 实现的 Cache API 还没有支持这些方法。

将 dist/serviceworker-cache-polyfill.js 放到你的网站中,在 service worker 中通过 importScripts 加载进来。被 service worker 加载的脚本文件会被自动缓存。

importScripts('serviceworker-cache-polyfill.js');

需要 HTTPS

在 localhost 和 127.0.0.1 的 host 下,能注册成功。这样就能保证我们在本地开发的时候也能直接注册。

但是一旦上线,就需要你的 server 支持 HTTPS。

你可以通过 service worker 劫持连接,伪造和过滤响应,非常逆天。即使你可以约束自己不干坏事,也会有人想干坏事。所以为了防止别人使坏,你只能在 HTTPS 的网页上注册 service workers,这样我们才可以防止加载 service worker 的时候不被坏人篡改。

作用域

通常情况下,在注册 sw.js 的时候会忽略 Service Worker 作用域的问题,Service Worker 默认的作用域就是注册时候的 path, 例如 Service Worker 注册的 path 为 /a/b/sw.js,则 scope 默认为 /a/b/。

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/a/b/sw.js').then(function (reg) {
    console.log(reg.scope);
    // scope => https://yourhost/a/b/
  });
}

当然也可以通过在注册时候传入 {scope: '/some/scope/'} 参数的方式自己指定 scope ,但是自己指定 scope 也是有一定的限制的,其中也隐藏着一些坑。

if (navigator.serviceWorker) {
	navigator.serviceWorker.register('/a/b/sw.js', {scope: '/a/b/c/'})
    .then(function (reg) {
      console.log(reg.scope);
      // scope => https://yourhost/a/b/c/
    });
}

只能指定 sw.js 所在路径的子路径,如果指定了父路径,则会报错:

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/a/b/sw.js', {scope: '/a/'})
    .then(function (reg) {
      console.log(reg.scope);
      // Ops !!!,报错啦!!
    });
}

作用域污染

通过对 Service Woker 作用域的了解,也许会发现这么样的一个问题:

假设在 https://yourhost 域下有 A 页面 (https://yourhost/a) 和 B 页面(https://yourhost/b)。

假设 A 页面在 /a/ 作用域下注册了一个 Service Worker,B 页面在 / 作用域下注册了一个 Service Worker,这种情况下 B 页面的 Service Worker 就可以控制 A 页面,因为 B 页面的作用域是包含 A 页面的最大作用域的(我们可以把这种情况称之为 作用域污染)。在开发环境开发者还可以通过 DevTools 进行手动 unregister 来清除掉污染的 Service Worker,但是如果用户在手机端被安装了 Service Worker 之后可以理解这就是个持久的过程。除非用户手动清除存储的缓存(这个也是不可能的),否则对用户来说就是个持久污染的噩梦。

当然,出现作用域污染的情况也不是没有办法补救的,比较合理的一种做法是,在新上线的版本中注册 Service Worker 之前将污染的 Service Worker 注销掉。

if (navigator.serviceWorker) {
  navigator.serviceWorker.getRegistrations().then(function (regs) {
    for (var reg of regs) {
      if (reg.scope === 'https://yourhost/') {
        reg.unregister();
      }
    }
    // 注销掉污染 Service Worker 之后再重新注册自己作用域的 Service Worker
    navigator.serviceWorker.register('/a/sw.js').then(function (reg) {
      // ...
    });
  });
}

对于一个拥有多个平行子站的大型站点,作用域污染的情况很有可能因为缺乏沟通或者滥用 Service Worker 而发生。

消息推送

  • 步骤一、提示用户并获得他们的订阅详细信息
  • 步骤二、将这些详细信息保存在服务器上
  • 步骤三、在需要时发送任何消息

不同浏览器需要用不同的推送消息服务器。以 Chrome 上使用 Google Cloud Messaging<GCM> 作为推送服务为例,第一步是注册 applicationServerKey(通过 GCM 注册获取),并在页面上进行订阅或发起订阅。每一个会话会有一个独立的端点(endpoint),订阅对象的属性(PushSubscription.endpoint) 即为端点值。将端点发送给服务器后,服务器用这一值来发送消息给会话的激活的 Service Worker (通过 GCM 与浏览器客户端沟通)。

步骤一和步骤二

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Progressive Times</title>
    <link rel="manifest" href="/manifest.json">
  </head>
  <body>
    <script>
      var endpoint;
      var key;
      var authSecret;
      var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';
      // 方法很复杂,但是可以不用具体看,知识用来转化vapidPublicKey用
      function urlBase64ToUint8Array(base64String) {
        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding)
          .replace(/\-/g, '+')
          .replace(/_/g, '/');
        const rawData = window.atob(base64);
        const outputArray = new Uint8Array(rawData.length);
        for (let i = 0; i < rawData.length; ++i) {
          outputArray[i] = rawData.charCodeAt(i);
        }
        return outputArray;
      }
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('sw.js').then(function (registration) {
          return registration.pushManager.getSubscription()
            .then(function (subscription) {
              if (subscription) {
                return;
              }
              return registration.pushManager.subscribe({
                  userVisibleOnly: true,
                  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
                })
                .then(function (subscription) {
                  var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
                  key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
                  var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
                  authSecret = rawAuthSecret ?
                    btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
                  endpoint = subscription.endpoint;
                  return fetch('./register', {
                    method: 'post',
                    headers: new Headers({
                      'content-type': 'application/json'
                    }),
                    body: JSON.stringify({
                      endpoint: subscription.endpoint,
                      key: key,
                      authSecret: authSecret,
                    }),
                  });
                });
            });
        }).catch(function (err) {
          // 注册失败 :(
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

步骤三 服务器发送消息给service worker

app.js

const webpush = require('web-push');
const express = require('express');
var bodyParser = require('body-parser');
const app = express();
webpush.setVapidDetails(
  'mailto:contact@deanhume.com',
  'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
  'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
app.post('/register', function (req, res) {
  var endpoint = req.body.endpoint;
  saveRegistrationDetails(endpoint, key, authSecret);
  const pushSubscription = {
    endpoint: req.body.endpoint,
    keys: {
      auth: req.body.authSecret,
      p256dh: req.body.key
    }
  };
  var body = 'Thank you for registering';
  var iconUrl = 'https://example.com/images/homescreen.png';
  // 发送 Web 推送消息
  webpush.sendNotification(pushSubscription,
      JSON.stringify({
        msg: body,
        url: 'http://localhost:3111/',
        icon: iconUrl
      }))
    .then(result => res.sendStatus(201))
    .catch(err => {
      console.log(err);
    });
});
app.listen(3111, function () {
  console.log('Web push app listening on port 3111!')
});

service worker监听push事件,将通知详情推送给用户

service-worker.js

self.addEventListener('push', function (event) {
 // 检查服务端是否发来了任何有效载荷数据
  var payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
  var title = 'Progressive Times';
  event.waitUntil(
    // 使用提供的信息来显示 Web 推送通知
    self.registration.showNotification(title, {
      body: payload.msg,
      url: payload.url,
      icon: payload.icon
    })
  );
});

更多详细信息请参见:https://developer.mozilla.org/zh-CN/docs/Web/API/ServiceWorker

Service Worker 更新

Service Worker 的更新也会影响到 Service Worker 的注册,在这里,重点剖析一下 Service Worker 更新的问题。

当页面注册好了一个 Service Worker 之后,Service Worker 会被安装、激活、通过 fetch 事件监听作用域下站点的网络请求等等行为,为了 Web App 的首屏体验,AppShell 作为最小优先展现单元,其中的 html 页面和静态资源是需要被持久缓存起来的。也就是说保证用户能在离线之后至少优先看到一个完整的 AppShell。

这个和优雅的注册 Service Worker 有个啥子关系?

拿 SPA 为例,作为 AppShell 的载体 index.html 是会被缓存起来的,AppShell 的静态资源也都会被缓存起来的,然而 Service Worker 的注册必然是需要在 index.html<script></script> 标签或者被缓存住的 js 文件中做的。

如果 sw.js 发生了更新,我们预期的是希望浏览器立即更新当前页面的缓存,并且立即加载最新的内容和资源。sw.js 的更新包含她 URL 的更新和内容的更新,Service Worker 本身的机制能够 diff 到 sw.js 的更新,如果在注册时候通过 Service Worker Update 算法 diff 到 URL 或者 内容的更新,则马上启动新的 sw.js 文件的安装、激活,但因为用户当前的页面已经使用老的缓存中的内容加载完成,所以需要等到第二次进入页面的时候才能真正使用新的静态资源和网络请求。

这个机制是有以下两个坑的:

  • sw.js 自身也会被浏览器缓存(也就是 diff 不能做到实时)
  • 就算 diff 到了最新的 sw.js,用户在当前的这次访问中的任何交互还是使用老的缓存内容,需要等到第二次进入页面才能更新缓存

对于 sw.js HTTP 缓存的问题解决方案肯定是让这个文件永远都不缓存(暂时不讨论请求开销的问题)

no-cache 处理

为了能让 Service Worker 做到实时更新,必须要解决 Service Worker 文件 sw.js HTTP 缓存的问题。 通常需要让文件完全无缓存,有两种思路:一种是在服务器端控制请求文件的 Cache-Control,另一种就是在前端通过版本号来改变浏览器缓存策略。

服务器 Cache-Control

服务器端的 Cache-Control 的控制是将 sw.js 的请求设置成 no-cache,以 nginx 为例:

location ~ \/sw\.js$ {
  add_header Cache-Control no-store;
  add_header Pragma no-cache;
}

通过配置服务器这种方式的好处是:只要做好了 sw.js 缓存实时更新问题之后,就可以不用关心整个 Web App 的实时更新问题,浏览器都会参照 「sw.js 的 diff」 -> 「重新安装新 sw.js」 -> 「激活并删除老的缓存」 ->「用户第二次进入页面重新更新缓存」的套路来自行搞定。

当然,这种处理方式也有很大的局限性,如果您将静态资源都部署在第三方的 CDN 静态资源服务器,单独针对某一个文件进行服务器设置 sw.js 还是感觉很麻烦。尤其是对于大型站点的运维人员来说,在服务器新增一个路由不是一件很随意的事情。

前端版本控制

对于前端版本控制,前端开发者应该并不陌生,如果需要一个静态资源的请求永远不会被缓存,下面这种做法就很好理解了

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/sw.js?v=' + Date.now());
}

这段代码一祭出,就解决了之前所提到的 sw.js 被浏览器缓存的问题了。

但是,这种做法又引发出了其他的问题,每次执行注册 Service Worker 代码逻辑的时候,Service Worker 都能 diff 到变化(URL 的变化也是一种更新的 diff),每次都会在第一次安装,第二次激活并且更新缓存,这种做法使得 Service Worker 的缓存完全没有生效,和每次都和请求最新的 Network 请求内容没什么区别,理论上讲,这种方式由于缓存的频繁读取和删除,甚至比每次直接无缓存刷新的性能更加糟糕。

提醒

在 Service Worker 得注册过程中,慎用时间戳来做版本控制,会导致一些意想不到的坑。事实也证明这种做法也是不可取的。

接下来转变一下思路,这个时候需要先想一想如何优雅的做好无缓存的版本控制了。如果不能对 sw.js 直接做版本控制,能不能对别的文件做无缓存的版本控制,然后在这个文件中再执行 Service Worker 的注册逻辑?

假设这个文件叫 sw-register.js,其代码如下:

// sw-register.js
if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/sw.js').then(function (reg) {
    // ...
  });
}

然后在 index.html 中对 sw-register.js 做版本控制就好了:

<script>
window.onload = function () {
  var script = document.createElement('script');
  var firstScript = document.getElementsByTagName('script')[0];
  script.type = 'text/javascript';
  script.async = true;
  script.src = '/sw-register.js?v=' + Date.now();
  firstScript.parentNode.insertBefore(script, firstScript);
};
</script>

这样处理之后,sw-register.js 就不会被浏览器缓存了,并且由于 sw-register.js 是异步加载的,也不会造成页面 block,但还有个问题,当前的 sw.js 依然会被浏览器 HTTP 缓存。根本问题还是没有解决。

其实设想一下,每次 Service Worker 的更新都是因为工程的上线,如果能够保证每次上线一次就赋给 sw.js 一个版本,等新上线之后就用新的版本号替换老的版本号,从而触发 Service Worker 的 diff,并且能保证每次上线之后就更新了新的 sw.js

// sw-register.js
if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/sw.js?v=buildVersion').then(function (reg) {
    // ...
  });
}

其中 buildVersion 是每次上线前构建的一个唯一版本号。

这样看来,是解决了之前 Service Worker 更新不及时的问题。但是代价是增加了一次 sw-register.js 的请求,由于 sw-register.js 通常只做 Service Worker 的注册工作,体量不会太大,所以应该还是可以接受,相比于在服务器端的配置,前端的版本控制的方案应该更加的简单方便。

为什么不直接使用 buildTime 做版本控制?

绕了一圈,版本控制为什么不直接在 注册 sw.js 时候做,为什么非要借助一个 sw-register.js 文件?就像如下代码:

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/sw.js?v=buildTime').then(function () {});
}

为了保证离线可用,所有和 AppShell 相关的 html 和静态资源都要被缓存住,此时,就算上线时候更改了 buildTime, 但是 Service Worker 所有可能被注册的地方由于被缓存了是感知不到变化的,除非是用 Date.now() 这种变量时间戳的方式自动轮询,但是这种方案的弊端在前面已经分析过了。

Service Worker 缓存实时生效

Service Worker 是一个独立于浏览器主线程的 Worker 线程,在这个线程 Context 中是不允许操作页面的 DOM,但是 Worker 线程可以通过 postMessage 机制与主线程进行通信。

通过前面对 Service Worker 的介绍,已经了解到 Service Worker 更新的第二个痛点是必须要等到用户第二次进入页面的时候才能使用 Service Worker 更新之后的内容,我们的预期是如果 Web App 重新上线了,那用户在任何时候打开页面都能使用到最新的内容,并且同时还要保持 Service Worker 离线缓存的特性。

通过对 sw.js 文件的无缓存处理,我们能做到实时的检测更新,接下来需要处理缓存更新实时生效的问题。

当注册 Service Worker 得时候,实时监测到 sw.js 更新之后,则浏览器会立即立即安装、激活,然而激活完成并清除老的缓存之后,如果有一种途径告诉主线程 sw 完成了更新 这样也会对用户比较友好。

// sw.js 文件
// 新的 Service Worker 更新时,进入激活状态后,会触发 activate 事件
self.addEventListener('activate', function (event) {
  var cacheName = 'a_cache_name';
  event.waitUntil(
    caches.open(cacheName)
      .then(function (cache) {
        // 进行老缓存的清除...(略过..)
      })
      .then(function () {
        // 完成缓存删除之后就可以通知浏览器主线程啦
        // 当然这里也可以判断如果缓存内本来就没内容
        // 就代表是首次安装,就不要发 message了 (这个逻辑略过...)
        return self.clients.matchAll()
          .then(function (clients) {
            if (clients && clients.length) {
              clients.forEach(function (client) {
                // 给每个已经打开的标签都 postMessage
                client.postMessage('sw.update');
              })
            }
          })
      })
  )
})

这样的话,相当于我们在自己的业务代码中只要监听 message 事件,监听到 sw.update 这个 message 就知道 Service Worker 更新成功了。看来这段代码写在 sw-register.js 中比较优雅,我们可以把 sw-register.js 这个文件就当成专门处理 Service Worker 的文件好了。

// sw-register.js
if (navigator.serviceWorker) {
  navigator.serviceWorker.addEventListener('message', function (e) {
    if (e.data === 'sw.update') {
      // 如果代码走到了在这里,就知道了,Service Worker 已经更新完成了
      // 可以做点什么事情让用户体验更好
    }
  })
}

Service Worker 实时生效的策略

通常对用户比较友好的实时生效策略有两种:

  • 监听到 Service Worker 成功更新后,直接 location.reload() 刷新当前页面
  • 通过 toast 的形式提示用户主动刷新当前页面

目前百度 Lavas 解决方案推荐的是第二种引导用户刷新的方式。

Service Worker 集成

在不同的项目中,Service Worker 有不同的集成方式。甚至可以用插件实现。

SPA 注册 Service Worker

SPA(Single Page Applications),单页 Web 应用,在工程架构上只有一个 index.html 的入口,站点的内容都是异步请求数据之后在前端渲染的,应用中的页面切换都是在前端路由控制的。

通常会将这个 index.html 部署到 https://yourhost,对于 SPA 的 Service Worker,只会在 index.html 中注册一次,所以我们会将 sw.js 直接放在站点的根目录保证可访问,Service Worker 的 scope 通常就是 /,这样能够控制整个 SPA 的缓存。

SPA 每次路由的切换都是前端渲染的过程,本质上还是在 index.html 上的前端交互,通常 Service Worker 会缓存 SPA 中的 AppShell 所需的静态资源和 index.html。当然有一种情况比较特殊,当用户从 /a 页面切换到 /b 页面,然后这时候刷新页面,此时首先渲染的还是 index.html,在执行 SPA 的路由逻辑之后,通过 SPA 前端路由的处理继续在前端渲染相应的路由对应的 Component。

MPA 注册 Service Worker

MPA(Multi Page Applications),多页应用,这种架构的模式在现如今的大型站点非常常见,例如 ele.me 就是采用这种模式来架构的站点,这种站点有常规的 Web App 的特性,但是相比较 SPA 能够承受更重的业务体量,并且利于大型站点的后期维护和扩展。针对 MPA 的 PWA 可以阅读 饿了么的 PWA 升级实践 进行更加深入了解。

在这里我们可以更加深入的了解一下 MPA PWA 是如何注册 sw.js 的,MPA 可以理解为是有多个 html 文件对应着多个不同的服务端路由,也就是说 https://yourhost/a 映射到 a.html, https://yourhost/b 映射到 b.html 等等。

那么这种架构下怎么去注册 Service Worker 呢?是不同的页面注册不同的 Service Worker,还是所有的页面都注册同一个 Service Worker?结论是:需要根据实际情况来定。

在 Vue.js 中集成

这里假设项目是使用 vue-cli 创建的,模板为 webpack,要集成 service worker。

假设要注册的 service worker 文件名为 sw.js

首先,我们知道,如果将文件直接放到根目录下,在地址中输入 localhost/sw.js 是访问不不到的,如果将 sw.js 放到 /static 目录下,倒是可以通过 localhost/static/sw.js 进行访问,但是,这样的话,service worker 只能收到 /static 路径下的 fetch 事件(例如: /static/page1/, /static/page2/)。

因此,我们得想一个办法,让 service worker 监听到站点下所有路径的 fetch 事件。

我们借助 copy-webpack-plugin 插件即可完成这个工作。

首先,安装 copy-webpack-plugin

yarn add copy-webpack-plugin

然后,改动 build/webpack.base.conf.js 文件:

const CopyWebpackPlugin = require('copy-webpack-plugin')
// ...
const originalConfig = {
  // ...
  plugins: [
    new CopyWebpackPlugin([
      {
        from: path.resolve(__dirname, '../sw.js'),
        to: config.build.assetsRoot,
        ignore: ['.*']
      }
    ])
  ]
}
// ...

这样,就可以放心地将 sw.js 存于站点根目录了,webpack 会将此文件复制到 dist 目录下,进行正常的 service worker 注册。

参考:https://webpack.js.org/plugins/copy-webpack-plugin/

index.html

<script>
  // 测试 service worker
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js').then(function(registration) {
      // Registration was successful
      console.log('ServiceWorker registration successful with scope: ',    registration.scope);
    }).catch(function(err) {
      // registration failed :(
      console.log('ServiceWorker registration failed: ', err);
    });
  }
</script>

sw.js

/* eslint-disable */
// Set the callback for the install step
self.addEventListener('install', function(event) {
  // Perform install steps
});
// ...

同样的,要加入 manifest.json 也如法炮制:

// ...
    new CopyWebpackPlugin([
      {
        from: path.resolve(__dirname, '../sw.js'),
        to: config.build.assetsRoot
      },
      {
        from: path.resolve(__dirname, '../manifest.json'),
        to: config.build.assetsRoot
      }
    ])
// ...

index.html 引入即可

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

在 Vuepress 中集成

在 Vuepress 中集成 PWA 就简单了,在 0.10 版本之后,可以直接在 config.js 配置文件中添加:

serviceWorker: true

Vuepress 会自动生成并注册 service worker,用户只需要在 .vuepress/public 中添加 manifest.json 即可。

sw-register-webpack-plugin

无论是 Service Worker 作用域问题,还是 Service Worker 的更新问题,都与 Service Worker 的注册息息相关,一个看似简单的 Service Worker 的注册还是有很多地方需要注意,但是如果这些都需要在每个项目中都要自己完全实现一遍,还是非常繁琐的。而 sw-register-webpack-plugin 作为一个 Webpack Plugin 很好的帮助我们解决了 优雅的注册 Service Worker 的问题

  • 如果项目是基于 Webpack 开发的
  • 如果不希望自己考虑繁琐的 Service Worker 问题
  • 无论是 SPA 还是 MPA

基于以上的考虑,都可以尝试一下 sw-register-webpack-plugin

安装

yarn add sw-register-webpack-plugin

配置 Webpack

// ...
import SwRegisterWebpackPlugin from 'sw-register-webpack-plugin'
// ...
webpack({
  // ...
  plugins: [
    new SwReginsterWebpackPlugin(/* options */)
  ]
  // ...
})
// ...

参考资料

相关PWA

PWA Rocks

MIT Licensed | Copyright © 2018-present 滇ICP备16006294号

Design by Quanzaiyu | Power by VuePress