Service Worker 學習筆記

Service Worker 學習筆記

Service Worker挺有意思的,前段時間看了相關的資料,本身動手調了調demo,記錄一下學習過程。文中不只會介紹Service Worker的使用,對fetchpushcache等Service Worker配套的API都會涉及,畢竟Service Worker與這些API配合使用才能發揮出真正的威力css

Chrome對Service Worker的開發者支持較好,Dev tools裏能夠簡單的調試,Firefox還未提供調試用的工具,可是對API的支持更好。建議開發和測試的話,在Chrome上進行html

文中有把Service Worker簡寫SW,不要以爲奇怪~前端

Service Worker

Service workers essentially act as proxy servers that sit between web applications, and the browser and network (when available). They are intended to (amongst other things) enable the creation of effective offline experiences, intercepting network requests and taking appropriate action based on whether the network is available and updated assets reside on the server. They will also allow access to push notifications and background sync APIs.node

Lifecycle

一個ServiceWorker從被加載到生效,有這麼幾個生命週期:git

  1. Installing 這個階段能夠監聽install事件,並使用event.waitUtil來作Install完成前的準備,好比cache一些數據之類的,另外還有self.skipWaiting在serviceworker被跳過install過程時觸發github

    > for example by creating a cache using the built in storage API, and placing assets inside it that you'll want for running your app offline.
  2. Installed 加載完成,等待被激活,也就是新的serverworker替換舊的web

  3. Activating 也可使用event.waitUtil事件,和self.clients.clainmajax

    > If there is an **existing** service worker available, the new version is installed in the background, but not yet **activated** — at this point it is called the worker in waiting. **It is only activated when there are no longer any pages loaded that are still using the old service worker**. As soon as there are no more such pages still loaded, the new service worker activates (becoming the active worker).
    
    **這說明serviceWorker被替換是有條件的,即便有新的serviceworker,也得等舊的沒有被使用才能替換**。最明顯的體現是,刷新頁面並不必定能加載到新聞serviceworker
  4. Activated 文章上的解釋是the service worker can now handle functional eventschrome

  5. Redundant 被替換,即被銷燬npm

Fetch

fetch是新的Ajax標準接口,已經有不少瀏覽器原生支持了,用來代替繁瑣的XMLHttpRequestjQuery.ajax再好不過了。對於還未支持的瀏覽器,能夠用isomorphic-fetch polyfill。

fetch的API很簡潔,這篇文檔講的很清晰。下面記錄一下以前被我忽略的2個API

Response

Response 寫個fetch的栗子

fetch('/style.css')
        // 這裏的response,就是一個Response實例
    .then(response => response.text())
    .then(text => {
        console.log(text);
    });

Response的API,列幾個比較經常使用的:

  • Response.clone() Creates a clone of a Response object. 這個常常用在cache直接緩存返回結果的場景

  • Body.blob() 這裏寫的是Body,其實調用接口仍是用response,這裏取Blob數據的數據流。MDN是這麼說的:

    > Response implements Body, so it also has the following methods available to it:
  • Body.json()

  • Body.text()

  • Body.formData() Takes a Response stream and reads it to completion. It returns a promise that resolves with a FormData object.

Request

Request應該不會單獨new出來使用,由於不少Request相關的參數,在Request的實例中都是只讀的,而真正能夠配置Request屬性的地方,是fetch的第二個參數:

// fetch的第一個參數是URI路徑,第二個參數則是生成Request的配置,
// 而若是直接傳給fetch一個request對象,其實只有URI是可配置的,
// 由於其餘的配置如headers都是readonly,不能直接從Request處配置
let request = new Request('./style.css');

request.method = 'POST'; // Uncaught TypeError: Cannot set property method of #<Request> which has only a getter

fetch(request).then(response => response.text())
    .then(text => {
        console.log(text);
    });

Cache

Cache是Service Worker衍生出來的API,配合Service Worker實現對資源請求的緩存。

有意思的是cache並不直接緩存字符串(想一想localstorage),而是直接緩存資源請求(css、js、html等)。cache也是key-value形式,通常來講key就是request,value就是response

API

  • caches.open(cacheName) 打開一個cache,cachesglobal對象,返回一個帶有cache返回值的Promise

  • cache.keys() 遍歷cache中全部鍵,獲得value的集合

    caches.open('v1').then(cache => {
        // responses爲value的數組
        cache.keys().then(responses => {
            responses.forEach((res, index) => {
                console.log(res);
            });
        });
    });
  • cache.match(Request|url) 在cache中匹配傳入的request,返回Promisecache.matchAll只有第一個參數與match不一樣,須要一個request的數組,固然返回的結果也是response的數組

  • cache.add(Request|url) 並非單純的add,由於傳入的是request或者url,在cache.add內部會自動去調用fetch取回request的請求結果,而後纔是把response存入cache;cache.addAll相似,一般在sw install的時候用cache.addAll把全部須要緩存的文件都請求一遍

  • cache.put(Request, Response) 這個至關於cache.add的第二步,即fetch到response後存入cache

  • cache.delete(Request|url) 刪除緩存

Tips

Note: Cache.put, Cache.add, and Cache.addAll only allow GET requests to be stored in the cache.

As of Chrome 46, the Cache API will only store requests from secure origins, meaning those served over HTTPS.

Service Worker通訊

Service Worker是worker的一種,跟Web Worker同樣,不在瀏覽器的主線程裏運行,於是和Web Worker同樣,有跟主線程通訊的能力。

postMessage

window.postMessage(message, target[, transfer])這個API以前也用過,在iframe之間通訊(onmessage接收信息)。簡單記下參數:

  • message 能夠是字符串,或者是JSON序列化後的字符串,在接收端保存在event.data

  • target 須要傳輸的URL域,具體看API文檔

  • transfer 用mdn的說法,是一個transferable的對象,好比MessagePortArrayBuffer

另外說明一點,postMessage的調用者是被push數據一方的引用,即我要向sw post數據,就須要sw的引用

注意,上面的postMessage是在document中使用的。在sw的context裏使用略有不一樣:沒有target參數具體看這個API文檔

在sw中與主線程通訊

先看個栗子:

// main thread
if (serviceWorker) {
    // 建立信道
        var channel = new MessageChannel();
        // port1留給本身
        channel.port1.onmessage = e => {
            console.log('main thread receive message...');
            console.log(e);
        }

    // port2給對方
        serviceWorker.postMessage('hello world!', [channel.port2]);
        serviceWorker.addEventListener('statechange', function (e) {
            // logState(e.target.state);
        });
    }
    
// sw
self.addEventListener('message', ev => {
    console.log('sw receive message..');
    console.log(ev);
    // 取main thread傳來的port2
    ev.ports[0].postMessage('Hi, hello too');
});

在sw裏須要傳遞MessagePort,這個是由MessageChannel生成的通訊的兩端,在己方的一端爲channel.port1,使用channel.port1.onmessage便可監遵從另外一端返回的信息。而須要在postMessage裏傳的是channel.port2,給另外一端postMessage使用。在sw端經過監聽message事件就能夠監聽到主線程的postMessage,在messageevent.ports[0]裏便可找到主線程傳過來的port,以後就能夠用event.ports[0].postMessage來向主線程發送信息了。

MessageChannel

這裏用到了MessageChannel。這是一個很簡單的APi,完成在兩個不一樣的cotext中通訊的功能。

在上面已經提到了,MessageChannel在一端建立,而後用channel.port1.onmesssage監聽另外一端post的message,而將channel.port2經過postMessage的第二個參數(transfer)傳給另外一端,讓另外一端也能用MessagePort作一樣的操做。

須要注意的是channel的port1和port2的區別:port1是new MessageChannel的一方須要使用的,port2是另外一方使用的

Push API

若是說fetch事件是sw攔截客戶端請求的能力,那麼push事件就是sw攔截服務端「請求」的能力。這裏的「請求」打了引號,你能夠把Push當成WebSocket,也就是服務端能夠主動推送消息到客戶端。

與WebSocket不一樣的是,服務端的消息在到達客戶端以前會被sw攔截,要不要給瀏覽器,給什麼,能夠在sw裏控制,這就是Push API的做用。

push-api-demo

MDN上有個push-api-demo,是個簡易聊天器。具體搭建的方法在這個repo上有,再也不贅述。由於有些Push API只有Firefox Nightly版本支持,因此demo也只能跑在這個瀏覽器上,我還沒下好,沒跑起來,等明天看吧~

記幾個Push API:

  • ServiceWorkerRegistration.showNotification(title, options) 這個能夠理解成alert的升級版,網頁版的wechat的通知就是這個。

  • Notification.requestPermission() 提示用戶是否容許瀏覽器通知

  • PushManager Push API的核心對象,註冊Push API從這裏開始,放在 ServiceWorkerRegistration

    • PushManager.subscribe 返回一個帶有PushSubscription的Promise,經過PushSubscription對象才能生成公鑰(PushSubscription.getKey(),這個方法只有firefox有,這也是chrome不能執行的緣由),獲取endpoint

    • PushManager.getSubscription() 獲取當前註冊好的PushSubscription對象

  • atob()btob() 意外撿到兩個API,用於瀏覽器編碼、解碼base64

仍是看個栗子:

// 瀏覽器端的main.js, 代碼來自push-api-demo
navigator.serviceWorker.ready.then(function(reg) {
    // 註冊push
        reg.pushManager.subscribe({userVisibleOnly: true})
           // 獲得PushSubscription對象
          .then(function(subscription) {
            // The subscription was successful
            isPushEnabled = true;
            subBtn.textContent = 'Unsubscribe from Push Messaging';
            subBtn.disabled = false;
            
            // Update status to subscribe current user on server, and to let
            // other users know this user has subscribed
            var endpoint = subscription.endpoint;
            // 生成公鑰
            var key = subscription.getKey('p256dh');
            // 這一步是個ajax,把公鑰和endpoint傳給server,由於是https因此不怕公鑰泄露
            updateStatus(endpoint,key,'subscribe');
          })
});
    
// 服務端 server.js,接收並存下公鑰、endpoint
...
} else if(obj.statusType === 'subscribe') {
// bodyArray裏是ajax傳上來的key和endpoint
    fs.appendFile('endpoint.txt', bodyArray + '\n', function (err) {
      if (err) throw err;
      fs.readFile("endpoint.txt", function (err, buffer) {
        var string = buffer.toString();
        var array = string.split('\n');
        for(i = 0; i < (array.length-1); i++) {
          var subscriber = array[i].split(',');
          webPush.sendNotification(subscriber[2], 200, obj.key, JSON.stringify({
            action: 'subscribe',
            name: subscriber[1]
          }));
        };
      });
    });
  }
  ...
  
  // 仍是服務端 server.js,推送信息到service worker
  if(obj.statusType === 'chatMsg') {
      // 取出客戶端傳來的公鑰和endpoint
    fs.readFile("endpoint.txt", function (err, buffer) {
      var string = buffer.toString();
      var array = string.split('\n');
      for(i = 0; i < (array.length-1); i++) {
        var subscriber = array[i].split(',');
     // 這裏用了web-push這個node的庫,sendNotification裏有key,說明對信息加密了
        webPush.sendNotification(subscriber[2], 200, obj.key, JSON.stringify({
          action: 'chatMsg',
          name: obj.name,
          msg: obj.msg
        }));
      };
    });
  }

Client端

  1. 進入頁面後先註冊ServiceWorker,而後subscribe PushManager,把公鑰endpoint傳給Server端(ajax)保存下來,便於以後的通訊(都是加密的)

  2. 而後建立一個MessageChannelServiceWorker通訊

準備工做到這裏就作完了。Client與Server端的通訊仍是ajax,聊天室嘛就是傳用戶發送的消息。ServiceWorker去監聽push事件接住Server端push來的數據,在這個demo裏都是Server端接到Client的ajax請求的響應,固然也能夠又Server端主動發起一個push。當同時有兩個以上的Client都與這個Server通訊,那麼這幾個Client能看到全部與Server的消息,這纔是聊天室嘛,不過要驗證至少須要兩臺機器

Server端

一個HTTPS服務,加了Web-Push這個module,這裏面確定有用公鑰和endpoint給push信息加密的功能。webPush.sendNotification這個API能把Server端的push消息廣播到全部的Client端

Web-push這個庫還得看看

MDN Demo:sw-test

MDN上有一個完整的使用Service Worker的Demo,一個簡易的聊天室,能夠本身玩玩兒。

這個demo的思路是:installfetch須要緩存的文件,用cache.addAll緩存到cacheStorage裏。在fetch事件觸發時,先cache.match這些緩存,若存在則直接返回,若不存在則用fetch抓這個request,而後在cache.put進緩存。

調試ServiceWorker

Dev tools

Chrome has chrome://inspect/#service-workers, which shows current service worker activity and storage on a device, and chrome://serviceworker-internals, which shows more detail and allows you to start/stop/debug the worker process. In the future they will have throttling/offline modes to simulate bad or non-existent connections, which will be a really good thing.

最新的Chrome版本,Dev toolsResource選項卡里已經添加了Service Workers,能夠查看當前頁面是否有使用Service Worker,和它當前的生命週期

卸載上一個activated的service worker的方法

service worker很頑強,一個新的service worker install以後不能直接active,須要等到全部使用這個service worker的頁面都卸載以後能替換,不利於調試。今天試出來一個100%能卸載的方法

  1. chrome://inspect/#service-workers中terminate相應的service worker

  2. chrome://serviceworker-internals/中unregister相應的service worker

  3. 關閉調試頁面,再打開

調試service worker能夠在chrome://inspect/#service-workers裏inspect相應的Devtool

Tricks

  • 若是在緩存中找不到對應的資源,把攔截的請求發回原來的流程

    If a match wasn’t found in the cache, you could tell the browser to simply fetch the default network request for that resource, to get the new resource from the network if it is available:

    fetch(event.request)

  • 複製response的返回結果,下次直接從cache裏取出來用

    this.addEventListener('fetch', function(event) {
         event.respondWith(
           caches.match(event.request).catch(function() {
             return fetch(event.request).then(function(response) {
               return caches.open('v1').then(function(cache) {
                 cache.put(event.request, response.clone());
                 return response;
               });  
             });
           })
         );
  • cache未命中且網絡不可用的狀況,這裏Promise用了兩次catch,第一次還報錯的話第二次catch纔會執行

    this.addEventListener('fetch', function(event) {
         event.respondWith(
           caches.match(event.request).catch(function() {
             return fetch(event.request).then(function(response) {
               return caches.open('v1').then(function(cache) {
                 cache.put(event.request, response.clone());
                 return response;
               });  
             });
           }).catch(function() {
             return caches.match('/sw-test/gallery/myLittleVader.jpg');
           })
         );
  • activated以前清除不須要的緩存

    this.addEventListener('activate', function(event) {
      var cacheWhitelist = ['v2'];
        
      event.waitUntil(
        caches.keys().then(function(keyList) {
          return Promise.all(keyList.map(function(key) {
            if (cacheWhitelist.indexOf(key) === -1) {
              return caches.delete(key);
            }
          }));
        })
      );
    });
  • 僞造Response

    // service-worker.js
    self.addEventListener('fetch', ev => {
        var reqUrl = ev.request.url;
        console.log('hijack request: ' + reqUrl);
        console.log(ev.request);
        
        // 如果text.css的請求被攔截,返回僞造信息
        if (reqUrl.indexOf('test.css') > -1) {
            console.log('hijack text.css');
            ev.respondWith(
                new Response('hahah', {
                    headers: {'Content-Type': 'text/css'}
                })
            );
        }
        // 繼續請求
        else {
            ev.respondWith(fetch(ev.request));
        }
    });
    // app.js
    window.onload = () => {
        // 請求test.css
        fetch('/service-worker-demo/test.css')
        .then(response => {
            return response.text();
        })
        .then(text => {
            console.log('text.css: ' + text); // 在service worker install時返回真實的文本,在sw active時返回hahah,即僞造的文本
            return text;
        });
## 未解之謎

1. `serviceworker.register(url, { scope: 'xxx' })`,這裏的`scope`彷佛沒用。在這個scope上級的靜態資源請求也會被`fetch`攔截,在`HTTPS`上也無效,能夠看看[這個demo](https://ydss.github.io/service-worker-demo/)



## Reference

- [Using Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers)
- [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
- [Using the Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API/Using_the_Push_API)
- [PushManager](https://developer.mozilla.org/en-US/docs/Web/API/PushManager)
- [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API)
- [Service Worker MDN demo](https://github.com/mdn/sw-test/)
- [當前端也擁有 Server 的能力](http://www.barretlee.com/blog/2016/02/16/when-fe-has-the-power-of-server)
相關文章
相關標籤/搜索