這是一篇技術文,在開始閱讀這篇文章以前,先了解如下內容更能加深您的理解:
這篇文章主要探討的是如何讓 PWA 優雅合理的註冊一個 Service Worker,從而讓站點擁有 Service Worker 所提供的能力,若是看到標題的第一想法是 「 Service Worker 註冊一個這麼簡單的話題有啥可講的」,看到後面能夠發現仍是有一些坑的。 本文少圖較長,慎讀。
轉載請註明來源: zhuanlan.zhihu.com/p/28161855
經過對 PWA 文檔的學習和理解,咱們應該都知道了 PWA (Progressive Web Apps) 不是一項技術,不是一個框架,若是咱們非要說 PWA 是個什麼,能夠把她理解爲一種模式,一種經過應用一些技術將 Web App 在安全、性能和體驗等方面帶來漸進式的提高的一種 Web App 模式。若是對 PWA 進行一個簡單粗粒度的拆解的話,她主要包含三個方面:javascript
做爲一個開發者,也許更關心是經過怎樣的技術來怎麼實現這三個方向的功能特性。經過前面的 Service Worker 文檔連接學習,你們應該知道 Service Worker 具備離線緩存(Offline Cache),消息推送(Notification Push),後端同步(Background sync)的能力。在弱網環境下快速加載,Service Worker 的離線緩存功能功不可沒,以及在其餘體驗優化和提高用戶粘性方面 Service Worker 都發揮着重要的做用。html
您可能已經瞭解了 Service Worker,可是這裏仍是有必要再簡單的探討一下什麼是 Service Worker,對於瀏覽器來講,Service Worker 是一個獨立於 js 主線程的一種 Web Worker 線程,一個獨立於主線程的 Context,可是面向開發者來講 Service Worker 的形態其實就是一個須要開發者本身維護的文件,咱們假設這個文件叫作 sw.js,此文件的內容就是定製 Service Worker 生命週期中每一個階段所處理的定製化的細節邏輯,好比緩存 Cache 的讀寫,更新的策略,推送的策略等等,一般 sw.js 文件是處於項目的根目錄,而且須要保證能直接經過 https: //yourhost/sw.js 這種形式直接被訪問到才行。前端
固然,若是快速寫一個 sw.js 用來註冊玩一下而已仍是蠻簡單的,基本就是以下的套路(代碼爲示意代碼):java
// sw.js 文件
// 安裝
self.addEventListener('install', function (e) {
// 緩存 App Shell 等關鍵靜態資源和 html (保證能緩存的內容能在離線狀態跑起來)
});
// 激活
self.addEventListener('activate', function (e) {
// 激活的狀態,這裏就作一作老的緩存的清理工做
});
// 緩存請求和返回(這是個簡單的緩存優先的例子)
self.addEventListener('fetch', function (e) {
e.respondWith(caches.match(e.request)
.then(function (response) {
if (response) {
return response;
}
// fetchAndCache 方法並不存在,須要本身定義,這裏只是示意代碼
return fetchAndCache(e.request);
})
);
});
複製代碼
一般開發者都不太願意從無到有去本身寫一個 sw.js 的,通常都會選擇使用工具來輔助生成一個相對複雜和完善的 Service Worker 文件,例如 sw-precache + sw-toolbox 組合的方式,這樣的話就省去了其中的不少緩存策略的細節考慮以及細節邏輯處理問題。固然,Service Worker 文件如何生成不是咱們今天所要講的重點話題。假設在正式開始咱們今天的主題以前已經生成好了一份sw.js 文件了,接下來就深刻的探討一下如何優雅的去註冊一個已經生成好的 sw.js。webpack
註冊 Service Worker 仍是蠻簡單的,只要小段代碼。只要在工程中的 html 文檔的 <script> 標籤裏或者隨便在頁面的哪一個 javaScript 模塊中加以下這行代碼就搞定了,nginx
// 只要保證 https://yourhost/sw.js 可訪問就行
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js');
}
複製代碼
由於 Service Worker 的載入是徹底異步的(Chrome DevTools 中 Network 的 XHR 中能夠找到),註冊的時候不用擔憂 block 的問題。git
理想很豐滿,現實太骨感,生產環境下的 Web App 開發中若是真是這麼簡單的話就行了,那在這裏就不必來寫這篇文章。這句代碼的確能註冊 Service Worker,可是 Service Worker 註冊這個看似簡單的工做遠比咱們想象的要複雜。接下來一點一點的來深刻。github
HTTPS 是 Service Worker 所必須依賴的應用層協議,Service Worker 只有在 Web App 爲 HTTPS 的環境下才能被註冊成功,但是咱們開發的時候應該不會直接在線上開發,擁有一個 HTTPS 的測試環境成本很高。web
各大瀏覽器廠商也考慮到了這個問題,如 Chrome,Firefox,在 localhost 和 127.0.0.1 的 host 下,也能註冊成功。這樣就能保證咱們在本地開發的時候也能直接在本地註冊。算法
對於不少開發者來講,大部分狀況是有本身的開發環境的機器,可是沒有配置 HTTPS,能夠經過改 host 的方式來將遠程的 IP 對應到 localhost 的域就能夠了,這樣既能保證訪問到的是真實的開發環境,而且不用費很大勁去弄 HTTPS 環境的把 Service Worker 給註冊了。
# /private/etc/hosts 或 /windows/system32/drivers/etc/hosts
# 開發環境 IP 爲 12.23.34.45
12.23.34.45 localhost
複製代碼
對於遠程開發環境還能夠經過本地服務器(nginx 或 apache 等)代理的方式去作,在這裏就不作深刻的探討。
一般狀況下,在註冊 sw.js 的時候會忽略 Service Worker 做用域的問題,Service Worker 默認的做用域就是註冊時候的 path, 例如 Service Worker 註冊的 Service Worker 文件爲 /a/b/sw.js,則 scope 默認爲 /a/b/。
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/a/b/sw.js').then(function (reg) {
console.log(reg.scope);
// scope => https://yourhost/a/b/
});
}
複製代碼
固然也能夠經過在註冊時候傳入 {scope: '/some/scope/'} 參數的方式本身指定 scope ,可是本身指定 scope 也是有必定的限制的,其中也隱藏着一些坑。
當合理的指定 scope 的狀況下:
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/a/b/sw.js', {scope: '/a/b/c/'})
.then(function (reg) {
console.log(reg.scope);
// scope => https://yourhost/a/b/c/
});
}
複製代碼
可是也存在指定錯誤的 scope 的狀況:
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/a/b/sw.js', {scope: '/a/'})
.then(function (reg) {
console.log(reg.scope);
// Ops !!!,報錯啦!!
});
}
複製代碼
經過報錯信息,能夠知道 sw.js 文件是在 /a/b/ 這個 path 下才能被訪問到,則默認的 scope 和最大的 scope 都是 /a/b/。通俗的講:Service Worker 最多隻能在這個 path 範圍內發揮做用,以代碼爲例,/a/b/,/a/b/c/,/a/b/c/d/ 下的頁面均可以被註冊的 Service Worker 控制,可是/a/,/e/f/ 下面的頁面是不受註冊的 Service Worker 的控制的(固然瀏覽器也會拋出錯誤告知開發者)。
也就是說,在最大 scope 的基礎上才能指定自定義的 scope, 例如 /a/b/c/ 。
值得注意的是:
相似於 Ajax 的跨域請求能夠經過對請求的 Access-Control-Allow-Origin 設置,咱們也能夠經過服務器對 sw.js 這個文件的請求頭進行設置,就可以突破 scope 的限制,只須要設置 Service-Worker-Allowed 爲更大控制範圍或者其餘控制範圍的 scope 便可。
經過對 Service Woker 做用域的瞭解,也許會發現這麼樣的一個問題:
假設在 https: //yourhost 域下有 A 頁面 (https: //yourhost/a) 和 B 頁面(https: //yourhost/b)。
假設 A 頁面在 /a/ 做用域下注冊了一個 Service Worker,B 頁面在 / 做用域下注冊了一個 Service Worker,這種狀況下 B 頁面的 Service Worker 就能夠控制 A 頁面,由於 B 頁面的做用域是包含 A 頁面的最大做用域的(咱們能夠把這種狀況稱之爲 做用域污染)。在開發環境開發者還能夠經過 DevTools 進行手動 unregister 來清除掉污染的 Service Worker,可是若是用戶在手機端被安裝了 Service Worker 以後能夠理解這就是個持久的過程。除非用戶手動清除存儲的緩存(這個也是不可能的),不然對用戶來講就是個持久污染的噩夢。
固然,出現做用域污染的狀況也不是沒有辦法補救的,比較合理的一種作法是,在新上線的版本中註冊 Service Worker 以前將污染的 Service Worker 註銷掉。
if (navigator.serviceWorker) {
navigator.serviceWorker.getRegistrations().then(function (regs) {
for (var reg of regs) {
if (reg.scope === 'https://yourhost/') {
reg.unregister();
}
}
// 註銷掉污染 Service Worker 以後再從新註冊本身做用域的 Service Worker
navigator.serviceWorker.register('/a/sw.js').then(function (reg) {
// ...
});
});
}
複製代碼
對於一個擁有多個平行子站的大型站點,做用域污染的狀況頗有可能由於缺少溝通或者濫用 Service Worker 而發生。
SPA(Single Page Applications),單頁 Web 應用,在工程架構上只有一個 index.html 的入口,站點的內容都是異步請求數據以後在前端渲染的,應用中的頁面切換都是在前端路由控制的。
一般會將這個 index.html 部署到 https: //yourhost,對於 SPA 的 Service Worker,只會在 index.html 中註冊一次,因此咱們會將 sw.js 直接放在站點的根目錄保證可訪問,Service Worker 的 scope 一般就是 /,這樣可以控制整個 SPA 的緩存。
SPA 每次路由的切換都是前端渲染的過程,本質上仍是在 index.html 上的前端交互,一般 Service Worker 會緩存 SPA 中的 AppShell 所需的靜態資源和 index.html。固然有一種狀況比較特殊,當用戶從 /a 頁面切換到 /b 頁面,而後這時候刷新頁面,此時首先渲染的仍是 index.html,在執行 SPA 的路由邏輯以後,經過 SPA 前端路由的處理繼續在前端渲染相應的路由對應的 Component。
MPA(multi page applications),多頁應用,這種架構的模式在現現在的大型站點很是常見,例如 ele.me 就是採用這種模式來架構的站點,這種站點有常規的 Web App 的特性,可是相比較 SPA 可以承受更重的業務體量,而且利於大型站點的後期維護和擴展。針對 MPA 的 PWA 能夠閱讀 餓了麼的 PWA 升級實踐 進行更加深刻了解。
在這裏咱們能夠更加深刻的瞭解一下 MPA PWA 是如何註冊 sw.js 的,MPA 能夠理解爲是有多個 html 文件對應着多個不一樣的服務端路由,也就是說 https: //yourhost/a 映射到 a.html,https: //yourhost/b 映射到 b.html 等等。
那麼這種架構下怎麼去註冊 Service Worker 呢?是不一樣的頁面註冊不一樣的 Service Worker,仍是全部的頁面都註冊同一個 Service Worker?結論是:須要根據實際狀況來定。
在每一個頁面之間的業務類似度較高,或者每一個頁面之間的公共靜態資源或異步請求較多,這種 MPA 是很是適合在全部的頁面只註冊一個 Service Worker。
例如 https: //yourhost/a 和 https: //yourhost/b 之間的公共內容較多,則一般狀況下在 / 做用域下注冊一個 Service Worker。這樣這個 Service Worker 可以控制 https: //yourhost 域下的全部頁面。
維護單個 Service Worker 有以下特色:
適用於主站很是龐大,而且是以 path 分隔的形式鋪展垂類子站的大型站點(如今這種畢竟少了,基本都用二級域名區分子站),這種狀況下不適合只在跟做用域下注冊一個 Service Worker。
例如,https: //yourhost/a 和 https: //yourhost/b 幾乎是兩個站點,其中公共使用的靜態資源或異步請求很是少,則比較適合每一個子站註冊維護本身的 Service Worker,https: //yourhost/a 註冊 Servcie Worker 的做用域爲 /a/,最好是存在 /a/sw.js 可訪問,儘可能不要使用某一個公用的 /sw.js 而後使用 scope 參數來自定義做用域。這樣會增長後期的維護成本以及增長出現 bug 的概率。
子站在實現上還要考慮一點是,防止主站 https: //yourhost 的 Service Worker 對自身形成污染,須要在註冊子站 Service Worker 以前將主站的 Service Worker 註銷掉(這個方法也不是很好,至關於剝奪了主站 Service Worker 的權利)。
註冊多個 Service Worker 有以下特色:
Service Worker 的更新也會影響到 Service Worker 的註冊,在這裏,重點剖析一下 Service Worker 更新的問題。
當頁面註冊好了一個 Service Worker 以後,Service Worker 會被安裝、激活、經過 fetch 事件監聽做用域下站點的網絡請求等等行爲,爲了 Web App 的首屏體驗,AppShell 做爲最小優先展示單元,其中的 html 頁面和靜態資源是須要被持久緩存起來的。也就是說保證用戶能在離線以後至少優先看到一個完整的 AppShell。
這個和優雅的註冊 Service Worker 有個啥子關係?
拿 SPA 爲例,做爲 AppShell 的載體 index.html 是會被緩存起來的,AppShell 的靜態資源也都會被緩存起來的,然而 Service Worker 的註冊必然是須要在 index.html 的 <script></script> 標籤或者被緩存住的 js 文件中作的。
若是 sw.js 發生了更新,咱們預期的是但願瀏覽器當即更新當前頁面的緩存,而且當即加載最新的內容和資源。sw.js 的更新包含她 URL 的更新和內容的更新,Service Worker 自己的機制可以 diff 到 sw.js 的更新,若是在註冊時候經過 Service Worker Update 算法 diff 到 URL 或者 內容的更新,則立刻啓動新的 sw.js 文件的安裝、激活,但由於用戶當前的頁面已經使用老的緩存中的內容加載完成,因此須要等到第二次進入頁面的時候才能真正使用新的靜態資源和網絡請求。
這個機制是有如下兩個坑的:
對於 sw.js HTTP 緩存的問題解決方案確定是讓這個文件永遠都不緩存(暫時不討論請求開銷的問題)
爲了能讓 Service Worker 作到實時更新,必需要解決 Service Worker 文件 sw.js HTTP 緩存的問題。 一般須要讓文件徹底無緩存,有兩種思路:一種是在服務器端控制請求文件的 Cache-Control,另外一種就是在前端經過版本號來改變瀏覽器緩存策略。
服務器端的 Cache-Control 的控制是將 sw.js 的請求設置成 no-cache,以 nginx 爲例:
location ~ \/sw\.js$ {
add_header Cache-Control no-store;
add_header Pragma no-cache;
}
複製代碼
經過配置服務器這種方式的好處是:只要作好了 sw.js 緩存實時更新問題以後,就能夠不用關心整個 Web App 的實時更新問題,瀏覽器都會參照 「sw.js 的 diff」 -> 「從新安裝新 sw.js」 -> 「激活並刪除老的緩存」 ->「用戶第二次進入頁面從新更新緩存」的套路來自行搞定。
固然,這種處理方式也有很大的侷限性,若是您將靜態資源都部署在第三方的 CDN 靜態資源服務器,單獨針對某一個文件進行服務器設置 sw.js 仍是感受很麻煩。尤爲是對於大型站點的運維人員來講,在服務器新增一個路由不是一件很隨意的事情。
對於前端版本控制,前端開發者應該並不陌生,若是須要一個靜態資源的請求永遠不會被緩存,下面這種作法就很好理解了
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js?v=' + Date.now());
}
複製代碼
這段代碼一祭出,就解決了以前所提到的 sw.js 被瀏覽器緩存的問題了。
可是,這種作法又引起出了其餘的問題,每次執行註冊 Service Worker 代碼邏輯的時候,Service Worker 都能 diff 到變化(URL 的變化也是一種更新的 diff),每次都會在第一次安裝,第二次激活而且更新緩存,這種作法使得 Service Worker 的緩存徹底沒有生效,和每次都和請求最新的 Network 請求內容沒什麼區別,理論上講,這種方式因爲緩存的頻繁讀取和刪除,甚至比每次直接無緩存刷新的性能更加糟糕。
在這裏也須要提醒你們注意
在 Service Worker 得註冊過程當中,慎用時間戳來作版本控制,會致使一些意想不到的坑。事實也證實這種作法也是不可取的。
接下來轉變一下思路,這個時候須要先想想如何優雅的作好無緩存的版本控制了。若是不能對sw.js 直接作版本控制,能不能對別的文件作無緩存的版本控制,而後在這個文件中再執行 Service Worker 的註冊邏輯?
假設這個文件叫 sw-register.js,其代碼以下:
// sw-register.js
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js').then(function (reg) {
// ...
});
}
複製代碼
而後在 index.html 中對 sw-register.js 作版本控制就行了:
<script>
window.onload = function () {
var script = document.createElement('script');
var firstScript = document.getElementsByTagName('script')[0];
script.type = 'text/javascript';
script.async = true;
script.src = '/sw-register.js?v=' + Date.now();
firstScript.parentNode.insertBefore(script, firstScript);
};
</script>
複製代碼
這樣處理以後,sw-register.js 就不會被瀏覽器緩存了,而且因爲 sw-register.js 是異步加載的,也不會形成頁面 block,但還有個問題,當前的 sw.js 依然會被瀏覽器 HTTP 緩存。根本問題仍是沒有解決。
其實設想一下,每次 Service Worker 的更新都是由於工程的上線,若是可以保證每次上線一次就賦給 sw.js 一個版本,等新上線以後就用新的版本號替換老的版本號,從而觸發 Service Worker 的 diff,而且能保證每次上線以後就更新了新的 sw.js。
// sw-register.js
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js?v=buildVersion').then(function (reg) {
// ...
});
}
複製代碼
其中 buildVersion 是每次上線前構建的一個惟一版本號。
這樣看來,是解決了以前 Service Worker 更新不及時的問題。可是代價是增長了一次 sw-register.js 的請求,因爲 sw-register.js 一般只作 Service Worker 的註冊工做,體量不會太大,因此應該仍是能夠接受,相比於在服務器端的配置,前端的版本控制的方案應該更加的簡單方便。
繞了一圈,版本控制爲何不直接在 註冊 sw.js 時候作,爲何非要藉助一個 sw-register.js 文件?就像以下代碼:
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js?v=buildTime').then(function () {});
}
複製代碼
爲了保證離線可用,全部和 AppShell 相關的 html 和靜態資源都要被緩存住,此時,就算上線時候更改了 buildTime, 可是 Service Worker 全部可能被註冊的地方因爲被緩存了是感知不到變化的,除非是用 Date.now() 這種變量時間戳的方式自動輪詢,可是這種方案的弊端在前面已經分析過了。
Service Worker 是一個獨立於瀏覽器主線程的 Worker 線程,在這個線程 Context 中是不容許操做頁面的 DOM,可是 Worker 線程能夠經過 postMessage 機制與主線程進行通訊。
經過前面對 Service Worker 的介紹,已經瞭解到 Service Worker 更新的第二個痛點是必需要等到用戶第二次進入頁面的時候才能使用 Service Worker 更新以後的內容,咱們的預期是若是 Web App 從新上線了,那用戶在任什麼時候候打開頁面都能使用到最新的內容,而且同時還要保持 Service Worker 離線緩存的特性。
經過對 sw.js 文件的無緩存處理,咱們能作到實時的檢測更新,接下來須要處理緩存更新實時生效的問題。
當註冊 Service Worker 得時候,實時監測到 sw.js 更新以後,則瀏覽器會當即安裝、激活,然而激活完成並清除老的緩存以後,若是有一種途徑告訴主線程 Service Worker 完成了更新 這樣也會對用戶比較友好。
// sw.js 文件
// 新的 Service Worker 更新時,進入激活狀態後,會觸發 activate 事件
self.addEventListener('activate', function (event) {
var cacheName = 'a_cache_name';
event.waitUntil(
caches.open(cacheName)
.then(function (cache) {
// 進行老緩存的清除...(略過..)
})
.then(function () {
// 完成緩存刪除以後就能夠通知瀏覽器主線程啦
// 固然這裏也能夠判斷若是緩存內原本就沒內容
// 就表明是首次安裝,就不要發 message了 (這個邏輯略過...)
return self.clients.matchAll()
.then(function (clients) {
if (clients && clients.length) {
clients.forEach(function (client) {
// 給每一個已經打開的標籤都 postMessage
client.postMessage('sw.update');
})
}
})
})
);
})
複製代碼
這樣的話,至關於咱們在本身的業務代碼中只要監聽 message 事件,監聽到 sw.update 這個 message 就知道 Service Worker 更新成功了。看來這段代碼寫在 sw-register.js 中比較優雅,咱們能夠把 sw-register.js 這個文件就當成專門處理 Service Worker 的文件好了。
// sw-register.js
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', function (e) {
if (e.data === 'sw.update') {
// 若是代碼走到了在這裏,就知道了,Service Worker 已經更新完成了
// 能夠作點什麼事情讓用戶體驗更好
}
});
}
複製代碼
一般對用戶比較友好的實時生效策略有兩種:
目前百度 Lavas 解決方案推薦的是第二種引導用戶刷新的方式,Filpkart Lite(牆外) 也是使用引導用戶的方式,固然隨意添加 toast 可能會引發產品點擊率等方面的影響,具體使用哪一種策略固然是由產品設計師決定。咱們在這裏講的是使用技術手段創建的這套機制。
不管是 Service Worker 做用域問題,仍是 Service Worker 的更新問題,都與 Service Worker 的註冊息息相關,一個看似簡單的 Service Worker 的註冊仍是有不少地方須要注意,可是若是這些都須要在每一個項目中都要本身徹底實現一遍,仍是很是繁瑣的。而 sw-register-webpack-plugin做爲一個 Webpack Plugin 很好的幫助咱們解決了 優雅的註冊 Service Worker 的問題
基於以上的考慮,均可以嘗試一下 sw-register-webpack-plugin
在項目中引入 sw-register-webpack-plugin
npm install --save-dev sw-register-webpack-plugin
複製代碼
配置 Webpack 的配置
var SwRegisterWebpackPlugin = require('sw-register-webpack-plugin');
webpack({
// ...
plugins: [
// ... some plugins
new SwRegisterWebpackPlugin({ /* options */})
// ... some plugins
]
// ...
})
複製代碼
參數介紹詳見 sw-register-webpack-plugin 在 github 中的介紹
百度 Lavas 解決方案中關於 Service Worker 的解決方案採用的就是 sw-register-webpack-plugin
關於 sw.js 文件生成 Lavas 各個模版中採用的是 sw-precache-webpack-plugin 插件,底層使用的是 sw-precache + sw-toolbox 解決方案, Lavas 生成的 sw.js 在更新完成後會經過 postMessage 發送 sw.update 的消息