Web 推送技術

伴隨着今年 Google I/O 大會的召開,一個很火的概念--Progressive Web Apps 誕生了。這表明着咱們 web 端有了和原生 APP 媲美的能力。可是,有一個很重要的痛點,web 一直不能使用消息推送,雖然,後面提出了 Notification API,但這須要網頁持續打開,這對於常規 APP 實現的推送,根本就不是一個量級的。因此,開發者一直在呼籲能不能退出一款可以在網頁關閉狀況下的 web 推送呢?
如今,Web 時代已經到來!
爲了作到在網頁關閉的狀況下,還能繼續發送 Notification,咱們就只能使用駐留進程。而如今 Web 的駐留進程就是如今正在大力普及的 Service Worker。換句話說,咱們的想要實現斷線 Notification 的話,須要用的技術棧是:html

  • Pushnode

  • Notificationgit

  • Service Workergithub

這裏,我先一個簡單的 demo 樣式。web

noti

說實在的,我其實 TM 很煩的這 Noti。通常使用 PC 端的,也沒見有啥消息彈出來,可是,如今好了 Web 一搞,結果三端通用。你若是不由用的話,保不許每天彈。。。算法

SW(Service Worker) 我已經在前一篇文章裏面講清楚了。這裏主要探究一下另外兩個技術 PushNotification。首先,有一個問題,這兩個技術是用來幹嗎的呢?chrome

Push && Notification

這兩個技術,咱們能夠理解爲就是 server 和 SW 之間,SW 和 user 之間的消息通訊。json

  • push: server 將更新的信息傳遞給 SWapi

  • notification: SW 將更新的信息推送給用戶數組

能夠看出,兩個技術是緊密鏈接到一塊兒的。這裏,咱們先來說解一下 notification 的相關技術。

Notification

那如今,咱們想給用戶發送一個消息的話應該怎麼發送呢?
代碼很簡單,我直接放了:

self.addEventListener('push', function(event) {
  var title = 'Yay a message.';
  var body = 'We have received a push message.';
  var icon = '/images/icon-192x192.png';
  var tag = 'simple-push-demo-notification-tag';
  var data = {
    doge: {
        wow: 'such amaze notification data'
    }
  };
  event.waitUntil(
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag,
      data: data
    })
  );
});

你們一開始看見這個代碼,可能會以爲有點陌生。實際上,這裏是結合 SW 來完成的。push 是 SW 接收到後臺的 push 信息而後出發。固然,咱們獲取信息的主要途徑也是從 event 中獲取的。這裏爲了簡便,就直接使用寫死的信息了。大體解釋一下 API。

  • event.waitUntil(promise): 該方法是用來延遲 SW 的結束。由於,SW 可能在任什麼時候間結束,爲了防止這樣的狀況,須要使用 waitUntil 監聽 promise,使系統不會在 promise 執行時就結束 SW。

  • ServiceWorkerRegistration.showNotification(title, [options]): 該方法執行後,會發回一個 promise 對象。

不過,咱們須要記住的是 SW 中的 notification 只是很早之前就退出的桌面 notification 的繼承對象。這意味着,你們若是想要嘗試一下 notification,並不須要手動創建一個 notification,而只要使用

// 桌面端
var not = new Notification("show note", { icon: "newsong.svg", tag: "song" });
not.onclick = function() { dosth(this); };

// 在 SW 中使用
self.registration.showNotification("New mail from Alice", {
  actions: [{action: 'archive', title: "Archive"}]
});

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  if (event.action === 'archive') {
    silentlyArchiveEmail();
  } else {
    clients.openWindow("/inbox");
  }
}, false);

不過,若是你想設置本身想要的 note 效果的話,則須要瞭解一下,showNotification 裏面具體每次參數表明的含義,參考 Mozilla,咱們能夠了解到基本的使用方式。如上,API 的基本格式爲 showNotification(title, [options])

  • title: 很簡單,就是該次 Not(Notification) 的標題

  • options: 這個而是一個對象,裏面能夠接受不少參數。

    • actions[Array]:該對象是一個數組,裏面包含一個一個對象元素。每一個對象包含內容爲:

      • action[String]: 表示該 Not 的行爲。後面是經過監聽 notificationclick 來進行相關處理

      • title[String]: 該 action 的標題

      • icon[URL]: 該 action 顯示的 logo。大小一般爲 24*24

actions 的上限值,一般根據 Notification.maxActions 肯定。經過在 Not 中定義好 actions 觸發,最後咱們會經過,監聽的 notificationclick 來作相關處理:

self.addEventListener('notificationclick', function(event) {  
  var messageId = event.notification.data;
  
  event.notification.close();
    // 經過設置的 actions 來作適當的響應
  if (event.action === 'like') {  
    silentlyLikeItem();  
  }  
  else if (event.action === 'reply') {  
    clients.openWindow("/messages?reply=" + messageId);  
  }  
  else {  
    clients.openWindow("/messages?reply=" + messageId);  
  }  
}, false);
    • body[String]: Not 顯示的主體信息

    • dir[String]: Not 顯示信息的方向,一般能夠取:auto, ltr, or rtl

    • icon[String]:Not 顯示的 Icon 圖片路徑。

    • image[String]:Not 在 body 裏面附帶顯示的圖片 URL,大小最好是 4:3 的比例。

    • tag[String]:用來標識每一個 Not。方便後續對 Not 進行相關管理。

    • renotify[Boolean]:當重複的 Not 觸發時,標識是否禁用振動和聲音,默認爲 false

    • vibrate[Array]:用來設置振動的範圍。格式爲:[振動,暫停,振動,暫停...]。具體取值單位爲 ms。好比:[100,200,100]。振動 100ms,靜止 200ms,振動 100ms。這樣的話,咱們能夠設置本身 APP 都有的振動提示頻率。

    • sound[String]: 設置音頻的地址。例如: /audio/notification-sound.mp3

    • data[Any]: 用來附帶在 Not 裏面的信息。咱們通常能夠在 notificationclick 事件中,對回調參數進行調用event.notification.data

針對於推送的圖片來講,可能會針對不一樣的手機用到的圖片尺寸會有所區別,例如,針對不一樣的 dpi。

具體參照:

cut

看下 MDN 提供的 demo:

function showNotification() {
  Notification.requestPermission(function(result) {
    if (result === 'granted') {
      navigator.serviceWorker.ready.then(function(registration) {
        registration.showNotification('Vibration Sample', {
          body: 'Buzz! Buzz!',
          icon: '../images/touch/chrome-touch-icon-192x192.png',
          vibrate: [200, 100, 200, 100, 200, 100, 200],
          tag: 'vibration-sample'
        });
      });
    }
  });
}

固然,簡單 API 的使用就是上面那樣。可是,若是咱們不加剋制的使用 Not,可能會讓用戶徹底屏蔽掉咱們的推送,得不償失。因此,咱們須要遵循必定的原則去發送。

推送原則

  1. 推送必須簡潔
    遵循時間,地點,人物要素進行相關信息的設置。

  2. 儘可能不要讓用戶打開網頁查看
    雖然這看起來有點違背咱們最初的意圖。不過,這樣確實可以提升用戶的體驗。好比在信息回覆中,直接顯示:XX回覆:... 這樣的格式,能夠徹底省去用戶的打開網頁的麻煩。

  3. 不要在 title 和 body 出現同樣的信息
    好比:

correct:
first
incorrect
此處輸入圖片的描述

  1. 不要推薦原生 APP
    由於頗有可能形成推送信息重複

  2. 不要寫上本身的網址
    由於,Not 已經幫你寫好了

website_name

  1. 儘可能讓 icon 和推送有關聯
    沒用的 icon:

icon
實用的 icon:
icon

推送權限

實際上,Not 並不全在 SW 中運行,對於設計用戶初始權限,咱們須要在主頁面中,作出相關的響應。固然,在設置推送的時候,咱們須要考慮到用戶是否會禁用,這裏影響仍是特別大的。
咱們,獲取用戶權限通常能夠直接使用 Notification 上掛載的 permission 屬性來獲取的。

  • defualt: 表示須要進行詢問。默認狀況是不顯示推送

  • denied: 不顯示推送

  • granted: 顯示推送

簡單的來講爲:

function initialiseState() {
  if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
    return;
  }

  // 檢查是否能夠進行服務器推
  if (!('PushManager' in window)) {
    return;
  }

  // 是否被禁用
  if (Notification.permission === 'denied') {
    return;
  }

  if (Notification.permission === 'granted') {
    // dosth();
    return;
  }

  // 若是還處於默認狀況下,則進行詢問
  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    // 檢查訂閱
    serviceWorkerRegistration.pushManager.getSubscription()
      .then(function(subscription) {
        // 檢查是否已經被訂閱
        if (!subscription) {
          // 沒有
          return;
        }
        // 有
        // doSth();
      })
      .catch(function(err) {
        window.Demo.debug.log('Error during getSubscription()', err);
      });
  });
}

咱們在加載的時候,須要先進行檢查一遍,若是是默認狀況,則須要發起訂閱的請求。而後再開始進行處理。
那,咱們上面的那段代碼該放在哪一個位置呢?首先,這裏使用到了 SW,這意味着,咱們須要將 SW 先註冊成功才行。實際代碼應放在 SW 註冊成功的回調中:

window.addEventListener('load', function() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./service-worker.js')
    .then(initialiseState);
  } else {
    window.Demo.debug.log('Service workers aren\'t supported in this browser.');
  }
});

爲了更好的顯示信息,咱們還能夠將受權代碼放到後面去。好比,將 subscribe 和 btn 的 click 事件進行綁定。這時候,咱們並不須要考慮 SW 是否已經註冊好了,由於SW 的註冊時間遠遠不及用戶的反應時間。
例如:

var pushButton = document.querySelector('.js-push-button');
  pushButton.addEventListener('click', function() {
    if (isPushEnabled) {
      unsubscribe();
    } else {
      subscribe();
    }
  });

咱們具體看一下 subscribe 內容:

function subscribe() {
  var pushButton = document.querySelector('.js-push-button');
  pushButton.disabled = true;

  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    // 請求訂閱
    serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true})
      .then(function(subscription) {
        isPushEnabled = true;
        pushButton.textContent = 'Disable Push Messages';
        pushButton.disabled = false;
        return sendSubscriptionToServer(subscription);
      })
  });
}

說道這裏,你們可能會看的雲裏霧裏,這裏咱們來具體看一下 serviceWorkerRegistration.pushManager 具體含義。該參數是從 SW 註冊事件回調函數獲取的。也就是說,它是咱們和 SW 交互的通道。該對象上,綁定了幾個獲取訂閱相關的 API:

  • subscribe(options) [Promise]: 該方法就是咱們經常用來觸發詢問的 API。他返回一個 promise 對象.回調參數爲 pushSubscription 對象。這裏,咱們後面再進行討論。這裏主要說一下 options 裏面有哪些內容

    • options[Object]

      • userVisibleOnly[Boolean]:用來表示後續信息是否展現給用戶。一般設置爲 true.

      • applicationServerKey: 一個 public key。用來加密 server 端 push 的信息。該 key 是一個 Uint8Array 對象。

例如:

registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: new Uint8Array([...])
    });
  • getSubscription() [Promise]: 用來獲取已經訂閱的 push subscription 對象。

  • permissionState(options) [Promise]: 該 API 用來獲取當前網頁消息推送的狀態 'prompt', 'denied', 或 'granted'。裏面的 options 和 subscribe 裏面的內容一致。

爲了更好的體驗,咱們能夠將二者結合起來,進行相關推送檢查,具體的 load 中,則爲:

window.addEventListener('load', function() {
  var pushButton = document.querySelector('.js-push-button');
  pushButton.addEventListener('click', function() {
    if (isPushEnabled) {
      unsubscribe();
    } else {
      subscribe();
    }
  });
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./service-worker.js')
    .then(initialiseState);
  } else {
    window.Demo.debug.log('Service workers aren\'t supported in this browser.');
  }
});

固然,這裏面還會涉及其餘的一些細節,我這裏就不過多贅述了。詳情能夠查閱: Notification demo

咱們開啓一個 Not 詢問很簡單,但關鍵是,若是讓用戶贊成。若是咱們一開始就進行詢問,這樣成功性的可能性過低。咱們能夠在頁面加載後進行詢問。這裏,也有一些提醒原則:

  1. 經過具體行爲進行詢問
    好比,當我在查詢車票時,就可讓用戶在退出時選擇是否接受推送信息。好比,國外的飛機延遲通知網頁:

delay

  1. 讓用戶來決定是否進行推送
    由於用戶不是技術人員,咱們須要將一些接口,暴露給用戶。針對推送而言,咱們可讓用戶選擇是否進行推送,而且,在提示的同時,顯示的信息應該儘可能和用戶相關。

user

推送處理

web push 在實際協議中,會設計到兩個 server,比較複雜,這裏咱們先來看一下。client 是如何處理接受到的信息的。
當 SW 接受到 server 傳遞過來的信息時,會先觸發 push 事件。咱們一般作以下處理:

self.addEventListener('push', function(event) { 
if (event.data) {
    console.log('This push event has data: ',event.data.text()); 
    } else {
    console.log('This push event has no data.');
  }
});

其中,咱們經過 server push 過來的 msg 一般是掛載到 event.data 裏的。而且,該部署了 Response 的相關 API:

  • text(): 返回 string 的內容

  • json(): 返回 通過 json parse 的對象

  • blob(): 返回 blob 對象

  • arrayBuffer(): 返回 arrayBuffer 對象

咱們知道 Service Worker 並非常駐進程,有童鞋可能會問到,那怎麼利用 SW 監聽 push 事件呢?
這裏就不用擔憂了,由於瀏覽器本身會打開一個端口監聽接受到的信息,而後喚起指定的 SW(若是你的瀏覽器是關閉的,那麼你能夠洗洗睡了)。並且,因爲這樣隨機關閉的機制,咱們須要上述提到的 event.waitUntil API 來幫助咱們完成持續 alive SW 的效果,防止正在執行的異步程序被終止。針對於咱們的 notification 來講,實際上就是一個異步,因此,咱們須要使用上述 API 進行包裹。

self.addEventListener('push', function(event) {
const promiseChain = self.registration.showNotification('Hello, World.');
  event.waitUntil(promiseChain);
});

固然,若是你想在 SW 裏面作更多的異步事情的話,可使用 Promise.all 進行包裹。

self.addEventListener('push', function(event) {
    const promiseChain = Promise.all([ async1,async2 ]);
  event.waitUntil(promiseChain);
});

以後,就是將具體信息展現推送給用戶了。上面已經將了具體 showNotification 裏面的參數有哪些。不過,這可能不夠直觀,咱們可使用一張圖來感覺一下:

cur

(左:firefox,右:Chrome)

另外,在 showNotification options 裏面,還有一些屬性須要咱們額外注意。

屬性注意

tag

對於指定的 Not 咱們可使用 tag 來代表其惟一性,這表明着當咱們在使用相同 tag 的 Not 時,上一條 Not 會被最新擁有同一個 tag 的Not 替換。即:

const title = 'First Notification';
    const options = {
      body: 'With \'tag\' of \'message-group-1\'',
      tag: 'message-group-1'
    };
    registration.showNotification(title, options);

顯示樣式爲:

first

接着,我顯示一個不一樣 tag 的 Not:

const title = 'Second Notification';
const options = {
     body: 'With \'tag\' of \'message-group-2\'',
     tag: 'message-group-2'
};
registration.showNotification(title, options);

結果爲:

second

而後,我使用一個一樣 tag 的 Not:

const title = 'Third Notification';
      const options = {
        body: 'With \'tag\' of \'message-group-1\'',
        tag: 'message-group-1'
      };
      registration.showNotification(title, options);

則相同的 tag 會被最新 tag 的 Not 替換:

last

Renotify

該屬性是 Not 裏面又一個比較尷尬的屬性,它的實際應用場景是當有重複 Not 被替換時,震動和聲音能不能被重複播放,但默認爲 false。
那何爲重複呢?
就是,上面咱們提到的 tag 被替換。通常應用場景就是和同一個對象聊天時,發送多個信息來時,咱們不可能推送多個提示信息,通常就是把已經存在的 Not 進行替換就 ok,那麼這就是上面提到的由於重複,被替換的 Not。
通常咱們對於這樣的 Not 能夠設置爲:

const title = 'Second Notification';
      const options = {
        body: 'With "renotify: true" and "tag: \'renotify\'".',
        tag: 'renotify',
        renotify: true
      };
      registration.showNotification(title, options);

而且,若是你設置了 renotify 而沒有設置 tag 的話,這是會報錯的 !!!

silent

防止本身推送的 Not 發出任何額外的提示操做(震動,聲音)。默認爲 false。不過,咱們能夠在須要的時候,設置爲 true:

const title = 'Silent Notification';
    const options = {
      body: 'With "silent: \'true\'".',
      silent: true
    };
    registration.showNotification(title, options);

requireInteraction

對於通常的 Not 來講,當展現必定時間事後,就能夠自行消失。不過,若是你的 Not 必定須要用戶去消除的話,可使用 requireInteraction 來進行長時間留存。通常它的默認值爲 false。

const title = 'Require Interaction Notification';
    const options = {
      body: 'With "requireInteraction: \'true\'".',
      requireInteraction: true
    };
    registration.showNotification(title, options);

交互響應

如今,你的 Not 已經顯示給用戶,不過,默認狀況下,Not 自己是不會作任何處理的。咱們須要監聽用戶,對其的相關操做(其實就是 click 事件)。

self.addEventListener('notificationclick', function(event) {
    // do nothing
});

另外,經過咱們在 showNotification 裏面設置的 action,咱們能夠根據其做出不一樣的響應。

self.addEventListener('notificationclick', function(event) {
if (event.action) {
    console.log('Action Button Click.', event.action);
} else {
    console.log('Notification Click.');
}
});

關閉推送

這是應該算是最經常使用的一個,只是用來提示用戶的相關信息:

self.addEventListener('notificationclick', function(event) {
  event.notification.close();

  // Do something as the result of the notification click
});

打開一個新的窗口

這裏,須要使用到咱們的 service 裏面的一個新的 API clients

event.waitUntil(
  // examplePage 就是當前頁面的 url
    clients.openWindow(examplePage)
  );

這裏須要注意的是 examplePage 必須是和當前 SW 同域名才行。不過,這裏有兩種狀況,須要咱們考慮:

  1. 指定的網頁已經打開?

  2. 當前沒網?

聚焦已經打開的頁面

這裏,咱們能夠利用 cilents 提供的相關 API 獲取,當前瀏覽器已經打開的頁面 URLs。不過這些 URLs 只能是和你 SW 同域的。而後,經過匹配 URL,經過 matchingClient.focus() 進行聚焦。沒有的話,則新打開頁面便可。

const urlToOpen = self.location.origin + examplePage;

  const promiseChain = clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  })
  .then((windowClients) => {
    let matchingClient = null;

    for (let i = 0; i < windowClients.length; i++) {
      const windowClient = windowClients[i];
      if (windowClient.url === urlToOpen) {
        matchingClient = windowClient;
        break;
      }
    }

    if (matchingClient) {
      return matchingClient.focus();
    } else {
      return clients.openWindow(urlToOpen);
    }
  });

  event.waitUntil(promiseChain);

檢測是否須要推送

另外,若是用戶已經停留在當前的網頁,那咱們可能就不須要推送了,那麼針對於這種狀況,咱們應該怎麼檢測用戶是否正在網頁上呢?

const promiseChain = ({
    type: 'window',
    includeUncontrolled: true
  })
  .then((windowClients) => {
    let mustShowNotification = true;

    for (let i = 0; i < windowClients.length; i++) {
      const windowClient = windowClients[i];
      if (windowClient.focused) {
        mustShowNotification = false;
        break;
      }
    }

    return mustShowNotification;
  })
  .then((mustShowNotification) => {
    if (mustShowNotification) {
      return self.registration.showNotification('Had to show a notification.');
    } else {
      console.log('Don\'t need to show a notification.');
    }
  });

  event.waitUntil(promiseChain);

固然,若是你本身的網頁已經被用戶打開,咱們一樣也能夠根據推送信息直接將信息傳遞給對應的 window。咱們經過 clients.matchAll 得到的 windowClient 對象,調用 postMessage 來進行消息的推送。

windowClient.postMessage({
          message: 'Received a push message.',
          time: new Date().toString()
        });

合併消息

該場景的主要針對消息的合併。好比,聊天消息,當有一個用戶給你發送一個消息時,你能夠直接推送,那若是該用戶又發送一個消息呢?
這時候,比較好的用戶體驗是直接將推送合併爲一個,而後替換便可。
那麼,此時咱們就須要得到當前已經展現的推送消息,這裏主要經過 registration.getNotifications() API 來進行獲取。該 API 返回的也是一個 Promise 對象。
固然,咱們怎麼肯定兩個消息是同一我的發送的呢?這裏,就須要使用到,上面提到的 Not.data 的屬性。這是咱們在 showNotification 裏面附帶的,能夠直接在 Notification 對象中獲取。

return registration.getNotifications()
    .then(notifications => {
      let currentNotification;

      for(let i = 0; i < notifications.length; i++) {
      // 檢測已經存在的 Not.data.userName 和新消息的 userName 是否一致
        if (notifications[i].data &&
          notifications[i].data.userName === userName) {
          currentNotification = notifications[i];
        }
      }

      return currentNotification;
    })
    // 而後,進行相關的邏輯處理,將 body 的內容進行更替
    .then((currentNotification) => {
      let notificationTitle;
      const options = {
        icon: userIcon,
      }

      if (currentNotification) {
        // We have an open notification, let's so something with it.
        const messageCount = currentNotification.data.newMessageCount + 1;

        options.body = `You have ${messageCount} new messages from ${userName}.`;
        options.data = {
          userName: userName,
          newMessageCount: messageCount
        };
        notificationTitle = `New Messages from ${userName}`;

        currentNotification.close();
      } else {
        options.body = `"${userMessage}"`;
        options.data = {
          userName: userName,
          newMessageCount: 1
        };
        notificationTitle = `New Message from ${userName}`;
      }

      return registration.showNotification(
        notificationTitle,
        options
      );
    });

至關於從:

one

變爲:

two

上面提到了在 SW 中使用,clients 獲取窗口信息,這裏咱們先補充一下相關的知識。

Clients Object

咱們能夠將 Clients 理解爲咱們如今所在的瀏覽器,不過特殊的地方在於,它是遵照同域規則的,即,你只能操做和你域名一致的窗口。一樣,Clients 也只是一個集合,用來管理你當前全部打開的頁面,實際上,每一個打開的頁面都是使用一個 cilent object 進行表示的。這裏,咱們先來探討一下 cilent object:

  • Client.postMessage(msg[,transfer]): 用來和指定的窗口進行通訊

  • Client.frameType: 代表當前窗口的上下文。該值能夠爲: auxiliary, top-level, nested, 或者 none.

  • Client.id[String]: 使用一個惟一的 id 表示當前窗口

  • Client.url: 當前窗口的 url。

  • WindowClient.focus(): 該方法是用來聚焦到當前 SW 控制的頁面。下面幾個也是 Client,不過是專門針對 type=window 的client。

  • WindowClient.navigate(url): 將當前頁面到想到指定 url

  • WindowClient.focused[boolean]: 表示用戶是否停留在當前 client

  • WindowClient.visibilityState: 用來表示當前 client 的可見性。實際和 focused 沒太大的區別。可取值爲: hidden, visible, prerender, or unloaded

而後,Clients Object 就是用來管理每一個窗口的。經常使用方法有:

  • Clients.get(id): 用來得到某個具體的 client object

self.clients.get(id).then(function(client) {
  // 打開具體某個窗口
  self.clients.openWindow(client.url); 
});
  • Clients.matchAll(options): 用來匹配當前 SW 控制的窗口。因爲 SW 是根據路徑來控制的,有可能只返回一部分,而不是同域。若是須要返回同域的窗口,則須要設置響應的 options。

    • includeUncontrolled[Boolean]: 是否返回全部同域的 client。默認爲 false。只返回當前 SW 控制的窗口。

    • type: 設置返回 client 的類型。一般有:window, worker, sharedworker, 和 all。默認是 all

// 經常使用屬性爲:
clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  }).then(function(clientList) {
  for(var i = 0 ; i < clients.length ; i++) {
    if(clientList[i].url === 'index.html') {
      clients.openWindow(clientList[i]);
    }
  }
});
  • Clients.openWindow(url): 用來打開具體某個頁面

  • Clients.claim(): 用來設置當前 SW 和同域的 cilent 進行關聯。

Push

先貼一張 google 關於 web push 的詳解圖:

web push

上述圖,簡單闡述了從 server 產生信息,最終到手機生成提示信息的一系列過程。
先說一下中間那個 Message Server。這是獨立於咱們經常使用的 Server -> Client 的架構,瀏覽器能夠本身選擇 push service,開發者通常也不用關心。不過,若是你想使用本身定製的 push serivce 的話,只須要保證你的 service 可以提供同樣的 API 便可。上述過程爲:

  1. 用於打開你的網頁,而且,已經生成好用來進行 push 的 applicationServerKey。而後,phone 開始初始化 SW

  2. 用戶訂閱該網頁的推送,此時會給 message server 發送一個請求,建立一個訂閱,而後返回 message server 的相關信息。

  3. 瀏覽器得到 message server 的相關信息後,而後在發送一個請求給該網頁的 server。

  4. 若是 server 這邊檢測到有新的信息須要推送,則它會想 message server 發送相關請求便可。

這裏,咱們能夠預先看一下 message server 返回來的內容:

{
  "endpoint": "https://random-push-service.com/some-kind-of-unique-id-1234/v2/",
  "keys": {
    "p256dh" : "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
    "auth"   : "tBHItJI5svbpez7KI4CCXg=="
  }
}

endpoint 就是瀏覽器訂閱的 message server 的地址。這裏的 keys 咱們放到後面講解,主要就是用來進行 push message 的加密。
根據官方解釋,Message Server 與用戶將的通訊,借用的是 HTTP/2 的 server push 協議。上面的圖,其實能夠表達爲:

+-------+           +--------------+       +-------------+
    |  UA   |           | Push Service |       | Application |
    +-------+           +--------------+       |   Server    |
        |                      |               +-------------+
        |      Subscribe       |                      |
        |--------------------->|                      |
        |       Monitor        |                      |
        |<====================>|                      |
        |                      |                      |
        |          Distribute Push Resource           |
        |-------------------------------------------->|
        |                      |                      |
        :                      :                      :
        |                      |     Push Message     |
        |    Push Message      |<---------------------|
        |<---------------------|                      |
        |                      |                      |

接下來,咱們就須要簡單的來看一下使用 Web Push 的基本原則。

Push 基本原則

  1. 首先,server 發送的 push msg 必須被加密,由於這防止了中間的 push service 去查看咱們的推送的信息。

  2. 經過 server 發送的 msg 須要設置一個失效時間,覺得 Web Push 真正可以做用的時間是當用戶打開瀏覽器的時候,若是用戶沒有打開瀏覽器,那麼 push service 會一直保存該信息直到該條 push msg 過時。

那麼若是咱們想讓用戶訂閱咱們的 push service 咱們首先須要獲得用戶是否進行提示的許可。固然,一開始咱們還須要判斷一下,該用戶是否已經受權,仍是拒絕,或者是還未處理。這裏,能夠參考上面提到的推送權限一節中的 initialiseState 函數方法。
這裏咱們主要研究一下具體的訂閱環節(假設用戶已經贊成推送)。基本格式爲:

navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    const subscribeOptions = {
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U'
      )
    };

    return registration.pushManager.subscribe(subscribeOptions);
    )}
      .then(function(subscription) {
        return subscription
      })

這裏有兩個參數 userVisibleOnlyapplicationServerKey。這兩個屬性值具體表明什麼意思呢?

userVisibleOnly

該屬性能夠算是強制屬性(你必須填,並且只能填 true)。由於,一開始 Notification 的設計是 能夠在用戶拒絕的狀況下繼續在後臺執行推送操做,這形成了另一種狀況,開發者能夠在用戶關閉的狀況下,經過 web push 獲取用戶的相關信息。因此,爲了安全性保證,咱們通常只能使用該屬性,而且只能爲 true(若是,不呢?瀏覽器就會報錯)。

applicationServerKey

前面說過它是一個 public key。用來加密 server 端 push 的信息。該 key 是一個 Uint8Array 對象,並且它 須要符合 VAPID 規範實際,因此咱們通常能夠叫作 application server keys 或者 VAPID keys,咱們的 server 其實有私鑰和公鑰兩把鑰匙,這裏和 TLS/SSL 協商機制相似,不過不會協商出 session key,直接經過 pub/pri key 進行信息加/解密。不過,它還有其餘的用處:

  • 對於信息

    • 進行加密/解密,加強安全性

  • 對於 push service

    • 保證惟一性,由於 subscribe 會將該 key 發送過去。在 push service 那邊,會根據該 key 針對每次發送生成獨一無二的 endpoint,而後根據該 endpoint 給某些指定用戶信息 push message。

整個流程圖爲:

server push

另外,該 key 還有一個更重要的用途是,當在後臺 server 須要進行 push message,向 push service 發送請求時,會有一個 Authorization 頭,該頭的內容時由 private key 進行加密的。而後,push service 接受到以後,會根據配套的 endpoint 的 public key 進行解密,若是解密成功則表示該條信息是有效信息(發送的 server 是合法的)。

流程圖爲:

servcer key

經過 subscribe() 異步調用返回的值 subscription 的具體格式爲:

{
  "endpoint": "https://some.pushservice.com/something-unique",
  "keys": {
    "p256dh": "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=",
    "auth":"FPssNDTKnInHVndSTdbKFw=="
  }
}

簡單說一下參數,endpoint 就是 push service 的 URL,咱們的 server 若是有消息須要推送,就是想該路由發送請求。而 keys 就是用來對信息加密的鑰匙。獲得返回的 subscription 以後,咱們須要發送給後臺 server 進行存儲。由於,每一個用戶的訂閱都會產生獨一無二的 endpoint,因此,咱們只須要將 endpoint 和關聯用戶存儲起來就 ok 了。

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

接下來就到了 server 推送 msg 的環節了。

服務器推送信息

當服務器有新的消息須要推送時,就須要向 push service 發送相關的請求進行 web push。不過,這裏咱們須要瞭解,從服務器到 push service的請求,實際上就是 HTTP 的 post method。咱們看一個具體的請求例子:

POST /push-service/send/dbDqU8xX10w:APA91b... HTTP/1.1  
Host: push.example.net  
Push-Receipt: https://push.example.net/r/3ZtI4YVNBnUUZhuoChl6omU  
TTL: 43200  
Content-Type: text/plain;charset=utf8  
Content-Length: 36  
Authorization: WebPush
eyJ0eXAiOiJKV1QiLCJErtm.ysazNjjvW2L9OkSSHzvoD1oA  
Crypto-Key:
p256ecdsa=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU\_RCPCfA5aq9ojSwk5Y2EmClBPsiChYuI3jMzt3ir20P8r\_jgRR-dSuN182x7iB

固然,變化的是裏面推送的具體的 Headers 和 body 內容。咱們能夠看一下具體頭部表明的意思:

頭部參考

Header Content
Authorization 能夠理解該頭是一個 JSON Web Token,用來驗證是不是真實的訂閱 server
Crypto-Key 用來表示加密的 key。它由兩部分組成:dh=publicKey,p256ecdsa=applicationServerKey。其中 p256ecdsa 就是由 pub key 加密的 base64 的 url
Encryption 它用來放置加鹽祕鑰。用來加密 payload
Content-Type 若是你沒發送 payload 的話,那麼就不用發送該頭。若是發送了,則須要將其設置爲 application/octet-stream。這是爲了告訴瀏覽器我發送的是 stream data
Content-Length 用來描述 payload 的長度(沒有 payload 的不用)
Content-Encoding 該頭必須一直是 aesgcm 不論你是否發送 payload
TTL (Time to Live) 表示該 message 能夠在 push service 上停留多長時間(爲何停留?由於用戶沒有打開指定瀏覽器,push service 發佈過去)。若是 TTL 爲 0,表示當有推送信息時,而且此時 push service 可以和用戶的瀏覽器創建聯繫,則第一時間發送過去。不然當即失效
Topic 該頭實際上和 Notification 中的 tag 頭相似。若是 server 前後發送了兩次擁有相同 Topic 的 message 請求,若是前一條 topic 正在 pending 狀態,則會被最新一條 topic 代替。不過,該 Topic 必須 <= 32 個字符
Urgency[實驗特性] 表示該消息的優先級,優先級高的 Notification 會優先發送。默認值爲: default。可取值爲: "very-low" "low" "normal" "high"

返回的響應碼

一般,push service 接受以後,會返回相關的狀態碼,來表示具體操做結果:

statusCode Description
201 表示推送消息在 push service 中已經成功建立
429 此時,push service 有太多的推送請求,沒法響應你的請求。而且,push service 會返回 Retry-After 的頭部,表示你下次重試的時間。
400 無效請求,表示你的請求中,有不符合規範的頭部
413 你的 payload 過大。最小的 payload 大小爲 4kb

發送過程

能夠從上面頭部看出,push service 須要的頭很複雜,若是咱們純原生手寫的話,估計很快就寫煩了。這裏推薦一下 github 裏面的庫,能夠直接根據 app server key 來生成咱們想要的請求頭。這裏,咱們打算細節的瞭解一下每一個頭部內容產生的相關協議。

applicationServerKey

首先,這個 key 是怎麼拿到的?須要申請嗎?
答案是:不須要。這個 key 只要你符合必定規範就 ok。不過一旦生成以後,不要輕易改動,由於後面你會一直用到它進行信息交流。規則簡單來講爲:

  • 它是 server 端生成 pub/pri keys 的公鑰

  • 它是能夠經過 crypto 加密庫,依照 P-256 曲線,生成`ECDSA` 簽名方式。

  • 該 key 須要是一個 8 位的非負整型數組(Unit8Array)

簡單 demo 爲:

function generateVAPIDKeys() {
  var curve = crypto.createECDH('prime256v1');
  curve.generateKeys();

  return {
    publicKey: curve.getPublicKey(),
    privateKey: curve.getPrivateKey(),
  };
}

// 也能夠直接根據 web-push 庫生成
const vapidKeys = webpush.generateVAPIDKeys();

具體頭部詳細信息以下:

頭部參考

Authorization

Authorization 頭部的值(上面也提到了)是一個 JSON web token(簡稱爲 JWT)。基本格式爲:

Authorization: WebPush <JWT Info>.<JWT Payload>.<Signature>

實際上,該頭涵蓋了不少信息(手寫很累的。。。)。因此,咱們這裏能夠利用現有的一些 github 庫,好比 jsonwebtoken。專門用來生成,JWT 的。咱們看一下它顯示的例子:

demo

簡單來講,上面 3 部分都是將對象經過 private key 加密生成的字符串。
info 表明:

{  
  "typ": "JWT",  
  "alg": "ES256"  
}

用來表示 JWT 的加密算法是啥。
Payload 表明:

{  
  "aud": "https://some-push-service.org",
  "exp": "1469618703",
  "sub": "mailto:example@web-push-book.org"  
}

其中

  • aud 表示,push service 是誰

  • exp(expire)表示過時時間,而且是以秒爲單位,最多隻能是一天。

  • sub 用來表示 push service 的聯繫方式。

Signature 表明:
它是用來驗證信息安全性的頭。它是前面兩個,JWT.info + '.' + JWT.payload 的字符串經過私有 key 加密的生成的結果。

Crypto-Key

這就是咱們公鑰的內容,簡單格式爲:

Crypto-Key: dh=<URL Safe Base64 Encoded String>, p256ecdsa=<URL Safe Base64 Public Application Server Key>

// 兩個參數分別表明:

dh=publicKey,p256ecdsa=applicationServerKey

Content-Type, Length & Encoding

這幾個頭是涉及 payload 傳輸時,須要用到的。基本格式爲:

Content-Length: <Number of Bytes in Encrypted Payload>
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'

其中,只有 Content-Length 是可變的,用來表示 payload 的長度。

TTL,Topic & Urgency

這幾個頭上面已經說清楚了,我這裏就不贅述了。

最後放一張關於 SW 的總結圖:

Service+Worker.svg-44kB

相關文章
相關標籤/搜索