1. 程式人生 > >漸進式web應用開發---Service Worker 與頁面通訊(七)

漸進式web應用開發---Service Worker 與頁面通訊(七)

閱讀目錄

  • 一:頁面視窗向 service worker 通訊
  • 二:service worker 向所有開啟的視窗頁面通訊
  • 三:service worker 向特定的視窗通訊
  • 四:學習 MessageChannel 訊息通道
  • 五:視窗之間的通訊
  • 六:從sync事件向頁面傳遞訊息
回到頂部

一:頁面視窗向 service worker 通訊

Service Worker 沒有直接操作頁面DOM的許可權。但是可以通過postMessage方法和web頁面進行通訊。讓頁面操作DOM。並且這種操作是雙向的。

頁面向service worker 傳送訊息,首先我們要獲取當前控制頁面的 service worker。可以使用 navigator.serviceWorker.controller 來獲取這個service worker. 之後我們就可以使用 service worker 中的 postMessage() 方法,該方法接收的第一個引數為訊息本身,該引數可以是任何值,可以是js物件,字串、物件、陣列、布林型等。

比如如下程式碼是 頁面向service worker 傳送了一條簡單物件的訊息:

navigator.serviceWorker.controller.postMessage({
  'userName': 'kongzhi',
  'age': 31,
  'sex': 'men',
  'marriage': 'single'
});

訊息一旦釋出,service worker 就可以通過監聽 message 事件來捕獲它。如下程式碼:

self.addEventListener("message", function(event) {
  console.log(event.data);
});

在程式碼演示之前,我們來看下我們專案中的目錄結構如下:

|----- service-worker-demo7
|  |--- node_modules        # 專案依賴的包
|  |--- public              # 存放靜態資原始檔
|  | |--- js
|  | | |--- main.js         # js 的入口檔案
|  | | |--- store.js        # indexedDB儲存
|  | | |--- myAccount.js    
|  | |--- styles
|  | |--- images
|  | |--- index.html        # html 檔案
|  |--- package.json
|  |--- webpack.config.js
|  |--- sw.js

如上就是我們目前的專案架構,這篇文章的專案架構是基於上篇文章的架構的基礎之上的,可以請移步檢視上一篇文章。

因此在入口檔案 main.js 程式碼新增如下程式碼:

// 頁面向 service worker 傳送一條訊息
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
  navigator.serviceWorker.controller.postMessage({
    'userName': 'kongzhi',
    'age': 31,
    'sex': 'men',
    'marriage': 'single'
  });
}

在我們的sw.js 裡面,我們監聽 message 訊息即可;新增如下程式碼所示:

self.addEventListener("message", function(event) {
  console.log(event.data);
  console.log(event);
});

注意:當我們第一次重新整理頁面註冊service worker的時候並沒有傳送訊息,那是因為第一次重新整理頁面的時候並沒有註冊service worker,只有註冊完成後,我們再重新整理頁面就可以列印訊息出來了。因此我們上面加了 if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {} 這個來判斷。
如上列印 console.log(event.data); 訊息如下所示:

當我們列印 console.log(event); 的時候;如下圖所示:

如上 列印 event 的時候,我們除了列印 event.data 可以獲取到訊息之外的資料,我們還可以拿到 event.source 裡面包含了傳送訊息的視窗的相關資訊。

視窗向service worker 通訊的具體用途如下:

比如說我們網站有很多很多頁面,是一個非常大型的網站,我們不可能對每個頁面進行快取,我們可以對使用者訪問的頁面來進行快取,那麼這個時候我們可以通過 postMessage() 方法向用戶傳送一條訊息,告訴使用者該頁面需要被快取了。

因此我們對某個頁面新增 js 程式碼如下:

navigator.serviceWorker.controller.postMessage("cache-current-page");

當用戶訪問該頁面的時候,會發送一條訊息到我們的 service worker 中,service worker 可以監聽這些訊息,並使用事件的 source 屬性,判斷需要快取那個頁面;具體判斷程式碼如下:

self.addEventListener('message', function(event) {
  if (event.data === "cache-current-page") {
    var sourceUrl = event.source.url;
    if (event.source.visibilityState === 'visible') {
      // 快取 sourceUrl 和相關的檔案
    } else {
      // 將sourceUrl和相關的檔案新增到佇列中。稍後快取
    }
  }
});

如上程式碼;在sw.js 中我們可以根據 sourceUrl 來 確定需要快取那個頁面,因為不同的頁面,他們的 sourceUrl 是不相同的。從那個頁面傳送訊息過來,那麼就對應那個頁面的url。並且程式碼裡面根據頁面的可見狀態來判斷對應請求快取哪個頁面。

回到頂部

二:service worker 向所有開啟的視窗頁面通訊

在service worker 內,我們可以使用 service worker 的全域性物件中的clients物件,獲取 service worker作用域內所有當前開啟的視窗。clients包含了一個 matchAll() 方法,我們可以使用這個方法獲取service worker 作用域內所有當前開啟的視窗。
matchAll() 返回一個promise物件。返回一個包含0個或多個 WindowClient 物件的陣列。

為了有多個頁面,因此我們需要在專案中的根目錄新增一個新頁面,比如叫 a.html. 因此目錄結構變成如下:

|----- service-worker-demo7
|  |--- node_modules        # 專案依賴的包
|  |--- public              # 存放靜態資原始檔
|  | |--- js
|  | | |--- main.js         # js 的入口檔案
|  | | |--- store.js        # indexedDB儲存
|  | | |--- myAccount.js    
|  | |--- styles
|  | |--- images
|  | |--- index.html        # html 檔案
|  |--- package.json
|  |--- webpack.config.js
|  |--- sw.js
|  |--- a.html

因此在 sw.js 程式碼中新增如下程式碼:

self.clients.matchAll().then(function(clients) {
  console.log(clients);
  clients.forEach(function(client) {
    console.log(client);
    if (client.url.includes('/a.html')) {
      // 首頁
      client.postMessage('hello world' + client.id);
    }
  });
});

然後我們在 main.js 程式碼下 新增如下程式碼:

if ("serviceWorker" in navigator && navigator.serviceWorker) {
  navigator.serviceWorker.addEventListener("message", function(event) {
    console.log(event.data);
  })
}

如上如果執行正常的話,就可以在控制檯中看到類似如下資訊:hello world7f71806e-7699-45f3-8d5b-50fdc67b34fc

注意:但是把我們的程式碼放到 servcie worker 頂部是不行的,如果把程式碼放在事件之外的話,它只會在 service worker 指令碼載入後,service worker 安裝前以及任何客戶端監聽之前,它只會執行一次。因此我們需要放到 install 事件中,比如我之前快取所有的頁面中install 事件中,放在如下程式碼中即可:

// 監聽 install 事件,把所有的資原始檔快取起來
self.addEventListener("install", function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll(CACHE_URLS);
    }).then(function(){
      return self.clients.matchAll({includeUncontrolled: true});
    }).then(function(clients){
      console.log(clients);
      clients.forEach(function(client) {
        client.postMessage('hello world' + client.id);
      });
    })
  )
});

如上程式碼,我們列印 console.log(clients); 可以看到如下資訊:

現在不管我們的頁面是線上也好還是離線也好,都會執行程式碼。我們可以在service worker 安裝並快取所有的資原始檔後,立即會向用戶傳送一條訊息。

回到頂部

三:service worker 向特定的視窗通訊

除了上面的 matchAll()方法之外,clients物件還有另一個方法。我們可以通過 get()方法獲取單個客戶端的物件。通過傳遞一個已知客戶端的ID給get()方法,我們就可以得到一個promise。當其完成的時候我們就會得到 WindowClient物件,之後我們就可以使用該物件,給客戶端傳送訊息。

比如我們之前的客戶端的ID為 "87f07759-2e9e-4ecd-a9b2-3c64f843b9c7";

那麼我們就可以使用該get()方法獲取該ID,然後會返回一個Promise物件,如下程式碼所示:

self.clients.get("87f07759-2e9e-4ecd-a9b2-3c64f843b9c7").then(function(client) {
  client.postMessage("hello world");
});

有以下兩種方式可以找到客戶端的id, 第一種方式是使用 clients.matchAll()迭代所有的開啟的客戶端,通過WindowClient物件的id屬性獲取。如下程式碼所示:

self.clients.matchAll().then(function(clients) {
  clients.forEach(function(client){
    self.clients.get(client.id).then(function(client) {
      client.postMessage("Messaging using clients.matchAll()");
    })
  }) 
});

第二種方法是通過postMessage事件的source屬性獲取,如下程式碼所示:

self.addEventListener("message", function(event) {
  self.clients.get(event.source.id).then(function(client) {
    client.postMessage("Messaging using clients.get(event.source.id)");
  });
});
回到頂部

四:學習 MessageChannel 訊息通道

我們前面的demo使用了 WindowClient 或 service worker 物件傳送訊息,並且只看到了 postMessage()只接收了第一個引數。
但是我們的postMessage方法可以接收第二個引數,我們可以使用該引數來保持雙方之間的通訊渠道開啟。可以來回傳送訊息。

那麼這種通訊 是通過 MessageChannel 物件處理的。我們可以通過建構函式 MessageChannel() 可以建立一個訊息通道,該實列會有2個屬性,分別為 port1 和 port2; 如下程式碼所示:

var msg = new MessageChannel();
console.log(msg);

列印資訊如下所示:

如上圖我們可以看到,該物件有 onmessage 和 onmessageerror 兩個屬性是兩個回撥方法。我們可以使用 MessagePort.postMessage 方法傳送訊息的時候,我們就可以通過另一個埠的 onmessage 來監聽該訊息。

也就是說訊息通道是有兩個口子,那麼這兩個口子分別是 port1 和 port2。這兩個口子可以相互發送訊息,port1口子傳送的訊息,我們可以在port2口子中接收到訊息。

比如如下程式碼:

var msg = new MessageChannel();
var p1 = msg.port1;
var p2 = msg.port2;

// 使用p1口子監聽訊息
p1.onmessage = function(msg) {
  console.log('接收到的訊息:' + msg.data);
}

// 使用p2口子傳送訊息
p2.postMessage("hello world");

列印資訊如下所示:

如上我們可以看到,MessageChannel物件有兩個口子,分別為 port1 和 port2; 我們在port2上使用 postMessage 傳送訊息,我們可以在 port1上監聽到該訊息。

現在我們把該 MessageChannel 訊息通道使用到我們的 service worker 當中來,當我們從視窗向service worker 通訊時(或者反正都可以),我們可以在視窗中建立一個新的 MessageChannel 物件,並且通過 postMessage 將其中一個口子傳遞給 serviceworker, 當訊息到達後,就可以在service worker 中訪問埠了。如下:

首先我們在我們的 main.js(入口檔案)新增如下程式碼:

var msgChan = new MessageChannel();
var p1 = msgChan.port1;

// 使用p1口子監聽訊息
p1.onmessage = function(msg) {
  console.log('接收到的訊息:' + msg.data);
}

var msg = {
  name: 'kongzhi',
  age: 31,
  value: 2
};

if ("serviceWorker" in navigator && navigator.serviceWorker) {
  navigator.serviceWorker.controller.postMessage(msg, [msgChan.port2]);
}

然後在我們的 service worker.js 中新增如下程式碼:

// service worker 程式碼
self.addEventListener("message", function(event) {
  var data = event.data;
  var port = event.ports[0];
  if (data.name === 'kongzhi') {
    port.postMessage(data.value * 2);
  }
});

然後在頁面上會列印如下資訊:

如上程式碼我們可以看到,我們在main.js 程式碼中建立了一個新的 MessageChannel, 並且在port1中的口子上添加了事件監聽器。如果收到任何訊息就會打印出來,然後我們就會使用 navigator.serviceWorker.controller.postMessage 程式碼向 service worker傳送一條訊息。同時將 MessageChannel 第二個口子傳遞過去,這邊使用了一個數組傳遞過去,以便我們在service worker中通過0或者多個埠進行通訊。

在service worker.js 中,我們監聽了message事件,當檢測到該事件的時候,我們使用 event.data 獲取到訊息的內容,和頁面的埠,並且檢測該訊息的 name 屬性 等於 'kongzhi' 這個字串的話,那麼我們就使用第二個口子 port2傳送一個訊息過去,那麼在main.js 中,我們使用第一個口子 port1 來監聽該訊息,然後就能接收到訊息來了,最後列印資訊了,如上所示。

如上demo我們演示了 使用 MessageChannel 來實現兩個口子(port1, port2) 之間通訊的問題。那麼現在我們使用 MessageChannel 如何在頁面和service worker 之間保持連續通訊通道開啟。

回到頂部

五:視窗之間的通訊

通過以上一些知識點,我們現在再來看看如何在不同的視窗之間進行通訊呢?現在我們可以通過使用上面的知識點來實現視窗之間傳送訊息。

比如我現在頁面上有一個登出操作,當我們使用者點選該操作時,該連結會把使用者返回到首頁,我們之前會在頁面上增加一個 a 連結按鈕,點選該登出按鈕的時候,我們會發送一個ajax請求,請求成功後,我們會跳轉到登入頁面去。

現在我們需要使用service worker 來做同樣的操作,唯一不同的是,假如我們的頁面 打開了多個index.html頁面,比如網址為:
http://localhost:8082/index.html 這樣的,多個標籤頁都打開了該頁面,如果我們點選登出按鈕後,所有開啟該頁面都會被同時退出到登入頁面去。也就是說,在支援service worker 的瀏覽器下,支援多個視窗同時退出。

首先我們需要在我們的 main.js 新增如下程式碼:

$(function(){
  if ("serviceWorker" in navigator && navigator.serviceWorker) {
    console.log(navigator.serviceWorker.controller);
    $('#logout').click(function(e) {
      e.preventDefault();
      navigator.serviceWorker.controller.postMessage({
        action: "logout"
      });
    });
    navigator.serviceWorker.addEventListener("message", function(event) {
      var data = event.data;
      if (data.action === "navigate") {
        window.location.href = data.url;
      }
    });
  }
});

如上程式碼,當我們點選 登出按鈕 id 為 logout 的時候,我們會使用 service worker中的postMessage中的方法:
navigator.serviceWorker.controller.postMessage 傳送一個訊息過去。然後我們sw.js 程式碼中會監聽該訊息,比如如下程式碼:

// service worker 程式碼
self.addEventListener("message", function(event) {
  var data = event.data;
  if (data.action === 'logout') {
    self.clients.matchAll().then(function(clients) {
      clients.forEach(function(client) {
        console.log(client.url);
        if (client.url.includes("http://localhost:8082/index.html")) {
          client.postMessage({
            action: "navigate",
            url: 'http://www.baidu.com'
          })
        }
      })
    });
  }
});

然後會獲取到 訊息內容 event.data; 然後會判斷該 action 是否等於 'logout' 這個字串,如果相等的話,監聽器就會獲取當前開啟的所有的 WindowClient, 逐個遍歷,並且檢查視窗是否包含 "http://localhost:8082/index.html", 如果包含的話,就向這個視窗傳送訊息,其中我們的鍵action包含了一個"navigate"字串,可以隨便取名字。

然後在我們的main.js 會有如下監聽事件程式碼,如下所示:

navigator.serviceWorker.addEventListener("message", function(event) {
  var data = event.data;
  if (data.action === "navigate") {
    window.location.href = data.url;
  }
});

如果監聽到該訊息,就重置向到 登入頁面去,我這邊直接使用 百度 首頁打比方。當然當我們點選登出按鈕的時候,我們需要傳送ajax請求,請求成功後,我們再使用如上的操作程式碼。如上程式碼,就可以使所有開啟該頁面,都會重置到登入頁面去。

回到頂部

六:從sync事件向頁面傳遞訊息

 該功能是建立在前一篇 使用後臺保證離線功能 的頁面基礎之上的,想看之前的頁面,請點選這裡。 之前我們點選該按鈕的時候,如下所示: 在頁面上我們點選新增這個按鈕的時候,我們會呼叫 如下程式碼:
var addStore = function(id, name, age) {
  var obj = {
    id: id,
    name: name,
    age: age
  };
  addToObjectStore("store", obj);
  renderHTMLFunc(obj);
  // 先判斷瀏覽器支付支援sync事件
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    navigator.serviceWorker.ready.then(function(registration) {
      registration.sync.register("sync-store").then(function() {
        console.log("後臺同步已觸發");
      }).catch(function(err){
        console.log('後臺同步觸發失敗', err);
      })
    });
  } else {
    $.getJSON("http://localhost:8082/public/json/index.json", obj, function(data) {
      updateDisplay(data);
    });
  }
};
$("#submit").click(function(e) {
  addStore(1, 'kongzhi111', '28');
});

然後會呼叫 registration.sync.register("sync-store") 註冊一個同步事件,然後會在我們的 sw.js 下會監聽該事件;
如下程式碼:

self.addEventListener("sync", function(event) {
  if (event.tag === "sync-store") {
    console.log('sync-store')
    event.waitUntil(syncStores());
  }
});

如上我們呼叫了 syncStores 這個函式,我們來看下該函式的程式碼如下:

var syncStores = function() {
  return getStore().then(function(reservations) {
    console.log(reservations);
    return Promise.all(
      reservations.map(function(reservation){
        var reservationUrl = createStoreUrl(reservation);
        return fetch(reservationUrl).then(function(response) {
          return response.json();
        }).then(function(newResponse) {
          return updateInObjectStore("store", 1, newResponse).then(function(){

          })
        })
      })
    )
  });
};

如上程式碼,我們可以看到在我們的 最後一句程式碼 return updateInObjectStore("store", 1, newResponse).then(function() { }) 中,最後呼叫了 updateInObjectStore 更新 indexedDB資料庫操作,但是我們如何把更新後的資料傳送給DOM操作呢?我們之前學習了 postMessage() 這個,使頁面能和service worker 進行通訊操作,我們把該技術運用起來。

因此我們需要把上面的sw.js 中的 syncStores 函式 程式碼改成如下所示的:

// 新增的程式碼:
var postStoreDetails = function(data) {
  self.clients.matchAll({ includeUncontrolled: true }).then(function(clients) {
    clients.forEach(function(client) {
      client.postMessage({
        action: 'update-store',
        data: data
      })
    });
  });
};
var syncStores = function() {
  return getStore().then(function(reservations) {
    console.log(reservations);
    return Promise.all(
      reservations.map(function(reservation){
        var reservationUrl = createStoreUrl(reservation);
        return fetch(reservationUrl).then(function(response) {
          return response.json();
        }).then(function(newResponse) {
          return updateInObjectStore("store", 1, newResponse).then(function() {
            // 新增的程式碼如下:
            postStoreDetails(newResponse);

          })
        })
      })
    )
  });
};

如上我們在 updateInObjectStore 中的回撥中添加了 postStoreDetails 這個函式程式碼,然後把新的物件傳遞給函式,該函式如上程式碼,會使用postMessage事件傳送訊息過去,然後我們需要在我們的 myAccount.js 中js操作頁面去使用 message 事件去監聽該訊息,程式碼如下所示:

function updateDisplay(d) {
  console.log(d);
};

if ("serviceWorker" in navigator && navigator.serviceWorker) {
  navigator.serviceWorker.addEventListener("message", function(event) {
    var data = event.data;
    if (data.action === 'update-store') {
      console.log('函式終於被呼叫了');
      updateDisplay(data);
    }
  });
}

最後我們點選下該按鈕,會列印如下資訊了;如下圖所示:

現在我們就可以拿到新增後或更新後的資料,在頁面DOM上進行操作資料了。

檢視github原始碼