本站Web Push功能開發的紀錄
編輯日期:2025-05-27
發布日期:2025-05-27
軟體開發
通用開發

這是本站開發Web Push功能的紀錄,到現在我也還是沒有完全搞懂整個的邏輯,就將目前了解的部分整理出來供大家參考,也讓我自己比較好理清相關的內容w。

Web Push是什麼 🔗

簡單來說,就是在沒有開網頁、瀏覽器沒開的情況下,也可以發送通知給用戶的技術。

電腦上通常是在右下角的系統通知會跳出;在手機上面就會在手機的通知上面有通知出現。這個訂閱是跟隨著你的瀏覽器的,像你在Chrome和Firefox都訂閱了,那麼一次通知就會兩邊都跳。

Web Push的運作原理 🔗

不免俗的,這個功能還是會分前後端:

前端 🔗

先說前端,會在這邊做一個Service Worker,訂閱的時候就是註冊在這個Service Worker上面,接著就會產生訂閱資訊Push Subscription

Service Worker會在收到屬於他的訂閱資訊的時候做對應的動作(發送通知給目標)

後端 🔗

這邊就是透過訂閱資訊,搭配Web Push的lib進行通知的發送。目前應該大部分的語言都有對應的Web Push相關lib可以使用。

Web Push 流程 🔗

這個參考Codus大的流程圖,基本上的流程就是

  1. 取得金鑰
  2. 寫好Service Worker處理推播事件的邏輯
  3. 註冊Service Worker
  4. 用金鑰公鑰向推播伺服器取得訂閱資訊subscription
  5. 訂閱資訊subscription存入後台
  6. 後台發送推播的時候使用訂閱資訊subscription
  7. Service Worker根據推播事件邏輯進行推播

實作範例 🔗

這邊以我的前後台設計做示範(Nuxt3-Vite + Springboot-JDK21)

前台 - Service Worker.js 🔗

push負責推送通知、notificationclick處理點擊的情況,注意這個必須是靜態資源,所以我放在專案目錄的public下

self.addEventListener('push', function (event) {
  const data = event.data.json();
  const title = data.title || '通知';
  const body = data.body || '你有蜴則新通知!';
  const url = data.url || '/';
  const options = {
    body: body,
    icon: '/seagalogs.ico',
    data: { url: url }
  };

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

self.addEventListener('notificationclick', function (event) {
  event.notification.close();
  const url = event.notification.data?.url || '/';
  event.waitUntil(
    clients.openWindow(url)
  );
});

前台 - Client端 Nuxt Plugin(type script) 🔗

這邊把公鑰、後台資訊隱藏起來了,不過基本上外框就長這樣,把TODO改寫一下就可以用了。值得一提的是這邊有特別針對不能使用推播的瀏覽器進行判斷,雖然有大部分內容是靠著GPT協助生成的。

export default defineNuxtPlugin(() => {
  if (
    !('serviceWorker' in navigator) ||
    !('PushManager' in window) ||
    !('Notification' in window)
  ) {
    console.warn('[Push] 此瀏覽器不支援 Push Notification')
    return
  }

  const registerPush = async () => {
    try {

      const reg = await navigator.serviceWorker.register('/service-worker.js')

      const existingSubscription = await reg.pushManager.getSubscription()

      if (Notification.permission === 'granted') {
        const subscription = existingSubscription
          ? existingSubscription
          : await reg.pushManager.subscribe({
              userVisibleOnly: true,
              applicationServerKey: "PUBLIC_KEY"//TODO public key,
            })

        //TODO 送出訂閱資訊到後端

        console.log('[Push] 訂閱資訊已送出至後端')

      } else if (Notification.permission === 'default') {
        const permission = await Notification.requestPermission()
        if (permission === 'granted') {
          await registerPush() // 再次執行
        } else {
          console.warn('[Push] 使用者未授權通知')
        }
      } else {
        console.warn('[Push] 通知已封鎖')
      }

    } catch (err) {
      console.error('[Push] 訂閱處理失敗:', err)
    }
  }

  const tryRegister = () => {
    registerPush()
  }

  if (document.readyState === 'complete') {
    tryRegister()
  } else {
    const intervalId = setInterval(() => {
      if (document.readyState === 'complete') {
        clearInterval(intervalId)
        tryRegister()
      }
    }, 100)
  }

  const urlBase64ToUint8Array = (base64String: string) => {
    const padding = '='.repeat((4 - base64String.length % 4) % 4)
    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
    const rawData = atob(base64)
    return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
  }
})

後台 - PushServiceConfig.java 🔗

使用nl.martijndwars.webpush作為Web Push的libs

package com.blog.seagalogs.config;

import com.blog.seagalogs.common.properties.VapidProperties;
import nl.martijndwars.webpush.PushService;
import nl.martijndwars.webpush.Utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.security.Security;

@Configuration
public class PushServiceConfig {

	@Autowired
	private VapidProperties vapidProperties;

	@Bean
	public PushService pushService() throws Exception {

		// 註冊 BouncyCastle 提供者(只需要一次)
		Security
				.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());

		String publicKey = vapidProperties.getPublicKey();
		String privateKey = vapidProperties.getPrivateKey();
		String subject = vapidProperties.getSubject();

		PushService pushService = new PushService().setPublicKey(
				Utils.loadPublicKey(publicKey)).setPrivateKey(
				Utils.loadPrivateKey(privateKey)).setSubject(subject);

		return pushService;
	}
}

後台 - 推送的方法 🔗

endpoint, p256dh, auth就是訂閱資訊的內容,這邊利用DB儲存這三個資訊後,依序打每個訂閱資訊即可給每個訂閱資訊發送通知。

String payload = """
            {
              "title": "%s",
              "body": "%s",
              "url": "%s"
            }
            """.formatted(title, body, url);

            Notification notification = new Notification(
                    endpoint,
                    p256dh,
                    auth,
                    payload
            );

            HttpResponse response = pushService.send(notification);

注意這邊若傳404, 410代表這個訂閱無效了,最好搭配刪除機制避免太多無效訂閱塞爆你的DB。


跌跌撞撞2~3天把這個研究出來,雖然說不一定可以增加流量或是可見度之類的,但是有這樣的功能還是挺輕便又方便的是吧?