認識 Service Worker

wow_worktile發表於2019-02-22

Service Worker

隨著前端快速發展,應用的效能已經變得至關重要,關於這一點大佬做了很多統計。你可以去看看

如何降低一個頁面的網路請求成本從而縮短頁面載入資源的時間並降低使用者可感知的延時是非常重要的一部分。對於提升應用的載入速度常用的手段有Http Cache、非同步載入、304快取、檔案壓縮、CDN、CSS Sprite、開啟GZIP等等。這些手段無非是在做一件事情,就是讓資源更快速的下載到瀏覽器端。但是除了這些方法,其實還有更加強大的Service Worker執行緒。

Service Worker與PWA的現狀

說起service worker就不得不提起PWA了,service worker做為PWA的核心技術之一,多年來一直被Google大力推廣,這裡簡單介紹一下。

通俗來說,PWA就是漸進式web應用(Progressive Web App)。早在16年初,Google便提出PWA,希望提供更強大的web體驗,引導開發者迴歸開放網際網路。它彌補了web對比Native App急缺的幾個能力,比如離線使用、後臺載入、新增到主屏和訊息推送等,同時它還具備了小程式標榜的“無需安裝、用完即走”的特性。

雖然PWA技術已經被W3C列為標準,但是其落地情況一直以來是很讓人失望的,始終受到蘋果的阻礙,最重要的原因在於PWA繞過Apple Store稽核,直接推給使用者。如果普及,這將威脅到蘋果的平臺權威,也就意味著蘋果與開發者的三七分成生意將會落空。

所以一直以來safrai不支援mainfest以及service worker這兩項關鍵技術,即使在18年開始支援了,但是對PWA的支援力度也遠遠低於安卓,具體體現在service worker快取無法永久儲存,以及service worker的API支援不夠完善,一個最明顯的不同在於安卓版本的PWA會保留你的登入狀態,並且會系統級推送訊息。而在蘋果上,這兩點都做不到。也就是說,iPhone上的微博PWA,每次開啟都要重新登入,而且不會收到任何推送資訊。

另外由於某些不可描述的原因,在國內無法使用Service Worker的推送功能,雖然國內已經有兩家公司做了service worker的瀏覽器推送,但是成熟度還有待調研。
由於目前各版本手機瀏覽器對service worker的支援度都不太相同,同一個介面也存在差異化還有待統一,之於我們來說,也只能用Service Worker做一做PC瀏覽器的快取了。

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 存在很多缺點,你可以親自看看

sw是由事件驅動的,具有生命週期,可以攔截處理頁面的所有網路請求(fetch),可以訪問cache和indexDB,支援推送,並且可以讓開發者自己控制管理快取的內容以及版本,為離線弱網環境下的 web 的執行提供了可能,讓 web 在體驗上更加貼近 native。換句話說他可以把你應用裡的所有靜態動態資源根據不同策略快取起來,在你下次開啟時不再需要去伺服器請求,這樣一來就減少了網路耗時,使得web應用可以秒開,並且在離線環境下也變得可用。做到這一切你只需要增加一個sw檔案,不會對原有的程式碼產生任何侵入,是不是很perfect?

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技術部落格 

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

文章轉載請註明出處。


相關文章