什么是ServiceWorker
在介紹ServiceWorker之前,我們先來談談PWA。PWA (Progressive Web Apps) 是一種 Web App 新模型,並不是具體指某一種前沿的技術或者某一個單一的知識點,,這是一個漸進式的 Web App,是通過一系列新的 Web 特性,配合優秀的 UI 交互設計,逐步的增強 Web App 的用戶體驗。
- Https環境部署
- 響應式設計,一次部署,可以在移動設備和 PC 設備上運行 在不同瀏覽器下可正常訪問。
- 瀏覽器離線和弱網環境可極速訪問。
- 可以把 App Icon 入口添加到桌面。
- 點擊 Icon 入口有類似 Native App 的動畫效果。
- 靈活的熱更新
在PWA要求的各種能力上,關於離線環境的支持我們就需要仰賴ServiceWorker。Service workers 本質上充當Web應用程序與瀏覽器之間的代理服務器,也可以在網絡可用時作為瀏覽器和網絡間的代理。它們旨在(除其他之外)使得能夠創建有效的離線體驗,攔截網絡請求並基於網絡是否可用以及更新的資源是否駐留在服務器上來采取適當的動作。由於PWA是谷歌提出,那么對ServiceWorker,同樣也提出一些能力要求:
- 后台消息傳遞
- 網絡代理,轉發請求,偽造響應
- 離線緩存
- 消息推送
在目前階段,ServiceWorker的主要能力集中在網絡代理和離線緩存上。具體的實現上,可以理解為ServiceWorker是一個能在網頁關閉時仍然運行的WebWorker。
ServiceWorker的生命周期
剛才講到ServiceWorker擁有離線能力的WebWorker,既然這么強的能力,那就需要好好管理起來。所以我們要明白ServiceWorker的生命周期,也就是它從創建到銷毀的過程。在所有介紹ServiceWorker生命周期的文章中最常見的就是下面這張圖。
整個過程中一個ServiceWorker會經歷:安裝、激活、等待、銷毀的階段。但實際上這張圖我感覺並沒有清晰的解釋ServiceWorker的聲明周期,所以我制作了下面這張圖。
這張圖把ServiceWorker的聲明周期分為了兩部分,主線程中的狀態和ServiceWorker子線程中的狀態。子線程中的代碼處在一個單獨的模塊中,當我們需要使用ServiceWorker時,按照如下的方式來加載:
if (navigator.serviceWorker != null) { // 使用瀏覽器特定方法注冊一個新的service worker navigator.serviceWorker.register('sw.js') .then(function(registration) { window.registration = registration; console.log('Registered events at scope: ', registration.scope); }); }
這個時候ServiceWorker處於Parsed解析階段。當解析完成后ServiceWorker處於Installing安裝階段,主線程的registration的installing屬性代表正在安裝的ServiceWorker實例,同時子線程中會觸發install事件,並在install事件中指定緩存資源
var cacheStorageKey = 'minimal-pwa-3'; var cacheList = [ '/', "index.html", "main.css", "e.png", "pwa-fonts.png" ] // 當瀏覽器解析完sw文件時,serviceworker內部觸發install事件 self.addEventListener('install', function(e) { console.log('Cache event!') // 打開一個緩存空間,將相關需要緩存的資源添加到緩存里面 e.waitUntil( caches.open(cacheStorageKey).then(function(cache) { console.log('Adding to Cache:', cacheList) return cache.addAll(cacheList) }) ) })
這里使用了Cache API來將資源緩存起來,同時使用e.waitUntil接手一個Promise來等待資源緩存成功,等到這個Promise狀態成功后,ServiceWorker進入installed狀態,意味着安裝完畢。這時候主線程中返回的registration.waiting屬性代表進入installed狀態的ServiceWorker。
/* In main.js */ navigator.serviceWorker.register('./sw.js').then(function(registration) { if (registration.waiting) { // Service Worker is Waiting } })
然而這個時候並不意味着這個ServiceWorker會立馬進入下一個階段,除非之前沒有新的ServiceWorker實例,如果之前已有ServiceWorker,這個版本只是對ServiceWorker進行了更新,那么需要滿足如下任意一個條件,新的ServiceWorker才會進入下一個階段:
- 在新的ServiceWorker線程代碼里,使用了
self.skipWaiting()
- 或者當用戶導航到別的網頁,因此釋放了舊的ServiceWorker時候
- 或者指定的時間過去后,釋放了之前的ServiceWorker
這個時候ServiceWorker的生命周期進入Activating階段,ServiceWorker子線程接收到activate事件:
// 如果當前瀏覽器沒有激活的service worker或者已經激活的worker被解雇, // 新的service worker進入active事件 self.addEventListener('activate', function(e) { console.log('Activate event'); console.log('Promise all', Promise, Promise.all); // active事件中通常做一些過期資源釋放的工作 var cacheDeletePromises = caches.keys().then(cacheNames => { console.log('cacheNames', cacheNames, cacheNames.map); return Promise.all(cacheNames.map(name => { if (name !== cacheStorageKey) { // 如果資源的key與當前需要緩存的key不同則釋放資源 console.log('caches.delete', caches.delete); var deletePromise = caches.delete(name); console.log('cache delete result: ', deletePromise); return deletePromise; } else { return Promise.resolve(); } })); }); console.log('cacheDeletePromises: ', cacheDeletePromises); e.waitUntil( Promise.all([cacheDeletePromises] ) ) })
這個時候通常做一些緩存清理工作,當e.waitUntil接收的Promise進入成功狀態后,ServiceWorker的生命周期則進入activated狀態。這個時候主線程中的registration的active屬性代表進入activated狀態的ServiceWorker實例
/* In main.js */ navigator.serviceWorker.register('./sw.js').then(function(registration) { if (registration.active) { // Service Worker is Active } })
到此一個ServiceWorker正式進入激活狀態,可以攔截網絡請求了。如果主線程有fetch方式請求資源,那么就可以在ServiceWorker代碼中觸發fetch事件:
fetch('./data.json')
這時在子線程就會觸發fetch事件:
self.addEventListener('fetch', function(e) { console.log('Fetch event ' + cacheStorageKey + ' :', e.request.url); e.respondWith( // 首先判斷緩存當中是否已有相同資源 caches.match(e.request).then(function(response) { if (response != null) { // 如果緩存中已有資源則直接使用 // 否則使用fetch API請求新的資源 console.log('Using cache for:', e.request.url) return response } console.log('Fallback to fetch:', e.request.url) return fetch(e.request.url); }) ) })
那么如果在install或者active事件中失敗,ServiceWorker則會直接進入Redundant狀態,瀏覽器會釋放資源銷毀ServiceWorker。
現在如果沒有網絡進入離線狀態,或者資源命中緩存那么就會優先讀取緩存的資源:
緩存資源更新
那么如果我們在新版本中更新了ServiceWorker子線程代碼,當訪問網站頁面時瀏覽器獲取了新的文件,逐字節比對 /sw.js 文件發現不同時它會認為有更新啟動 更新算法open_in_new,於是會安裝新的文件並觸發 install 事件。但是此時已經處於激活狀態的舊的 Service Worker 還在運行,新的 Service Worker 完成安裝后會進入 waiting 狀態。直到所有已打開的頁面都關閉,舊的 Service Worker 自動停止,新的 Service Worker 才會在接下來重新打開的頁面里生效。如果想要立即更新需要在新的代碼中做一些處理。首先在install事件中調用self.skipWaiting()方法,然后在active事件中調用self.clients.claim()方法通知各個客戶端。
// 當瀏覽器解析完sw文件時,serviceworker內部觸發install事件 self.addEventListener('install', function(e) { debugger; console.log('Cache event!') // 打開一個緩存空間,將相關需要緩存的資源添加到緩存里面 e.waitUntil( caches.open(cacheStorageKey).then(function(cache) { console.log('Adding to Cache:', cacheList) return cache.addAll(cacheList) }).then(function() { console.log('install event open cache ' + cacheStorageKey); console.log('Skip waiting!') return self.skipWaiting(); }) ) }) // 如果當前瀏覽器沒有激活的service worker或者已經激活的worker被解雇, // 新的service worker進入active事件 self.addEventListener('activate', function(e) { debugger; console.log('Activate event'); console.log('Promise all', Promise, Promise.all); // active事件中通常做一些過期資源釋放的工作 var cacheDeletePromises = caches.keys().then(cacheNames => { console.log('cacheNames', cacheNames, cacheNames.map); return Promise.all(cacheNames.map(name => { if (name !== cacheStorageKey) { // 如果資源的key與當前需要緩存的key不同則釋放資源 console.log('caches.delete', caches.delete); var deletePromise = caches.delete(name); console.log('cache delete result: ', deletePromise); return deletePromise; } else { return Promise.resolve(); } })); }); console.log('cacheDeletePromises: ', cacheDeletePromises); e.waitUntil( Promise.all([cacheDeletePromises] ).then(() => { console.log('activate event ' + cacheStorageKey); console.log('Clients claims.') return self.clients.claim(); }) ) })
注意這里說的是瀏覽器獲取了新版本的ServiceWorker代碼,如果瀏覽器本身對sw.js進行緩存的話,也不會得到最新代碼,所以對sw文件最好配置成cache-control: no-cache或者添加md5。
實際過程中像我們剛才把index.html也放到了緩存中,而在我們的fetch事件中,如果緩存命中那么直接從緩存中取,這就會導致即使我們的index頁面有更新,瀏覽器獲取到的永遠也是都是之前的ServiceWorker緩存的index頁面,所以有些ServiceWorker框架支持我們配置資源更新策略,比如我們可以對主頁這種做策略,首先使用網絡請求獲取資源,如果獲取到資源就使用新資源,同時更新緩存,如果沒有獲取到則使用緩存中的資源。代碼如下:
self.addEventListener('fetch', function(e) { console.log('Fetch event ' + cacheStorageKey + ' :', e.request.url); e.respondWith( // 該策略先從網絡中獲取資源,如果獲取失敗則再從緩存中讀取資源 fetch(e.request.url) .then(function (httpRes) { // 請求失敗了,直接返回失敗的結果 if (!httpRes || httpRes.status !== 200) { // return httpRes; return caches.match(e.request) } // 請求成功的話,將請求緩存起來。 var responseClone = httpRes.clone(); caches.open(cacheStorageKey).then(function (cache) { return cache.delete(e.request) .then(function() { cache.put(e.request, responseClone); }); }); return httpRes; }) .catch(function(err) { // 無網絡情況下從緩存中讀取 console.error(err); return caches.match(e.request); }) ) })
注意事項
ServiceWorker是一項新能力,目前IOS平台對他的支持性並不友好,但是在安卓側已經沒有大問題。而微信平台對它的支持也不錯。
依賴項:
- 依賴Cache API
- 依賴Fetch API Promise API
- Https環境
錯誤排查:
- install或active事件失敗
- 非Https環境
- sw.js安裝路徑問題
- scope設置
同時這里我也為大家錄制視頻,可以更清晰的看到這些細節。