Service Worker初探

__MrWang__發表於2020-03-22

本文是奇舞團泛前端分享會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同樣可以和伺服器通訊。完成尚未完成的資料請求,可以確保使用者的任何操作都可以傳送到伺服器。

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的狀態。

只有在這個狀態下,fetchsyncmessage的一系列事件事件才能夠正常監聽。所以,有的時候我們發現,在頁面第一次載入,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快取的關係

Service Worker初探

在傳送http請求的時候,請求會先到達Service Worker。在Service Worker中,使用CacheStorage來查詢是否具有可用的快取。

如果沒有,瀏覽器先會檢測Cache-Control是否使用當前的瀏覽器快取,這就是我們常說的強快取。

如果瀏覽器快取已過期,請求正式到達伺服器。再去判斷資源的ETagLast-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拼接的字串,作為快取名稱來使用。VERSIONCACHE_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個步驟。

  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的優化能力不僅僅是這些,相信它還有更加強大的作用等著我們一起來挖掘!

相關文章