Progressive Web Application Day08 — 好好用的推播訊息 Part 2 — 動手做
繼上篇( Progressive Web Application Day07 — 好好用的推播訊息 Part 1 — 簡介 )簡單的介紹了推播流程,接下來就要進入動手做的系列了;由於推播的部分,需要跟著後端一起配合,這次動手做也會實作這部分。
這次的教學文章中,每個小段落都會有 Git Commit 的連結,可以看出這次的段落到底新增了什麼東西,亦或是刪除了哪些區塊,可以當作每個小段落的總結。
Functional Map
這次 Demo Site 會專注在推播的部分,因此把其餘的部分都先移除掉,預計會有的功能如下:
雖然看起來功能少少的,但其中的步驟還蠻繁瑣的 😩 。
Wireframe
這個是預計會完成的樣子,有點像是個人介紹的區塊;區塊中會有大頭貼、摘要還有訂閱的按鈕,蠻符合這次 Demo 的核心目標。
接著,就把版面先切出來吧。
切版
下面就是版面已經完成的模樣,基本上就是只有一個訂閱的功能而已 😆 ;有看程式碼的夥伴們,會發現樣式的命名方式,採用 BEM 的命名規則,對 BEM 有興趣的夥伴們,可以在下方敲碗、留言,找時間再寫一篇。
前端—新增訂閱
在開始新增訂閱資訊到後端,必須要完成一些初始化的工作:
- 註冊 Service Worker
- 公、私金鑰
註冊 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 金鑰。
貼心小提示:產生出的金鑰,請勿將私鑰放置在程式碼中;這次的程式碼為了展示方便,所以會這樣做,但在實務上請勿這樣做。
至此,初始化的步驟已經完成,接下來要開始處理:使用者點擊訂閱按鈕後的事件處理。
當使用者點擊訂閱按鈕後,會發生幾件事情:
- 取得使用者訂閱資訊
- 同步訂閱資訊到伺服器端
取得使用者訂閱資訊
當使用者點擊訂閱的按鈕後,先取得使用者的訂閱狀態,若使用者已經訂閱,則將訂閱狀態更新到伺服器端;倘若使用者尚未訂閱,則向使用者要求通知權限並且訂閱。
在向使用者要求權限時,應該要避免直接跳出權限要求,而是使用 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'))
}
值得注意的有幾個部分:
- 避免直接跟使用者要求權限
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-admin
、 express
、 cors
這幾個套件,並將其初始化與套用。
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 ,但是從 true
或 false
可以分辨出是否執行成功。
在程式碼都完成後,可以透過 npm run serve
將 API 部署在本機,接著就可以測試 API 是否如預期中執行。
紅框處代表的是本機後端 API 的網址,此時就可以將這串網址,貼到 app.js
的 API_HOST
中測試看看。
const API_HOST = 'http://localhost:5000/pwa-day08/us-central1/api'
測試成功後,記得要把後端的程式碼,透過 npm run deploy
部署到 Firebase Functions 上,部署完成之後,一樣也會有一串 API 的網址,但是此時這串的的網域就不會在 localhost 了,而是在 Firebase Functions 上。
特別注意:部署完成之後,記得將
index.html
的API_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 ,而可接受的欄位會有 title
、 content
。
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 即時更換。
接著,使用 PAW 來測試 API ,送出的內容如下圖,同時也要確保前端的 Service Worker 是在最新的版本;只要發出 Request 後,前端能夠正確顯示推播訊息,這樣就大功告成啦。
接著就只剩下將後端程式碼,部署到 Firebase Functions 上。
後記
這篇文章真的是拖了世界久,主要是因為有點不知該如何下手,同時也藉著這次的文章,將 PWA 推播的流程重新順了一次;這次也算是第一次寫這麼長的教學文章,應該也有蠻多地方不太清楚,或是寫得不好的,也歡迎大家多給些建議,看看下次應該要如何改進才好;或是你覺得寫得不錯,也歡迎多給些鼓勵,地方的開發者,需要你的鼓勵 😝 。