2024/10/16

推播服務 DIY - 使用 node.js 實作


 

 

 

在製作 Prototype 產品時,例如電商網站的促銷活動、新聞網站的即時通知等,推播是一項必要且方便的技術,以往只能使用 email 作為推播的手段,現在有了 Web Push 功能,推播就更方便了。


Web Push 支援程度

 

Web Push 技術已行之有年,但各家瀏覽器支援的時間卻不一定,Safari on iOS 在 2018 開始支援後,推播技術也算是達到了真正的跨平台技術。以下是最早支援 Web Push 的瀏覽器版本。

 


Minimal version Supported year
Safari on iOS 11.3 iPhone XR (2018)
Chrome Android 40 2012
Firefox Android 44 2011
Chrome / Edge 40 / 17 2015
Firefox 44 2016

 

雖然知道現在多數的瀏覽器都支援,在實作上,仍然需要先進行檢查,因為很難保證不會有使用者仍然使用 IE 瀏覽器。

 

先以建立 Express.js 服務作為開始吧!這裡要謝謝 Whien 寫的這篇「建立 Service Worker Web Push Notification — (Web Notification 實作紀錄)」 文章,本篇文章的程式碼大多參考自該篇文章。檔案目錄結構如下所示:

 

├── server
│   └── server.js
└── js
    ├── main.js
    └── sw.js

 

server.js 的程式碼如下:

 

const path = require('path');
const express = require('express');
const app = express();
app.use('/js', express.static(path.resolve(__dirname, '../js')));
app.get('/', (req, res) => {
  res.send(`
    <html>
      <head></head>
      <body>
        <h1>Push Notification</h1>
        <script src='js/main.js'></script>
      </body>
    </html>
  `);
});app.listen(8998);

 

 

檢查 Web Push 機能

 

在實作 Web Push 前,要先確認瀏覽器是否支援這項技術。瀏覽器的 Web Push 標準是以 "PushManager" 和 "ServiceWorker" 兩個 API 架構而成。 

 

  • PushManager:瀏覽器提供的推播管理 API,負責處理推播訊息的訂閱和接收
  • ServiceWorker:瀏覽器提供的 Service Worker API,允許在背景執行 JavaScript 程式碼,即使網頁關閉也能接收推播訊息

 

ServiceWorker 具體實作內容交由開發者自行決定。由於 serviceWorker 比 PushManager 還要晚推出,所以要各別檢查 (又或是檢查 serviceWorker 即可)。


使用以下程式碼作為 main.js 程式碼的起始:


if ('serviceWorker' in navigator && 'PushManager' in window) {
  console.log('Service Worker and Push is supported')
} else {
  console.log('Push is not supported')
}

 

 

註冊 ServiceWorker

 

Service Worker 是在背景執行的 JavaScript 檔案,負責接收和處理推播訊息。必須先向瀏覽器註冊 Service Worker 才能使用 Web Push。以下程式碼示範如何註冊 Service Worker:

 

if ('serviceWorker' in navigator && 'PushManager' in window) {
//...
  navigator.serviceWorker
    .register('/js/sw.js')
    .then(swReg => {
      console.log('Service Worker is registered', swReg);
    })
    .catch(err => console.log('Service Worker register error.', err));
//...

 

 

  • navigator.serviceWorker.register('/js/sw.js') 會向瀏覽器註冊位於 /js/sw.js 的 Service Worker 檔案。
  • .then(swReg => { ... }) 會在 Service Worker 註冊成功後執行,swReg 參數包含 Service Worker 的註冊資訊。 
  • .catch(err => console.log('Service Worker register error.', err)) 會在 Service Worker 註冊失敗時執行,err 參數包含錯誤資訊。

 

 

建立 Web Push 金鑰

 

Web Push 規範中對資料的加密有嚴格的要求,這部份可以透過 node.js 裡的 web-push 函式庫產生 VAPID 金鑰:

 


npx web-push generate-vapid-keys

=======================================

Public Key:
BNuUKzbcqU288QpgHA4PMSAfw8I________________XYLHBZCuPH27DMlLJMhXTlr84fpHtKs3g

Private Key:
ywMS_d68vai________________lrNSOz3IjxU22ks-IFhGc

=======================================

 

 

接著把 Public Key 在 main.js 最上層放入變數:


const applicationServerPublicKey = "BNuUKzbcqU288QpgHA4PMSAfw8I________________XYLHBZCuPH27DMlLJMhXTlr84fpHtKs3g"

 

 

 

檢查使用者是否已訂閱

 

接下來在訂閱推播訊息之前,我們需要先檢查使用者是否已經訂閱,避免重複訂閱。程式碼變化後如下所示:

 


navigator.serviceWorker
  .register('/js/sw.js')
  .then(swReg => {
    console.log('Service Worker is registered', swReg);
    let swRegistration = swReg;
    return Promise.resolve(swRegistration)
  })
  .then(swRegistration => {
    inititalUI(swRegistration)
  })


return Promise.resolve(swRegistration)swRegistration 包裝成一個 Promise 物件,並返回。 這樣可以將 Service Worker 註冊的結果傳遞給後續的 .then() 方法,以便進行訂閱等操作。如此使用 Promise 可以把註冊、訂閱程式碼邏輯拆開,方便日後程式碼的維護。


initialUI 可以實作顯示訂閱的按鈕,範例程式碼如下所示:


function inititalUI(swRegistration) {
  const pushButton = document.createElement('button');
  pushButton.textContent = '啟用推播';
  document.body.appendChild(pushButton);

  swRegistration.pushManager.getSubscription().then(subscription => {
    updatePushButton(subscription, pushButton);

    pushButton.addEventListener('click', () => {
      if (subscription) {
        unsubscribeUser(subscription, pushButton, swRegistration);
      } else {
        // 檢查通知權限狀態
        if (Notification.permission === 'denied') {
          // 如果已封鎖,引導使用者前往瀏覽器設定頁面
          alert('您已封鎖通知權限,請前往瀏覽器設定頁面開啟通知。');
          // (Optional)  您可以根據瀏覽器類型,提供更具體的引導方式
          // window.open('chrome://settings/content/notifications'); // Chrome 
          // window.open('about:preferences#privacy'); // Firefox
        } else {
          subscribeUser(swRegistration, pushButton);
        }
      }
    });
  });
}

function updatePushButton(subscription, pushButton) {
  if (subscription) {
    console.log('使用者已訂閱.');
    pushButton.textContent = '取消推播';
  } else {
    console.log('使用者未訂閱.');
    pushButton.textContent = '啟用推播';
  }
}

 

  • initialUI 所建立的「啟用推播」按鈕按下後,實際上只會詢問使用者一次 (瀏覽器預設為「詢問」), 按下允許 / 封鎖後,就不會再從瀏覽器跳出,要使用者自行進瀏覽器變更設定。
  • swRegistration.pushManager.getSubscription() 方法會返回一個 Promise,其中包含使用者的訂閱資訊。如果使用者尚未訂閱,則 Promise 的值為 null。


啟用推播和取消推播的程式碼如下:

 


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
    })
    .then(subscription => {
      console.log('User is subscribe.');
      updatePushButton(subscription, pushButton);
    })
    .catch(err => console.log('Failed to subscribe the user: ', err));
}

function unsubscribeUser(subscription, pushButton, swRegistration) {
  subscription.unsubscribe()
    .then(() => {
      console.log('使用者已取消訂閱.');
      updatePushButton(null, pushButton);

      // (Optional) 向伺服器發送取消訂閱請求
      // ...
    })
    .catch(err => console.log('取消訂閱失敗: ', err));
}

 

  • urlB64ToUint8Array(base64String) 函式:將 Base64 編碼的字串轉換為 Uint8Array 格式。這是 Web Push 訂閱所需的格式。
  • subscribeUser(swRegistration) 函式:訂閱使用者的推播通知。
  • userVisibleOnly: true :確保所有推播通知都顯示給使用者。
  • unsubscribeUser(subscription, pushButton, swRegistration) 函式:取消訂閱使用者的推播通知。



實作 Service Worker


Chrome / Edge 的 DevTools 的「應用程式」頁籤有提供 Service Worker 測試推播功能,不用送 request 到後端,直接觸發 API 呼叫 push 事件,以下程式碼示範如何在 Service Worker 中處理 push 事件,並顯示推播通知:


const SW = '[Service Worker]';

self.addEventListener('push', event => {  // 監聽 push 事件
  const eventData = event.data.text();  // 提取推播訊息
  console.log(`${SW} Push Received.`);
  console.log(`${SW} Push had this data: ${eventData}`);

  const title = '推送好消息';  // 設定通知標題
  const options = {  // 設定通知選項
    body: eventData,  // 設定通知內容
    // icon: '',  // 設定通知圖示
    // badge: ''  // 設定通知徽章
  };

  // 使用 showNotification() 方法顯示推播通知
  event.waitUntil(
    new Promise(resolve => { 
      setTimeout(() => {
        self.registration.showNotification(title, options).then(resolve); 
      }, 5000);  // 延遲 5 秒顯示通知
    })
  );
});


這段程式碼會監聽 push 事件,並從事件資料中提取推播訊息。 然後,它會使用 showNotification() 方法顯示推播通知,並且通知的標題和內容中顯示提取到的訊息。 


  • 監聽 push 事件:self.addEventListener('push', event => { ... });
  • 提取推播訊息:const eventData = event.data.text();
  • 設定通知標題和選項:const title = '推送好消息'; 和 const options = { ... };
  • 顯示推播通知:self.registration.showNotification(title, options);
  • 延遲顯示通知:setTimeout(() => { ... }, 5000);
  • 使用 waitUntil() 方法確保通知成功顯示:event.waitUntil( ... );


 

要注意的地方是,某些瀏覽器到現在 2024 年,Service Worker 都還不支援 ECMAScript modules 設計,所以 Servcie Worker 實作上建議盡量使用簡單的方式設計。



總結

 

呼~終於完成了!這篇文章帶大家用瀏覽器的內建功能,完整體驗了一次 Web Push 的流程,也實際看到了推播通知的效果。相當的簡單方便!

從檢查瀏覽器支援性、註冊 Service Worker、產生 Web Push 金鑰,再到訂閱和取消訂閱推播訊息,最後實作 Service Worker API 進行推播通知的顯示。希望這些步驟都能幫助大家理解 Web Push 的運作方式。

當然,這只是 Web Push 的單機入門介紹,之後如何與後端伺服器整合,發送客製化的推播訊息等相關資訊,我會在之後的文章中陸續跟大家分享,敬請期待!

最後,要再次感謝 Whien 的文章「建立 Service Worker Web Push Notification — (Web Notification 實作紀錄)」提供的程式碼範例和靈感。 也歡迎大家參考 Whien 的文章,了解更多 Web Push 的實作細節。

希望這篇文章對大家有所幫助,也歡迎大家留言分享你的學習心得和問題喔!

 

 

See also