serviceworker 運用與實踐
作者:網易 @brizer
https://github.com/omnipotent-front-end/blog/issues/2
前言
本文首先會簡單介紹下前端的常見緩存方式,再引入 serviceworker 的概念,針對其原理和如何運用進行介紹。然後基於 google 推出的第三方庫 workbox,在產品中進行運用實踐,並對其原理進行簡要剖析。
前端緩存簡介
先簡單介紹一下現有的前端緩存技術方案,主要分爲 http 緩存和瀏覽器緩存。
http 緩存
http 緩存都是第二次請求時開始的,這也是個老生常談的話題了。無非也是那幾個 http 頭的問題:
Expires
HTTP1.0 的內容,服務器使用 Expires 頭來告訴 Web 客戶端它可以使用當前副本,直到指定的時間爲止。
Cache-Control
HTTP1.1 引入了 Cathe-Control,它使用 max-age 指定資源被緩存多久,主要是解決了 Expires 一個重大的缺陷,就是它設置的是一個固定的時間點,客戶端時間和服務端時間可能有誤差。
所以一般會把兩個頭都帶上,這種緩存稱爲強緩存,表現形式爲:
Last-Modified / If-Modified-Since
Last-Modified 是服務器告訴瀏覽器該資源的最後修改時間,If-Modified-Since 是請求頭帶上的,上次服務器給自己的該資源的最後修改時間。然後服務器拿去對比。
若資源的最後修改時間大於 If-Modified-Since,說明資源又被改動過,則響應整片資源內容,返回狀態碼 200;
若資源的最後修改時間小於或等於 If-Modified-Since,說明資源無新修改,則響應 HTTP 304,告知瀏覽器繼續使用當前版本。
Etag / If-None-Match
前面提到由文件的修改時間來判斷文件是否改動,還是會帶來一定的誤差,比如註釋等無關緊要的修改等。所以推出了新的方式。
Etag 是由服務端特定算法生成的該文件的唯一標識,而請求頭把返回的 Etag 值通過 If-None-Match 再帶給服務端,服務端通過比對從而決定是否響應新內容。這也是 304 緩存。
瀏覽器緩存
storage
簡單的緩存方式有 cookie,localStorage 和 sessionStorage。這裏就不詳細介紹他們的區別了,這裏說下通過 localStorage 來緩存靜態資源的優化方案。
localStorage 通常有 5MB 的存儲空間,我們以[微信文章頁](https://mp.weixin.qq.com/s?__biz=MzAwNjMxMTA5Mw==&mid=2651340686&idx=1&sn=3714a2d0941667bed71228c66351ee76&chksm=80f3c254b7844b42f38f61de40a50076d69a1fe752c46348e4121525d12144f0bedbb90c79c6&mpshare=1&scene=21&srcid=0906lJRVVg3vsE0wP2BnDlLu&key=ff3a3f386f4471abb7bae740ce6af96999ee908a3e121c460d8a5152d30f4a98db19585834e9746bf411d11071c558231692934f2c20adf0a393c35d83836ee543b76a7f95ffdc8192edbfaac941f906&ascene=0&uin=MjUwMTIyNjY4Mg==&devicetype=iMac MacBookPro13,2 OSX OSX 10.12.1 build(16B2657)&version=12020810&nettype=WIFI&lang=zh_CN&fontScale=100&pass_ticket=b33mKzDsMeDiiLYT97yWzaAceoFf/ly7szwZmE/62 tvutX3nrerhLuye wpBGCb#wechat_redirect)爲例。
查看請求發現,基本沒有 js 和 css 的請求,因爲它把全部的不需要改動的資源都放到了 localStorage 中:
所以微信的文章頁加載非常的快。
前端數據庫
前端數據庫有 WebSql 和 IndexDB,其中 WebSql 被規範廢棄,他們都有大約 50MB 的最大容量,可以理解爲 localStorage 的加強版。
應用緩存
應用緩存主要是通過 manifest 文件來註冊被緩存的靜態資源,已經被廢棄,因爲他的設計有些不合理的地方,他在緩存靜態文件的同時,也會默認緩存 html 文件。這導致頁面的更新只能通過 manifest 文件中的版本號來決定。所以,應用緩存只適合那種常年不變化的靜態網站。如此的不方便,也是被廢棄的重要原因。
PWA 也運用了該文件,不同於 manifest 簡單的將文件通過是否緩存進行分類,PWA 用 manifest 構建了自己的 APP 骨架,並運用 Servie Worker 來控制緩存,這也是今天的主角。
Service Worker
Service Worker 本質上也是瀏覽器緩存資源用的,只不過他不僅僅是 cache,也是通過 worker 的方式來進一步優化。
他基於 h5 的 web worker,所以絕對不會阻礙當前 js 線程的執行,sw 最重要的工作原理就是
1、後臺線程:獨立於當前網頁線程;
2、網絡代理:在網頁發起請求時代理,來緩存文件;
兼容性
可以看到,基本上新版瀏覽器還是兼容滴。之前是隻有 chrome 和 firefox 支持,現在微軟和蘋果也相繼支持了。
成熟程度
判斷一個技術是否值得嘗試,肯定要考慮下它的成熟程度,否則過一段時間又和應用緩存一樣被規範拋棄就尷尬了。
所以這裏我列舉了幾個使用 Service Worker 的頁面:
淘寶
網易新聞
考拉
如果想看看還有哪些你瀏覽過的網站啓動了該功能,可以通過 chrome 訪問 chrome://serviceworker-internals/?devtools
所以說還是可以嘗試下的。
調試方法
一個網站是否啓用 Service Worker,可以通過開發者工具中的 Application 來查看:
被 Service Worker 緩存的文件,可以在 Network 中看到 Size 項爲 from ServiceWorker:
也可以在 Application 的 Cache Storage 中查看緩存的具體內容:
如果是具體的斷點調試,需要使用對應的線程,不再是 main 線程了,這也是 webworker 的通用調試方法:
使用條件
sw 是基於 HTTPS 的,因爲 service worker 中涉及到請求攔截,所以必須使用 HTTPS 協議來保障安全。如果是本地調試的話,localhost 是可以的。
而我們剛好全站強制 https 化,所以正好可以使用。
生命週期
大概可以用如下圖片來解釋:
註冊
要使用 Service worker,首先需要註冊一個 sw,通知瀏覽器爲該頁面分配一塊內存,然後 sw 就會進入安裝階段。
一個簡單的註冊方式:
(function() {
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js');
}
})()
當然也可以考慮全面點,參考網易新聞的註冊方式:
"serviceWorker" in navigator && window.addEventListener("load",
function() {
var e = location.pathname.match(/\/news\/[a-z]{1,}\//)[0] + "article-sw.js?v=08494f887a520e6455fa";
navigator.serviceWorker.register(e).then(function(n) {
n.onupdatefound = function() {
var e = n.installing;
e.onstatechange = function() {
switch (e.state) {
case "installed":
navigator.serviceWorker.controller ? console.log("New or updated content is available.") : console.log("Content is now available offline!");
break;
case "redundant":
console.error("The installing service worker became redundant.")
}
}
}
}).
catch(function(e) {
console.error("Error during service worker registration:", e)
})
})
前面提到過,由於 sw 會監聽和代理所有的請求,所以 sw 的作用域就顯得額外的重要了,比如說我們只想監聽我們專題頁的所有請求,就在註冊時指定路徑:
navigator.serviceWorker.register('/topics/sw.js');
這樣就只會對 topics / 下面的路徑進行優化。
installing
我們註冊後,瀏覽器就會開始安裝 sw,可以通過事件監聽:
//service worker安裝成功後開始緩存所需的資源
var CACHE_PREFIX = 'cms-sw-cache';
var CACHE_VERSION = '0.0.20';
var CACHE_NAME = CACHE_PREFIX+'-'+CACHE_VERSION;
var allAssets = [
'./main.css'
];
self.addEventListener('install', function(event) {
//調試時跳過等待過程
self.skipWaiting();
// Perform install steps
//首先 event.waitUntil 你可以理解爲 new Promise,
//它接受的實際參數只能是一個 promise,因爲,caches 和 cache.addAll 返回的都是 Promise,
//這裏就是一個串行的異步加載,當所有加載都成功時,那麼 SW 就可以下一步。
//另外,event.waitUntil 還有另外一個重要好處,它可以用來延長一個事件作用的時間,
//這裏特別針對於我們 SW 來說,比如我們使用 caches.open 是用來打開指定的緩存,但開啓的時候,
//並不是一下就能調用成功,也有可能有一定延遲,由於系統會隨時睡眠 SW,所以,爲了防止執行中斷,
//就需要使用 event.waitUntil 進行捕獲。另外,event.waitUntil 會監聽所有的異步 promise
//如果其中一個 promise 是 reject 狀態,那麼該次 event 是失敗的。這就導致,我們的 SW 開啓失敗。
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('[SW]: Opened cache');
return cache.addAll(allAssets);
})
);
});
安裝時,sw 就開始緩存文件了,會檢查所有文件的緩存狀態,如果都已經緩存了,則安裝成功,進入下一階段。
activated
如果是第一次加載 sw,在安裝後,會直接進入 activated 階段,而如果 sw 進行更新,情況就會顯得複雜一些。流程如下:
首先老的 sw 爲 A,新的 sw 版本爲 B。
B 進入 install 階段,而 A 還處於工作狀態,所以 B 進入 waiting 階段。只有等到 A 被 terminated 後,B 才能正常替換 A 的工作。
這個 terminated 的時機有如下幾種方式:
1、關閉瀏覽器一段時間;
2、手動清除 serviceworker;
3、在 sw 安裝時直接跳過 waiting 階段
//service worker安裝成功後開始緩存所需的資源
self.addEventListener('install', function(event) {
//跳過等待過程
self.skipWaiting();
});
然後就進入了 activated 階段,激活 sw 工作。
activated 階段可以做很多有意義的事情,比如更新存儲在 cache 中的 key 和 value:
var CACHE_PREFIX = 'cms-sw-cache';
var CACHE_VERSION = '0.0.20';
/**
* 找出對應的其他key並進行刪除操作
* @returns {*}
*/
function deleteOldCaches() {
return caches.keys().then(function (keys) {
var all = keys.map(function (key) {
if (key.indexOf(CACHE_PREFIX) !== -1 && key.indexOf(CACHE_VERSION) === -1){
console.log('[SW]: Delete cache:' + key);
return caches.delete(key);
}
});
return Promise.all(all);
});
}
//sw激活階段,說明上一sw已失效
self.addEventListener('activate', function(event) {
event.waitUntil(
// 遍歷 caches 裏所有緩存的 keys 值
caches.keys().then(deleteOldCaches)
);
});
idle
這個空閒狀態一般是不可見的,這種一般說明 sw 的事情都處理完畢了,然後處於閒置狀態了。
瀏覽器會週期性的輪詢,去釋放處於 idle 的 sw 佔用的資源。
fetch
該階段是 sw 最爲關鍵的一個階段,用於攔截代理所有指定的請求,並進行對應的操作。
所有的緩存部分,都是在該階段,這裏舉一個簡單的例子:
//監聽瀏覽器的所有fetch請求,對已經緩存的資源使用本地緩存回覆
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
//該fetch請求已經緩存
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
生命週期大概講清楚了,我們就以一個具體的例子來說明下原生的 serviceworker 是如何在生產環境中使用的吧。
舉個栗子
我們可以以網易新聞的 wap 頁爲例, 其針對不怎麼變化的靜態資源開啓了 sw 緩存,具體的 sw.js 邏輯和解讀如下:
'use strict';
//需要緩存的資源列表
var precacheConfig = [
["https://static.ws.126.net/163/wap/f2e/milk_index/bg_img_sm_minfy.png",
"c4f55f5a9784ed2093009dadf1e954f9"],
["https://static.ws.126.net/163/wap/f2e/milk_index/change.png",
"9af1b102ef784b8ff08567ba25f31d95"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-download.png",
"1c02c724381d77a1a19ca18925e9b30c"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-login-dark.png",
"b59ba5abe97ff29855dfa4bd3a7a9f35"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-refresh.png",
"a5b1084e41939885969a13f8dbc88abd"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-video-play.png",
"065ff496d7d36345196d254aff027240"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon.ico",
"a14e5365cc2b27ec57e1ab7866c6a228"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.eot",
"e4d2788fef09eb0630d66cc7e6b1ab79"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.svg",
"d9e57c341608fddd7c140570167bdabb"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.ttf",
"f422407038a3180bb3ce941a4a52bfa2"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.woff",
"ead2bef59378b00425779c4ca558d9bd"],
["https://static.ws.126.net/163/wap/f2e/milk_index/index.5cdf03e8.js",
"6262ac947d12a7b0baf32be79e273083"],
["https://static.ws.126.net/163/wap/f2e/milk_index/index.bc729f8a.css",
"58e54a2c735f72a24715af7dab757739"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-bohe.png",
"ac5116d8f5fcb3e7c49e962c54ff9766"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-mail.png",
"a12bbfaeee7fbf025d5ee85634fca1eb"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-manhua.png",
"b8905b119cf19a43caa2d8a0120bdd06"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-open.png",
"b7cc76ba7874b2132f407049d3e4e6e6"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-read.png",
"e6e9c8bc72f857960822df13141cbbfd"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-site.png",
"2b0d728b46518870a7e2fe424e9c0085"],
["https://static.ws.126.net/163/wap/f2e/milk_index/version_no_pic.png",
"aef80885188e9d763282735e53b25c0e"],
["https://static.ws.126.net/163/wap/f2e/milk_index/version_pc.png",
"42f3cc914eab7be4258fac3a4889d41d"],
["https://static.ws.126.net/163/wap/f2e/milk_index/version_standard.png",
"573408fa002e58c347041e9f41a5cd0d"]
];
var cacheName = 'sw-precache-v3-new-wap-index-' + (self.registration ? self.registration.scope : '');
var ignoreUrlParametersMatching = [/^utm_/];
var addDirectoryIndex = function(originalUrl, index) {
var url = new URL(originalUrl);
if (url.pathname.slice(-1) === '/') {
url.pathname += index;
}
return url.toString();
};
var cleanResponse = function(originalResponse) {
// If this is not a redirected response, then we don't have to do anything.
if (!originalResponse.redirected) {
return Promise.resolve(originalResponse);
}
// Firefox 50 and below doesn't support the Response.body stream, so we may
// need to read the entire body to memory as a Blob.
var bodyPromise = 'body' in originalResponse ?
Promise.resolve(originalResponse.body) :
originalResponse.blob();
return bodyPromise.then(function(body) {
// new Response() is happy when passed either a stream or a Blob.
return new Response(body, {
headers: originalResponse.headers,
status: originalResponse.status,
statusText: originalResponse.statusText
});
});
};
var createCacheKey = function(originalUrl, paramName, paramValue,
dontCacheBustUrlsMatching) {
// Create a new URL object to avoid modifying originalUrl.
var url = new URL(originalUrl);
// If dontCacheBustUrlsMatching is not set, or if we don't have a match,
// then add in the extra cache-busting URL parameter.
if (!dontCacheBustUrlsMatching ||
!(url.pathname.match(dontCacheBustUrlsMatching))) {
url.search += (url.search ? '&' : '') +
encodeURIComponent(paramName) + '=' + encodeURIComponent(paramValue);
}
return url.toString();
};
var isPathWhitelisted = function(whitelist, absoluteUrlString) {
// If the whitelist is empty, then consider all URLs to be whitelisted.
if (whitelist.length === 0) {
return true;
}
// Otherwise compare each path regex to the path of the URL passed in.
var path = (new URL(absoluteUrlString)).pathname;
return whitelist.some(function(whitelistedPathRegex) {
return path.match(whitelistedPathRegex);
});
};
var stripIgnoredUrlParameters = function(originalUrl,
ignoreUrlParametersMatching) {
var url = new URL(originalUrl);
// Remove the hash; see https://github.com/GoogleChrome/sw-precache/issues/290
url.hash = '';
url.search = url.search.slice(1) // Exclude initial '?'
.split('&') // Split into an array of 'key=value' strings
.map(function(kv) {
return kv.split('='); // Split each 'key=value' string into a [key, value] array
})
.filter(function(kv) {
return ignoreUrlParametersMatching.every(function(ignoredRegex) {
return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes.
});
})
.map(function(kv) {
return kv.join('='); // Join each [key, value] array into a 'key=value' string
})
.join('&'); // Join the array of 'key=value' strings into a string with '&' in between each
return url.toString();
};
var hashParamName = '_sw-precache';
//定義需要緩存的url列表
var urlsToCacheKeys = new Map(
precacheConfig.map(function(item) {
var relativeUrl = item[0];
var hash = item[1];
var absoluteUrl = new URL(relativeUrl, self.location);
var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false);
return [absoluteUrl.toString(), cacheKey];
})
);
//把cache中的url提取出來,進行去重操作
function setOfCachedUrls(cache) {
return cache.keys().then(function(requests) {
//提取url
return requests.map(function(request) {
return request.url;
});
}).then(function(urls) {
//去重
return new Set(urls);
});
}
//sw安裝階段
self.addEventListener('install', function(event) {
event.waitUntil(
//首先嚐試取出存在客戶端cache中的數據
caches.open(cacheName).then(function(cache) {
return setOfCachedUrls(cache).then(function(cachedUrls) {
return Promise.all(
Array.from(urlsToCacheKeys.values()).map(function(cacheKey) {
//如果需要緩存的url不在當前cache中,則添加到cache
if (!cachedUrls.has(cacheKey)) {
//設置same-origin是爲了兼容舊版本safari中其默認值不爲same-origin,
//只有當URL與響應腳本同源才發送 cookies、 HTTP Basic authentication 等驗證信息
var request = new Request(cacheKey, {credentials: 'same-origin'});
return fetch(request).then(function(response) {
//通過fetch api請求資源
if (!response.ok) {
throw new Error('Request for ' + cacheKey + ' returned a ' +
'response with status ' + response.status);
}
return cleanResponse(response).then(function(responseToCache) {
//並設置到當前cache中
return cache.put(cacheKey, responseToCache);
});
});
}
})
);
});
}).then(function() {
//強制跳過等待階段,進入激活階段
return self.skipWaiting();
})
);
});
self.addEventListener('activate', function(event) {
//清除cache中原來老的一批相同key的數據
var setOfExpectedUrls = new Set(urlsToCacheKeys.values());
event.waitUntil(
caches.open(cacheName).then(function(cache) {
return cache.keys().then(function(existingRequests) {
return Promise.all(
existingRequests.map(function(existingRequest) {
if (!setOfExpectedUrls.has(existingRequest.url)) {
//cache中刪除指定對象
return cache.delete(existingRequest);
}
})
);
});
}).then(function() {
//self相當於webworker線程的當前作用域
//當一個 service worker 被初始註冊時,頁面在下次加載之前不會使用它。 claim() 方法會立即控制這些頁面
//從而更新客戶端上的serviceworker
return self.clients.claim();
})
);
});
self.addEventListener('fetch', function(event) {
if (event.request.method === 'GET') {
// 標識位,用來判斷是否需要緩存
var shouldRespond;
// 對url進行一些處理,移除一些不必要的參數
var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching);
// 如果該url不是我們想要緩存的url,置爲false
shouldRespond = urlsToCacheKeys.has(url);
// 如果shouldRespond未false,再次驗證
var directoryIndex = 'index.html';
if (!shouldRespond && directoryIndex) {
url = addDirectoryIndex(url, directoryIndex);
shouldRespond = urlsToCacheKeys.has(url);
}
// 再次驗證,判斷其是否是一個navigation類型的請求
var navigateFallback = '';
if (!shouldRespond &&
navigateFallback &&
(event.request.mode === 'navigate') &&
isPathWhitelisted([], event.request.url)) {
url = new URL(navigateFallback, self.location).toString();
shouldRespond = urlsToCacheKeys.has(url);
}
// 如果標識位爲true
if (shouldRespond) {
event.respondWith(
caches.open(cacheName).then(function(cache) {
//去緩存cache中找對應的url的值
return cache.match(urlsToCacheKeys.get(url)).then(function(response) {
//如果找到了,就返回value
if (response) {
return response;
}
throw Error('The cached response that was expected is missing.');
});
}).catch(function(e) {
// 如果沒找到則請求該資源
console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e);
return fetch(event.request);
})
);
}
}
});
這裏的策略大概就是優先在 cache 中尋找資源,如果找不到再請求資源。可以看出,爲了實現一個較爲簡單的緩存,還是比較複雜和繁瑣的,所以很多工具就應運而生了。
Workbox
由於直接寫原生的 sw.js,比較繁瑣和複雜,所以一些工具就出現了,而 workbox 是其中的佼佼者,由 google 團隊推出。
簡介
在 Workbox 之前,GoogleChrome 團隊較早時間推出過 sw-precache 和 sw-toolbox 庫,但是在 GoogleChrome 工程師們看來,workbox 纔是真正能方便統一的處理離線能力的更完美的方案,所以停止了對 sw-precache 和 sw-toolbox 的維護。
使用者
有很多團隊也是啓用該工具來實現 serviceworker 的緩存,比如說:
淘寶首頁
網易新聞 wap 文章頁
百度的 Lavas
基本配置
首先,需要在項目的 sw.js 文件中,引入 workbox 的官方 js,這裏用了我們自己的靜態資源:
importScripts(
"https://edu-cms.nosdn.127.net/topics/js/workbox_9cc4c3d662a4266fe6691d0d5d83f4dc.js"
);
其中 importScripts 是 webworker 中加載 js 的方式。
引入 workbox 後,全局會掛載一個 workbox 對象
if (workbox) {
console.log('workbox加載成功');
} else {
console.log('workbox加載失敗');
}
然後需要在使用其他的 api 前,提前使用配置
//關閉控制檯中的輸出
workbox.setConfig({ debug: false });
也可以統一指定存儲時 cache 的名稱:
//設置緩存cachestorage的名稱
workbox.core.setCacheNameDetails({
prefix:'edu-cms',
suffix:'v1'
});
precache
workbox 的緩存分爲兩種,一種的 precache,一種的 runtimecache。
precache 對應的是在 installing 階段進行讀取緩存的操作。它讓開發人員可以確定緩存文件的時間和長度,以及在不進入網絡的情況下將其提供給瀏覽器,這意味着它可以用於創建 Web 離線工作的應用。
工作原理
首次加載 Web 應用程序時,workbox 會下載指定的資源,並存儲具體內容和相關修訂的信息在 indexedDB 中。
當資源內容和 sw.js 更新後,workbox 會去比對資源,然後將新的資源存入 cache,並修改 indexedDB 中的版本信息。
我們舉一個例子:
workbox.precaching.precacheAndRoute([
'./main.css'
]);
indexedDB 中會保存其相關信息
這個時候我們把 main.css 的內容改變後,再刷新頁面,會發現除非強制刷新,否則 workbox 還是會讀取 cache 中存在的老的 main.css 內容。
即使我們把 main.css 從服務器上刪除,也不會對頁面造成影響。
所以這種方式的緩存都需要配置一個版本號。在修改 sw.js 時,對應的版本也需要變更。
使用實踐
當然了,一般我們的一些不經常變的資源,都會使用 cdn,所以這裏自然就需要支持域外資源了,配置方式如下:
var fileList = [
{
url:'https://edu-cms.nosdn.127.net/topics/js/cms_specialWebCommon_js_f26c710bd7cd055a64b67456192ed32a.js'
},
{
url:'https://static.ws.126.net/163/frontend/share/css/article.207ac19ad70fd0e54d4a.css'
}
];
//precache 適用於支持跨域的cdn和域內靜態資源
workbox.precaching.suppressWarnings();
workbox.precaching.precacheAndRoute(fileList, {
"ignoreUrlParametersMatching": [/./]
});
這裏需要對應的資源配置跨域允許頭,否則是不能正常加載的。且文件都要以版本文件名的方式,來確保修改後 cache 和 indexDB 會得到更新。
理解了原理和實踐後,說明這種方式適合於上線後就不會經常變動的靜態資源。
runtimecache
運行時緩存是在 install 之後,activated 和 fetch 階段做的事情。
既然在 fetch 階段發送,那麼 runtimecache 往往應對着各種類型的資源,對於不同類型的資源往往也有不同的緩存策略。
緩存策略
workbox 提供的緩存策劃有以下幾種,通過不同的配置可以針對自己的業務達到不同的效果:
staleWhileRevalidate
這種策略的意思是當請求的路由有對應的 Cache 緩存結果就直接返回,
在返回 Cache 緩存結果的同時會在後臺發起網絡請求拿到請求結果並更新 Cache 緩存,如果本來就沒有 Cache 緩存的話,直接就發起網絡請求並返回結果,這對用戶來說是一種非常安全的策略,能保證用戶最快速的拿到請求的結果。
但是也有一定的缺點,就是還是會有網絡請求佔用了用戶的網絡帶寬。可以像如下的方式使用 State While Revalidate 策略:
workbox.routing.registerRoute(
new RegExp('https://edu-cms\.nosdn\.127\.net/topics/'),
workbox.strategies.staleWhileRevalidate({
//cache名稱
cacheName: 'lf-sw:static',
plugins: [
new workbox.expiration.Plugin({
//cache最大數量
maxEntries: 30
})
]
})
);
networkFirst
這種策略就是當請求路由是被匹配的,就採用網絡優先的策略,也就是優先嚐試拿到網絡請求的返回結果,如果拿到網絡請求的結果,就將結果返回給客戶端並且寫入 Cache 緩存。
如果網絡請求失敗,那最後被緩存的 Cache 緩存結果就會被返回到客戶端,這種策略一般適用於返回結果不太固定或對實時性有要求的請求,爲網絡請求失敗進行兜底。可以像如下方式使用 Network First 策略:
//自定義要緩存的html列表
var cacheList = [
'/Hexo/public/demo/PWADemo/workbox/index.html'
];
workbox.routing.registerRoute(
//自定義過濾方法
function(event) {
// 需要緩存的HTML路徑列表
if (event.url.host === 'localhost:63342') {
if (~cacheList.indexOf(event.url.pathname)) return true;
else return false;
} else {
return false;
}
},
workbox.strategies.networkFirst({
cacheName: 'lf-sw:html',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 10
})
]
})
);
cacheFirst
這個策略的意思就是當匹配到請求之後直接從 Cache 緩存中取得結果,如果 Cache 緩存中沒有結果,那就會發起網絡請求,拿到網絡請求結果並將結果更新至 Cache 緩存,並將結果返回給客戶端。這種策略比較適合結果不怎麼變動且對實時性要求不高的請求。可以像如下方式使用 Cache First 策略:
workbox.routing.registerRoute(
new RegExp('https://edu-image\.nosdn\.127\.net/'),
workbox.strategies.cacheFirst({
cacheName: 'lf-sw:img',
plugins: [
//如果要拿到域外的資源,必須配置
//因爲跨域使用fetch配置了
//mode: 'no-cors',所以status返回值爲0,故而需要兼容
new workbox.cacheableResponse.Plugin({
statuses: [0, 200]
}),
new workbox.expiration.Plugin({
maxEntries: 40,
//緩存的時間
maxAgeSeconds: 12 * 60 * 60
})
]
})
);
networkOnly
比較直接的策略,直接強制使用正常的網絡請求,並將結果返回給客戶端,這種策略比較適合對實時性要求非常高的請求。
cacheOnly
這個策略也比較直接,直接使用 Cache 緩存的結果,並將結果返回給客戶端,這種策略比較適合一上線就不會變的靜態資源請求。
舉個栗子
又到了舉個栗子的階段了,這次我們用淘寶好了,看看他們是如何通過 workbox 來配置 serviceworker 的:
//首先是異常處理
self.addEventListener('error', function(e) {
self.clients.matchAll()
.then(function (clients) {
if (clients && clients.length) {
clients[0].postMessage({
type: 'ERROR',
msg: e.message || null,
stack: e.error ? e.error.stack : null
});
}
});
});
self.addEventListener('unhandledrejection', function(e) {
self.clients.matchAll()
.then(function (clients) {
if (clients && clients.length) {
clients[0].postMessage({
type: 'REJECTION',
msg: e.reason ? e.reason.message : null,
stack: e.reason ? e.reason.stack : null
});
}
});
})
//然後引入workbox
importScripts('https://g.alicdn.com/kg/workbox/3.3.0/workbox-sw.js');
workbox.setConfig({
debug: false,
modulePathPrefix: 'https://g.alicdn.com/kg/workbox/3.3.0/'
});
//直接激活跳過等待階段
workbox.skipWaiting();
workbox.clientsClaim();
//定義要緩存的html
var cacheList = [
'/',
'/tbhome/home-2017',
'/tbhome/page/market-list'
];
//html採用networkFirst策略,支持離線也能大體訪問
workbox.routing.registerRoute(
function(event) {
// 需要緩存的HTML路徑列表
if (event.url.host === 'www.taobao.com') {
if (~cacheList.indexOf(event.url.pathname)) return true;
else return false;
} else {
return false;
}
},
workbox.strategies.networkFirst({
cacheName: 'tbh:html',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 10
})
]
})
);
//靜態資源採用staleWhileRevalidate策略,安全可靠
workbox.routing.registerRoute(
new RegExp('https://g\.alicdn\.com/'),
workbox.strategies.staleWhileRevalidate({
cacheName: 'tbh:static',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 20
})
]
})
);
//圖片採用cacheFirst策略,提升速度
workbox.routing.registerRoute(
new RegExp('https://img\.alicdn\.com/'),
workbox.strategies.cacheFirst({
cacheName: 'tbh:img',
plugins: [
new workbox.cacheableResponse.Plugin({
statuses: [0, 200]
}),
new workbox.expiration.Plugin({
maxEntries: 20,
maxAgeSeconds: 12 * 60 * 60
})
]
})
);
workbox.routing.registerRoute(
new RegExp('https://gtms01\.alicdn\.com/'),
workbox.strategies.cacheFirst({
cacheName: 'tbh:img',
plugins: [
new workbox.cacheableResponse.Plugin({
statuses: [0, 200]
}),
new workbox.expiration.Plugin({
maxEntries: 30,
maxAgeSeconds: 12 * 60 * 60
})
]
})
);
可以看出,使用 workbox 比起直接手擼來,要快很多,也明確很多。
原理
目前分析 serviceworker 和 workbox 的文章不少,但是介紹 workbox 原理的文章卻不多。
這裏簡單介紹下 workbox 這個工具庫的原理。
簡單提幾個 workbox 源碼的亮點。
通過 Proxy 按需依賴
熟悉了 workbox 後會得知,它是有很多個子模塊的,各個子模塊再通過用到的時候按需 importScript 到線程中。
做到按需依賴的原理就是通過 Proxy 對全局對象 workbox 進行代理:
new Proxy(this, {
get(t, s) {
//如果workbox對象上不存在指定對象,就依賴注入該對象對應的腳本
if (t[s]) return t[s];
const o = e[s];
return o && t.loadModule(`workbox-${o}`), t[s];
}
})
如果找不到對應模塊,則通過 importScripts 主動加載:
/**
* 加載前端模塊
* @param {Strnig} t
*/
loadModule(t) {
const e = this.o(t);
try {
importScripts(e), (this.s = !0);
} catch (s) {
throw (console.error(`Unable to import module '${t}' from '${e}'.`), s);
}
}
通過 freeze 凍結對外暴露 api
workbox.core 模塊中提供了幾個核心操作模塊,如封裝了 indexedDB 操作的 DBWrapper、對 cacheStorage 進行讀取的 cacheWrapper,以及發送請求的 fetchWrapper 和日誌管理的 logger 等等。
爲了防止外部對內部模塊暴露出去的 api 進行修改,導致出現不可預估的錯誤,內部模塊可以通過 Object.freeze 將 api 進行凍結保護:
var _private = /*#__PURE__*/Object.freeze({
DBWrapper: DBWrapper,
WorkboxError: WorkboxError,
assert: finalAssertExports,
cacheNames: cacheNames,
cacheWrapper: cacheWrapper,
fetchWrapper: fetchWrapper,
getFriendlyURL: getFriendlyURL,
logger: defaultExport
});
總結
通過對 serviceworker 的理解和 workbox 的應用,可以進一步提升產品的性能和弱網情況下的體驗。有興趣的同學也可以對 workbox 的源碼細細評讀,其中還有很多不錯的設計模式和編程風格值得學習。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/jUwH8AM5OS-k_OeZTE5sgA