Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Service Worker学习与实践(三)——消息推送 #39

Open
xingbofeng opened this issue Oct 21, 2018 · 0 comments
Open

Service Worker学习与实践(三)——消息推送 #39

xingbofeng opened this issue Oct 21, 2018 · 0 comments

Comments

@xingbofeng
Copy link
Owner

在上一篇文章Service Worker学习与实践(二)——PWA简介中,已经讲到PWA的起源,优势与劣势,并通过一个简单的例子说明了如何在桌面端和移动端将一个PWA安装到桌面上,这篇文章,将通过一个例子阐述如何使用Service Worker的消息推送功能,并配合PWA技术,带来原生应用般的消息推送体验。

Notification

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

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

// 在主线程中使用
let notification = new Notification('您有新消息', {
  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' in navigator) {
  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方法,向用户申请消息推送的权限:

function subscribeUser(swReg) {
  const applicationServerKey = 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转换的,这一函数通常是固定的,如下所示:
function urlB64ToUint8Array(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;
}

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

处理拒绝的权限

如果在调用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) {
  const title = event.data.text();
  const options = {
    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版本

const webpush = require('web-push');

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

webpush.setGCMAPIKey('<Your GCM API Key Here>');
webpush.setVapidDetails(
  'mailto:[email protected]',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

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

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

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

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

const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:[email protected]',
  'BCx1qqSFCJBRGZzPaFa8AbvjxtuJj9zJie_pXom2HI-gisHUUnlAFzrkb-W1_IisYnTcUXHmc5Ie3F58M1uYhZU',
  'g5pubRphHZkMQhvgjdnVvq8_4bs7qmCrlX-zWAJE9u8'
);

const 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"
  }
};

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();
  const urlToOpen = self.location.origin + '/index.html';

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

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

      if (matchingClient) {
        return matchingClient.focus();
      } else {
        return clients.openWindow(urlToOpen);
      }
    });

  event.waitUntil(promiseChain);
});

检测是否需要推送

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

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

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

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

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

合并消息

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

self.addEventListener('push', function(event) {
  // ...
    .then((mustShowNotification) => {
      if (mustShowNotification) {
        return registration.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();
            }
            return self.registration.showNotification(title, options);

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

小结

本文通过一个简单的例子,讲述了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也在运行,即使用户未打开应用程序,也会收到消息通知。

在下一篇文章中,我将尝试在我所在的项目中使用Service Worker,并通过WebpackWorkbox配置来讲述Service Worker的最佳实践。

@xingbofeng xingbofeng changed the title Service Worker学习与实践(三)————消息推送 Service Worker学习与实践(三)——消息推送 Oct 21, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant