PWA 推送实践

PWA 推送实践

最近公司内录任务的系统总是忘记录任务,而那个系统又没有通知,所以想要实现一个浏览器的通知功能,免得自己忘记录入任务。

前端实现通知的几种方式

想要实现通知,我们就需要有个客户端,对于前端同学来说,我们的客户端就是浏览器,我们每天基本上都是长开浏览器,所以用浏览器做个通知效果更好。既然是浏览器,在PWA 出现之前我们就只有 chrome 插件可以用,现在既然有了 PWA,我们有一个更为方便的方案:PWA。

为什么选用 PWA?由于内部系统的任何信息,包括域名都不能上传到外部,如果使用插件的方式,那么不可避免代码要发布到应用商店,那么恭喜,你要被约谈了。

PWA 基础介绍

PWA(Progress Web Application), 渐近式网页应用,相比于普通的网页,它具备了客户端的特性,下面是官方特性介绍

Reliable - Load instantly and never show the downasaur, even in uncertain network conditions.
Fast - Respond quickly to user interactions with silky smooth animations and no janky scrolling.
Engaging - Feel like a natural app on the device, with an immersive user experience.

使用的理由:

可发送至桌面上,以 APP 标识展示

可靠性高,可离线使用

增加用户粘性,打开的频率会更高

提高用户转化率

简单来讲, PWA 对于 Web 主要意义在于:推送和后台服务。这两个特性使得 PWA 在某种意义上可以替代部分 Chrome 插件功能(当然安全权限的原因,PWA 部分功能无法实现)。

PWA 涉及两个主要的方面:推送(含通知等)和 service worker。

关于实现一个简单的例子: https://segmentfault.com/a/1190000012462202

注册 service worker

首先我们要注册 service-worker,当然本文章不讨论兼容性问题,可自行添加相关的判断。注册完成后,需要在 service worker ready 后才可以执行其他操作

window.addEventListener('load', function() {
// register 方法里第一个参数为 Service Worker 要加载的文件;第二个参数 scope 可选,用来指定 Service Worker 控制的内容的子目录
  navigator.serviceWorker.register('./ServiceWorker.js').then(function(registration) {
    // Service Worker 注册成功
    console.log('ServiceWorker registration successful with scope: ', registration.scope);
  }).catch(function(err) {
    // Service Worker 注册失败
    console.log('ServiceWorker registration failed: ', err);
  });
});

推送注册

在 service worker ready 后,我们就可以进行订阅通知了:

function subscribePush() {
  navigator.serviceWorker.ready.then(function(registration) {
    if (!registration.pushManager) {
      alert('Your browser doesn\'t support push notification.');
      return false;
    }

    registration.pushManager.subscribe({
      userVisibleOnly: true //Always show notification when received
    })
    .then(function (subscription) {
      console.log(subscription);
    });
  })
}

function unsubscribePush() {
  navigator.serviceWorker.ready
  .then(function(registration) {
    //Get `push subscription`
    registration.pushManager.getSubscription()
    .then(function (subscription) {
      //If no `push subscription`, then return
      if(!subscription) {
        alert('Unable to unregister push notification.');
        return;
      }

      //Unsubscribe `push notification`
      subscription.unsubscribe()
        .then(function () {
          console.log(subscription);
        })
    })
    .catch(function (error) {
      console.error('Failed to unsubscribe push notification.');
    });
  })
}

订阅完成通知后,我们需要从 subscription 中获取用户的推送 ID,从而使用该 ID 对用户进行推送控制。

添加 manifest.json

要想发送到桌面上,并接受推送,我们需要配置 manifest.json。其中最关键的要配置 gcm_sender_id,这个是需要在 firebase 中获取的。

{
  "name": "PWA - Commits",
  "short_name": "PWA",
  "description": "Progressive Web Apps for Resources I like",
  "start_url": "./index.html?utm=homescreen",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#f5f5f5",
  "theme_color": "#f5f5f5",
  "icons": [
    {
      "src": "./images/192x192.png",
      "type": "image/png",
      "sizes": "192x192"
    }
  ],
  "author": {
    "name": "Prosper Otemuyiwa",
    "website": "https://twitter.com/unicodeveloper",
    "github": "https://github.com/unicodeveloper",
    "source-repo": "https://github.com/unicodeveloper/pwa-commits"
  },
  "gcm_sender_id": "571712848651"
}

获取允许通知权限

想到给用户发通知,需要先请求用户权限:

window.Notification.requestPermission((permission) => {
  if (permission === 'granted') {
    const notice = payload.notification || {}
    const n = new Notification(notice.title, { ...notice })
    n.onclick = function (e) {
      if (payload.notification.click_action) {
        window.open(payload.notification.click_action, '_blank')
      }
      n.onclick = undefined
      n.close()
    }
  }
})

workbox

当然,这些都是些重复的工作,实际上 firebase 已经给我们封装好了一个现成的库,我们可以直接调用。同样,google 提供了一个 workbox 库,专门用于 service worker 功能。它的主要功能是:pre-cache 和 route request。简单来讲,就是设置某些文件预缓存,从 service worker 的 cache 中获取。Router Request 主是拦截请求,根据不同的请求定制规则,不需要自己再监听 fetch 事件,手写一大堆代码。相关文档可以直接看 https://developers.google.com/web/tools/workbox/guides/get-started .

PWA 实现推送的设计

PWA 的基本功能我们都知道了,那么我们就来实现一个 firebase 的推送。我们需要在 firebase 中创建项目,这一部分可以搜搜教程,这里就不详解了。我们的后端采用 Nodejs 来实现,使用 node-gcm 库进行通知的发送。

firebase 和项目配置

使用 firebase 进行推送和之前提到浏览器推送不同,我们需要使用 firebase 的库,并设置相关的 id。

  1. 首先我们需要在 html 中添加 firebase 库地址, 我们只需要推送功能,所以只引入两个脚本:
<script src="https://www.gstatic.com/firebasejs/5.8.4/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/5.8.4/firebase-messaging.js"></script>

关于使用全部文档参见这里 https://firebase.google.com/docs/web/setup?authuser=0

引用后我们需要进行配置,这段代码可以在 firebase 网站上找到。 建完应用后,在设置 -> 添加应用, 然后复制代码至 body 中。大概是下面这个样子的

<!-- The core Firebase JS SDK is always required and must be listed first -->
<script src="https://www.gstatic.com/firebasejs/5.11.1/firebase-app.js"></script>

<!-- TODO: Add SDKs for Firebase products that you want to use
     https://firebase.google.com/docs/web/setup#config-web-app -->

<script>
  // Your web app's Firebase configuration
  var firebaseConfig = {
    apiKey: "2333333xxxx-9Co0",
    authDomain: "project-name.firebaseapp.com",
    databaseURL: "https://project-name.firebaseio.com",
    projectId: "project-name",
    storageBucket: "project-name.appspot.com",
    messagingSenderId: "21590860940",
    appId: "1:21590860940:web:22222"
  };
  // Initialize Firebase
  firebase.initializeApp(firebaseConfig);
</script>
  1. 基本引入完成后,我们设置 manifest.json 中的 gcm_sender_id103953800507, 表示使用 firebase 的推送。

  2. 创建 firebase-messaging-sw.js

/* eslint-disable */
importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-app.js')
importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-messaging.js')

// Initialize the Firebase app in the service worker by passing in the
// messagingSenderId.
firebase.initializeApp({
  'messagingSenderId': '21590860940'
})

// Retrieve an instance of Firebase Messaging so that it can handle background
// messages.
const messaging = firebase.messaging()
  1. 引入 sw 文件

需要设置云消息的公钥:可在设置 云消息集成 -> 网络配置 创建公钥。

const messaging = firebase.messaging()
messaging.usePublicVapidKey('BJBX2316OIair2mmmlcmUy6Turkwg2dqK1hHq4uj17oEQ6wk76bpUfDlMCNxXUuLebge2AneQBabVRUoiEGVmbE')
  1. token 上传

我们虽然已经接入了 firebase,但需要获取到 firebase 对应用户的 token 才能给用户推送消息,所以我们要获取 token 并上传

// 获取通知权限,并上传 token
export function subscribe () {
  return messaging.requestPermission().then(function () {
    getToken()
    refreshToken()
  }).catch(function (err) {
    console.log('Unable to get permission to notify.', err)
  })
}

function getToken () {
  messaging.getToken().then((fcm) => {
    if (!fcm) {
      console.log('error')
      return axios.delete('/fcm/token', { fcm })
    }
    console.log(fcm)
    return axios.put('/fcm/token', { fcm })
  })
    .catch((err) => {
      console.error(err)
      return axios.delete('/fcm/token')
    })
}

function refreshToken () {
  messaging.onTokenRefresh(() => {
    getToken()
  })
}

这样,我们页面端的推送的基本设置就完成了。下面是设置服务端发送消息(如何存储用户 token 需要自行设计), 这里面使用 node-gcm,当然你也可以考虑使用其他的库。

const fcm = require('node-gcm')
const FCM_KEY = '云消息 -> 服务器密钥';

const defaultOption = {
  // collapseKey: 'demo',
  // priority: 'high',
  // contentAvailable: true,
  // delayWhileIdle: true,
  // timeToLive: 3,
  // dryRun: true,
};

const router = new Router({});

async function push (option) {
  const { message, tokens } = option
  const msg = new fcm.Message(Object.assign({}, defaultOption, message));
  const sender = new fcm.Sender(FCM_KEY);
  try {
    // const result = await send(msg, { registrationTokens: tokens });
    const result = await new Promise((resolve, reject) => {
      sender.sendNoRetry(msg, { registrationTokens: tokens }, (err, res) => {
        if (err) {
          return reject(err)
        }
        return resolve(res)
      })
    })
    return result
  } catch (e) {
    console.error(e)
  }
};

以上这些步骤基本上可以实现推送了,可以手动尝试下推送,看是否能接收到。

几个要解决的问题

推送服务独立

由于墙的存在,内网机器无法访问 firebase 服务

解决方案:我们需要把 firebase 推送功能独立出来,形成一个 http 服务,放在可以访问 firebase 的机器上,也是就是进行拆分只需要重开个项目即可

证书

service worker 需要 https 才可以安装

这个问题是不可避免的,如果是有公网 IP 的机器,可以考虑使用 let's encrypt,推荐使用自动化脚本 acme.sh https://github.com/Neilpang/acme.sh

内网机器就没这么方便了,没有 DNS 操作权限,只能使用自建的 CA 来发证书。使用 mkcert 即可:

mkcert 生成证书的步骤:

a) 下载linux 下的二进制文件,存放在某个目录下,然后使用 ln -s ./xxx /usr/bin/mkcert,软链过去

b) 生成 ca 证书, 后面有 ca 证书的位置,把公钥拷贝出来,用于安装

mkcert -install

c) 生成证书,根据你的服务域名,生成证书

mkcert example.com

把公钥都复制出来,私钥和 key 用于 nginx 等配置。生成完成证书后 https 就没有问题了。不过这个方案下需要用户安装 ca 的证书才可以,增加了使用的门槛。

发送通知的主逻辑

前端提到的是一些准备工作,包括怎么引入 firebase 和推送实现,怎样避免墙的问题。而什么时候发送推送,为什么要发送推送才是工作的关键。

怎么检测用户需要通知?

服务端与其他服务是隔离的,没办法获取,只能通过接口去获取用户的状态。如果他们没有提供服务,我们就只能拿用户的 token 定时查询,如果查询结果发现当前用户没有任务,那么就调用推送接口。逻辑很简单,功能上需要获取用户登录信息,并定时查询

登录设计

想要获取用户 token,就需要根据同域原理,取在种在用户 cookie 中的 token。所幸我们虽然是内网,有子域名,取到 token 不成问题,同时用前面生成的证书,整个登录就没问题了。

由于 token 可能会失效,我们需要存储最新有效的 token,用于调用接口查询用户状态。这样流程是:
接收到用户访问页面时查询指定服务状态的请求 -> 取到当前的 token -> 使用当前 token 去查询接口 -> 成功后返回数据,并更新数据库中的 token -> 失败则使用数据库中的 token 再去查询。

登录设计一般都不相同,并涉及数据库,这里就不展示代码了。

定时任务

定时使用可以使用一些现成的库,比如说 cron,当然,如果简单的话可以自行实现一个无限循环的函数。
循环中每次遍历所有的用户,取出 token 和推送的 token,使用 token 查询服务,然后使用推送的 token 进行相关的推送。下面是无限循环类的写法:

export interface QueueOption {
  time?: number;
  onExecEnd?: any;
}

export default class Queue {
  actions: any[];
  handle: any;
  onExecEnd: any;
  time: number;
  constructor (func: any, { time = 3000, onExecEnd }: QueueOption) {
    this.actions = [{ func, count: 0, errorCount: 0, maxTime: 0 }];
    this.time = time;
    this.onExecEnd = onExecEnd;
    this.start();
  }
  start () {
    clearTimeout(this.handle);
    this.handle = setTimeout(() => {
      this.execActions().then((time) => {
        this.onExecEnd && this.onExecEnd(time, this.actions);
        this.start();
      });
    }, this.time);
  }
  add (func: any) {
    this.actions.push({ func, count: 0, errorCount: 0, maxTime: 0 });
  }
  async execActions () {
    const startTime = new Date().getTime();
    for (const action of this.actions) {
      const startStamp = process.hrtime();
      try {
        await action.func();
      } catch (e) {
        action.errorCount++;
        console.error(e);
      }
      action.count++;
      // 统计执行时间
      const execStamp = process.hrtime();
      const execCost = (execStamp[0] - startStamp[0]) * 1000 + (execStamp[1] - startStamp[1]) / 1e6;
      action.maxTime = Math.max(action.maxTime, execCost);
    }
    return (new Date()).getTime() - startTime;
  }
}

注意:独立循环要单独的进程,不要和其他服务一起,便于其他服务的重启和开启多核。

被墙下的替代方案

讲到这里基本上已经完成了,如果用户没有翻墙,那么同样是收不到消息,如果我们自己来实现一个推送服务用来替代呢?思路是:在 service worker 启动时创建一个长链接,链接到服务端,一有消息,浏览器收到消息调用通知功能通知用户。很简单是吧,就是使用 SSE 的功能而已。关于 sse 有很多现成的库,这里就不展示自己实现的代码了,要注意几点:

  1. SSE 的句柄需要保存在内存中,这种情况下只能开启一个线程,免得找不到句柄,无法写入
  2. SSE 在断开后会自动重连,需要移除那么失效的句柄

这个方案和后面要介绍的 PWA 纯本地服务一样,有一个大问题:浏览器重启后不会继续执行,需要用户访问一次,然后重启该功能。

PWA 纯本地服务设计

上面我们已经实现了 PWA 推送和自定义的推送,为什么还要使用纯本地推送?主要根源在于:我们保存了 token。作为 SSO,token 可用于多个服务,那么推送服务的持有者可以使用其他人的 token 做一些事情,或者是 token 泄漏,这就是一个大的安全问题。所以我们需要来一个不需要存储 token 的推送。

纯本地服务后, server 端只做代理转发,解决浏览器无法重写 cookie 的问题,其他的功能均由 service worker 内部的功能来实现。

注册和通讯设计

纯的 service worker 就不需要 firebase 的功能了,我们使用 workbox 库来注册,简化操作,同时它默认实现了一些缓存功能,加速网站访问速度。下面是 sw-loop.js 的代码中注册和生成通知的代码:

// 循环实现

importScripts('https://cdn.jsdelivr.net/npm/idb-keyval@3/dist/idb-keyval-iife.min.js')
importScripts('/workbox-sw-4.2.js')

workbox.precaching.precacheAndRoute(self.__precacheManifest || [])

self.addEventListener('notificationclick', e => {
  // eslint-disable-next-line no-undef
  const f = clients.matchAll({
    includeUncontrolled: true,
    type: 'window'
  })
    .then(function (clientList) {
      if (e.notification.data) {
        // eslint-disable-next-line no-undef
        clients.openWindow(e.notification.data.click_action).then(function (windowClient) {})
      }
    })
  e.notification.close()
  e.waitUntil(f)
})

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})

在 main.js 中进行 workbox 的注册:

import { Workbox } from 'workbox-window'

if ('serviceWorker' in navigator) {
  const wb = new Workbox('/sw-loop.js')

  // Add an event listener to detect when the registered
  // service worker has installed but is waiting to activate.
  wb.addEventListener('waiting', (event) => {
    wb.addEventListener('controlling', (event) => {
      window.location.reload()
    })

    // Send a message telling the service worker to skip waiting.
    // This will trigger the `controlling` event handler above.
    // Note: for this to work, you have to add a message
    // listener in your service worker. See below.
    wb.messageSW({ type: 'SKIP_WAITING' })
  })

  wb.register()
}

循环设计

本质上只是把服务端的搬运过来而已。由于在浏览器端实现,

service worker 与页面通讯

从上面的注册中我们看到,可以使用 workbox 的功能发送通知。不过我们功能简单,直接采用共享数据库的方式。在 service worker 中可以使用的存储是 indexDb,容量也比较大,完全够我们使用。现在有很多方案解决 indexDb 的 callback 模式,比如 idb。我们用存储只用于 key-value 形式,使用 idb-keyval即可。
使用方式和 localStorage 没有什么差别,除了是异步:

await idbKeyval.set('validToken', cookie)
await idbKeyval.get('validToken')

无限循环

直接使用我们上面贴出的无限循环类即可。其他代码如下:


function timeStampToISOString (time = new Date(), noHour = false) {
  const date = new Date(time)
  if (isNaN(date.getTime())) {
    return ''
  }
  date.setMinutes(date.getMinutes() - date.getTimezoneOffset())
  const str = date.toISOString()
  return noHour
    ? `${str.slice(0, 10)}`
    : `${str.slice(0, 10)} ${str.slice(11, 19)}`
}

const time = 1000 * 30
// eslint-disable-next-line no-new
new Queue(loopFunc, { time })

function noticeData (notification) {
  self.registration.showNotification(notification.title, notification)
}

async function authRequest (url, options = {}, cookie) {
  /* eslint-disable no-undef */
  const validToken = await idbKeyval.get('validToken')

  const firstResult = await fetch(url, {
    ...options,
    headers: {
      ...(options.headers || {}),
      'X-Token': cookie
    }
  })

  if (firstResult.status !== 401) {
    await idbKeyval.set('validToken', cookie)
    if (firstResult.status >= 400) {
      return Promise.reject(firstResult)
    }
    return firstResult
  }

  const finalResult = await fetch(url, {
    ...options,
    headers: {
      ...(options.headers || {}),
      'X-Token': validToken
    }
  })
  if (finalResult.status >= 400) {
    if (finalResult.status === 401) {
      const notification = {
        title: '助手提醒',
        tag: `${Date.now()}`,
        icon: '图片.png',
        body: '您没有登录,请点击登录',
        data: {
          click_action: '页面地址'
        }
      }
      noticeData(notification)
      return Promise.reject(finalResult)
    }
    return finalResult
  }
}

async function loopFunc () {
  // 你的逻辑
}

其他

作为一个练手的小项目,很多功能还是有问题的:

使用 es2018,没有转换

在 webpack 中没有办法直接转换 sw-loop.js 为 es5,需要独立进行配置。不过考虑到浏览器的版本,目前使用的 async await 等支持的情况还是比较好的。

两种方案效果

如果没有墙的存在,使用 firebase 进行推送是最完美的方法,可惜国内无法访问。作为开发人员,长期可以翻墙是正常的,所以最终还是保留了 firebase 推送。事实证明, firebase 的才是最可靠的,即使你重启浏览器或者重启电脑。纯本地的服务和自定义的推送,由于浏览器关闭后就不再有入口,永远无法再循环,需要用户访问网页来再次触发,或者使用推送通知再来实现。由于有一个能用,懒得再去修正了,如果后面有兴趣再修复吧。

posted @ 2019-05-06 16:18 无梦灬 阅读(...) 评论(...) 编辑 收藏