Logo

新人日誌

首頁關於我部落格

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

Web Push API:網頁關掉了,通知還是送得到

最後更新:2026年8月12日基礎概念

你有沒有想過,為什麼分頁明明已經關掉了,Gmail 還是能在你的桌面右下角跳出「你有一封新信」?

網頁不是關掉就什麼都不會執行了嗎,是誰在背後收這則訊息?

還有一個更奇怪的問題——你的後端伺服器,怎麼會知道要「主動連線到使用者的電腦」?

這篇文章會把 Web Push 從頭到尾拆開來看。

我們從最根本的問題開始:為什麼需要推播。

為什麼不能用 polling 就好

在 Web Push API 出現之前,網頁想要拿到新訊息,只有一個辦法:自己一直問。

這種做法叫 polling(輪詢),每隔幾秒就發一次請求問伺服器「有新東西嗎」。

// ❌ 傳統的做法:每 5 秒問一次
setInterval(async () => {
  const response = await fetch('/api/notifications');
  const data = await response.json();
  if (data.length > 0) renderNewMessages(data); // 你自己寫的顯示函式
}, 5000);

這段程式碼每 5 秒送一次請求,大部分時候伺服器都回「沒有新東西」。

一天下來就是 17280 次沒有意義的請求,而且訊息還是有最多 5 秒的延遲。

最致命的是:setInterval 只有在網頁開著的時候才會跑。

分頁一關,這段程式碼就死了,使用者不會再收到任何東西。

Web Push API 就是為了取代這種做法而出現的,它的想法剛好跟 polling 相反:不要讓瀏覽器一直問,而是讓伺服器在有新東西的時候主動送過來。

polling 是瀏覽器把資料「拉」回來,push 是伺服器把資料「推」出去,這個名字講的就是資料流動的方向。

但這裡有一個明顯的矛盾:使用者的電腦沒有固定 IP,可能在 NAT 後面,還可能整台關機。

你的伺服器根本沒辦法「連線到使用者」。

所以 Web Push 的第一件事,是引進一個中間人。

Push Service:那個中間人

Web Push 不是你的伺服器直接跟使用者說話,中間隔了一個叫 Push Service 的東西。

Push Service 是什麼

Push Service 是瀏覽器廠商自己營運的伺服器:Chrome 用 Google 的 FCM,Firefox 用 Mozilla 的 autopush,Safari 用 Apple 的 APNs。

你不需要去申請或設定它,使用者用哪個瀏覽器,就會自動用到哪一家的 Push Service。

不過這裡有個矛盾:Push Service 說到底也只是一台伺服器。

前面才說過伺服器沒辦法主動連線到使用者,那它憑什麼就能把訊息送到使用者的裝置上?

那條永遠掛著的連線

答案是它換了一個方向做這件事。

既然伺服器沒辦法主動連線到使用者,那就反過來,讓使用者的裝置先連上伺服器,而且連上之後不要斷。

這件事瀏覽器在你打開它的時候就默默做完了。

Chrome 一啟動,就會主動對 Google 的 Push Service 開一條連線,然後一直掛在那裡不關掉。

方向是關鍵:這條連線是從使用者的裝置往外撥的。

所以 NAT 也好、防火牆也好,都不會擋——它們擋的是外面主動連進來的請求,而不是裡面主動連出去的。

這條連線一旦建立,就變成一條雙向的通道。

Push Service 想送東西給這個瀏覽器時,不需要重新連線,直接沿著這條既有的通道推下去就好。

這條連線的兩個特性

第一,這條連線是瀏覽器層級的,不是網頁層級的。

整個瀏覽器只有一條,所有網站的推播都共用它——你訂閱了十個網站的通知,也不會有十條連線,只是同一條連線上多了十個收件地址。

也因為是瀏覽器層級的,它跟你的網頁有沒有開著完全無關。

分頁關掉、甚至你的網站從頭到尾沒被打開過,這條連線都還在。

第二,如果裝置關機或離線,這條連線當然就斷了。

這時候 Push Service 會先把訊息存著,等裝置重新上線、連線重新建立,再把積著的訊息送出去。

所以推播不會因為使用者關機就消失,只是會延後——這個「延後」的行為後面講 TTL 的時候還會再遇到。

一則通知的完整旅程

那條連線是瀏覽器幫你準備好的,你的程式碼一行都不用寫。

但光有通道還不夠用,因為它沒有名字。

你的後端要發通知的時候,總得有辦法指名「送到誰的哪一個瀏覽器」,而通道本身給不了你這個資訊。

所以你的程式碼要做的第一件事,是去跟 Push Service 要一個地址——這個動作叫訂閱,要到的地址就是 endpoint。

一個 endpoint 對應到一個瀏覽器加一個網站。

換一個瀏覽器就換一個地址:同一個人的筆電 Chrome 和手機 Chrome 各訂閱一次,拿到的是兩個不同的 endpoint。

同一個瀏覽器訂閱十個網站,則會拿到十個 endpoint,但連線始終只有那一條。

也就是說,訂閱不會建立任何新的連線,通道早就在了,它只是去要一個地址。

從使用者按下訂閱按鈕到通知跳出來,中間會經過四個角色,拆成四步。

第一步,你的網頁向 Push Service 訂閱,拿到一個專屬的網址,這個網址叫 endpoint。

第二步,網頁把這個 endpoint 傳回你的後端存起來。

第三步,當你想發通知時,你的後端對這個 endpoint 發一個 HTTP 請求。

第四步,Push Service 透過它跟瀏覽器之間那條既有的連線,把訊息推下去。

關鍵在於:你的後端從頭到尾都沒有連線到使用者,它只是對一個網址發 POST 而已。

真正「找到使用者」這件事,是 Push Service 在做的。

那訊息推到瀏覽器之後,網頁已經關掉了,誰來處理?

Service Worker:網頁關掉後還活著的那段程式

它跟一般的 JavaScript 差在哪

Service Worker 是一段跟你的網頁分開執行的 JavaScript。

它不在網頁的分頁裡跑,而是在瀏覽器的背景跑,所以分頁關掉它還在。

它也沒辦法碰到 DOM,因為它根本不屬於任何一個頁面。

你可以把它想成一個常駐的事件處理器:平常是睡著的,有事件發生時被瀏覽器叫醒,處理完再睡回去。

註冊要寫的那兩行

實際做起來會有兩個檔案。

一個是你原本就有的網頁程式碼,這裡叫 main.js;另一個是新建的 sw.js,Service Worker 的程式碼就寫在裡面,現在它可以先是空的。

sw.js 這個檔名沒有規定,叫什麼都行,重點是等一下要把路徑告訴瀏覽器。

在 main.js 裡叫瀏覽器去載入它,只要兩行:

// main.js(在你的網頁裡執行)
async function setupServiceWorker() {
  const registration = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;
  return registration;
}

register('/sw.js') 的意思是「請瀏覽器去拿這個路徑底下的檔案,把它裝成這個網站的 Service Worker」。

這裡的關鍵是「請」——它送出的是一個請求,不是一個完成。

瀏覽器接下這個請求之後,還要去下載檔案、安裝、啟動,這幾步都需要時間。

所以第一行的 await 結束時,Service Worker 通常還沒開始運作。

第二行的 navigator.serviceWorker.ready 就是在等它真的進入啟動狀態。

少了這一行,你可能會在它還沒啟動的時候就去訂閱推播,那一步會失敗。

等到啟動完成,register() 回傳的 registration 就是你之後操作推播的入口,訂閱、查詢目前的訂閱都要透過它。

兩個檔案各自負責什麼

registration 是在 main.js 裡拿到的,也就是你的網頁裡。

接下來的要權限、訂閱、把 endpoint 送到後端,這些也都寫在 main.js——它們屬於「設定階段」,是使用者開著你的網頁時做的事。

sw.js 裡寫的則是「通知來了之後」的事:收推播、顯示通知、處理點擊。

這些事發生的時候,使用者的分頁可能早就關掉了,所以不能寫在 main.js 裡。

兩個檔案是分開執行的,也不能互相呼叫對方的函式,這就是為什麼推播的程式碼會分散在兩個地方。

而 sw.js 放的位置也不只是檔案位置,還決定了這個 Service Worker 能管到哪些頁面:放在根目錄就管整個網站,放在 /app/sw.js 就只管 /app/ 底下的頁面。

Service Worker 的前提:網站必須跑在 HTTPS 上

為什麼這條規則沒有商量餘地

不是 HTTPS,Service Worker 就不會啟動——沒有降級方案,也沒有設定可以關掉這個檢查。

這個嚴格程度跟 Service Worker 的能力有關。

它可以攔截網頁發出的每一個請求,決定要回傳真實的回應、還是自己編造一個。

而且它裝上去之後會常駐在使用者的瀏覽器裡,就算你的網站關掉了,它還在。

HTTP 的內容在傳輸過程中是沒有保護的,經過的每一台機器都看得到,也都改得動。

所以假設你的網站跑在 HTTP,而使用者連著咖啡廳的 Wi-Fi,情況會是這樣。

使用者打開你的網站,網頁執行到 register('/sw.js'),瀏覽器就會去你的伺服器下載 /sw.js 這個 JavaScript 檔案。

這個下載請求會先經過咖啡廳的路由器才到你的伺服器,而伺服器回傳的檔案內容,也要沿著同一條路走回來。

架設那台路由器的人可以攔下回傳的內容,把檔案換成他自己寫的一份,再交給瀏覽器。

瀏覽器沒有辦法分辨這份檔案是不是你給的,於是照常註冊。

從這一刻開始,那段程式碼就以「你的網站」的身分常駐在使用者的裝置上。

它可以攔截使用者之後對你網站發出的每一個請求,回傳假造的頁面、或是把送出去的帳號密碼複製一份。

而且使用者離開那間咖啡廳、換回自己家的網路之後,它還在——Service Worker 不會因為換了網路就消失。

HTTPS 擋掉的就是中間那一步:內容經過加密和簽章,路由器改了任何一個位元,瀏覽器都會發現。

開發的時候怎麼辦

localhost 是這條規則唯一的例外,方便你在本機開發,127.0.0.1 也算。

這裡有個很多人第一次做推播時會卡住的地方:用區域網路 IP 開手機測試不算例外。

你在筆電跑 npm run dev,然後手機連 http://192.168.1.5:3000 想測推播,會發現什麼都沒發生——因為那不是 localhost,也不是 HTTPS。

解法通常是用 ngrok、cloudflared 這類工具開一條臨時的 HTTPS 通道,或是在本機開發伺服器上掛自簽憑證。

失敗的時候不會告訴你是 HTTPS 的問題

在不安全的環境下,瀏覽器不是讓 register() 失敗,而是整個 navigator.serviceWorker 都不會存在。

所以你如果直接呼叫 navigator.serviceWorker.register(),拿到的會是「無法讀取 undefined 的屬性」這種看起來跟 HTTPS 毫無關係的錯誤。

先做一道存在性檢查,比較容易查出真正的原因:

if (!('serviceWorker' in navigator)) {
  // 非 HTTPS 環境下,這個 API 根本不存在
  return;
}

同一條規則也管到訂閱推播用的 pushManager,下一節就會用到它。

如果哪天你發現 registration.pushManager 是 undefined,先檢查網址列是不是 HTTPS,多半就是這個原因。

通知權限:你只有一次機會

在訂閱推播之前,必須先拿到使用者的通知權限。

const permission = await Notification.requestPermission();
// permission 會是 'granted'、'denied' 或 'default'
if (permission !== 'granted') {
  return;
}

這行會跳出瀏覽器原生的權限對話框。

granted 是同意,denied 是拒絕,default 是使用者把對話框關掉、沒有做選擇。

這裡有個很多人踩到的坑:一旦使用者選了 denied,你就再也叫不出這個對話框了。

之後每次呼叫 requestPermission() 都會直接回 denied,連問都不會問。

使用者必須自己去瀏覽器的網站設定裡手動改回來,而幾乎沒有人會這樣做。

所以「什麼時候問」比「怎麼問」重要得多。

// ❌ 有問題的寫法:一進站就問
window.addEventListener('load', () => {
  Notification.requestPermission();
});

使用者才剛進到網站,還不知道你是誰、要通知他什麼,看到對話框的直覺反應就是點掉。

而這一點,就把這個使用者永久排除在推播之外了。

// ✅ 改善後的寫法:讓使用者自己觸發
subscribeButton.addEventListener('click', async () => {
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    await subscribeToPush();
  }
});

先在畫面上放一個「訂閱新文章通知」的按鈕,說清楚會收到什麼。

使用者按下去代表他已經想要了,這時候跳對話框,同意率會高非常多。

順帶一提,Notification 這個 API 本身跟推播是兩件事,它只負責「顯示一則通知」,就算完全不用 Push 也能用。

Web Push API 負責的是「從伺服器收到訊息」,兩者搭起來才是完整的推播。

訂閱:拿到那個 endpoint

權限拿到了,接下來向 Push Service 訂閱。

再提醒一次前面說過的:subscribe() 不是在建立連線,它只是在那條既有的連線上登記一個 endpoint。

不過登記這一步會實際連到 Push Service:瀏覽器把請求送出去,Push Service 產生一個 endpoint 再回傳。

所以裝置離線的時候 subscribe() 會失敗。用 Wi-Fi 還是行動網路都沒差,只要連得出去就行。

好消息是登記只需要做一次,結果會被瀏覽器保存下來。

使用者下次回到你的網站,不用重新訂閱,用 getSubscription() 就能拿回同一個 endpoint。

兩個參數各自在管什麼

const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});

pushManager 是 registration 上的一個屬性,所以要先有 registration 才能用它。

userVisibleOnly: true 的意思是:你的網站每次收到推播,都會顯示一則通知給使用者看,不會偷偷在背景處理掉。

這個參數目前只能填 true,填別的會直接失敗。

如果它可以填 false,代表你的網站能收到推播卻不顯示任何通知,使用者完全不知道剛剛發生過這件事。

這種用法叫 silent push(靜默推播)。

聽起來很方便,可以拿來在背景同步資料或更新快取。

但換個角度看,它等於讓任何網站都能從遠端喚醒使用者的瀏覽器執行程式碼,而且不留痕跡——追蹤位置、回報使用狀況、消耗電量,使用者都無從察覺。

所以瀏覽器直接把這條路封死:想收推播,就得讓使用者看得到。

而且這不只是規定,它會實際檢查你有沒有做到。

如果收到推播卻沒有顯示通知,瀏覽器會自己跳一則「這個網站在背景更新了內容」的通用通知,次數多了甚至可能收回你的推播權限。

applicationServerKey 是下一節的主角,先知道它是一把公鑰就好,不過在傳進去之前還要處理一下格式。

公鑰要先轉成 Uint8Array

金鑰本質上是一串位元組(byte),不是文字。

位元組沒辦法直接貼進程式碼,也沒辦法寫進 .env 檔,那些地方只放得下文字。

所以慣例上會先把位元組編碼成一串可以列印的字元,長得像這樣:

BPd7Ke9mQvX2LtYhNc4JrWgFa8ZsUiO1pTbEnKxMv0A

這就是 VAPID 公鑰編碼後的樣子。VAPID 是下一節的主題,這裡先把它當成一串要傳給 subscribe() 的公鑰就好。

這串字元本身沒有意義,它只是那堆位元組的一種寫法,方便你複製貼上、存進設定檔。

最常見的編碼方式是 base64,不過 Web Push 用的是它的變體 base64url。

差別在三個字元。

base64 的輸出會用到 +、/,結尾還可能補上 =,而這三個字元出現在網址或 HTTP header 裡都有各自的特殊意義,會被解讀成別的東西。

金鑰在 Web Push 裡剛好兩個地方都會經過,所以改用 base64url:+ 換成 -、/ 換成 _、結尾的 = 直接省略。

而 applicationServerKey 這個參數要的是原本的位元組,不是那串字元。

問題是 JavaScript 沒辦法用一般的字串裝位元組,得用一個專門的型別,叫 Uint8Array。

所以在傳進去之前要做一次還原:把 base64url 字串解碼回位元組,再包成 Uint8Array。

function urlBase64ToUint8Array(base64String) {
  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)));
}

前兩行就是在還原剛剛講的那三個字元:先把省略掉的 = 補回去,再把 - 和 _ 換回 + 和 /,這樣就是一段標準的 base64 了。

atob() 把它解碼成二進位字串,最後逐字元轉成位元組陣列。

這段程式碼幾乎每個 Web Push 教學都會出現,直接抄就好。

轉換好之後,就可以把它交給一開始那段訂閱程式碼了:

const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});

VAPID_PUBLIC_KEY 是那串 base64url 字元,urlBase64ToUint8Array() 把它變回 Uint8Array,這才是 subscribe() 接受的型別。

拿到的 subscription 裡有什麼

訂閱成功後拿到的 subscription 物件,轉成 JSON 長這樣:

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/dGhpcyBpcyBhbiBleGFtcGxl...",
  "expirationTime": null,
  "keys": {
    "p256dh": "BEl6dOubCTLIeQTgvOJcgpQLGqUJ9AmFrxNRUJ0Yc-c...",
    "auth": "K9xN2mQvBpRtLwFg7YhZsA"
  }
}

endpoint 就是這個瀏覽器的專屬收件地址,你的後端之後要對它發請求。

keys 裡面那兩把鑰匙是給加密用的,p256dh 是這個瀏覽器產生的公鑰,auth 是一組隨機的驗證用祕密值。

這三個欄位缺一不可,所以整包送去後端存起來:

await fetch('/api/subscriptions', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(subscription),
});

這裡有個容易踩到的細節:subscription 不是一般的物件。

它是瀏覽器給你的 PushSubscription 實例,endpoint 和 keys 這些資料藏在唯讀的屬性後面,不是物件本身持有的一般欄位。

如果它只是一般物件,JSON.stringify() 會逐一取出欄位轉成 JSON;但對這種物件,直接轉可能只得到 {},什麼都拿不到。

好在 PushSubscription 這個型別本身就提供了一個 toJSON() 方法。

JSON.stringify() 遇到有 toJSON() 的物件時,會先呼叫這個方法,再把它回傳的結果轉成 JSON 字串。

所以直接把 subscription 丟進去就對了,出來的就是前面看到的那包 JSON,不用自己一個欄位一個欄位取出來拼。

後端把整包存進資料庫,通常用 endpoint 當作唯一鍵,因為它本身就是唯一的。

到這裡前端的工作大致完成了,但有個問題還沒解決:Push Service 憑什麼相信是「你」在發這則訊息?

VAPID:證明訊息是你發的

endpoint 本身沒有任何保護。

它只是一個網址,如果被別人拿到,那個人就能對你的使用者發通知——冒充你的網站。

VAPID 就是為了解決這件事。

它不是一個要安裝的套件,也不是要去申請的服務,而是 Web Push 裡的一套約定:你的後端要怎麼向 Push Service 證明自己的身分。

全名是 Voluntary Application Server Identification,拆開來看剛好說明了它在做什麼。

Application Server 指的就是你的後端,Identification 是身分識別,而 Voluntary 是因為這件事是你主動去做的——你自己提供一組金鑰讓 Push Service 認得你,而不是去跟誰註冊帳號。

產生金鑰,然後放對地方

實際的做法是一組公私鑰:你產生一對金鑰,公鑰給瀏覽器,私鑰自己留著。

npx web-push generate-vapid-keys

這行會產生一組金鑰,公鑰放前端(它本來就會被看到,沒關係),私鑰放後端的環境變數。

// ❌ 絕對不要這樣:私鑰出現在前端
const VAPID_PRIVATE_KEY = 'aBcD1234...'; // 這會被打包進 bundle

前端的所有程式碼使用者都看得到,私鑰一旦進了 bundle,等於公開發布。

// ✅ 私鑰只存在後端
// .env
// VAPID_PUBLIC_KEY=BPd7Ke9mQvX2...
// VAPID_PRIVATE_KEY=aBcD1234...

簽章怎麼擋掉冒充者

訂閱時傳進去的 applicationServerKey 就是那把公鑰,也就是前面那段訂閱程式碼裡的這一行:

const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), // ← 公鑰在這裡
});

當時只說它是「下一節的主角」,現在可以看出它的用途了:這一步等於把你的公鑰交給 Push Service 保管。

Push Service 會把它記在這筆訂閱底下。

之後每次你的後端要發訊息,都要用私鑰做一次「簽章」。

簽章的意思是:拿私鑰對這次請求的內容算出一小段數值,附在請求裡一起送出。

這段數值只有握有私鑰的人算得出來,但任何拿到公鑰的人都能驗證它是否正確。

所以 Push Service 收到請求後,用當初訂閱時記下的那把公鑰檢查這段簽章。

對得上,代表這則請求確實出自持有私鑰的人,也就是你;對不上就直接拒絕。

換句話說,就算 endpoint 外流,別人也發不出東西——他沒有私鑰,算不出正確的簽章。

實務上交給 web-push 套件

簽章的細節 web-push 套件會幫你處理,你只要把金鑰交給它:

import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:you@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

第一個參數是聯絡方式,可以是 mailto: 或網址。

Push Service 如果發現你的推播有問題(例如大量發給已失效的訂閱),會用這個聯絡你。

有一件事值得注意:公鑰是綁在訂閱上的。

如果你之後換了 VAPID 金鑰,所有舊訂閱都會失效,必須讓使用者重新訂閱。

發送:後端真正送出通知

前置作業都完成了,現在來送一則通知。

import webpush from 'web-push';

const payload = JSON.stringify({
  title: 'New comment',
  body: 'Someone replied to your post',
  url: '/posts/42',
});

await webpush.sendNotification(subscription, payload);

一行程式碼,背後三件事

真正做事的只有最後一行,前面都是在準備要送的內容。

sendNotification() 需要兩個參數。

第一個 subscription 是你從資料庫撈出來的那包 JSON,也就是前面存進去的 endpoint 加上兩把鑰匙。

第二個 payload 是這則通知的正文,使用者最後會看到的字就在裡面。

它必須是字串,所以這裡先用 JSON.stringify() 包起來。

裡面的欄位名稱由你自己決定,因為等一下在 Service Worker 裡也是你自己解析它。

看起來只是一個函式呼叫,但 sendNotification() 背後做了三件事,而且各自用到 subscription 裡的不同部分。

第一,用你的私鑰對這個請求做簽章,讓 Push Service 認得出是你發的。

第二,用 p256dh 和 auth 這兩把鑰匙把 payload 加密。

第三,把成品對 endpoint 發一個 POST。

前面幾節做的所有準備,到這一行全部被用上了。

加密:連 Push Service 都看不到內容

上面第二件事的理由,跟你想的可能不太一樣。

Push Service 是第三方伺服器,訊息會經過它,但你不會希望 Google 或 Mozilla 讀得到你的通知內容。

所以 payload 在離開你的伺服器之前就被加密了,用的是訂閱時拿到的那兩把鑰匙,只有那個瀏覽器解得開。

Push Service 從頭到尾只看得到「有一包加密資料要送到這個 endpoint」。

payload 有大小限制,大約 4KB,而且是加密後的大小。

所以不要塞整篇文章進去,只放標題、摘要和一個網址,需要完整內容的話讓使用者點進來再抓。

失敗的時候要分兩種處理

sendNotification() 不一定會成功,而失敗的原因分成兩類,處理方式完全不同:

try {
  await webpush.sendNotification(subscription, payload);
} catch (error) {
  if (error.statusCode === 404 || error.statusCode === 410) {
    // 訂閱已經失效,從資料庫刪掉
    await deleteSubscription(subscription.endpoint);
  } else {
    throw error;
  }
}

404 和 410 代表這個 endpoint 已經不存在了——使用者可能清除了瀏覽器資料、解除了訂閱,或是換了裝置。

這種錯誤不需要重試,直接把資料庫裡那筆刪掉。

如果不刪,你的資料庫會慢慢累積一堆死掉的訂閱,每次發送都在浪費請求。

其他錯誤(例如 429 太頻繁、500 伺服器問題)才需要重試或記錄下來。

訊息送出去了,接下來看瀏覽器那一端怎麼接。

接收:Service Worker 裡的 push 事件

訊息到了瀏覽器,會喚醒你的 Service Worker,觸發一個 push 事件。

// sw.js
self.addEventListener('push', (event) => {
  const payload = event.data ? event.data.json() : {};

  event.waitUntil(          // ← 從這裡開始包住
    self.registration.showNotification(payload.title ?? 'Notification', {
      body: payload.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: payload.url },   // ← 這個 data 是通知的隨身資料
    })
  );                        // ← 包到這裡結束
});

把訊息拆開,變成一則通知

先看 self,它是 Service Worker 裡的全域物件,相當於網頁裡的 window——因為這裡沒有頁面,所以也沒有 window 可以用。

訊息本體放在 event.data 裡,呼叫 event.data.json() 就能把後端送來的 payload 解析成物件,拿到 title、body、url 這些欄位。

這裡你拿到的是明文。

後端加密的那一步,瀏覽器在交給你之前已經用它自己的金鑰解開了,你不用碰任何解密的程式碼。

有了內容,showNotification() 就是真正讓通知跳出來的那一步,前面所有工作都是為了走到這裡。

它的第二個參數是一包選項,body、icon、badge 都是在設定這則通知長什麼樣子。

其中 data: { url: payload.url } 比較特別,它不影響外觀,是留給下一步的線索。

這個 data 跟前面的 event.data 沒有關係,它的用途是讓你把任意資料附在這則通知上,跟著通知一起存著。

等使用者點下去的時候,這包資料可以原封不動拿回來——所以先把 url 放進去,下一節處理點擊時就知道該把使用者帶去哪。

waitUntil:漏了它,通知會偶爾不見

event.waitUntil() 是整段程式碼最容易被忽略、但最關鍵的部分。

注意它的位置:showNotification() 整個被包在 event.waitUntil(...) 的括號裡面,當成參數傳進去。

為什麼要多包這一層?把它拆掉看看就知道了。

// ❌ 有問題的寫法:沒有 waitUntil
self.addEventListener('push', (event) => {
  const payload = event.data.json();
  self.registration.showNotification(payload.title, { body: payload.body });
});

這樣寫看起來很正常,通知也真的會跳出來,大部分時候都沒事——但偶爾會不見。

原因在於 showNotification() 回傳的是 Promise,也就是說它是非同步的:這一行執行完,通知還沒真的顯示出來。

而事件處理函式到這裡就結束了。

瀏覽器看到函式跑完,會認為這個 Service Worker 沒事做了,就可能把它關掉——關掉的時間點如果早於通知顯示完成,這則通知就消失了。

所以症狀是通知偶爾出現、偶爾不見,而且在效能差的裝置上更常發生。

// ✅ 改善後的寫法:用 waitUntil 把時間延長
self.addEventListener('push', (event) => {
  const payload = event.data.json();
  event.waitUntil(
    self.registration.showNotification(payload.title, { body: payload.body })
  );
});

waitUntil() 等於跟瀏覽器說「這個 Promise 完成之前不要關掉我」。

只要在 Service Worker 裡做非同步的事,就要記得包一層——這在 fetch、sync 等其他事件裡也一樣。

點擊:把使用者帶到該去的地方

通知跳出來只是一半,使用者點下去要有反應。

// sw.js
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const targetUrl = event.notification.data?.url ?? '/';

  event.waitUntil(
    clients
      .matchAll({ type: 'window', includeUncontrolled: true })   // ← 撈出開著的分頁
      .then((windowClients) => {
        const existing = windowClients.find(
          (client) => client.url.includes(targetUrl)
        );
        if (existing) {
          return existing.focus();                                // ← 有現成的就叫到前景
        }
        return clients.openWindow(targetUrl);                     // ← 沒有才開新的
      })
  );
});

點下去之後要做兩件事:關掉通知,然後把使用者帶到對的頁面。

第一件很簡單,event.notification.close() 手動關掉就好。

有些平台點了不會自動關,所以自己來比較保險。

第二件要先知道「對的頁面」是哪一個,而答案就存在通知自己身上。

event.notification.data 就是前一節在 showNotification() 選項裡設定的 data: { url: payload.url },原封不動跟著通知留到現在,url 從這裡拿回來。

有了網址,下一個問題是要不要直接開一個新分頁。

如果使用者本來就開著那個頁面,再開一個會多出一個重複的分頁,所以動手開之前要先找找看有沒有現成的。

程式碼裡的 clients 是 Service Worker 用來操作它管轄的那些頁面的入口,clients.matchAll() 那一行就是在把目前開著的分頁全部撈出來。

其中 includeUncontrolled: true 這個參數不能省:剛開沒多久的分頁可能還沒被這個 Service Worker 接管,不加的話會漏掉它們。

撈出來之後比對網址,找到就用 focus() 把那個分頁叫到前景;找不到才用 openWindow() 開新的。

整串操作都是非同步的,所以一樣要用 event.waitUntil() 包起來,理由跟上一節完全相同。

訂閱會過期:pushsubscriptionchange

訂閱不是永久的。

瀏覽器可能因為安全考量、太久沒使用、或內部機制更新而換掉一組訂閱,這時候你資料庫裡那筆就變成死的了。

瀏覽器會發一個事件通知你

瀏覽器會在 Service Worker 裡觸發一個 pushsubscriptionchange 事件通知你:

// sw.js
self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil(
    self.registration.pushManager
      .subscribe({
        userVisibleOnly: true,
        applicationServerKey: event.oldSubscription?.options.applicationServerKey,
      })
      .then((newSubscription) =>
        fetch('/api/subscriptions', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            oldEndpoint: event.oldSubscription?.endpoint,
            subscription: newSubscription,
          }),
        })
      )
  );
});

這段程式碼要做兩件事:重新訂閱一次,然後讓後端把舊的那筆換成新的。

第一件事是 subscribe(),跟一開始訂閱時用的是同一個方法。

差別在參數:applicationServerKey 直接從 event.oldSubscription 拿。

這個物件是瀏覽器交給你的舊訂閱資料,裡面就存著當初傳進去的那把公鑰,所以不用再從環境變數讀一次,也不用再做一次格式轉換。

第二件事是把結果送回後端,而這裡除了新的 subscription,還多送了一個 oldEndpoint。

原因是後端的資料庫用 endpoint 當唯一鍵,但新舊訂閱的 endpoint 是不一樣的。

如果只送新的那筆,後端會當成一筆全新的訂閱新增進去,舊的死訂閱則會一直留在資料庫裡。

所以 event.oldSubscription 在這段程式碼裡出現了兩次,用途不同:一次是拿公鑰,一次是告訴後端要換掉哪一筆。

再加一道保險:使用者回訪時對一次

pushsubscriptionchange 這個事件的瀏覽器支援度不算完美,所以更保險的做法是另外加一道:使用者每次回到網站時,檢查一次目前的訂閱狀態並同步給後端。

const subscription = await registration.pushManager.getSubscription();
if (subscription) {
  await syncSubscriptionToServer(subscription);
}

getSubscription() 回傳目前這個瀏覽器的訂閱,沒有訂閱就回 null。

這一步順便也解決了另一個問題:你可以用它判斷要顯示「訂閱通知」還是「取消訂閱」按鈕。

什麼時候該用推播

推播的成本比想像中高——不只是開發成本,更是使用者的注意力成本。

它適合的是「有時效性、而且使用者不在網站上也想知道」的事:有人回覆了你的訊息、你的訂單出貨了、你追蹤的商品降價了。

它不適合的是「你想讓使用者回來」的事:一週沒登入、你可能會喜歡這篇文章、限時優惠只剩三小時。

這類通知的下場通常是被關掉權限,然後連前面那些真正重要的通知也一起收不到了。

實作上還有幾件事要先確認。

Web Push 需要 HTTPS,本機開發用 localhost 可以。

iOS 上的 Safari 從 16.4 開始支援 Web Push,但有個額外條件:使用者必須先把網站「加入主畫面」,變成一個 PWA(Progressive Web App,可以像 app 一樣安裝到裝置上的網站)才收得到,一般在 Safari 分頁裡瀏覽是沒有的。

而且各家 Push Service 的行為不完全一致,通知的樣式選項(圖片、按鈕、震動)在不同瀏覽器上支援度不一樣,寫的時候要當作它們可能不生效。

最後是一個容易被忽略的取捨:推播的送達不保證即時。

裝置關機、離線、或作業系統為了省電而限制背景活動時,訊息會被 Push Service 暫存,等連線恢復才送。

sendNotification() 可以帶 TTL 參數決定要暫存多久,過期就丟棄——對於「五分鐘後開會」這種訊息,遲到的通知比沒有通知更糟,把 TTL 設短一點是對的。

重點整理

  • Web Push 的核心是有一個中間人:你的後端不會連線到使用者,而是對 Push Service 給的一個網址發 POST,由 Push Service 負責找到使用者。
  • Service Worker 是一段在瀏覽器背景執行、跟網頁分開的程式,網頁關掉它還活著,所以由它負責收訊息和顯示通知。
  • 「連線」和「訂閱」是兩件事:連線是瀏覽器一啟動就跟 Push Service 建立的,全瀏覽器共用一條;訂閱是你的網頁執行 subscribe(),在那條連線上登記一個屬於你網站的收件地址。
  • 通知權限只有一次機會,被拒絕就叫不回來了,所以要等使用者主動按下訂閱按鈕再問。
  • 訂閱後拿到的 subscription 裡有三樣東西:endpoint 是收件地址,p256dh 和 auth 是給加密用的鑰匙,整包存到後端。
  • VAPID 是一組公私鑰,公鑰給瀏覽器、私鑰留在後端,讓 Push Service 能驗證這則推播真的是你發的。
  • payload 在離開你的伺服器前就加密了,Push Service 讀不到內容,大小上限約 4KB。
  • Service Worker 裡做任何非同步的事都要包 event.waitUntil(),不然瀏覽器可能在事情做完之前就把它關掉。
  • 收到 404 或 410 代表訂閱已失效,直接從資料庫刪掉,不要重試。
  • 訂閱會自己失效,除了聽 pushsubscriptionchange,也要在使用者回訪時用 getSubscription() 對一次。
  • 推播適合有時效、使用者真的想知道的事;拿來做行銷召回,換來的通常是被永久關掉權限。

目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

基礎概念

目錄

  • 為什麼不能用 polling 就好
  • Push Service:那個中間人
  • Push Service 是什麼
  • 那條永遠掛著的連線
  • 這條連線的兩個特性
  • 一則通知的完整旅程
  • Service Worker:網頁關掉後還活著的那段程式
  • 它跟一般的 JavaScript 差在哪
  • 註冊要寫的那兩行
  • 兩個檔案各自負責什麼
  • Service Worker 的前提:網站必須跑在 HTTPS 上
  • 為什麼這條規則沒有商量餘地
  • 開發的時候怎麼辦
  • 失敗的時候不會告訴你是 HTTPS 的問題
  • 通知權限:你只有一次機會
  • 訂閱:拿到那個 endpoint
  • 兩個參數各自在管什麼
  • 公鑰要先轉成 Uint8Array
  • 拿到的 subscription 裡有什麼
  • VAPID:證明訊息是你發的
  • 產生金鑰,然後放對地方
  • 簽章怎麼擋掉冒充者
  • 實務上交給 web-push 套件
  • 發送:後端真正送出通知
  • 一行程式碼,背後三件事
  • 加密:連 Push Service 都看不到內容
  • 失敗的時候要分兩種處理
  • 接收:Service Worker 裡的 push 事件
  • 把訊息拆開,變成一則通知
  • waitUntil:漏了它,通知會偶爾不見
  • 點擊:把使用者帶到該去的地方
  • 訂閱會過期:pushsubscriptionchange
  • 瀏覽器會發一個事件通知你
  • 再加一道保險:使用者回訪時對一次
  • 什麼時候該用推播
  • 重點整理