Service Worker,PWA

什么是Service Worker

Service Worker本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步API

  • Service Worker的本质是一个Web Worker,它独立于JavaScript主线程,因此它不能直接访问DOM,也不能直接访问window对象,但是,Service Worker可以访问navigator对象,也可以通过消息传递的方式(postMessage)与JavaScript主线程进行通信。
  • Service Worker是一个网络代理,它可以控制Web页面的所有网络请求。
  • Service Worker具有自身的生命周期,使用好Service Worker的关键是灵活控制其生命周期。

Service Worker的作用

  • 用于浏览器缓存
  • 实现离线Web APP
  • 消息推送

Service Worker兼容性

Service Worker是现代浏览器的一个高级特性,它依赖于fetch APICache StoragePromise等,其中,Cache提供了Request / Response对象对的存储机制,Cache Storage存储多个Cache

示例

在了解Service Worker的原理之前,先来看一段Service Worker的示例:

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

var urlsToCache = [
  '/',
  '/index.js',
  '/style.css',
  '/favicon.ico',
];

varCACHE_NAME='counterxing';

self.addEventListener('install', function(event) {
  self.skipWaiting();
  event.waitUntil(
    caches.open(CACHE_NAME)
    .then(function(cache) {
      returncache.addAll(urlsToCache);
    })
  );
});

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


self.addEventListener('activate', function(event) {
  var cacheWhitelist = ['counterxing'];

  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      returnPromise.all(
        cacheNames.map(function(cacheName) {
          if (cacheWhitelist.indexOf(cacheName) ===-1) {
            returncaches.delete(cacheName);
          }
        })
      );
    })
  );
});

下面开始逐段逐段地分析,揭开Service Worker的神秘面纱:

polyfill

首先看第一行:self.importScripts('./serviceworker-cache-polyfill.js');,这里引入了Cache API的一个polyfill,这个polyfill支持使得在较低版本的浏览器下也可以使用Cache Storage API。想要实现Service Worker的功能,一般都需要搭配Cache API代理网络请求到缓存中。

Service Worker线程中,使用importScripts引入polyfill脚本,目的是对低版本浏览器的兼容。

Cache Resources List And Cache Name

之后,使用一个urlsToCache列表来声明需要缓存的静态资源,再使用一个变量CACHE_NAME来确定当前缓存的Cache Storage Name,这里可以理解成Cache Storage是一个DB,而CACHE_NAME则是DB名:

var urlsToCache = [
  '/',
  '/index.js',
  '/style.css',
  '/favicon.ico',
];

varCACHE_NAME='counterxing';

Lifecycle

Service Worker独立于浏览器JavaScript主线程,有它自己独立的生命周期。

如果需要在网站上安装Service Worker,则需要在JavaScript主线程中使用以下代码引入Service Worker

if ('serviceWorker'innavigator) {
  navigator.serviceWorker.register('/sw.js').then(function(registration) {
    console.log('成功安装', registration.scope);
  }).catch(function(err) {
    console.log(err);
  });
}

此处,一定要注意sw.js文件的路径,在我的示例中,处于当前域根目录下,这意味着,Service Worker和网站是同源的,可以为当前网站的所有请求做代理,如果Service Worker被注册到/imaging/sw.js下,那只能代理/imaging下的网络请求。

可以使用Chrome控制台,查看当前页面的Service Worker情况:

安装完成后,Service Worker会经历以下生命周期:

  1. 下载(download
  2. 安装(install
  3. 激活(activate
  • 用户首次访问Service Worker控制的网站或页面时,Service Worker会立刻被下载。之后至少每24小时它会被下载一次。它可能被更频繁地下载,不过每24小时一定会被下载一次,以避免不良脚本长时间生效。

  • 在下载完成后,开始安装Service Worker,在安装阶段,通常需要缓存一些我们预先声明的静态资源,在我们的示例中,通过urlsToCache预先声明。

  • 在安装完成后,会开始进行激活,浏览器会尝试下载Service Worker脚本文件,下载成功后,会与前一次已缓存的Service Worker脚本文件做对比,如果与前一次的Service Worker脚本文件不同,证明Service Worker已经更新,会触发activate事件。完成激活。

如图所示,为Service Worker大致的生命周期:

install

在安装完成后,尝试缓存一些静态资源:

self.addEventListener('install', function(event) {
  self.skipWaiting();
  event.waitUntil(
    caches.open(CACHE_NAME)
    .then(function(cache) {
      returncache.addAll(urlsToCache);
    })
  );
});

首先,self.skipWaiting()执行,告知浏览器直接跳过等待阶段,淘汰过期的sw.jsService Worker脚本,直接开始尝试激活新的Service Worker

然后使用caches.open打开一个Cache,打开后,通过cache.addAll尝试缓存我们预先声明的静态文件。

监听fetch,代理网络请求

页面的所有网络请求,都会通过Service Workerfetch事件触发,Service Worker通过caches.match尝试从Cache中查找缓存,缓存如果命中,则直接返回缓存中的response,否则,创建一个真实的网络请求。

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

如果我们需要在请求过程中,再向Cache Storage中添加新的缓存,可以通过cache.put方法添加,看以下例子:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
    .then(function(response) {
      // 缓存命中if (response) {
        return response;
      }

      // 注意,这里必须使用clone方法克隆这个请求// 原因是response是一个Stream,为了让浏览器跟缓存都使用这个response// 必须克隆这个response,一份到浏览器,一份到缓存中缓存。// 只能被消费一次,想要再次消费,必须clone一次var fetchRequest =event.request.clone();

      returnfetch(fetchRequest).then(
        function(response) {
          // 必须是有效请求,必须是同源响应,第三方的请求,因为不可控,最好不要缓存if (!response ||response.status!==200||response.type!=='basic') {
            return response;
          }

          // 消费过一次,又需要再克隆一次var responseToCache =response.clone();
          caches.open(CACHE_NAME)
            .then(function(cache) {
              cache.put(event.request, responseToCache);
            });
          return response;
        }
      );
    })
  );
});

在项目中,一定要注意控制缓存,接口请求一般是不推荐缓存的。所以在我自己的项目中,并没有在这里做动态的缓存方案。

activate

Service Worker总有需要更新的一天,随着版本迭代,某一天,我们需要把新版本的功能发布上线,此时需要淘汰掉旧的缓存,旧的Service WorkerCache Storage如何淘汰呢?

self.addEventListener('activate', function(event) {
  var cacheWhitelist = ['counterxing'];

  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      returnPromise.all(
        cacheNames.map(function(cacheName) {
          if (cacheWhitelist.indexOf(cacheName) ===-1) {
            returncaches.delete(cacheName);
          }
        })
      );
    })
  );
});
  1. 首先有一个白名单,白名单中的Cache是不被淘汰的。
  2. 之后通过caches.keys()拿到所有的Cache Storage,把不在白名单中的Cache淘汰。
  3. 淘汰使用caches.delete()方法。它接收cacheName作为参数,删除该cacheName所有缓存。

sw-precache-webpack-plugin

sw-precache-webpack-plugin是一个webpack plugin,可以通过配置的方式在webpack打包时生成我们想要的sw.jsService Worker脚本。

一个最简单的配置如下:

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

constPUBLIC_PATH='https://www.my-project-name.com/';  // webpack needs the trailing slash for output.publicPathmodule.exports= {

  entry: {
    main:path.resolve(__dirname, 'src/index'),
  },

  output: {
    path:path.resolve(__dirname, 'src/bundles/'),
    filename:'[name]-[hash].js',
    publicPath:PUBLIC_PATH,
  },

  plugins: [
    newSWPrecacheWebpackPlugin(
      {
        cacheId:'my-project-name',
        dontCacheBustUrlsMatching:/\.\w{8}\./,
        filename:'service-worker.js',
        minify:true,
        navigateFallback:PUBLIC_PATH+'index.html',
        staticFileGlobsIgnorePatterns: [/\.map$/,/asset-manifest\.json$/],
      }
    ),
  ],
}

在执行webpack打包后,会生成一个名为service-worker.js文件,用于缓存webpack打包后的静态文件。

一个最简单的示例

Service Worker Cache VS Http Cache

对比起Http Header缓存,Service Worker配合Cache Storage也有自己的优势:

  1. 缓存与更新并存:每次更新版本,借助Service Worker可以立马使用缓存返回,但与此同时可以发起请求,校验是否有新版本更新。
  2. 无侵入式:hash值实在是太难看了。
  3. 不易被冲掉:Http缓存容易被冲掉,也容易过期,而Cache Storage则不容易被冲掉。也没有过期时间的说法。
  4. 离线:借助Service Worker可以实现离线访问应用。

但是缺点是,由于Service Worker依赖于fetch API、依赖于PromiseCache Storage等,兼容性不太好。

后话

本文只是简单总结了Service Worker的基本使用和使用Service Worker做客户端缓存的简单方式,然而,Service Worker的作用远不止于此,例如:借助Service Worker做离线应用、用于做网络应用的推送(可参考push-notifications)等。

甚至可以借助Service Worker,对接口进行缓存,在我所在的项目中,其实并不会做的这么复杂。不过做接口缓存的好处是支持离线访问,对离线状态下也能正常访问我们的Web应用。

Cache StorageService Worker总是分不开的。Service Worker的最佳用法其实就是配合Cache Storage做离线缓存。借助于Service Worker,可以轻松实现对网络请求的控制,对于不同的网络请求,采取不同的策略。例如对于Cache的策略,其实也是存在多种情况。例如可以优先使用网络请求,在网络请求失败时再使用缓存、亦可以同时使用缓存和网络请求,一方面检查请求,一方面有检查缓存,然后看两个谁快,就用谁。


Progressive Web Apps(PWA)

Progressive Web App, 简称 PWA,是提升Web App的体验的一种新方法,能给用户原生应用的体验。

PWA能做到原生应用的体验不是靠特指某一项技术,而是经过应用一些新技术进行改进,在安全、性能和体验三个方面都有很大提升,PWA本质上是Web App,借助一些新技术也具备了Native App的一些特性,兼具Web AppNative App的优点。

PWA的主要特点包括下面三点:

可靠 - 即使在不稳定的网络环境下,也能瞬间加载并展现

体验 - 快速响应,并且有平滑的动画响应用户的操作

粘性 - 像设备上的原生应用,具有沉浸式的用户体验,用户可以添加到桌面
PWA本身强调渐进式,并不要求一次性达到安全、性能和体验上的所有要求,开发者可以通过PWA Checklist查看现有的特征。

通过上面的PWA Checklist,总结起来,PWA大致有以下的优势:

  • 体验:通过Service Worker配合Cache Storage API,保证了PWA首屏的加载效率,甚至配合本地存储可以支持离线应用;
  • 粘性:PWA是可以安装的,用户点击安装到桌面后,会在桌面创建一个 PWA 应用,并且不需要从应用商店下载,可以借助Web App Manifest提供给用户和Native App一样的沉浸式体验,可以通过给用户发送离线通知,让用户回流;
  • 渐进式:适用于大多数现代浏览器,随着浏览器版本的迭代,其功能是渐进增强的;
  • 无版本问题:如Web应用的优势,更新版本只需要更新应用程序对应的静态文件即可,Service Worker会帮助我们进行更新;
  • 跨平台:WindowsMac OSXAndroidIOS,一套代码,多处使用;
  • 消息推送:即使用户已经关闭应用程序,仍然可以对用户进行消息推送;

总的说来,只要Web应用支持的功能,对于PWA而言,基本都支持,此外,还提供了原生能力。

使用PWA manifest添加桌面入口

注意这里说的manifest不是指的manifest缓存,这个manifest是一个JSON文件,开发者可以利用它控制在用户想要看到应用的区域(例如移动设备主屏幕)中如何向用户显示网络应用或网站,指示用户可以启动哪些功能,以及定义其在启动时的外观。

manifest提供了将网站书签保存到设备主屏幕的功能。当网站以这种方式启动时:

  • 它具有唯一的图标和名称,以便用户将其与其他网站区分开来。
  • 它会在下载资源或从缓存恢复资源时向用户显示某些信息。
  • 它会向浏览器提供默认显示特性,以避免网站资源可用时的过渡过于生硬。

下面是我的博客网站的manifest.json文件,作为桌面入口配置文件的示例:

{
  "name": "Counterxing",
  "short_name": "Counterxing",
  "description": "Why did you encounter me?",
  "start_url": "/index.html",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#ACE",
  "theme_color": "#ACE",
  "icons": [{
    "src": "/images/logo/logo072.png",
    "sizes": "72x72",
    "type": "image/png"
  }, {
    "src": "/images/logo/logo152.png",
    "sizes": "152x152",
    "type": "image/png"
  }, {
    "src": "/images/logo/logo192.png",
    "sizes": "192x192",
    "type": "image/png"
  }, {
    "src": "/images/logo/logo256.png",
    "sizes": "256x256",
    "type": "image/png"
  }, {
    "src": "/images/logo/logo512.png",
    "sizes": "512x512",
    "type": "image/png"
  }]
}

上面的字段含义也不用多解释了,大致就是启动的icon样式,应用名称、简写名称与描述等,其中必须确保有short_namename。此外,最好设定好start_url,表示启动的根页面路径,如果不添加,则是使用当前路径。

displaystandalone,则会隐藏浏览器的UI界面,如果设置displaybrowser,则启动时保存浏览器的UI界面。

orientation表示启动时的方向,横屏、竖屏等,具体参数值可参考文档

background_colortheme_color表示应用程序的背景颜色和主题颜色。

在创建好manifest.json后,将、使用link标签添加到应用程序的所有页面上,<link rel="manifest" href="/manifest.json">

安装到桌面

桌面端(以Mac OSX为例)

只有注册、激活了Service Worker的网站才能够安装到桌面,在Chrome 70版本之前,需要手动开启实验性功能,步骤如下:

  1. 进入chrome://flags
  2. 找到Desktop PWAs,选择Enabled


此时,进入一个支持PWA的网站(例如Google I/O),在Chrome浏览器右上角,点击安装。即可安装到桌面。这里以我的博客为例:

可以到awesome-pwa查找目前支持PWA的网站列表


接着点击安装:


这样,一个PWA应用就安装到你的机器上了,这里我的操作系统为Mac OSX,应用程序可以通过Launchpad打开,在Windows也是同理的,会被安装到桌面上,可通过开始菜单找到应用程序。


打开应用程序,发现其与原始应用几乎没有任何差距:

Windows与上述方法类似,这里就不做过多阐述

移动端(以IOS为例)

由于当初苹果推出PWA时,并没有一个统一的manifest的规范,最开始的设计是通过metalink标签来设定应用的对应参数的,所以,在移动端上的PWA应用,为了兼容Windows PhoneiPhone,需要在所有页面的HTMLhead中添加以下的标签:

<metaname="msapplication-TileImage"content="./images/logo/logo152.png">
<metaname="msapplication-TileColor"content="#2F3BA2">
<metaname="apple-mobile-web-app-capable"content="yes">
<metaname="apple-mobile-web-app-status-bar-style"content="black">
<metaname="apple-mobile-web-app-title"content="Counterxing">
<linkrel="apple-touch-icon"href="./images/logo/logo152.png">

添加好后,就可以体验我们的PWA了!

IOS11.3版本之后也支持PWA了,知道这一消息的我,卸载了手机上很多软件,立刻体验上了PWA

这里以豆瓣移动端为例使用Safiri浏览器打开一个网站,点击下方分享图标,选择添加到主屏幕。



然后在新弹出的一个浏览器页面,选择添加:


就以上简短的步骤,移动端上的一个PWA桌面应用就添加好了,赶紧体验吧!

小结

本文是笔者写的Service Worker学习与实践系列文章的第二篇,主要讲述的是配合Service Worker使用的PWA的优势,如何配置manifest.json文件来实现将PWA安装到桌面,并通过Mac OSXIOS如何安装PWA到桌面的详细步骤,阐述了如何配置PWA,使其方便地安装到桌面上。

下一篇文章中,主要讲述Service WorkerPWA实践中的重要能力:Web Push


Notification

说到底,PWA的消息推送也是服务端推送的一种,常见的服务端推送方法,例如广泛使用的轮询、长轮询、Web Socket等,说到底,都是客户端与服务端之间的通信,在Service Worker中,客户端接收到通知,是基于Notification来进行推送的。

那么,我们来看一下,如何直接使用Notification来发送一条推送呢?下面是一段示例代码:

// 在主线程中使用let notification =newNotification('您有新消息', {
  body:'Hello Service Worker',
  icon:'./images/logo/logo152.png',
});

notification.onclick=function() {
  console.log('点击了');
};

在控制台敲下上述代码后,则会弹出以下通知:


然而,Notification这个API,只推荐在Service Worker中使用,不推荐在主线程中使用,在Service Worker中的使用方法为:

// 添加notificationclick事件监听器,在点击notification时触发self.addEventListener('notificationclick', function(event) {
  // 关闭当前的弹窗event.notification.close();
  // 在新窗口打开页面event.waitUntil(
    clients.openWindow('https://google.com')
  );
});

// 触发一条通知self.registration.showNotification('您有新消息', {
  body:'Hello Service Worker',
  icon:'./images/logo/logo152.png',
});

读者可以在MDN Web Docs关于NotificationService Worker中的相关用法,在本文就不浪费大量篇幅来进行较为详细的阐述了。

申请推送的权限

如果浏览器直接给所有开发者开放向用户推送通知的权限,那么势必用户会受到大量垃圾信息的骚扰,因此这一权限是需要申请的,如果用户禁止了消息推送,开发者是没有权利向用户发起消息推送的。我们可以通过serviceWorkerRegistration.pushManager.getSubscription方法查看用户是否已经允许推送通知的权限。修改sw-register.js中的代码:

if ('serviceWorker'innavigator) {
  navigator.serviceWorker.register('/sw.js').then(function (swReg) {
    swReg.pushManager.getSubscription()
      .then(function(subscription) {
        if (subscription) {
          console.log(JSON.stringify(subscription));
        } else {
          console.log('没有订阅');
          subscribeUser(swReg);
        }
      });
  });
}

上面的代码调用了swReg.pushManagergetSubscription,可以知道用户是否已经允许进行消息推送,如果swReg.pushManager.getSubscriptionPromisereject了,则表示用户还没有订阅我们的消息,调用subscribeUser方法,向用户申请消息推送的权限:

functionsubscribeUser(swReg) {
  constapplicationServerKey=urlB64ToUint8Array(applicationServerPublicKey);
  swReg.pushManager.subscribe({
    userVisibleOnly:true,
    applicationServerKey: applicationServerKey
  })
  .then(function(subscription) {
    console.log(JSON.stringify(subscription));
  })
  .catch(function(err) {
    console.log('订阅失败: ', err);
  });
}

上面的代码通过serviceWorkerRegistration.pushManager.subscribe向用户发起订阅的权限,这个方法返回一个Promise,如果Promiseresolve,则表示用户允许应用程序推送消息,反之,如果被reject,则表示用户拒绝了应用程序的消息推送。如下图所示:


serviceWorkerRegistration.pushManager.subscribe方法通常需要传递两个参数:

  • userVisibleOnly,这个参数通常被设置为true,用来表示后续信息是否展示给用户。
  • applicationServerKey,这个参数是一个Uint8Array,用于加密服务端的推送信息,防止中间人攻击,会话被攻击者篡改。这一参数是由服务端生成的公钥,通过urlB64ToUint8Array转换的,这一函数通常是固定的,如下所示:

    functionurlB64ToUint8Array(base64String) {

    constpadding='='.repeat((4-base64String.length%4) %4);
    constbase64= (base64String + padding)
      .replace(/\-/g, '+')
      .replace(/_/g, '/');
    
    constrawData=window.atob(base64);
    constoutputArray=newUint8Array(rawData.length);
    
    for (let i =0; i <rawData.length; ++i) {
      outputArray[i] =rawData.charCodeAt(i);
    }
    return outputArray;
    

    }

关于服务端公钥如何获取,在文章后续会有相关阐述。

处理拒绝的权限

如果在调用serviceWorkerRegistration.pushManager.subscribe后,用户拒绝了推送权限,同样也可以在应用程序中,通过Notification.permission获取到这一状态,Notification.permission有以下三个取值,:

  • granted:用户已经明确的授予了显示通知的权限。
  • denied:用户已经明确的拒绝了显示通知的权限。
  • default:用户还未被询问是否授权,在应用程序中,这种情况下权限将视为denied

    if (Notification.permission===’granted’) {

    // 用户允许消息推送
    

    } else {

    // 还不允许消息推送,向用户申请消息推送的权限
    

    }

密钥生成

上述代码中的applicationServerPublicKey通常情况下是由服务端生成的公钥,在页面初始化的时候就会返回给客户端,服务端会保存每个用户对应的公钥与私钥,以便进行消息推送。

在我的示例演示中,我们可以使用Google配套的实验网站web-push-codelab生成公钥与私钥,以便发送消息通知:

发送推送

Service Worker中,通过监听push事件来处理消息推送:

self.addEventListener('push', function(event) {
  consttitle=event.data.text();
  constoptions= {
    body:event.data.text(),
    icon:'./images/logo/logo512.png',
  };

  event.waitUntil(self.registration.showNotification(title, options));
});

在上面的代码中,在push事件回调中,通过event.data.text()拿到消息推送的文本,然后调用上面所说的self.registration.showNotification来展示消息推送。

服务端发送

那么,如何在服务端识别指定的用户,向其发送对应的消息推送呢?

在调用swReg.pushManager.subscribe方法后,如果用户是允许消息推送的,那么该函数返回的Promise将会resolve,在then中获取到对应的subscription

subscription一般是下面的格式:

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E",
  "expirationTime": null,
  "keys": {
    "p256dh": "BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU",
    "auth": "XGWy-wlmrAw3Be818GLZ8Q"
  }
}

使用Google配套的实验网站web-push-codelab,发送消息推送。

web-push

在服务端,使用web-push-libs,实现公钥与私钥的生成,消息推送功能,Node.js版本

constwebpush=require('web-push');

// VAPID keys should only be generated only once.constvapidKeys=webpush.generateVAPIDKeys();

webpush.setGCMAPIKey('<Your GCM API Key Here>');
webpush.setVapidDetails(
  'mailto:example@yourdomain.org',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

// pushSubscription是前端通过swReg.pushManager.subscribe获取到的subscriptionconstpushSubscription= {
  endpoint:'.....',
  keys: {
    auth:'.....',
    p256dh:'.....'
  }
};

webpush.sendNotification(pushSubscription, 'Your Push Payload Text');

上面的代码中,GCM API Key需要在Firebase console中申请,申请教程可参考这篇博文

在这个我写的示例Demo中,我把subscription写死了:

constwebpush=require('web-push');

webpush.setVapidDetails(
  'mailto:503908971@qq.com',
  'BCx1qqSFCJBRGZzPaFa8AbvjxtuJj9zJie_pXom2HI-gisHUUnlAFzrkb-W1_IisYnTcUXHmc5Ie3F58M1uYhZU',
  'g5pubRphHZkMQhvgjdnVvq8_4bs7qmCrlX-zWAJE9u8'
);

constsubscription= {
  "endpoint":"https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E",
  "expirationTime":null,
  "keys": {
    "p256dh":"BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU",
    "auth":"XGWy-wlmrAw3Be818GLZ8Q"
  }
};

webpush.sendNotification(subscription, 'Counterxing');

交互响应

默认情况下,推送的消息点击后是没有对应的交互的,配合clients API可以实现一些类似于原生应用的交互,这里参考了这篇博文的实现:

Service Worker中的self.clients对象提供了Client的访问,Client接口表示一个可执行的上下文,如WorkerSharedWorkerWindow客户端由更具体的WindowClient表示。 你可以从Clients.matchAll()Clients.get()等方法获取Client/WindowClient对象。

新窗口打开

使用clients.openWindow在新窗口打开一个网页:

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  // 新窗口打开event.waitUntil(
    clients.openWindow('https://google.com/')
  );
});

聚焦已经打开的页面

利用cilents提供的相关API获取,当前浏览器已经打开的页面URLs。不过这些URLs只能是和你SW同域的。然后,通过匹配URL,通过matchingClient.focus()进行聚焦。没有的话,则新打开页面即可。

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  consturlToOpen=self.location.origin+'/index.html';

  constpromiseChain=clients.matchAll({
      type:'window',
      includeUncontrolled:true
    })
    .then((windowClients) => {
      let matchingClient =null;

      for (let i =0; i <windowClients.length; i++) {
        constwindowClient= windowClients[i];
        if (windowClient.url=== urlToOpen) {
          matchingClient = windowClient;
          break;
        }
      }

      if (matchingClient) {
        returnmatchingClient.focus();
      } else {
        returnclients.openWindow(urlToOpen);
      }
    });

  event.waitUntil(promiseChain);
});

检测是否需要推送

如果用户已经停留在当前的网页,那我们可能就不需要推送了,那么针对于这种情况,我们应该怎么检测用户是否正在网页上呢?

通过windowClient.focused可以检测到当前的Client是否处于聚焦状态。

self.addEventListener('push', function(event) {
  constpromiseChain=clients.matchAll({
      type:'window',
      includeUncontrolled:true
    })
    .then((windowClients) => {
      let mustShowNotification =true;

      for (let i =0; i <windowClients.length; i++) {
        constwindowClient= windowClients[i];
        if (windowClient.focused) {
          mustShowNotification =false;
          break;
        }
      }

      return mustShowNotification;
    })
    .then((mustShowNotification) => {
      if (mustShowNotification) {
        consttitle=event.data.text();
        constoptions= {
          body:event.data.text(),
          icon:'./images/logo/logo512.png',
        };
        returnself.registration.showNotification(title, options);
      } else {
        console.log('用户已经聚焦于当前页面,不需要推送。');
      }
    });
});

合并消息

该场景的主要针对消息的合并。比如,当只有一条消息时,可以直接推送,那如果该用户又发送一个消息呢? 这时候,比较好的用户体验是直接将推送合并为一个,然后替换即可。 那么,此时我们就需要获得当前已经展示的推送消息,这里主要通过registration.getNotifications() API来进行获取。该API返回的也是一个Promise对象。通过Promiseresolve后拿到的notifications,判断其length,进行消息合并。

self.addEventListener('push', function(event) {
  // ...
    .then((mustShowNotification) => {
      if (mustShowNotification) {
        returnregistration.getNotifications()
          .then(notifications=> {
            let options = {
              icon:'./images/logo/logo512.png',
              badge:'./images/logo/logo512.png'
            };
            let title =event.data.text();
            if (notifications.length) {
              options.body=`您有${notifications.length}条新消息`;
            } else {
              options.body=event.data.text();
            }
            returnself.registration.showNotification(title, options);

          });
      } else {
        console.log('用户已经聚焦于当前页面,不需要推送。');
      }
    });
  // ...
});

服务端推送的几种方式

服务端推送是现今Web开发过程中最常见的需求。例如:

  • 即时聊天工具
  • H5网络游戏
  • 消息通知

一般的服务器推送包括:

  • 最简单的是客户端轮询的方式,在客户端创建一个定时器,每隔一定的时间去请求服务端,每次请求检查状态变化以判断服务端是否有新数据更新。
  • 基于 AJAX 的长轮询(long-polling)方式,服务器在一段时间后再返回信息;
  • HTTP Streaming,通过iframe<script>标签完成数据的传输;
  • TCP长连接/WebSocket,可以实现服务器主动发送数据至网页端,它和HTTP一样,是一个基于HTTP的应用层协议,跑的是TCP,所以本质上还是个长连接,双向通信,意味着服务器端和客户端可以同时发送并响应请求,而不再像HTTP的请求和响应
  • SSE: Server-Sent Events,这是通过http协议变通实现的,通过服务端向客户端声明,接下来是要发送的是流信息,本质上就是完成一次耗时长的下载。

小结

本文通过一个简单的例子,讲述了Service Worker中消息推送的原理。Service Worker中的消息推送是基于Notification API的,这一API的使用首先需要用户授权,通过在Service Worker注册时的serviceWorkerRegistration.pushManager.subscribe方法来向用户申请权限,如果用户拒绝了消息推送,应用程序也需要相关处理。

消息推送是基于谷歌云服务的,因此,在国内,收到GFW的限制,这一功能的支持并不好,Google提供了一系列推送相关的库,例如Node.js中,使用web-push来实现。一般原理是:在服务端生成公钥和私钥,并针对用户将其公钥和私钥存储到服务端,客户端只存储公钥。Service WorkerswReg.pushManager.subscribe可以获取到subscription,并发送给服务端,服务端利用subscription向指定的用户发起消息推送。

消息推送功能可以配合clients API做特殊处理。

如果用户安装了PWA应用,即使用户关闭了应用程序,Service Worker也在运行,即使用户未打开应用程序,也会收到消息通知。