本文是奇舞團泛前端分享會Service Worker初探的一次記錄,是對360掃地機器人App內嵌web頁面使用Service Worker優化的一次總結。
本文所有程式碼示例均已提交到github,地址
Service Worker是什麼
Service Worker是漸進式web應用(pwa)的核心技術。
通過註冊之後,可以獨立於瀏覽器在後臺執行,控制我們的一個或者多個頁面。如果我們的頁面在多個視窗中開啟,Service Worker不會重複建立。
就算瀏覽器關閉之後,Service worker也同樣執行。但是瀏覽器是不會允許Service Worker一直處於工作狀態。因為隨著使用者開啟越來越多的註冊了Service Worker的頁面,效能肯定會收到影響。在後面的生命週期中,我們會一起探討Service Worker的執行原理。
Service Worker是客戶端和服務端的代理層,客戶端向伺服器傳送的請求,都可以被Service Worker攔截,並且可以修改請求,返回響應。
同時也會在使用者離線的時候正常工作,當瀏覽器傳送請求,Service Worker檢測到離線狀態的時候,可以直接返回快取資料和提前準備好的離線頁面。
進一步來講,使用者關閉了所有的頁面,Service Worker同樣可以和伺服器通訊。完成尚未完成的資料請求,可以確保使用者的任何操作都可以傳送到伺服器。
Service Worker的優勢
1. 支援離線訪問
傳統的web頁面,在每次訪問的時候,都會去請求伺服器的資源。在使用Service Worker之後,第一次訪問的時候,可以將我們的靜態資源快取下來,下次訪問的時候可以通過Service Worker返回快取,就可以支援離線訪問了。
2. 載入速度快
頁面資源快取之後,不需要依賴網路載入伺服器資源。無論使用者是否具有良好的的網路狀態,甚至在離線的情況下,都可以瞬間載入我們的web頁面。
3. 離線狀態下的可用性
在不追求返回結果的資料請求中,可以使用Service Worker進行代理。當客戶端從離線轉為線上的時候,就算已經關閉了頁面。Service Worker也能夠幫助我們繼續傳送代理的請求。 無論,使用者是線上、離線還是網路不穩定的時候,藉助Service Worker都能夠提供一個相對完整的使用者體驗。
安全策略
由於serviceworker功能強大,可以修改任何通過它的請求,因此需要對其進行一定的安全限制。
1. 使用https或者localhost本地域名的頁面才可以使用Service Worker
正常情況下,只有使用https的頁面才能夠註冊Service Worker。 為了方便我們的開發和除錯,在開發的過程中,可以使用localhost來使用Service Worker。 一旦把應用部署到伺服器之後,必須使用https保證Service Worker的正常工作。
2. Service Worker的作用域
每個Service Worker都有一個有限的控制範圍。這個範圍就是通過放置Service Worker的js檔案的目錄決定的,也就是Service Worker所在目錄以及所有的子目錄。
也可以通過註冊Service Worker的時候傳入一個scope
選項,用來覆蓋預設的作用域。但是,只能將作用域的範圍縮小,不能將它擴大。換句話來說,scope
的值,必須是Service Worker所在目錄或者是子目錄。
navigator.serviceWorker.register('serviceworker.js', { scope: '/' })
複製程式碼
如何使用
下面我們根據一個簡單的示例,看一下Service Worker是如何執行的。
在瀏覽器環境下,我們可以通過navigator.serviceWorker.register
註冊一個Service Worker。register方法的第一個引數是Service Worker的js檔案的地址,第二個引數是規定了Service Worker的作用域。
window.onload = function() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./serviceworker.js', { scope: '/' })
}
}
複製程式碼
註冊之後,Service Worker可以獨立於瀏覽器在後臺執行,來控制我們的頁面。如果我們的頁面在多個視窗中開啟,Service Worker不會重複建立,在不同視窗中的頁面,均由一個Service Worker統一管理。
下面我們建立一下serviceworker.js
檔案。
在這裡,監聽了兩個事件。在install
事件中,我們將一個離線頁面快取進來。在fetch
事件中,如果資源請求失敗的話,使用剛才快取的離線頁面。這樣,我們的web應用就會在離線狀態下,載入這個離線頁面了。
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('cache').then((cache) => {
return cache.add('./offline.html')
})
)
})
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match('./offline.html')
})
)
})
複製程式碼
請注意,我們剛剛提到過Service Worker的安全策略只允許我們在Https或者localhost下注冊它,所以我們一定要開啟一個本地伺服器來執行我們的程式碼示例。
下面,我們對於剛才的例子做一個小小的改動。我們新建一個new_offline.html
檔案,將serviceworker.js
中的offline.html
替換為new_offline.html
。如果你剛才已經執行過上一版的程式碼,你就會發現,頁面並沒有發生改變,在離線狀態下,頁面依然是舊版的offline.html
。
當我們關閉所有執行程式碼的標籤頁之後再次開啟,我們就會驚奇的發現,頁面更新了。想要搞明白這些問題,我們必須要了解Service Worker的生命週期。
生命週期
在註冊Service Worker之後,Service Worker會馬上進去installing
的生命週期進行安裝,同時會進入Service Worker的install
事件中。如果在installing
中,有任何資源載入失敗,都會導致安裝失敗,Service Worker會直接進入廢棄狀態。
在安裝成功之後,在正常情況下,會進入Activated
狀態,同時會進入Service Worker的activate
事件中。當activate
中的程式碼執行完成後,Service Worker會進入Idle
的狀態。
只有在這個狀態下,fetch
、sync
、message
的一系列事件事件才能夠正常監聽。所以,有的時候我們發現,在頁面第一次載入,fetch
中的邏輯並沒有生效,那是因為Service Worker在註冊完成之前,我們的資料請求早已經載入完成了。
同時,在這個狀態下。Service Worker是否工作也和這些事件繫結在一起。當某個Service Worker中的這些事件被觸發,Service Worker將被喚醒,處理事件,然後終止。 這樣,就會防止當瀏覽器載入越來越多的Service Worker的頁面導致瀏覽器卡頓的問題。
回到安裝的時候,如果當前的頁面已經存在了一個啟用的Service Worker的時候,在新的Service Worker安裝完成,會進入Waitingg
狀態。如果頁面所有的標籤頁全部關閉之後,或者導航到一個不在控制範圍內的頁面。再次開啟新的Service Worker才會生效。
CacheStorage API
在Service Worker中,我們通常使用CacheStorage來管理快取。
CacheStorage是一種全新的快取層,讓我們對快取具有完全的控制權。和Cookie
一樣,都是具有同源策略的。
CacheStorage為我們提供了一系列的api來操作快取。這些api都是基於Promise
的,所有方法的返回值都是一個Promise
。
caches.open(cacheName) => Primose<cache>
CacheStorage是可以分組的,可以通過這個方法傳入cacheName
來開啟一個分組。如果沒有這個分組,那就會建立。最終返回當前的cache
,一般情況下,基於這個cache
來操作快取。
caches.keys() => Primose<cacheName[]>
這個方法可以獲取所有的快取名稱的列表。
cache.addAll(url[])
通過open
方法拿到目標cache
,之後可以呼叫addAll
,傳入一個url列表之後,會將這些url全部快取下來。
cache.put(url)
如果我們要新增單個快取可以使用cache.put
方法
cache.add(key, value)
在快取一個請求資料的時候,我們希望將快取和當前的請求想匹配的話。不單單是匹配url
,還要匹配請求引數以及是POST
還是GET
甚至是匹配請求頭的時候,可以使用cache.put
方法,第一個引數是key
,這裡的key
可以是一個Request
物件,當我們去查詢快取的時候,只有當key
完全相等的時候才能夠匹配。第二個引數value
,必須是一個Response
的結構。
cache.delete(key)
已經不需要的快取可以通過cache.delete
方法進行刪除。
cache.match(url | Requst)
| caches.match(url | Requst)
在查詢相關的快取的時候,通過match
方法,傳入url
或者Request
。究竟傳入什麼引數,取決於如何新增的快取。如果在具體的cache
上呼叫這個方法,就是在當前快取下去查詢,如果在window.caches
下呼叫,就是在全域性快取中匹配。
CacheStorage和http快取的關係
在傳送http請求的時候,請求會先到達Service Worker。在Service Worker中,使用CacheStorage來查詢是否具有可用的快取。
如果沒有,瀏覽器先會檢測Cache-Control
是否使用當前的瀏覽器快取,這就是我們常說的強快取。
如果瀏覽器快取已過期,請求正式到達伺服器。再去判斷資源的ETag
和Last-Modified
有沒有發生變化,決定是否使用伺服器快取。
CacheStorage不能取代過去的HTTP快取。CacheStorage因為Service Worker的作用域問題,只能控制範圍內的快取,無法控制cdn和在其他域下的介面資料。
快取模式
快取模式主要探討了一個關於快取利用率和更新的權衡問題。如果快取利用率高了的話,程式碼更新速度必然收到影響。
我們先來看一下第一種,快取優先,在沒有快取的情況下請求網路資源。這是一種高效、省流量的方法。但是資源的更新可能會收到影響。這種模式通常適用於不會更新的靜態資源,比如圖片和程式碼庫。
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('cache-name').then((cache) => {
return cache.match(event.request).then((cacheResponse) => {
return cacheResponse || fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone())
return networkResponse
})
})
})
)
})
複製程式碼
第二種模式是,快取優先,頻繁更換資源。這是一種高效的方案。並且在第二次載入的時候顯示可用的最新版本。頻寬消耗和使用快取一樣。
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('cache-name').then((cache) => {
return caches.match(event.request).then((cacheResponse) => {
const fetchPromise = fetch(event.request).then((networkResponnse) => {
cache.put(event.request, networkResponnse)
return networkResponnse
})
return cacheResponse || fetchPromise
})
})
)
})
複製程式碼
第三種模式是,網路優先,失敗的時候使用快取。載入時間較慢,總是展示最新的檔案。在請求失敗的情況下,使用的快取也不一定是正在請求資源的快取,同樣也可以是其他的預設資源。就像第一個程式碼示例一樣,在html請求失敗的情況下,我們可以返回一個斷網頁面。在圖片請求失敗的情況下,我們可以提供一個預設圖片
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('cache-name').then((cache) => {
return fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone())
return networkResponse
}).cache(() => {
return cache.match(event.request)
})
})
)
})
複製程式碼
基於版本控制的快取模式。
在版本控制的快取模式下,可以既提高快取效率,又能解決版本更新不及時的問題。我們通過一個示例來闡述這種模式。
首先,還是要在瀏覽器環境下注冊Service Worker。和以往有所不同的是我們監聽了controllerchange
事件,當Service Worker發生變化的時候,就過載頁面,完成頁面的及時更新。
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', function () {
if (!navigator.serviceWorker.controller) {
try {
navigator.serviceWorker.register('serviceworker.js')
} catch (err) {
throw Error(err)
}
}
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload()
})
})
}
複製程式碼
對於Service Worker,我們將對沒有過期的資源永遠使用快取,對於過期的資源,載入網路資源並更新快取。快取是否過期的判斷依據使用,那就是版本號。下面,我們通過四個步驟藉助webpack來完成這件事情。藉助webpack的目的是,更加方便的獲取靜態資源列表,已經通過package.json
的version欄位來設定我們的版本號。
1. 定義資源版本號
首先我們要在serviceworker.js
中定義一些變數。cacheKey
就是一個特定字串和VERSION
拼接的字串,作為快取名稱來使用。VERSION
、CACHE_LIST
就需要藉助webpack的外掛幫助我們完成替換。
// serviceworker.js
const VERSION = self.__VERSION__
const cacheKey = 'cache-' + VERSION
const CACHE_LIST = self.__WEBPACK_INJECT_CACHE_LIST__
複製程式碼
下面我們再來看一下webpack外掛的配置,ServiceWorkerPlugin是我們的自定義外掛。
// webpack.config.js
const fs = require('fs')
const path = require('path')
class ServiceWorkerPlugin {
apply (compiler) {
compiler.hooks.emit.tap('ServiceWorkerPlugin', async (compilation) => {
const packageJson = fs.readFileSync(path.resolve(__dirname, './package.json'))
const version = JSON.parse(packageJson).version
const assetKeys = Object.keys(compilation.assets)
let source = compilation.assets['serviceworker.js'].source().toString()
source = source.replace('self.__WEBPACK_INJECT_CACHE_LIST__', JSON.stringify(assetKeys))
source = source.replace('self.__VERSION__', JSON.stringify(version))
compilation.assets['serviceworker.js'] = {
source: () => source,
size: () => source.length
}
})
}
}
module.exports = {
...
plugins: [
new ServiceWorkerPlugin()
]
}
複製程式碼
在ServiceWorkerPlugin外掛中,我們通過webpack的compilation.assets
拿到所有的靜態資源,通過package.json
獲取版本號,替換到我們的serviceworker.js
檔案中。
2. 根據版本號快取所有靜態資源
我們需要在Service Worker的安裝事件中,快取所有的靜態資源。self.skipWaiting
方法讓當前新版本的Service Worker跳過等待。
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(cacheKey)
.then((_cache) => _cache.addAll(CACHE_LIST))
.then(self.skipWaiting())
)
})
複製程式碼
3. 刪除過期資源,self.clients.claim
方法可以讓當前的Service Worker立刻掌控頁面,實現頁面的及時更新。
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then((keys) => (
Promise.all(
keys.filter((key) => key !== cacheKey)
.map((key) => caches.delete(key))
)
)).then(() => {
self.clients.claim()
})
)
})
複製程式碼
4. 使用未過期的快取
self.addEventListener('fetch', function (event) {
if (CACHE_LIST.find((cache) => {
return event.request.url.endsWith(cache)
})) {
event.respondWith(
caches.match((event.request)).then((cachedResponse) => (
cachedResponse || fetch(event.request)
))
)
}
})
複製程式碼
使用後臺同步保證離線功能
客戶端和web在使用者的角度看來,有一個很大的區別是,在客戶端執行了一些操作,比如釋出文章。就算在斷網狀態下,使用者也不會擔心自己編輯的內容丟失。如果在一般的web頁面,所有的資料只會跟隨瀏覽器的關閉而小時。
在Service Worker的支援下,我們可以頁面上註冊一個同步事件傳送到Service Worker。在Service Worker中完勝資料請求。
這樣,就不需要擔心使用者資料丟失的問題了。即使使用者在斷網的狀態下傳送的資料請求,當裝置重新聯網的時候,Service Worker會自動幫助我們完成傳送。
下面我們就來看一下,如何使用具體程式碼來實現這個功能。
需要注意的是,我們需要在Service Worker的ready
事件中去繫結按鈕的點選事件,來確保使用者點選的時候,Service Worker已經準備好了。
然後我們通過registration.sync.register('send-messages')
來傳送給同步事件。send-messages
只是當前事件的一個標識。在Service Worker中可以使用它來判斷應該處理什麼樣的邏輯。
事件標識是唯一的,如果Service Worker正在處理或者還沒有處理完成一個標識的時候,使用這個已有的標識再次註冊sync事件,那麼這個事件將會被忽略。如果我們不想讓新的操作被忽略,可以在事件後邊加上遞增ID,例如send-messages1
。
// html
<button id="submit">傳送請求</button>
// js
window.onload = function() {
navigator.serviceWorker.register('./serviceworker.js')
navigator.serviceWorker.ready.then((registration) => {
document.getElementById('submit').addEventListener('click', () => {
registration.sync.register('send-messages')
})
})
}
複製程式碼
在Service Worker中,註冊了一個同步事件,通過event.tag
拿到我們剛才傳送的標識。來處理髮送資訊的操作。
如果傳送資訊失敗,這個同步事件過一段時間將會再次嘗試傳送。當event.lastChance
屬性為true
時,將會放棄嘗試。在chrome瀏覽器中測試,一共會傳送三次,第一次到第二次的間隔為5分鐘,第二次到第三次的間隔為10分鐘。
function sendMessages() {
return fetch('http://localhost:3000/').then((response) => {
return response.json()
}).then((data) => {
console.log(data.errCode === 0)
return data.errCode === 0 ? Promise.resolve() : Promise.reject()
})
}
self.addEventListener('sync', (event) => {
if (event.tag === 'send-messages') {
event.waitUntil(
sendMessages().catch(() => {
if (event.lastChance) {
console.log('不會再次嘗試請求了')
}
return Promise.reject()
})
)
}
})
複製程式碼
下面,我們可以寫一個簡單伺服器,用來嘗試這個例子。
const express = require('express')
const app = express()
const port = 3000
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', '*');
next();
});
app.get('/', (req, res) => {
const response = { errCode: 0 };
const date = new Date();
const hour = date.getHours();
const minutes = date.getMinutes();
const second = date.getSeconds();
const time = `${hour}:${minutes}:${second}`;
console.log('請求成功!引數:', req.query, '返回值:', response, '時間:', time)
res.send(response)
})
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
複製程式碼
在斷網的時候,點選按鈕,伺服器不會收到請求。當裝置恢復網路的時候,伺服器會馬上收到請求。我們可以將返回值的errCode修改為1,嘗試下Service Worker是否會傳送多次請求。
sync事件的資料傳遞
上面的例子中,展示瞭如何使用Service Worker來代理資料請求。但是大部分的資料請求都是需要引數的,那麼如何將引數傳遞到Service Worker呢。
1. 使用標識傳遞引數
對於一些簡單引數而言,可以直接使用標示來傳遞。這樣的話,事件標示就有兩個組成部分,第一個部分是標識型別,規定了Service Worker的同步事件採取什麼樣的程式碼邏輯,第二個部分就是引數。這兩個部分使用"_"進行分割。
// 瀏覽器環境
navigator.serviceWorker.ready.then((registration) => {
document.getElementById('submit').addEventListener('click', () => {
const content = document.getElementById('content').value
registration.sync.register(`send-messages_${content}`)
})
})
複製程式碼
// Service Worker
function sendMessages(content) {
return fetch(`http://localhost:3000/?content=${content}`).then((response) => {
return response.json()
}).then((data) => {
console.log(data.errCode === 0)
return data.errCode === 0 ? Promise.resolve() : Promise.reject()
})
}
self.addEventListener('sync', (event) => {
if (event.tag.startsWith('send-messages')) {
const content = event.tag.split('_')[1]
event.waitUntil(
sendMessages(content).catch(() => {
if (event.lastChance) {
console.log('不會再次嘗試請求了')
}
return Promise.reject()
})
)
}
})
複製程式碼
2. 使用indexedDB傳遞引數
Service Worker環境中,除了CacheStorage外,也可以使用基於瀏覽器的本地資料庫indexedDB。
indexedDB是一個基於瀏覽器的本地資料庫,操作indexedDB基本可以分為4個步驟。
- 開啟資料庫
- 啟動事務
- 開啟物件儲存
- 在物件儲存中完成操作
通過程式碼的形式來展示一下如何操作indexedDB。
// 定義global物件 因為indexedDB的程式碼需要在瀏覽器和Service Worker兩個環境下執行
const _global = typeof window === 'undefined' ? self : window
// 開啟資料庫
// 如果indexedDB已經存在,window.indexedDB.open方法不會重新建立,只會開啟那個已經建立好的資料庫。window.indexedDB.open方法的第二個個引數是資料庫版本號。
// onupgradeneeded只會在資料庫版本升級的時候執行,用來建立物件儲存。
const openDataBase = function () {
return new Promise((resolve, reject) => {
const request = _global.indexedDB.open('conent-db', 1)
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains('list')) {
db.createObjectStore('list', {
keyPath: 'id',
autoIncrement: true
})
}
}
request.onerror = (err) => reject(err)
request.onsuccess = (event) => resolve(event.target.result)
})
}
// 啟動事務
const openObjectStore = async function (storeName, mode) {
const db = await openDataBase()
return db.transaction(storeName, mode).objectStore(storeName)
}
_global.db = {
set: async function (content) {
// 開啟資料儲存
const objectStore = await openObjectStore('list', 'readwrite')
// 新增資料
return objectStore.add({ content })
},
getAll: async function () {
// 開啟資料儲存
const objectStore = await openObjectStore('list')
return new Promise((resolve) => {
const data = []
// 根據遊標查詢資料
// 我們在建立資料庫的時候使用autoIncrement設定自增主鍵,所以需要通過遊標查詢所有的資料
objectStore.openCursor().onsuccess = function (event) {
const cursor = event.target.result
if (!cursor) {
return resolve(data)
} else {
data.push(cursor.value)
cursor.continue()
}
}
})
},
clear: async function (ids) {
// 開啟資料儲存
const objectStore = await openObjectStore('list', 'readwrite')
// 清空物件
return objectStore.clear()
}
}
複製程式碼
在瀏覽器環境下,呼叫剛才封裝的indexedDB的set方法完成對資料引數的儲存
document.getElementById('submit').addEventListener('click', async () => {
const content = document.getElementById('content').value
await db.set(content)
registration.sync.register(`send-messages`)
})
複製程式碼
在Service Worker中,獲取到所有的content,通過Promise.all全部傳送。成功之後清除資料。
self.addEventListener('sync', (event) => {
if (event.tag === 'send-messages') {
event.waitUntil(
self.db.getAll().then((contents) => {
return Promise.all(
contents.map(({ content }) => {
return sendMessages(content)
})
)
}).then(() => {
return self.db.clear()
})
)
}
})
複製程式碼
這樣我們就完成了使用indexedDB傳遞引數了。
總結
本文介紹了Service Worker的基本概念和特性,並且從快取和後臺傳送請求兩個方面闡述瞭如何優化我們的專案。
其實Service Worker的優化能力不僅僅是這些,相信它還有更加強大的作用等著我們一起來挖掘!