本文是奇舞團泛前端分享會Service Worker初探的一次記錄,是對App內嵌web頁面使用Service Worker優化的一次總結。
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安裝完成,會進入Waiting狀態。如果頁面所有的標籤頁全部關閉之後,或者導航到一個不在控制範圍內的頁面。再次打開新的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>發送請求/<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的優化能力不僅僅是這些,相信它還有更加強大的作用等著我們一起來挖掘!
閱讀更多 開發者 的文章