PWA 推送通知:ServiceWorker - push

推送通知 (Push Notification)

推送通知(Push Notification) 或者也可以稱作推播,有智慧手機的人我想應該非常熟悉,尤其是 App 盛行的時期幾乎每隻 App 都會有推送通知功能,這也是惡夢的開始,你會發覺在大半夜裏你的手機會三不五時地叫你起床,所以現在的手機幾乎都內建了勿擾模式,讓使用者可以設定每天的某個時段可以封鎖特定的訊息通知,甚至現在的系統都可以讓使用用決定 App 允許那些權限。

推送通知 就像一把雙面刃,懂得善用它可以提升用戶體驗,反之,若濫用它則會讓使用者觀感不好。

在本篇中筆者會將 推送通知(Push Notification) 分成 推送(Push) 與 通知(Notification) 來說明,推送指的是伺服器的後端程式與瀏覽器上的程式之間的溝通,而通知則是瀏覽器上的程式與使用者之間的溝通。

事前準備

我們接續 PWA 替身術:ServiceWorker - caches 的程式來當範例。
first-app_2017-09-26.zip

若要下載請記得先透過指令 npm install 來重新安裝 package。

接著編輯 src\sw.js,取消 fetch 事件的 console.log 以免開發者工具的 Console 視窗太多 fetch 事件,同時也取消 register_sw.js 的快取。

const cacheVersion = 'v2';
const filesToCache = [
  ...
  'polyfills.bundle.js',
  // 'register_sw.js',
  'styles.bundle.css',
  ...
];
...
self.addEventListener('fetch', event => {
  // console.log('[ServiceWorker] fetch', event.request);
  event.respondWith(
    ...
  );
});

再透過 ng build --prod -oh 指令來建置專案,預設會在專案目錄產生一個 dist 資料夾,並將編譯產生的檔案放在裡面,因為目前不會調整網站內容,所以我們改以 dist 當作專案資料夾來練習建立推送通知功能。

接下來我們嘗試改用 VS Code 的擴充功能 - Live Server 來執行網站。

當然也可以繼續沿用 Chrome 擴充功能-Web Server for Chrome 來執行。


再來我們改以 dist 資料夾當根目錄來開啟 VS Code,先選取一個檔案,接著我們可以透過右鍵的功能選單-Open with Live Server 或是下方狀態列的 Go Live 來啟動 ,Live Server 會自動開啟瀏覽器並載入我們選擇的檔案。

不過要特別注意的是我們是直接編輯 dist 資料夾下的檔案,這是 Angular CLI 透過 ng build 建置專案時所產生的資料夾,每次建置前都會將 dist 給刪除,所以請記得務必將異動過的檔案複製回 src 資料夾下的對應位置

通知 (Notification)

不論是 Google Chrome 或是 Mozilla FireFox 最近的版本都已經允許使用者可以依不同網站賦予不同的權限。


由上圖可以看到通知的設定剛好對應到 Notification APIpermission 屬性:

  • default:使用者尚未給予任何權限 (因此不會顯示任何通知)
  • granted:使用者允許接收到網站的通知
  • denied:使用者拒絕接收網站的通知

授權

由 Notification API 可以知道我們可以透過 Notification.requestPermission 方法在瀏覽器界面上產生一個允許通知的請求視窗,讓使用者允許 Web App 可以啟用通知功能。
requestPermission 方法只有在 permission 屬性為 default 才有效,我們可以看做網站第一次開啟時才會顯示,當使用者不論是選取選取 允許(granted) 或是 拒絕(denied),後續請求視窗都不會再出現,我們開啟 register_sw.js 並加入 requestPermission 方法。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./sw.js')
    .then(reg => {
      // registration worked
      console.log('[Service Worker] Registration succeeded. Scope is ' + reg.scope);

      if ('Notification' in window) {
        console.log('Notification permission default status:', Notification.permission);
        Notification.requestPermission(function (status) {
          console.log('Notification permission status:', status);
        });
      }
    }).catch(error => {
      // registration failed
      console.log('[Service Worker] Registration failed with ' + error);
    });
}

因為 推送(Push) 需要透過 ServiceWorker 所以程式加在 ServiceWorker 判斷式內,以確保我們執行的瀏覽器同時支援 ServiceWorker 與 Notification。

開啟瀏覽器,可以發現目前 permission 屬性為 default 所以畫面上也出現顯示通知的詢問視窗。

若按下允許permission 屬性會改為 granted

反之,按下拒絕permission 屬性會改為 denied

但是不論選取為何,重新開啟網頁後由 Console 視窗可以看到 requestPermission 方法雖然會被呼叫,但是介面上卻不會出現請求視窗。

對於網站開發者而言會比較擔心的是使用者選取拒絕,因為這時候就必須讓使用者手動變更設定,這對大部分的人來說應該是比較困難的,這時候其實可以透過 Notification.permission 方法來確認授權狀態,若是拒絕(denied)我們可以在頁面適當的位置呈現變更通知權限的教學。

訊息顯示

上面我們處理了授權問題,接下來在使用者允許通知的前提下,我們開始實作如何在畫面上顯示訊息。
透過由 MDN 上可以得知我們可以透過 ServiceWorkerRegistration.showNotification() 方法來呈現訊息,下面列常用的選擇性屬性。

屬性 說明
icon 圖示路徑
body 內文文字
image 內文圖片
requireInteraction false(預設):自動關閉(Chrome 大約20秒後關閉)。 true:訊息一直顯示,直到使用者點擊或是按關閉按鈕才會消失。
data 夾帶資料,讓我們可以在使用者在點擊時依照不同的參數資訊做不同的功能
actions 操作選單,可包含屬性有 action:操作識別名稱(程式可透過此設定值判斷使用者點選哪個選項),title:選單標題,icon:選單圖示。

其餘屬性請查詢 MDN - ServiceWorkerRegistration.showNotification()

我們開啟 register_sw.js 加入 displayNotification 方法,並在判斷 Notification 條件式之後去呼叫它,所以正常情況下當瀏覽器有支援而且使用者允許通知情況下網頁開啟後就會自動顯示,修改內容如下:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./sw.js')
    .then(reg => {
      ...
      if ('Notification' in window) {
        ...
        Notification.requestPermission(function (status) {
          console.log('Notification permission status:', status);
          displayNotification();
        });
      }

      console.log('[Service Worker] register end');
    }).catch(error => {
      ...
    });
}

function displayNotification() {
  if (Notification.permission == 'granted') {
    navigator.serviceWorker.getRegistration().then(reg => {
      var options = {
        icon: './assets/images/android_048.png',
        body: '歡迎加入 Angular 社群',
        image: 'https://augt-forum-upload.s3-ap-southeast-1.amazonaws.com/original/1X/6b3cd55281b7bedea101dc36a6ef24034806390b.png'
      };
      reg.showNotification('Angular User Group Taiwan', options);
      console.log('displayNotification');
    });
  }
}

儲存後,開啟瀏覽器可以發現通知訊息已經出現,但是目前除了點擊訊息視窗右上方關閉按鈕外,點擊其他地方都無任何反應。

雖然訊息視窗是透過 ServiceWorker 來呈現,但是其實我們仍然可以在前端網頁上透過 JavaScript 來呼叫,只是若要顯示的訊息與當前瀏覽器上正在進行的作業相關,那直接將訊息顯示在頁面內對使用者來說會更友善,不一定要透過通知視窗。

接下來我們嘗試讓使用者在點擊訊息視窗後開啟一個頁面,我們將要開啟的網頁網址加到 data 屬性內。

...
function displayNotification() {
  if (Notification.permission == 'granted') {
    navigator.serviceWorker.getRegistration().then(reg => {
      var options = {
        icon: './assets/images/android_048.png',
        body: '歡迎加入 Angular 社群',
        image: 'https://augt-forum-upload.s3-ap-southeast-1.amazonaws.com/original/1X/6b3cd55281b7bedea101dc36a6ef24034806390b.png',
        data: {
          link: 'https://forum.angular.tw/'
        }
      };
      ...
    });
  }
}

接著編輯 sw.js,加入監聽 notificationclick 事件來接收 data 屬性所傳遞的資料。

...
self.addEventListener('fetch', event => {
  ...
});

self.addEventListener('notificationclick', event => {
  const notification = event.notification;
  const action = event.action;
  const link = notification.data.link;
  if (action !== 'close') {
    if (link) {
      clients.openWindow(link);
    }
  }
  notification.close();
  console.log('notificationclick action is', action);
})

重新開啟瀏覽器可以看到點擊後已經可以開啟網頁。

進階互動

訊息視窗其實不只單單提供靜態資訊而已,其實還可以透過 actions 屬性加入選單功能與使用者互動,開啟 register_sw.js 將訊息視窗內容改為具有報名功能的活動宣傳,這邊順便加入 requireInteraction: true 設定以確保視窗不會自動消失。

...
function displayNotification() {
  if (Notification.permission == 'granted') {
    navigator.serviceWorker.getRegistration().then(reg => {
      var options = {
        icon: './assets/images/android_048.png',
        body: 'Angular 測試工作坊 9月23日(六)',
        image: 'https://scontent.ftpe7-1.fna.fbcdn.net/v/t31.0-8/21273134_10156585628499554_8520027102111869914_o.jpg?oh=9d7bcbc999c161f5ce778e361a4b9ea4&oe=5A47D9EE',
        data: {
          link: 'https://www.facebook.com/groups/augularjs.tw/',
          link_ok: 'https://www.facebook.com/events/188912961650574/?acontext=%7B%22ref%22%3A%224%22%2C%22feed_story_type%22%3A%22370%22%2C%22action_history%22%3A%22null%22%7D',
          link_ng: 'https://www.facebook.com/groups/angularstudygroup/'
        },
        requireInteraction: true,
        actions: [{
            action: 'yes',
            title: '參加',
            icon: './assets/images/img_ok.png'
          },
          {
            action: 'no',
            title: '不參加',
            icon: './assets/images/img_ng.png'
          },
        ]
      };
      reg.showNotification('線上 Angular 讀書會 活動', options);
      console.log('displayNotification');
    });
  }
}

接著修改 sw.js 增加選單對應的處理。

...
self.addEventListener('fetch', event => {
  ...
});

self.addEventListener('notificationclick', event => {
  const notification = event.notification;
  const action = event.action;
  const link = notification.data.link;
  const link_ok = notification.data.link_ok;
  const link_ng = notification.data.link_ng;
  switch (action) {
    case 'yes':
      if (link_ok) {
        clients.openWindow(link_ok);
      }
      break;
    case 'no':
      if (link_ng) {
        clients.openWindow(link_ng);
      }
      break;
    case 'close':

      break;
    default:
      if (link) {
        clients.openWindow(link);
      }
      break;
  }
  notification.close();
  console.log('notificationclick action is', action);
})

重新開啟瀏覽器可以看到點擊的各種效果:

  • 選取 參加 則會開啟對應的活動頁面
  • 選取 不參加 則會開啟讀書會網站
  • 選取 圖片 則會開啟社團網站
  • 選取 關閉 則只會關閉訊息視窗

參考資料:
MDN - 使用 Web Notifications

推送 (Push)

以往為了獲得後端變更的最新資訊,前端往往需要透過使用者手動或是系統棟頻繁的不斷重複查詢來讓我們及早獲得資訊,這種被動式查詢不僅對於網路頻寬、伺服器資源都會占用持續且龐大的成本,因此如果有一種機制可以在後台伺服端資料改變當下主動通知前端,這不僅可以大大降低硬體成本,對於需要這些資訊的使用者更可以及時獲得資訊
推送(Push) 就是提供這種機制的功能,而這種功能已經不只在 App 上可以做到,現在連 Web 都可以實現。

事前準備

編輯 register_sw.js,將 displayNotification 方法註解起來,這樣前端就不會再顯示訊息視窗。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./sw.js')
    .then(reg => {
      ...
      if ('Notification' in window) {
        ...
        Notification.requestPermission(function (status) {
          console.log('Notification permission status:', status);
          // displayNotification();
        });
      }
    ...
}

安裝 web-push

開啟 fitst-app 專案,透過下列指令安裝 web-push。
npm install web-push

參考資料:https://github.com/web-push-libs/web-push

產生金鑰

透過下列指令產生一組公鑰私鑰,當然私鑰要保管好不能外流,這邊因為是測試用得所以將私鑰也呈現出來讓大家比較容易理解範例程式。
web-push generate-vapid-keys

公鑰:BPkIUOIylNfWjC9MQ3_8oVx0MsaryiEaak1WyYWyqWp1-FuyQitttiMkdjvACkoEds94crwhyRIyVTyc2tVYICI
私鑰:5ZwdOLmdJEmdsYcp-ERUtmMg328EKq7jMGSAn3nSBgM

訂閱

接下來我們要透過 ServiceWorkerRegistration.pushManager 向推送伺服器發出請求,我們需要先將 base64 格式的公鑰轉換成 UInt8Array 格式,再連同 userVisibleOnly: true 參數一起發送至推送伺服器,伺服器會返回一組訂閱資訊(PushSubscription),推送伺服器可以憑藉此資訊來聯繫瀏覽器,而瀏覽器也可以知道這是來自哪一個 Web App,至於其背後運作原理不再這裡討論,我們可以把它想成跟身分證一樣,我們可以透過身分證的地址找到所在位置,再透過姓名找到是哪一隻 App。

ServiceWorkerRegistration 可從註冊 ServiceWork 時的返回值取得實體。

userVisibleOnly: true:表示允許推送到用戶端時顯示訊息。

編輯 register_sw.js,加入註冊方法-subscribeUser

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./sw.js')
    .then(reg => {
      ...
      subscribeUser(reg);
      ...
    }).catch(error => {
      ...
    });
}
...
const applicationServerPublicKey = `BPkIUOIylNfWjC9MQ3_8oVx0MsaryiEaak1WyYWyqWp1-FuyQitttiMkdjvACkoEds94crwhyRIyVTyc2tVYICI`;

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

function subscribeUser(swRegistration) {
  const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
  swRegistration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: applicationServerKey
    })
    .then(subscription => {
      console.log('User is subscribed');
      console.log(JSON.stringify(subscription));
    })
    .catch(err => {
      console.log('Failed to subscribe the user: ', err);
    });
}

執行網頁,當註冊成功時透過 console.log(JSON.stringify(subscription)); 可從開發人員工具查詢到 subscription 的訂閱資訊,正常情況下我們需要將這個資訊儲存到後台的資料庫,後續才可以用這個資訊透過推送伺服器來向瀏覽器發送訊息,眼尖的人可以發現它的 endpoint 資訊是網址而且是來自 https://fcm.googleapis.com/,其實它就是 Google 的 FCM (Firebase Cloud Messaging),這也表示我們中間是透過 FCM 來傳遞,所以客戶端與伺服端至少要能連結到這個外部服務平台,推送功能才能正常運行。

目前雖然我們可以從 FCM 取得 PushSubscription,但是依照筆者測試結果,因為我們是連結本機端網址(localhost),所以這是一個無效的 PushSubscription,所以測試時必須將網頁架設在有實體IP的網站下。

監聽 ServiceWorker 的 push 事件

當伺服器推送訊息給瀏覽器時,我們可以從 ServiceWorker 的 push 事件取得相關資訊,因此我們在這個事件內去抓取回傳過來的資訊並透過訊息通知視窗顯示出來,開啟 sw.js 修改如下:

...
self.addEventListener('push', event => {
  console.log('[Service Worker] Push Received.');
  let title = 'Server Push';
  let options = {
    body: 'push TEST',
    icon: './assets/images/android_048.png'
  };
  if (event.data) {
    options = event.data.json();
    title = options.title;
  }

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

移植至 GitHub

如前面提到在本機端(localhost)的環境下所得到的 PushSubscription 會是無效的,因此筆者暫時先將網頁上傳至 GitHub,因為 GitHub 本身又是 HTTPS,剛好又符合 ServiceWorker 要求,所以當作臨時的 demo 環境應該不錯,測試網址如下:
https://jonny-huang.github.io/demo/first-app/index.html

由開發者工具可以取得 PushSubscription 資訊如下:

{
  endpoint: "https://fcm.googleapis.com/fcm/send/fwNbCkZtyr0:APA91bF-tttRSH0KBHuZ3lGehkd7kcNzWOfAVTKeXp4cYUURgq2bEkTkCtLQAvrzDZ7q_N7on0ved-Ss9SGLRYGm61D2rkmPe2R2EUnLn7s1y7Fwrjts2I-qM94SQINyJA4VBV5spTdy",
  expirationTime: null,
  keys: {
    p256dh: "BEClFpLV0UV65m9wtmPxLVN6kIUTrALuRlUANPySm3eSXXArabumm4aziX4tPYefRtDaGpqL8SxwyyJlc9BvJzA=",
    auth: "I4qBuGIBj1pUVbT4sQnHEg=="
  }
}

後台推送

開啟 first-app 專案,在專案目錄建立一個 server 資料夾,並在裡面建立一個 push.ts 檔案。

接下來我們透過 web-push 來推送訊息,程式可參考 GitHub 範例,重點是:

  • 呼叫 setVapidDetails 方法,並帶入公鑰與私鑰。
  • 取得要推波對象的 PushSubscription。
  • 設定要傳遞的參數,這邊我們直接拿 register_sw.jsdisplayNotification 方法內的參數。
  • 透過 sendNotification 推送訊息。

push.ts 檔程式如下:

import * as webpush from 'web-push';

const vapidKeys = {
  publicKey: 'BPkIUOIylNfWjC9MQ3_8oVx0MsaryiEaak1WyYWyqWp1-FuyQitttiMkdjvACkoEds94crwhyRIyVTyc2tVYICI',
  privateKey: '5ZwdOLmdJEmdsYcp-ERUtmMg328EKq7jMGSAn3nSBgM'
};
const webPush = webpush;

webPush.setVapidDetails(
  'mailto:denhuang@gmail.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

const options = {
  icon: 'assets/images/android_048.png',
  body: 'Angular 測試工作坊 9月23日(六)',
  image: 'https://scontent.ftpe7-1.fna.fbcdn.net/v/t31.0-8/21273134_10156585628499554_8520027102111869914_o.jpg?oh=9d7bcbc999c161f5ce778e361a4b9ea4&oe=5A47D9EE',
  data: {
    link: 'https://www.facebook.com/groups/augularjs.tw/',
    link_ok: 'https://www.facebook.com/events/188912961650574/?acontext=%7B%22ref%22%3A%224%22%2C%22feed_story_type%22%3A%22370%22%2C%22action_history%22%3A%22null%22%7D',
    link_ng: 'https://www.facebook.com/groups/angularstudygroup/'
  },
  requireInteraction: true,
  actions: [{
    action: 'yes',
    title: '參加',
    icon: './assets/images/img_ok.png'
  },
  {
    action: 'no',
    title: '不參加',
    icon: './assets/images/img_ng.png'
  },
  ]
};

const subscription = {
  endpoint: 'https://fcm.googleapis.com/fcm/send/fwNbCkZtyr0:APA91bF-tttRSH0KBHuZ3lGehkd7kcNzWOfAVTKeXp4cYUURgq2bEkTkCtLQAvrzDZ7q_N7on0ved-Ss9SGLRYGm61D2rkmPe2R2EUnLn7s1y7Fwrjts2I-qM94SQINyJA4VBV5spTdy',
  expirationTime: null,
  keys: {
    p256dh: 'BEClFpLV0UV65m9wtmPxLVN6kIUTrALuRlUANPySm3eSXXArabumm4aziX4tPYefRtDaGpqL8SxwyyJlc9BvJzA=',
    auth: 'I4qBuGIBj1pUVbT4sQnHEg=='
  }
};

webPush.sendNotification(subscription, JSON.stringify(options));

接著我們在 package.jsonscripts 增加 "push": "ts-node ./server/push.ts" 指令。

桌面上至少開啟一個瀏覽器,瀏覽器可以是任意網頁,這時候我們只要在專案目錄下執行 npm run push,就可發現桌面會出現通知訊息視窗。

雖然看起來結果一樣,但是這次是由後端推送訊息,而且瀏覽器並不需要開啟我們開發的網站。

first-app_2017-09-29.zip