技術乾貨: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生命週期

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實踐

我們先從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事件來執行刷新操作。好了,這樣一來問題就解決了,但是這種方式只能通過去點擊更新按鈕而無法通過用戶刷新瀏覽器來更新。

Service Worker庫

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

sw-precachesw-toolbox

都有對應的webpack插件,但是請注意,這兩個從2016年開始已經不在維護了,因為有了更好的,google官方也推薦大家使用workbox,百度的lavas現在也是在使用該輪子。

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

點擊【瞭解更多

】查看更多優質文章,學習OKR/企業管理/項目管理/辦公協作秘籍~

關注公眾號【OKR有話說】,免費領取OKR資料大禮包!