Service Worker初探

本文是奇舞團泛前端分享會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同樣可以和服務器通信。完成尚未完成的數據請求,可以確保用戶的任何操作都可以發送到服務器。

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之後,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緩存的關係

Service Worker初探

在發送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字段來設置我們的版本號。

Service Worker初探

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個步驟。

  1. 打開數據庫
  2. 啟動事務
  3. 打開對象存儲
  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的優化能力不僅僅是這些,相信它還有更加強大的作用等著我們一起來挖掘!


分享到:


相關文章: