Progressive Web Application Day08 — 好好用的推播訊息 Part 2 — 動手做

Jacky810124
27 min readSep 18, 2018

--

Progressive Web Application

繼上篇( Progressive Web Application Day07 — 好好用的推播訊息 Part 1 — 簡介 )簡單的介紹了推播流程,接下來就要進入動手做的系列了;由於推播的部分,需要跟著後端一起配合,這次動手做也會實作這部分。

這次完整的程式碼都會放在 Github 上,有興趣的人可以參考一下連結。

範例網站可以參考這個連結。

這次的教學文章中,每個小段落都會有 Git Commit 的連結,可以看出這次的段落到底新增了什麼東西,亦或是刪除了哪些區塊,可以當作每個小段落的總結。

Git Commit Diff Demonstration

Functional Map

這次 Demo Site 會專注在推播的部分,因此把其餘的部分都先移除掉,預計會有的功能如下:

Demo Site Functional Map

雖然看起來功能少少的,但其中的步驟還蠻繁瑣的 😩 。

Wireframe

這個是預計會完成的樣子,有點像是個人介紹的區塊;區塊中會有大頭貼、摘要還有訂閱的按鈕,蠻符合這次 Demo 的核心目標。

Demo Site Wireframe

接著,就把版面先切出來吧。

切版

下面就是版面已經完成的模樣,基本上就是只有一個訂閱的功能而已 😆 ;有看程式碼的夥伴們,會發現樣式的命名方式,採用 BEM 的命名規則,對 BEM 有興趣的夥伴們,可以在下方敲碗、留言,找時間再寫一篇。

Demo Site Layout

這階段新增或刪除的程式碼,可以參考以下連結。

前端—新增訂閱

在開始新增訂閱資訊到後端,必須要完成一些初始化的工作:

  1. 註冊 Service Worker
  2. 公、私金鑰

註冊 Service Worker

前端想要接收到推播訊息,必須透過 Service Worker 在背景執行,才能夠隨時隨地,接收來自伺服器的推播訊息;因此使用者的裝置必須支援 Service Worker 和 Push Manager ,也必須要先註冊好 Service Worker ,才能夠進行接下來的步驟。

接著就開始註冊 Service Worker 吧。

在專案的根目錄下,新增 app.js 來擺放主要的 JavaScript 程式碼;並在 app.js 中新增以下的程式碼。

const PUBLIC_KEY = 'BCDMOxYAhVUZY1cXwkXsKuKztlqfOXjowcriucykb5qBmFq-8lVMVx3bJbFOmLSci1Jq_SPqBdFeWGi0jcHJfXM'
const NOTIFICATION_USABILITY = 'serviceWorker' in navigator && 'PushManager' in window
/**
* initialization function
*/
const init = () => {
const subscribeButton = document
.querySelector('.user-profile__subscribe-button')
if (!NOTIFICATION_USABILITY) {
console.log('Service Worker and Push is supported')
// Disable subscribe button when user device is not support
subscribeButton.innerHTML = '未支援訂閱'
subscribeButton.disabled = true
} else {
navigator
.serviceWorker
.register('sw.js')
.then((registration) => {
console.log('Service Worker is registered')
return getSubscription(registration)
})
.then(subscription => {
const isSubscribed = subscription !== null
if (isSubscribed) {
saveSubscription(subscription)
subscribeButton.innerHTML = '已訂閱'
subscribeButton.disabled = true
}
})
.catch(error => {
console.error('Service Worker Error', error)
})
}
}
const getSubscription = (registration) => {
return registration
.pushManager
.getSubscription()
}
const saveSubscription = (subscription) => {
// TODO: upload subscription data to server side
}
const subscribe = () => {
// TODO: click event handler
}
init()

NOTIFICATION_USABILITY 代表是否可以使用 Service Worker 以及 Push Manager ,若是這兩個其中一個不能使用,接下來的訂閱也無法實作了。

init 是初始化的 Function ,透過 NOTIFICATION_USABILITY 判斷使用者是否可以使用,若是無法使用,則需要禁用訂閱按鈕;或是已訂閱的使用者,也會將訂閱按鈕改成對應的狀態。

getSubscription 則是透過 Service Worker Registration 中的 Push Manager ,取得使用者目前的訂閱資訊,若是已訂閱則將最新版本的訂閱資訊,傳給 saveSubscription 同步到伺服器端。

公、私金鑰

前端訂閱 Push Service ,或是後端發送推播訊息,都需要一組公、私金鑰,可以透過這個網站來產生,或是自行產生 VAPID 金鑰。

貼心小提示:產生出的金鑰,請勿將私鑰放置在程式碼中;這次的程式碼為了展示方便,所以會這樣做,但在實務上請勿這樣做。

至此,初始化的步驟已經完成,接下來要開始處理:使用者點擊訂閱按鈕後的事件處理。

當使用者點擊訂閱按鈕後,會發生幾件事情:

  1. 取得使用者訂閱資訊
  2. 同步訂閱資訊到伺服器端

取得使用者訂閱資訊

當使用者點擊訂閱的按鈕後,先取得使用者的訂閱狀態,若使用者已經訂閱,則將訂閱狀態更新到伺服器端;倘若使用者尚未訂閱,則向使用者要求通知權限並且訂閱。

在向使用者要求權限時,應該要避免直接跳出權限要求,而是使用 confirm 或是跳出確認視窗,以免遭到使用者拒絕太多次後,被瀏覽器禁止向使用者要求權限。

貼心小提示:被瀏覽器禁止向使用者要求權限,可以參考這篇文章 Temporarily stop permission requests after 3 dismissals

subscribe 中處理使用者點擊訂閱按鈕後的訂閱流程。

const subscribe = () => {
let swRegistration
const subscribeButton = document
.querySelector('.user-profile__subscribe-button')
navigator
.serviceWorker
.ready
.then(registration => {
swRegistration = registration
return getSubscription(registration)
})
.then(subscription => {
const isSubscribed = subscription !== null
if (isSubscribed) {
return subscription
}
return requestSubscription(swRegistration)
})
.then(subscription => saveSubscription(subscription))
.then(response => {
if (response.ok) {
alert('已訂閱')
subscribeButton.innerHTML = '已訂閱'
subscribeButton.disabled = true
} else {
alert(response.message)
}
})
.catch(error => {
subscribeButton.disabled = false
console.error('Failed to subscribe user ', error)
})
}

Service Worker 啟用後,可以透過 ready 這個屬性來判斷;接著也是透過 Service Worker Registration 取得使用者的訂閱資訊,如果使用者已經訂閱,則直接將訂閱資訊往下傳;如果尚未訂閱,則透過 requestSubscription 來取得使用者的訂閱資訊;接著,透過 saveSubscription 將訂閱資訊同步到伺服器端。

requestSubscription 中的流程是長成這樣。

const requestSubscription = (registration) => {
const result = confirm('是否要訂閱 Jacky ?')
if (result) {
return registration
.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(PUBLIC_KEY)
})
}
return Promise.reject(new Error('Failed to request push notification permission'))
}

值得注意的有幾個部分:

  1. 避免直接跟使用者要求權限
  2. subscribe 的參數設定

向使用者要求權限時,如果被使用者拒絕太多次,瀏覽器會禁止向使用者要求權限一段時間,為了避免這種情況發生,實務中應該先向使用者詢問和告知權限的用途。

在訂閱時會將 userVisibleOnly 設成 true ,這是強制收到的推播訊息,都需要顯示出來,避免開發者在背後做些噁心的事情,詳細內容可以參考這篇文章applicationServerKey 則是需要將公鑰轉成 UInt8Array 的格式,這個可以直接使用 utils.js 中的 urlB64ToUint8Array 的 Function 。

同步訂閱資訊到伺服器端

取得訂閱資訊後,還需要將訂閱資訊儲存在伺服器端上,如此一來伺服器要發送推播訊息時,才知道要將推播訊息發送給誰。

儲存訂閱資訊時,因為每筆訂閱資訊,都需要有個唯一值來辨識是哪個人訂閱的,在這次演示中,使用 Fingerprint2 來創建這個唯一值,只需要在 index.html 中加入以下這段。

<script src="https://cdnjs.cloudflare.com/ajax/libs/fingerprintjs2/1.8.1/fingerprint2.min.js"></script>
<script src="utils.js"></script>
<script src="app.js"></script>

以及在 init 中修改一下即可完成; HAS_FINGERPRINT 則是用來判斷是否已經創建出唯一值; generateFingerprint 這個是已經寫好在 utils.js 裡。

const HAS_FINGERPRINT = !(localStorage.getItem('fingerprint') == null && localStorage.getItem('fingerprint') === '')
const init = () => {
const subscribeButton = document
.querySelector('.user-profile__subscribe-button')
if (HAS_FINGERPRINT) {
generateFingerprint()
.then(fingerprint => {
localStorage.setItem('fingerprint', fingerprint)
})
}
if (!NOTIFICATION_USABILITY) {...}
}

saveSubscription 中只需要透過 AJAX ,將訂閱資訊傳送給伺服器端即可,但是因為目前伺服器端尚未建立,因此還無法知道 API 網址是什麼,因此 API_HOST 先暫時為空字串。

const API_HOST = ''
const saveSubscription = (subscription) => {
const options = {
method: 'PUT',
body: JSON.stringify({
fingerprint: localStorage.getItem('fingerprint'),
subscription
}),
headers: new Headers({
'Content-Type': 'Application/json'
})
}
return fetch(`${API_HOST}/subscriptions`, options)
.then(response => response.json())
}

後端—儲存訂閱資訊

在前端基本的訂閱流程都完成後,接下來開始進行後端的部分。

後端會使用 Firebase Functions 和 Firebase Realtime Database ,因此必須要先到 Firebase Console 上申請帳號;申請完成之後新增專案,專案名稱和國家的區塊就根據個人需求填寫。

這次會用到的兩個服務, Functions 是拿來放置後端的程式碼; Realtime Database 則是存放訂閱資料的地方;接著就開始初始化後端的環境吧。

初始化後端環境

首先,需要安裝 firebase-tools 來初始化後端的環境。

npm install -g firebase-tools

安裝完成之後,就可以使用 firebase login 登入 Firebase ;登入之後,可以在專案的根目錄,執行 firebase init functions 即可初始化 Firebase Functions 的環境。

完成後,會在專案的根目錄下,多出 functions 的資料夾,這個資料夾會拿來擺放後端的程式碼;在開發前,必須先切換到 functions 這個資料夾下,執行 npm install 來安裝相依套件;或是在初始化 Firebase Functions 環境時,會詢問是否要安裝相依套件,選擇“是”即可自動將相依套件安裝好。

順帶一提,在 Firebase Functions 初始化完成後,會建立幾個指令在 package.json 中;比較常用到的有: npm run serve 主要是用在本機開發;而 npm run deploy 則是用在部署到 Firebase 上。

因為後端是使用 Express ( NodeJS 的框架)來開發,加上前後端可能會在不同的網域下,會有跨域的問題;另外還需要 Firebase Realtime Database ,因此必須要先安裝幾個相依套件。

npm install express firebase-admin cors

安裝完成後,前置作業大致上算處理完畢,接著就可以開始進行開發。

在 functions 這個資料夾中, index.js 這個檔案就是後端程式碼的進入點;首先,在 index.js 中引入會用到的相依套件,預設已經引入 Firebase Functions 的套件了,因此只需要再引入 firebase-adminexpresscors 這幾個套件,並將其初始化與套用。

const functions = require('firebase-functions')
const admin = require('firebase-admin')
const express = require('express')
const cors = require('cors')
admin.initializeApp({
databaseURL: 'https://pwa-day08.firebaseio.com/'
})
const db = admin.database()
const app = express()
app.use(cors())exports['api'] = functions.https.onRequest(app)

由於訂閱資訊是儲存在 Firebase Realtime Database 上,因此在這邊也先將資料庫初始化; databaseURL 則是代表 Firebase Realtime Database 上的網址,可以在 Firebase Console 上找到。

Firebase Functions 部署的後端程式碼,必須要匯出之後才能夠使用,因此這邊將 app 匯出,並且將前綴設為 api ,之後在 index.js 中開發的 API ,都會以 /api 作為前綴,以下是 API 範例。

POST   https://YOUR_API_HOST/api/messages
PUT https://YOUR_API_HOST/api/subscriptions
DELETE https://YOUR_API_HOST/api/subscriptions

同步訂閱資訊這邊的邏輯也蠻單純,基本上就是從 Request Body 將訂閱資訊以及 Fingerprint 取出;將 Fingerprint 當成是 ID ,再將訂閱資訊儲存在 Firebase Realtime Database 上。

值得注意的是,這個同步訂閱資訊的 API ,會設定成這樣。

PUT /subscriptions

不論使用者是否已經訂閱,伺服器端都會再次同步訂閱資料;因此在創建或是覆蓋資料時,會使用 PUT 這個 HTTP Method ,而 /subscriptions 則是代表訂閱資料的資源節點。

因此訂閱的 API 就會長得像這樣。

app.put('/subscriptions', (req, res) => {
const ref = db.ref(`/subscriptions/${req.body.fingerprint}`)
const callback = error => {
if (error) {
res.json({
ok: false,
message: '更新訂閱資訊失敗'
})
} else {
res.json({
ok: true
})
}
}
ref.set({ subscription: req.body.subscription }, callback)
})

不論是成功或是失敗,回傳的 JSON 中都會有 ok 這個 Key ,但是從 truefalse 可以分辨出是否執行成功。

在程式碼都完成後,可以透過 npm run serve 將 API 部署在本機,接著就可以測試 API 是否如預期中執行。

貼心小提示:測試 API 可以使用 Postman 或是 Paw 等測試工具。

紅框處代表的是本機後端 API 的網址,此時就可以將這串網址,貼到 app.jsAPI_HOST 中測試看看。

const API_HOST = 'http://localhost:5000/pwa-day08/us-central1/api'

測試成功後,記得要把後端的程式碼,透過 npm run deploy 部署到 Firebase Functions 上,部署完成之後,一樣也會有一串 API 的網址,但是此時這串的的網域就不會在 localhost 了,而是在 Firebase Functions 上。

特別注意:部署完成之後,記得將 index.htmlAPI_HOST 改成 Firebase Functions 上的網址;本機的僅供測試使用。

如果在終端機看不到 API 的網址,也可以到 Firebase Console 打開專案中的 Functions 來查看。

實際按下訂閱的按鈕,並且有訂閱成功後,就可在 Firebase Realtime Database 上看到訂閱的資料了。

後端—發送推播訊息

推播訊息會使用 web-push 來發送,因此需要先安裝相依套件。

npm install web-push

使用 web-push 發送推播訊息也非常容易,只需要將訂閱資訊,以及推播的訊息內容帶入,即可發送推播訊息給使用者,大致上會長得像下面這樣。

const webpush = require('web-push')webpush.setVapidDetails(
'mailto:YOUR_EMAIL',
'YOUR_PUBLICK_KEY',
'YOUR_PRIVATE_KEY'
)
const subscription = {
endpoint: '.....',
keys: {
auth: '.....',
p256dh: '.....'
}
}
webpush
.sendNotification(subscription, 'Your Push Payload Text')
.then((result) => {...})
.catch((error) => {...})

接著,來創建發送推播訊息的 API, 供使用者發送推播訊息給追蹤者;這個 API 會長得像這樣,以及 Request Body 的格式會是 JSON ,而可接受的欄位會有 titlecontent

POST /messages# Request Body{ "title": "...", "content": ""}

程式碼則是會長得像這樣。

const webpush = require('web-push')webpush.setVapidDetails(
'mailto:kang810124@gmail.com',
'BCDMOxYAhVUZY1cXwkXsKuKztlqfOXjowcriucykb5qBmFq-8lVMVx3bJbFOmLSci1Jq_SPqBdFeWGi0jcHJfXM',
'1jwNoayXn3XGHqYPib37COkGXpu91FyFxJMfHGcAzJk'
)
app.post('/messages', (req, res) => {
const ref = db.ref(`/subscriptions`)
const callback = (snapshot) => {
const subscriptions = snapshot.val()
const tasks = Object
.keys(subscriptions)
.map(key => {
const subscription = subscriptions[key].subscription
return webpush.sendNotification(
subscription,
JSON.stringify(req.body)
)
})

Promise
.all(tasks)
.then(result => {
res.json({
ok: true
})
})
.catch(error => {
res.json({
ok: false
})
})
}

ref.once('value', callback)
})

首先,先將 web-push 初始化,將先前取得的公、私鑰,帶入 setVapidDetails 初始化 web-push

將儲存在 Firebase Realtime Database 上的訂閱資料,透過 ref.once 取出後,把 Request Body 中的推播訊息,透過 webpush.sendNotification 傳給訂閱者,這邊需要注意一下,訊息必須為 String 或是 Buffer ,因此在這邊會使用 JSON.stringify 將 Request Body 中的推播訊息,轉成字串來送。

接下來,只剩下前端將接收到的推播訊息顯示出來。

前端—顯示推播訊息

在前端的世界,只要使用者將瀏覽器關閉了,整個網站就結束了;但是有了 Service Worker 後,就可以在背景執行,即便使用者關閉網站,還是可以在背景處理事情。

這次介紹的顯示推播訊息,即是使用 Service Worker 來實現,透過 Service Worker 在背景監聽 push 事件(也就是來自後端的推播訊息),收到事件後即可透過 Service Worker Registration 將訊息顯示出來。

由於顯示推播訊息這塊,是直接在 Service Worker 去操作,因此接下來的程式碼,會存在 Service Worker 中。

在 Service Worker 中需要監聽 push 事件。

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

這邊的 self 在 Service Worker 中,則是代表 Service Worker 自己,這算在 JavaScript Worker 中比較特殊的用法。

而具體在 sw.js 的程式碼會長得像這樣。

self.addEventListener('push', event => {
const message = JSON.parse(event.data.text())
const title = message.title
const options = {
body: message.content
}
event.waitUntil(
self.registration.showNotification(title, options)
)
})

透過 event.data.text() 即可舉得推播的訊息內容,在上個章節 後端—發送推播訊息 中,已經知道後端送過來的推播訊息格式,會是將 Object 轉成 JSON (也就是字串),因此需要經過 JSON.parse 將取得的字串重新轉為 Object 。

'{ "title": "", "content": ""}'

接下來就可以透過 Service Worker Registration 的 showNotification 將推播訊息內容顯示出來。

貼心小提示:在 Service Worker 中程式碼有變動時,不會即時的替換上去,而是會等到該網站全部的分頁都關閉後,才會替換上去;如果不想等待那麼久,可以透過 Devtool 中的 skip waiting 即時更換。

Devtool — Skip Waiting

接著,使用 PAW 來測試 API ,送出的內容如下圖,同時也要確保前端的 Service Worker 是在最新的版本;只要發出 Request 後,前端能夠正確顯示推播訊息,這樣就大功告成啦。

接著就只剩下將後端程式碼,部署到 Firebase Functions 上。

Finish

後記

這篇文章真的是拖了世界久,主要是因為有點不知該如何下手,同時也藉著這次的文章,將 PWA 推播的流程重新順了一次;這次也算是第一次寫這麼長的教學文章,應該也有蠻多地方不太清楚,或是寫得不好的,也歡迎大家多給些建議,看看下次應該要如何改進才好;或是你覺得寫得不錯,也歡迎多給些鼓勵,地方的開發者,需要你的鼓勵 😝 。

--

--