Service Worker

Service Worker

隨着前端快速發展,應用的性能已經變得相當重要,關於這一點大佬作了不少統計。你能夠去看看javascript

如何下降一個頁面的網絡請求成本從而縮短頁面加載資源的時間並下降用戶可感知的延時是很是重要的一部分。對於提高應用的加載速度經常使用的手段有Http Cache、異步加載、304緩存、文件壓縮、CDN、CSS Sprite、開啓GZIP等等。這些手段無非是在作一件事情,就是讓資源更快速的下載到瀏覽器端。可是除了這些方法,其實還有更增強大的Service Worker線程。css

Service Worker與PWA的現狀

提及service worker就不得不提起PWA了,service worker作爲PWA的核心技術之一,多年來一直被Google大力推廣,這裏簡單介紹一下。html

通俗來講,PWA就是漸進式web應用(Progressive Web App)。早在16年初,Google便提出PWA,但願提供更強大的web體驗,引導開發者迴歸開放互聯網。它彌補了web對比Native App急缺的幾個能力,好比離線使用、後臺加載、添加到主屏和消息推送等,同時它還具有了小程序標榜的「無需安裝、用完即走」的特性。前端

雖然PWA技術已經被W3C列爲標準,可是其落地狀況一直以來是很讓人失望的,始終受到蘋果的阻礙,最重要的緣由在於PWA繞過Apple Store審覈,直接推給用戶。若是普及,這將威脅到蘋果的平臺權威,也就意味着蘋果與開發者的三七分紅生意將會落空。java

因此一直以來safrai不支持mainfest以及service worker這兩項關鍵技術,即便在18年開始支持了,可是對PWA的支持力度也遠遠低於安卓,具體體如今service worker緩存沒法永久保存,以及service worker的API支持不夠完善,一個最明顯的不一樣在於安卓版本的PWA會保留你的登陸狀態,而且會系統級推送消息。而在蘋果上,這兩點都作不到。也就是說,iPhone上的微博PWA,每次打開都要從新登陸,並且不會收到任何推送信息。webpack

另外因爲某些不可描述的緣由,在國內沒法使用Service Worker的推送功能,雖然國內已經有兩家公司作了service worker的瀏覽器推送,可是成熟度還有待調研。
因爲目前各版本手機瀏覽器對service worker的支持度都不太相同,同一個接口也存在差別化還有待統一,之於咱們來講,也只能用Service Worker作一作PC瀏覽器的緩存了。git

Service Worker的由來

Service Worker(如下簡稱sw)是基於WEB Worker而來的。程序員

衆所周知,javaScript 是單線程的,隨着web業務的複雜化,開發者逐漸在js中作了許多耗費資源的運算過程,這使得單線程的弊端更加凹顯。web worker正是基於此被創造出來,它是脫離在主線程以外的,咱們能夠將複雜耗費時間的事情交給web worker來作。可是web worker做爲一個獨立的線程,他的功能應當不只於此。sw即是在web worker的基礎上增長了離線緩存的能力。固然在 Service Worker 以前也有在 HTML5 上作離線緩存的 API 叫 AppCache, 可是 AppCache 存在不少缺點,你能夠親自看看github

sw是由事件驅動的,具備生命週期,能夠攔截處理頁面的全部網絡請求(fetch),能夠訪問cache和indexDB,支持推送,而且可讓開發者本身控制管理緩存的內容以及版本,爲離線弱網環境下的 web 的運行提供了可能,讓 web 在體驗上更加貼近 native。換句話說他能夠把你應用裏的全部靜態動態資源根據不一樣策略緩存起來,在你下次打開時再也不須要去服務器請求,這樣一來就減小了網絡耗時,使得web應用能夠秒開,而且在離線環境下也變得可用。作到這一切你只須要增長一個sw文件,不會對原有的代碼產生任何侵入,是否是很perfect?web

Service Worker基本特徵

  • 沒法操做DOM

  • 只能使用HTTPS以及localhost

  • 能夠攔截全站請求從而控制你的應用
  • 與主線程獨立不會被阻塞(不要再應用加載時註冊sw)
  • 徹底異步,沒法使用XHR和localStorage
  • 一旦被 install,就永遠存在,除非被 uninstall或者dev模式手動刪除
  • 獨立上下文
  • 響應推送
  • 後臺同步
    。。。

service worker是事件驅動的worker,生命週期與頁面無關。 關聯頁面未關閉時,它也能夠退出,沒有關聯頁面時,它也能夠啓動。

Dedicated Worker以及Shared Worker與Service Worker三者很是重要的區別在於不一樣的生命週期。對於Service Worker來講文檔無關的生命週期,是它能提供可靠Web服務的一個重要基礎。

Service Worker生命週期

 

service worker生命週期

 

  • register 這個是由 client 端發起,註冊一個 serviceWorker,這須要一個專門處理sw邏輯的文件
  • Parsed 註冊完成,解析成功,還沒有安裝
  • installing 註冊中,此時 sw 中會觸發 install 事件, 需知 sw 中都是事件觸發的方式進行的邏輯調用,若是事件裏有 event.waitUntil() 則會等待傳入的 Promise 完成纔會成功
  • installed(waiting) 註冊完成,可是頁面被舊的 Service Worker 腳本控制, 因此當前的腳本還沒有激活處於等待中,能夠經過 self.skipWaiting() 跳過等待。
  • activating 安裝後要等待激活,也就是 activated 事件,只要 register 成功後就會觸發 install ,但不會當即觸發 activated,若是事件裏有 event.waitUntil() 則會等待這個 Promise 完成纔會成功,這時能夠調用 Clients.claim() 接管全部頁面。
  • activated 在 activated 以後就能夠開始對 client 的請求進行攔截處理,sw 發起請求用的是 fetch api,XHR沒法使用
  • fetch 激活之後開始對網頁中發起的請求進行攔截處理
    terminate 這一步是瀏覽器自身的判斷處理,當 sw 長時間不用以後,處於閒置狀態,瀏覽器會把該 sw 暫停,直到再次使用
  • update 瀏覽器會自動檢測 sw 文件的更新,當有更新時會下載並 install,但頁面中仍是老的 sw 在控制,只有當用戶新開窗口後新的 sw 才能激活控制頁面
  • redundant 安裝失敗, 或者激活失敗, 或者被新的 Service Worker 替代掉

Service Worker 腳本最經常使用的功能是截獲請求和緩存資源文件, 這些行爲能夠綁定在下面這些事件上:

  • install 事件中, 抓取資源進行緩存
  • activate 事件中, 遍歷緩存, 清除過時的資源
  • fetch 事件中, 攔截請求, 查詢緩存或者網絡, 返回請求的資源

Service Worker實踐

在這以前你能夠先看看Google的demo

咱們先從sw的註冊開始,官方給的demo裏的註冊是這樣的:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('service-worker.js');
}

可是這樣作會有一些問題,頁面在首次打開的時候就進行緩存sw的資源,由於sw內預緩存資源是須要下載的,sw線程一旦在首次打開時下載資源,將會佔用主線程的帶寬,以及加重對cpu和內存的使用,並且Service worker 啓動以前,它必須先向瀏覽器 UI 線程申請分派一個線程,再回到 IO 線程繼續執行 service worker 線程的啓動流程,而且在隨後屢次在ui線程和io線程之間切換,因此在啓動過程當中會存在必定的性能開銷,在手機端尤爲嚴重。

何況首次打開各類資源都很是寶貴,徹底沒有必要爭第一次打開頁面就要緩存資源。正確的作法是,頁面加載完之後sw的事。

正確的姿式:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js');
  });
}

可是僅僅是這樣就夠了嗎?只有註冊,那麼發生問題的時候怎麼註銷sw呢?註銷之後緩存如何處理?這些是要提早考慮好的

另外使用 sw 進行註冊時,還有一個很重要的特性,即,sw的做用域不一樣,監聽的 fetch 請求也是不同的。假設你的sw文件放在根目錄下位於/sw/sw.js路徑的話,那麼你的sw就只能監聽/sw/*下面的請求,若是想要監聽全部請求有兩個辦法,一個是將sw.js放在根目錄下,或者是在註冊是時候設置scope。

一個考慮了出錯降級的簡易註冊demo:

window.addEventListener('load', function() {
    const sw = window.navigator.serviceWorker
    const killSW = window.killSW || false
    if (!sw) {
        return
    }

    if (!!killSW) {
        sw.getRegistration('/serviceWorker').then(registration => {
            // 手動註銷
            registration.unregister();
            // 清除緩存
            window.caches && caches.keys && caches.keys().then(function(keys) {
                keys.forEach(function(key) {
                 caches.delete(key);
                });
            });
        })
    } else {
        // 表示該 sw 監聽的是根域名下的請求
        sw.register('/serviceWorker.js',{scope: '/'}).then(registration => {
            // 註冊成功後會進入回調
            console.log('Registered events at scope: ', registration.scope);
        }).catch(err => {
            console.error(err)
        })
    }
  });

下面部分是sw.js文件中要作的事情,在上面註冊的步驟成功之後咱們首先要在sw.js文件中監聽註冊成功之後拋出的install事件。

self.addEventListener('install', function(e) {
  // ...
})

一般來講,當咱們監聽到這個事件的時候要作的事情就是緩存全部靜態文件

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('cache-v1').then(function(cache) {
      return cache.addAll([
        '/',
        "index.html",
        "main.css",
      ]);
    })
  );
})

這裏首先執行了一個event.waitUntil函數,該函數是service worker標準提供的函數,接收一個promise參數,而且監聽函數內全部的promise,只要有一個promise的結果是reject,那麼此次安裝就會失敗。好比說cache.addAll 時,有一個資源下載不回來,即視爲整個安裝失敗,那麼後面的操做都不會執行,只能等待sw下一次從新註冊。另外waitUntil還有一個重要的特性,那就是延長事件生命週期的時間,因爲瀏覽器會隨時睡眠 sw,因此爲了防止執行中斷就須要使用 event.waitUntil 進行捕獲,當全部加載都成功時,那麼 sw 就能夠下一步。

另外這裏的緩存文件的列表一般來講咱們應當使用webpack的插件或者其餘工具在構建的時候自動生成。緩存的版本號也應當獨立出來修改,這裏咱們將每一次的構建視做一個新的版本。

安裝成功後就會等待進入activate階段,這裏要注意的是,並非install一旦成功就會當即拋出activate事件,若是當前頁面已經存在service worker進程,那麼就須要等待頁面下一次被打開時新的sw纔會被激活,或者使用 self.skipWaiting() 跳過等待。

const cacheStorageKey = 'testCache1';
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return cacheNames.filter(cacheName => cacheStorageKey !== cacheName);
    }).then(cachesToDelete => {
      return Promise.all(cachesToDelete.map(cacheToDelete => {
        return caches.delete(cacheToDelete);
      }));
    }).then(() => {
      // 當即接管全部頁面
      self.clients.claim()
    })
  );
});

在activate中一般咱們要檢查並刪除舊緩存,若是事件裏有 event.waitUntil() 則會等待這個 Promise 完成纔會成功。這時能夠調用 Clients.claim() 接管全部頁面,注意這會致使新版的sw接管舊版本頁面。

當激活完畢後就能夠在fetch事件中對站點做用範圍下的全部請求進行攔截處理了,你能夠在這個階段靈活的使用indexDB以及caches等api制定你的緩存規則。

// 發起請求時去根據uri去匹配緩存,沒法命中緩存則發起請求,而且緩存請求
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(resp) {
      return resp || fetch(event.request).then(function(response) {
        return caches.open('v1').then(function(cache) {
          cache.put(event.request, response.clone());
          return response;
        });  
      });
    })
  );
});

event.respondWith: 接收的是一個 promise 參數,把其結果返回到受控制的 client 中,內容能夠是任何自定義的響應生成代碼。

另外這裏有一些問題:

  • 默認發起的fetch好像不會攜帶cookie,須要設置{ credential: 'include' }
  • 對於跨域的資源,須要設置 { mode: 'cors' } ,不然 response 中拿不到對應的數據
  • 對於緩存請求時,Request & Response 中的 body 只能被讀取一次,由於請求和響應流只能被讀取一次,其中包含 bodyUsed 屬性,當使用事後,這個屬性值就會變爲 true, 不能再次讀取,解決方法是,把 Request & Response clone 下來: request.clone() || response.clone()

固然這只是一個demo,實際狀況是不可能像這樣緩存全部請求的。若是你使用工具來實現sw的話,好比sw-toolbox,一般有以下幾種緩存策略:

  • networkFirst:首先嚐試經過網絡來處理請求,若是成功就將響應存儲在緩存中,不然返回緩存中的資源來回應請求。它適用於如下類型的API請求,即你老是但願返回的數據是最新的,可是若是沒法獲取最新數據,則返回一個可用的舊數據。
  • cacheFirst:若是緩存中存在與網絡請求相匹配的資源,則返回相應資源,不然嘗試從網絡獲取資源。 同時,若是網絡請求成功則更新緩存。此選項適用於那些不常發生變化的資源,或者有其它更新機制的資源。
  • fastest:從緩存和網絡並行請求資源,並以首先返回的數據做爲響應,一般這意味着緩存版本則優先響應。一方面,這個策略總會產生網絡請求,即便資源已經被緩存了。另外一方面,當網絡請求完成時,現有緩存將被更新,從而使得下次讀取的緩存將是最新的。
  • cacheOnly:從緩存中解析請求,若是沒有對應緩存則請求失敗。此選項適用於須要保證不會發出網絡請求的狀況,例如在移動設備上節省電量。
  • networkOnly:嘗試從網絡獲取網址來處理請求。若是獲取資源失敗,則請求失敗,這基本上與不使用service worker的效果相同。

或者根據不一樣的請求類型或者文件類型給予不一樣的策略亦或者更加複雜的策略:

self.addEventListener('fetch', function (event) {
    var request = event.request;

    // 非 GET 請求
    if (request.method !== 'GET') {
        event.respondWith(
        ... 
        );
        return;
    }


    // HTML 頁面請求
    if (request.headers.get('Accept').indexOf('text/html') !== -1) {
        event.respondWith(
        ...
        );
        return;
    }


    // get 接口請求
    if (request.headers.get('Accept').indexOf('application/json') !== -1) {
        event.respondWith(
        ...
        );
        return;
    }

    // GET 請求 且 非頁面請求時 且 非 get 接口請求(通常請求靜態資源)
    event.respondWith(
        ...
    );
}

Service Worker的更新

用戶首次訪問sw控制的網站或頁面時,sw會馬上被下載。

以後至少每24小時它會被下載一次。它可能被更頻繁地下載,不過每24小時必定會被下載一次,以免不良腳本長時間生效,這個是瀏覽器本身的行爲。

瀏覽器會將每一次下載回來的sw與現有的sw進行逐字節的對比,一旦發現不一樣就會進行安裝。可是此時已經處於激活狀態的舊的 sw還在運行,新的 sw 完成安裝後會進入 waiting 狀態。直到全部已打開的頁面都關閉,舊的sw自動中止,新的sw纔會在接下來從新打開的頁面裏生效。

在 SW 中的更新能夠分爲兩種,基本靜態資源的更新和SW.js 文件自身的更新。可是無論是哪一種更新,你都必需要對sw文件進行改動,也就是說要從新安裝一個新的sw。

首先假設一種狀況,站點現有的sw緩存使用v1來進行命名,即在install的時候,咱們使用caches.open('v1')來進行預緩存,這時候舊的資源會所有存在caches裏的v1下。

self.addEventListener('install', function(e) {
  e.waitUntil(
    caches.open('v1').then(function(cache) {
      return cache.addAll([
       "index.html"
      ])
    })
  )
})

如今站點更新了,咱們能夠簡單的把chache裏的v1更名爲v2,這個時候因爲咱們修改了sw文件,瀏覽器會自發的更新sw.js文件並觸發install事件去下載最新的文件(更新緩存能夠發生在任何地方),這時新的站點會存在於v2緩存下,待到新的sw被激活以後,就會啓用v2緩存。

這是一種很簡單而且安全的方式,至關於舊版本的天然淘汰,但畢竟關閉全部頁面是用戶的選擇而不是程序員能控制的。另外咱們還需注意一點:因爲瀏覽器的內部實現原理,當頁面切換或者自身刷新時,瀏覽器是等到新的頁面完成渲染以後再銷燬舊的頁面。這表示新舊兩個頁面中間有共同存在的交叉時間,所以簡單的切換頁面或者刷新是不能使得sw進行更新的,老的sw依然接管頁面,新的sw依然在等待。也就是說,即便用戶知道你的站點更新了,用戶自行在瀏覽器端作f5操做,這時,因爲舊的sw還未死亡,因此用戶看到的仍是舊版本的頁面。那麼咱們如何能讓新的sw儘快接管頁面呢?

那就是在sw內部使用 self.skipWaiting() 方法。

self.addEventListener('install', function(e) {
  e.waitUntil(
    caches.open(cacheStorageKey).then(function(cache) {
      return cache.addAll(cacheList)
    }).then(function() {
      // 註冊成功跳過等待,酌情處理
      return self.skipWaiting()
    })
  )
})

可是很明顯,同一個頁面,前半部分的請求是由舊的sw控制,然後半部分是由新的sw控制。這二者的不一致性很容易致使問題,除非你能保證同一個頁面在兩個版本的sw相繼處理的狀況下依然可以正常工做,纔可以這樣作。

也就是說,咱們最好可以保證頁面從頭至尾都是由一個sw來處理的,其實也很簡單:

navigator.serviceWorker.addEventListener('controllerchange', () => {
  window.location.reload();
})

咱們能夠在註冊sw的地方監聽 controllerchange 事件來得知控制當前頁面的sw是否發生了改變,而後刷新站點,讓本身從頭至尾都被新的sw控制,就能避免sw新舊交替的問題了。可是sw的變動就發生在加載頁面後的幾秒內,用戶剛打開站點就趕上了莫名的刷新,若是你不想被用戶拍磚的話咱們再來考慮考慮更好的方式。

毫無徵兆的刷新頁面的確不可接受,讓咱們來看看百度的lavas框架是怎麼作的

當檢測到有新的sw被安裝以後彈出一個提示欄來告訴用戶站點已更新,而且讓用戶點擊更新按鈕,不過lavas這個通知欄很是簡單(醜),實際應用的話咱們能夠在上面豐富內容,好比增長更新日誌之類的東西,另外這個按鈕也不夠突出,我曾屢次覺得我按f5起到的做用和他是相同的,直到我理解了它的原理才發現只能經過點擊這個按鈕來完成新舊sw的更換。

 

 

新的sw安裝完成時會觸發onupdatefound的方法,經過監聽這個方法來彈出一個提示欄讓用戶去點擊按鈕。

navigator.serviceWorker.register('/service-worker.js').then(function (reg) {
   // Registration.waiting 會返回已安裝的sw的狀態,初始值爲null
   // 這裏是爲了解決當用戶沒有點擊按鈕時卻主動刷新了頁面,可是onupdatefound事件卻不會再次發生
   // 具體能夠參考 https://github.com/lavas-project/lavas/issues/212
   if (reg.waiting) {
     // 通知提示欄顯示
     return;
   }
   // 每當Registration.Installing屬性獲取新的sw時都會調用該方法
   reg.onupdatefound = function () {
     const installingWorker = reg.installing;
     // 
     installingWorker.onstatechange = function () {
       switch (installingWorker.state) {
         case 'installed':
           // 應爲在sw第一次安裝的時候也會調用onupdatefound,因此要檢查是否已經被sw控制
           if (navigator.serviceWorker.controller) {
             // 通知提示欄顯示
           }
           break;
       }
     };
   };
 }).catch(function(e) {
   console.error('Error during service worker registration:', e);
 });

而後就是處理通知欄點擊事件以後的事情,這裏只寫和sw交互的部分,向等待中的sw發送消息。

try {
  navigator.serviceWorker.getRegistration().then(reg => {
    reg.waiting.postMessage('skipWaiting');
  });
} catch (e) {
  window.location.reload();
}

當sw接收到消息之後,執行跳過等待操做。

// service-worker.js
// SW 再也不在 install 階段執行 skipWaiting 了
self.addEventListener('message', event => {
  if (event.data === 'skipWaiting') {
    self.skipWaiting();
  }
})

接下來就是經過navigator.serviceWorker監聽controllerchange事件來執行刷新操做。好了,這樣一來問題就解決了,可是這種方式只能經過去點擊更新按鈕而沒法經過用戶刷新瀏覽器來更新。

完整demo

Service Worker庫

谷歌在早期有兩個pwa的輪子:

  • sw-precache
  • sw-toolbox

都有對應的webpack插件,可是請注意,這兩個從2016年開始已經不在維護了,由於有了更好的GoogleChrome/workbox,google官方也推薦你們使用workbox,百度的lavas如今也是在使用該輪子。

另外還有同時支持AppCache的NekR/offline-plugin

 

本文做者:閆冬

文章來源:Worktile技術博客

歡迎訪問交流更多關於技術及協做的問題。

文章轉載請註明出處。

相關文章
相關標籤/搜索