天天看點

Service Worker:讓你的 Web 應用牛逼起來

Service Worker:讓你的 Web 應用牛逼起來

作者:網易@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一個重大的缺陷,就是它設定的是一個固定的時間點,用戶端時間和服務端時間可能有誤差。

是以一般會把兩個頭都帶上,這種緩存稱為強緩存,表現形式為:

Service Worker:讓你的 Web 應用牛逼起來

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的存儲空間,我們以微信文章頁為例。

檢視請求發現,基本沒有js和css的請求,因為它把全部的不需要改動的資源都放到了localStorage中:

Service Worker:讓你的 Web 應用牛逼起來

是以微信的文章頁加載非常的快。

前端資料庫

前端資料庫有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、網絡代理:在網頁發起請求時代理,來緩存檔案;

相容性

Service Worker:讓你的 Web 應用牛逼起來

可以看到,基本上新版浏覽器還是相容滴。之前是隻有chrome和firefox支援,現在微軟和蘋果也相繼支援了。

成熟程度

判斷一個技術是否值得嘗試,肯定要考慮下它的成熟程度,否則過一段時間又和應用緩存一樣被規範抛棄就尴尬了。

是以這裡我列舉了幾個使用Service Worker的頁面:

淘寶

網易新聞

考拉

是以說還是可以嘗試下的。

調試方法

一個網站是否啟用Service Worker,可以通過開發者工具中的Application來檢視:

Service Worker:讓你的 Web 應用牛逼起來

被Service Worker緩存的檔案,可以在Network中看到Size項為 from ServiceWorker:

Service Worker:讓你的 Web 應用牛逼起來

也可以在Application的Cache Storage中檢視緩存的具體内容:

Service Worker:讓你的 Web 應用牛逼起來

如果是具體的斷點調試,需要使用對應的線程,不再是main線程了,這也是webworker的通用調試方法:

Service Worker:讓你的 Web 應用牛逼起來

使用條件

sw 是基于 HTTPS 的,因為service worker中涉及到請求攔截,是以必須使用HTTPS協定來保障安全。如果是本地調試的話,localhost是可以的。

而我們剛好全站強制https化,是以正好可以使用。

生命周期

大概可以用如下圖檔來解釋:

Service Worker:讓你的 Web 應用牛逼起來

注冊

要使用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的工作。

Service Worker:讓你的 Web 應用牛逼起來

這個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'
]);
           

複制

Service Worker:讓你的 Web 應用牛逼起來

indexedDB中會儲存其相關資訊

Service Worker:讓你的 Web 應用牛逼起來

這個時候我們把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這個工具庫的原理。

首先将幾個我們産品用到的子產品圖奉上:

Service Worker:讓你的 Web 應用牛逼起來

簡單提幾個workbox源碼的亮點。

通過Proxy按需依賴

熟悉了workbox後會得知,它是有很多個子子產品的,各個子子產品再通過用到的時候按需importScript到線程中。

Service Worker:讓你的 Web 應用牛逼起來

做到按需依賴的原理就是通過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的源碼細細評讀,其中還有很多不錯的設計模式和程式設計風格值得學習。