前端快取機制提升網站效能 - Service Worker

小磊發表於2022-03-17

PWA 簡介

PWA(Progressive Web Apps)不是特指某一項技術,而是應用多項技術來改善使用者體驗的 Web App,為 Web App 提供類似 Native App 的使用者體驗。
其核心技術包括 Web App Manifest,Web Push,Service Worker 和 Cache Api 等,使用者體驗才是 PWA 的核心。
PWA 主要特點如下:

  • 可靠 - 即使在網路不穩定甚至斷網的環境下,也能瞬間載入並展現
  • 使用者體驗 - 快速響應,具有平滑的過渡動畫及使用者操作的反饋
  • 使用者黏性 - 和 Native App 一樣,可以被新增到桌面,能接受離線通知,具有沉浸式的使用者體驗

    寫在前面

  • 文章不具體講解 PWA 技術細節,如果對 PWA 技術感興趣,文末準備了一些資料,可以參考學習
  • 此次調研目的並非為網站完整接入 PWA 技術,而是利用其快取機制提升網站效能
  • 主要用到的技術為 Service Worker + Cache Api

    前端多級快取模型

    當瀏覽器想要獲取遠端的資料時,我們並不會立即動身(傳送請求),在計算機領域,很多效能問題都會通過增加快取來解決,前端也不例外。
    和許多後端服務一樣,前端快取也是多級的。

  • 本地讀取階段,這個階段我們不會發起任何 HTTP 請求,只在本地讀取資料作為響應
  • HTTP request 階段,這個階段我們發起了 HTTP 請求,但是資料依然是從本地讀取。目前為止,我們可能還沒有發出一個真正的請求。這也意味著,在快取檢查階段我們就會有很多機會將後續的效能問題扼殺在搖籃之中
  • 真正請求階段,如果很不幸本地沒有任何有效資料,這時候才會發起真正的請求
    前端多級快取詳細流程圖如下:
    image.png

    有了 HTTP 快取為什麼還需要 Service Worker?

Service worker除了針對PWA(推送和訊息)以外,對普通web來說,在快取方面,能比http快取帶來一些額外的好處。
可以理解為,SW就是瀏覽器把快取管理開放一層 介面 給開發者。
優勢如下:
1、改寫預設行為。
例如,瀏覽器預設在重新整理時,會對所有資源都重新發起請求,即使快取還是有效期內,而使用了SW,就可以改寫這個行為,直接返回快取。
2、快取和更新並存。
要讓網頁離線使用,就需要整站使用長快取,包括HTML。而HTML使用了長快取,就無法及時更新(瀏覽器沒有開放介面直接刪除某個html快取)。而使用SW就可以,每次先使用快取部分,然後再發起SW js的請求,這個請求我們可以實施變更,修改HTML版本,重新快取一份。那麼使用者下次開啟就可以看到新版本了。
3、無侵入式。
無侵入式版本控制。最優的版本控制,一般是HTML中記錄所有js css的檔名(HASH),然後按需發起請求。每個資源都長快取。而這個過程,就需要改變了專案結構,至少多一個js或者一段js控制版本號,發起請求時還需要url中注入冬天的檔名。使用了SW,就可以把這部分非業務邏輯整合到sw js中。
無侵入式請求統計。例如快取比例統計、圖片404統計。
4、額外快取。
HTTP快取空間有限,容易被沖掉。雖然部分瀏覽器實現SW的儲存也有淘汰機制,但多一層快取,命中的概率就要更高了。
5、離線處理。
當監測到離線,而且又沒有快取某個圖片時,可以做特殊處理,返回離線的提示。又或者做一個純前端的404/斷網頁面。類似Chrome的小恐龍頁面。
6、預載入資源。
這個類似prefetch標籤。
7、前置處理。
例如校驗html/JS是否被運營商劫持?js檔案到了UI程式執行後,就無法刪除惡意程式碼,而在SW中,我們可以當作文字一樣,輕鬆解決。當然,在HTTPS環境下出現劫持的概率是極低的。
來源:https://www.cnblogs.com/kenko...

Service Worker

簡介

Service Worker 的初衷是極致優化使用者體驗,帶來絲滑般流暢的離線應用。但同時也可以用作站點快取使用。它本身類似於一個介於瀏覽器和服務端之間的網路代理,可以攔截請求並操作響應內容。
Service Worker 在 Web Worker 的基礎上加上了持久離線快取能力,可以通過自身的生命週期特性保證複雜的工作只處理一次,並持久快取處理結果,直到修改了 Service Worker 的內在的處理邏輯。
特點總結如下:

  • 一個特殊的 worker 執行緒,獨立於當前網頁主執行緒,有自己的執行上下文
  • 一旦被安裝,就永遠存在,除非顯示取消註冊
  • 使用到的時候瀏覽器會自動喚醒,不用的時候自動休眠
  • 可攔截並代理請求和處理返回,可以操作本地快取,如 CacheStorage,IndexedDB 等
  • 離線內容開發者可控
  • 能接受伺服器推送的離線訊息
  • 非同步實現,內部介面非同步化基本是通過 Promise 實現
  • 不能直接操作 DOM
  • 必須在 HTTPS 環境下才能工作

    使用者

    有很多團隊也是啟用該工具來實現 Service Worker 的快取,比如說:

  • 淘寶首頁
  • 網易新聞 wap 文章頁
  • 百度的 Lavas
  • fullstory
    ... ...

相容性

如下圖所示,除了 IE 和 Opera Mini 不支援,大部分現代瀏覽器都沒有問題,相容度超過 96%

安全性

Service Worker 是一種獨立於瀏覽器主執行緒的工作執行緒,與當前的瀏覽器主執行緒是完全隔離的,並有自己獨立的執行上下文(context)。由於 Service Worker 執行緒是獨立於主執行緒的工作執行緒,所以在 Service Worker 中的任何操作都不會影響到主執行緒。
因此,在瀏覽器不支援 Service Worker、Service Worker 掛掉和 Service Worker 出錯等等情況下,主體網站都不會受到影響,因此從網站故障角度講是 100% 安全的。
其可能出現問題的地方在於資料的準確性,這涉及到快取策略和淘汰演算法等技術,也是配置 Service Worker 的重點。

作用域

Service Worker 註冊會有意想不到的作用域汙染問題
SPA 在工程架構上只有一個 index.html 的入口,站點的內容都是非同步請求資料之後在前端渲染的,應用中的頁面切換都是在前端路由控制的。
通常會將這個 index.html 部署到 https://somehost ,SPA 的 Service Worker 只需要在 index.html 中註冊一次。所以一般會將 sw.js 直接放在站點的根目錄保證可訪問,也就是說 Service Worker 的作用域通常就是 /,這樣 Service Worker 能夠控制 index.html,從而控制整個 SPA 的快取。

程式碼如下:

  var sp = window.location.protocol + '//' + window.location.host + '/';
  if ('serviceWorker' in navigator) {
    // 為了防止作用域汙染,將安裝前登出所有已生效的 Service Worker
    navigator.serviceWorker.getRegistrations().then(regs => {
      for (let reg of regs) {
        reg.unregister();
      }
      navigator.serviceWorker
        .register(sp + 'service-worker.js', {
          scope: sp,
        })
        .then(reg => {
          console.log('set scope: ', sp, 'service worker instance: ', reg)
        });
    });
  }

更新

在執行 navigator.serviceWorker.register() 方法註冊 Service Worker 的時候,瀏覽器通過自身 diff 演算法能夠檢測 sw.js 的更新包含兩種方式:

  • Service Worker 檔案 URL 的更新
  • Service Worker 檔案內容的更新

在實際專案中,在 Web App 新上線的時候,通常是在註冊 Service Worker 的時候,通過修改 Service Worker 檔案的 URL 來進行 Service Worker 的更新,這部分工作可以通過 webpack 外掛實現

快取策略

預快取

靜態資源具有確定性,因此可以主動獲取所需快取的資源列表,並且在 Service Worker 安裝階段就主動發起靜態資源請求並快取,這樣一旦新的 Service Worker 被啟用之後,快取就直接能投入使用了。這是一個資源預取的過程,因此靜態資源的快取方案也稱為預快取方案。關於預快取更多細節可以參考預快取方案

動態快取

在 Service Worker 環境下,可以通過 Fetch API 傳送網路請求獲取資源,也可以通過 Cache API、IndexedDB 等本地快取中獲取快取資源,甚至可以在 Service Worker 直接生成一個 Response 物件,以上這些都屬於資源響應的來源。資源請求響應策略的作用,就是用來解決響應的資源從哪裡來的問題。更多請求響應策略參考這裡

一些建議

  1. HTML,如果你想讓頁面離線可以訪問,使用 NetworkFirst,如果不需要離線訪問,使用 NetworkOnly,其他策略均不建議對 HTML 使用
  2. CSS 和 JS,情況比較複雜,因為一般站點的 CSS,JS 都在 CDN 上,SW 並沒有辦法判斷從 CDN 上請求下來的資源是否正確(HTTP 200),如果快取了失敗的結果,問題就大了。這種我建議使用 Stale-While-Revalidate 策略,既保證了頁面速度,即便失敗,使用者重新整理一下就更新了
  3. 如果你的 CSS,JS 與站點在同一個域下,並且檔名中帶了 Hash 版本號,那可以直接使用 Cache First 策略
  4. 圖片建議使用 Cache First,並設定一定的失效事件,請求一次就不會再變動了
  5. 所有介面類快取都建議 Stale-While-Revalidate 策略
  6. 對於不在同一域下的任何資源,絕對不能使用 Cache only 和 Cache first。

更多快取策略相關,可以在下面文章檢視:
PWA之Workbox快取策略分析
Service Worker 開發工具

生命週期

關於 Service Worker 生命週期相關的,主要是涉及 Service Worker 自身的更新和在什麼階段快取對應的資源。更多資訊點選這裡

Cache API

離線儲存方案對比

前端主流離線儲存方案對比如下所示:
image.png
Cache API 是為資源請求與響應的儲存量身定做的,它採用了鍵值對的資料模型儲存格式,以請求物件為鍵、響應物件為值,正好對應了發起網路資源請求時請求與響應一一對應的關係。因此 Cache API 適用於請求響應的本地儲存。

IndexedDB 則是一種非關係型(NoSQL)資料庫,它的儲存物件主要是資料,比如數字、字串、Plain Objects、Array 等,以及少量特殊物件比如 Date、RegExp、Map、Set 等等,對於 Request、Response 這些是無法直接被 IndexedDB 儲存的。

可以看到,Cache API 和 IndexedDB 在功能上是互補的。在設計本地資源快取方案時通常以 Cache API 為主,但在一些複雜的場景下,Cache API 這種請求與響應一一對應的形式存在著侷限性,因此需要結合上功能上更為靈活的 IndexedDB,通過 IndexedDB 存取一些關鍵的資料資訊,輔助 Cache API 進行資源管理。

相容性


總結

通過上述對比,我們可以使用 IndexedDB 及 CacheStorage 來為 Service Worker 的離線儲存提供底層服務,根據社群的經驗,它們各自的適用場景為:

  • 對於網址可定址的(比如指令碼、樣式、圖片、HTML 等)資源使用 CacheStorage
  • 其他資源則使用 IndexedDB

Workbox

簡介

在頁面執行緒中,雖然可以直接使用底層 API 來處理 Service Worker 的註冊、更新與通訊,但在較為複雜的應用場景下(比如,頁面中不同視窗註冊不同的 Service Worker),我們往往會因為要處理各種情況而逐步陷入複雜、混亂的深淵,並且,在出現執行結果與預期結果不一致時,我們往往不知所措、不知如何進行排查。正是因為這些原因,Google Chrome 團隊推出的一套 PWA 的解決方案 Workbox ,這套解決方案當中包含了核心庫和構建工具,因此我們可以利用 Workbox 實現 Service Worker 的快速開發。

webpack 外掛

官方提供 workbox-webpack-plugin 外掛為我們進一步節省開發成本(版本v6.4.2)

為什麼需要這個 webpack 外掛?

  • 給預快取打hash,開發的時候動態更新 hash
  • 更方便的介面去動態快取配置方式,自動生成和更新 sw

接入程式碼

const { InjectManifest } = require('workbox-webpack-plugin');
 // 注入模式
new InjectManifest({
  swSrc: path.resolve(__dirname, 'src/service-worker.js'), // 已有 SW 路徑
  swDest: 'service-worker.js', // 目標檔名(打包後)
  maximumFileSizeToCacheInBytes: 1024000 * 4, // 只快取 4M 以下的檔案
  include: [/.*.(png|jpg|jpeg|svg|ico|webp)$/, 'beautify.js'], // 僅包含圖片和beautify.js
}),

service-worker.js 完整程式碼

// 基礎配置
import { setCacheNameDetails, skipWaiting, clientsClaim } from 'workbox-core';
// 快取相關
import { precacheAndRoute } from 'workbox-precaching/precacheAndRoute';
import { registerRoute, setDefaultHandler } from 'workbox-routing';
import {
  NetworkFirst,
  StaleWhileRevalidate,
  CacheFirst,
  NetworkOnly,
} from 'workbox-strategies';
// 外掛
import { CacheableResponsePlugin } from 'workbox-cacheable-response/CacheableResponsePlugin';
import { ExpirationPlugin } from 'workbox-expiration/ExpirationPlugin';
// 內建方案
import { pageCache, offlineFallback } from 'workbox-recipes';

// 自定義外掛去掉請求引數 t
async function cacheKeyWillBeUsed({ request }) {
  const url = new URL(request.url);
  // Remove only paramToBeIgnored and keep other URL parameters.
  url.searchParams.delete('t');
  // Return the full remaining href.
  return url.href;
}

setCacheNameDetails({
  prefix: 'sw-tools',
  suffix: 'v1',
  precache: 'precache',
  runtime: 'runtime-cache',
});

skipWaiting();
clientsClaim();
/*
 通常當使用者訪問 / 時,對應的訪問的頁面 HTML 檔案是 /index.html,預設情況下,precache 路由機制會在任何 URL 的結尾的 / 後加上 index.html,這就以為著你預快取的任何 index.html 都可以通過 /index.html 或者 / 訪問到。當然,你也可以通過 directoryIndex 引數禁用掉這個預設行為
 */
precacheAndRoute(self.__WB_MANIFEST, {
  ignoreUrlParametersMatching: [/.*/],
  directoryIndex: null,
});

// 離線頁面快取
offlineFallback();
// URL navigation 快取
pageCache();

// html 的快取
// HTML,如果你想讓頁面離線可以訪問,使用 NetworkFirst,如果不需要離線訪問,使用 NetworkOnly,其他策略均不建議對 HTML 使用。
registerRoute(new RegExp(/.*\.html/), new NetworkFirst());

// 靜態資源的快取
//CSS 和 JS,情況比較複雜,因為一般站點的 CSS,JS 都在 CDN 上,SW 並沒有辦法判斷從 CDN 上請求下來的資源是否正確(HTTP 200),如果快取了失敗的結果,問題就大了。這種我建議使用 Stale-While-Revalidate 策略,既保證了頁面速度,即便失敗,使用者重新整理一下就更新了。如果你的 CSS,JS 與站點在同一個域下,並且檔名中帶了 Hash 版本號,那可以直接使用 Cache First 策略。
const staticMatchCallback = ({ request }) =>
  // CSS
  request.destination === 'style' ||
  // JavaScript
  request.destination === 'script' ||
  // Web Workers
  request.destination === 'worker';
registerRoute(
  staticMatchCallback,
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 500,
        maxAgeSeconds: 30 * 24 * 60 * 60,
        purgeOnQuotaError: true,
      }),
    ],
  }),
);

// 圖片的快取
// 圖片建議使用 Cache First,並設定一定的失效事件,請求一次就不會再變動了。
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 200,
        maxAgeSeconds: 30 * 24 * 60 * 60,
        purgeOnQuotaError: true,
      }),
    ],
  }),
);

// 事件流介面的快取
registerRoute(
  /^http(s)?:\/\/((dev\.)|(test\.)|(testing\.))?xxxx.net\/api\/v\d+\/(.*)?\/session_events.*/,
  new StaleWhileRevalidate({
    cacheName: 'session_events_cache',
    plugins: [
      { cacheKeyWillBeUsed },
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 2000,
        maxAgeSeconds: 7 * 24 * 60 * 60,
        purgeOnQuotaError: true,
      }),
    ],
  }),
);

// play/init 介面
registerRoute(
  /^http(s)?:\/\/((dev\.)|(test\.)|(testing\.))?xxxxx.net\/api\/v\d+\/(.*)?\/play\/init.*/,
  new StaleWhileRevalidate({
    cacheName: 'play_init_cache',
    plugins: [
      { cacheKeyWillBeUsed },
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 10000,
        maxAgeSeconds: 7 * 24 * 60 * 60,
        purgeOnQuotaError: true,
      }),
    ],
  }),
);

示例圖

圖 1

圖 2

相關資料

  1. 前端效能優化 - 快取
  2. 【MDN】IndexedDB 瀏覽器儲存限制和清理標準
  3. 網易雲課堂 Service Worker 運用與實踐
  4. workBox 官方
  5. 使用 Workbox
  6. workbox快取常用範例
  7. workbox路由請求
  8. 淘寶前端 Workbox 應用
  9. 神奇的 Workbox 3.0
  10. 餓了麼的 PWA 升級實踐
  11. 百度 Web 生態團隊《PWA 應用實戰》
  12. 深入淺出 PWA
  13. workbox-webpack-plugin 外掛相關

相關文章