瀏覽器緩存和Service Worker


瀏覽器緩存和Service Worker

@billshooting 2018-05-06 字數 6175 Follow me on Github
標簽: BOM


1. 傳統的HTTP瀏覽器緩存策略

在一個網頁的生命周期中,開發者為了縮短用戶打開頁面的時間,通常會設置很多緩存。其中包括了:

  • 瀏覽器緩存
  • 代理服務器緩存(CDN緩存)
  • 服務器緩存
  • 數據庫緩存

等各種緩存。這些緩存大多數和前端沒什么關系,也不由前端開發者控制,其中和前端較為密切的是瀏覽器緩存,但它本質上也是由服務器控制的。

在Service Worker還未問世之前,瀏覽器緩存主要是由HTTP緩存策略和瀏覽器內置的存儲功能(cookie,Local Storage,Session Storage等)來提供。其中HTTP緩存由於是欽定的,根正苗紅,瀏覽器支持的也很好,是最常用的瀏覽器緩存技術。而通過瀏覽器內置存儲功能來實現緩存,相比之下就沒那么高端大氣上檔次了。因為這種方式沒個標准范式,雖說可以通過JS進行控制顯得比HTTP緩存靈活,但效果嘛就只能依賴程序員的水平了,也沒有個統一的輪子能用,所以這種方式也就是小打小鬧,不成氣候。

下面介紹一下HTTP緩存的一些用法:

  • Expires頭部
    早在HTTP協議被設計的時候,協議的起草者們就想到了緩存的事情,自然也有相應的功能,那就是Expires這個頭部。每當瀏覽器請求時,服務器可以在相應的報文中附加這個Expires,它的典型值看起來是這樣的:
Expires: Tue, 01 May 2018 11:37:06 GMT

也就是在該資源在世界協調時2018/05/01 11:37:06才過期,我的請求時間是2018/05/01 07:37:06,所以就是這個資源在4小時之后過期,4小時之內對該資源的請求都直接使用緩存,除非你用Ctrl+F5刷新。但是呢,這種控制明顯是不夠精細的,這是個HTTP1.0協議中規定的頭部。由於我們現在都用HTTP2.0都已經來了,HTTP1.1已經全面普及了,這玩意自然已經用的不多了。

  • Cache-Control頭部
    Expires頭部只能控制過期時間,萬一請求的資源在過期時間之前就更新了,那就可能會出現顯示或者功能問題。為此,HTTP協議再更新到1.1版本的時候,增加了一個新的頭部Cache-Control並規定:如果同時存在Cache-ControlExpires前者有效。它有以下常用的值可選:public private max-age s-maxage no-cache no-store等。一個典型的值看起來是以下這樣:
Cache-Control: s-maxage=300, public, max-age=60

為了更好的說明各個字段的意義,先說下瀏覽器請求資源的步驟:

  1. 判斷請求是否命中緩存,如命中則執行步驟2;如沒有則執行步驟3;
  2. 判斷緩存是否過期,如沒有則直接返回;如過期則執行步驟3,並帶上緩存信息;
  3. 瀏覽器向服務器請求資源;
  4. 服務器判斷緩存信息,如資源尚未更新,則返回304,如沒有緩存信息或則資源已更新則返回200,並把資源返回。
  5. 瀏覽器根據響應頭部決定要不要存儲緩存(只有no-store時不存儲緩存信息)。

s-maxage表示共享緩存的時間,單位是s,也就是5分鍾;
public表示這是個共享緩存,可以被其他session使用;
max-age意義與s-maxage差不多,只是它用於private的情形;
no-cache這種策略下,瀏覽器會跳過步驟2,並帶上緩存信息向服務器發起請求。
no-store這種策略下,瀏覽器會跳過步驟5,由於沒有緩存信息,每次瀏覽器請求時都不會帶上緩存信息,就像第一次請求一樣(Ctrl+F5效果)。

  • Last-Modified/If-Modified-Since
    上面說了,瀏覽器在有緩存信息的情況下,會帶上緩存信息發起請求,那這個信息是怎么來的?又是怎么帶在Request的頭部當中呢?
    原來,服務器在響應請求時,除了返回Cache-Control頭部外,還會返回一個Last-Modified頭部,用於指定該資源的服務器更新時間。當該資源在瀏覽器端過期時(由max-age或者no-cache決定),瀏覽器會帶上緩存信息去發起請求,這個信息就由Request中的If-Modified-Since指定,通常也就是上次Response中Last-Modified的值。典型值如下:
//Response:
Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT
//Request:
If-Modified-Since: Sat, 01 Jan 2000 00:00:00 GMT
  • Etag/If-None-Match
    Last-Modified/If-Modified-Since提供的控制已經比較多了,但有些時候,開發者還是不滿意,因為它們只能提供對資源時間的控制,並只有精確到秒級。如果有些資源變化非常快,或者有些資源定時生成,但內容卻是一樣的,這些情況下Last-Modified/If-Modified-Since就不是很適用。
    為此,HTTP1.1規定了Etag/If-None-Match這兩個頭部,它們的用法和Last-Modified/If-Modified-Since完全相同,一個用於響應,一個用於請求。只不過Etag用的不是時間,而是服務器規定的一個標簽(通常是資源內容、大小、時間的hash值)。這樣服務器通過這個頭部可以更加啊精確地控制資源的緩存策略。
    同樣的,由於這個頭部控制更加精細, 所以它的優先級會高於Last-Modified/If-Modified-Since,就像Cache-Control高於Expires一樣。

2. Service Worker的原理

HTTP緩存已經足夠強大了,那開發者還有什么不滿意呢?后端的開發者自然沒什么不滿意,前端的開發者就要嘀咕了:“瀏覽器的事情,為什么要依賴於后端呢?后端就好好提供數據就行了,緩存這種事情我想自己控制”。確實有人這么嘗試過,就是之前說的用Local Storage或者Session Storage來存儲一些數據,但這種方法缺少很多關鍵的瀏覽器基礎設施,比如異步存儲、靜態資源存儲、URL匹配、請求攔截等功能。而Service Worker的出現填補了這些基礎設施缺少的問題。

需要指出的是,Service Worker並非專門為緩存而設計,它還可以解決Web應用推送、后台長計算等問題。能解決精細化緩存控制,實在是由於它的功能強大,因為它本質上就是一個全新的JavaScript線程,運行在與主Javascript線程不同的上下文。service worker線程被設計成完成異步,一些原本在主線程中的同步API,如XMLHTTPRequestlocalStorage是不能在service worker中使用的。

主Javascript線程是負責DOM的線程,而service worker線程被設計成無法訪問DOM。這是很自然的,一般從事過客戶端開發的開發者都知道,只能有一個UI線程,否則整個UI的控制會出現不可預估的問題。而保證UI順滑不卡頓的原則就是盡量不在UI線程做大量計算和同步IO處理

  1. sw線程能夠用來和服務器溝通數據(service worker的上下文內置了fetch和Push API)
  2. 能夠用來進行大量復雜的運算而不影響UI響應。
  3. 它能攔截所有的請求(通過監聽fetch事件,任何對網絡資源的請求都會觸發該事件),並內置了一個完全異步的存儲系統(Caches屬性,完全異步並能存儲全部種類的網絡資源),這是它能精細化控制緩存的關鍵。

可以看出service worker功能非常強大,特別是攔擊所有請求、充當代理服務器這個功能,是強大而危險的。所以為了這個功能不被別有用心的人利用,service worker必須運行在HTTPS的Origin中,同時localhost也被認為是安全的,可以用於調試開發使用。

3. Service Worker的緩存

如前所述,service worker如果用於緩存則關鍵在於監聽Fetch事件管理Cache資源,不過在使用它們之前,得先把service worker激活才行。而service worker的激活則要經過以下步驟:

  1. 瀏覽器發現當前頁面注冊了service worker(通過navigator.service.Worker.register('/sw.js'));
  2. 瀏覽器下載sw.js並執行,完成安裝階段;
  3. service worker等待Origin中其他worker失效,然后完成激活階段;
  4. service worker生效,注意它的生效范圍不是當前頁面,而是整個Origin。但是只有是在register()成功之后打開的頁面才受SW控制。所以執行注冊腳本的頁面通常需要重載一下,以便讓SW獲得完全的控制。

下圖是整個service worker的生命流程:
sw生命流程.png-38.4kB

下面用一個簡單的例子來介紹service worker如何控制緩存,通常它在index.html中被注冊:
代碼清單:index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <link href="style/style-1.css" rel-"stylesheet">
    </head>
    <body>
        <img src="image/image-1.png" />
        <script async src="js/script-1.js"></script>
        <script>
            if ('serivceWorker' in navigator) {
                navigator.serviceWorker.register('/sw.js')
                    .then(reg => console.log('Service worker registered successfully!'))
                    .catch(err => console.log('Service worker failed to register!'));
            }
        </script>
    </body>
</html>

可以看到這個頁面有4個資源style-1.css image-1.png script-1.js以及sw.js。當頁面中JS執行到register方法時,瀏覽器下載sw.js並根據sw.js內容准備安裝Service worker。
代碼清單: sw.js

let cacheName = 'indexCache';
self.addEventListener('install', event => {
    //waitUntil接受一個Promise,直到這個promise被resolve,安裝階段才算結束
    event.waitUntil(
        caches.open(cacheName)
            .then(cache => cacheAll(['/style/style-1.css',
                                     '/image/image-1.png',
                                     '/script/script-1.js',
                                    ]))
                    );
});

//監聽activate事件,可以在這個事件里情況上個sw緩存的內容
self.addEventListener('activate', event => ...}

//監聽fetch事件,可以攔截所有請求並處理
self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request)
            .then(res => {
                //1. 如果請求的資源已被緩存,則直接返回
                if (res) return res;
                //2. 沒有,則發起請求並緩存結果
                let requestClone = event.request.clone();
                return fetch(requestClone).then(netRes => {
                    if(!netRes || netRes.status !== 200) return netRes;
                    let responseClone = netRes.clone();
                    caches.open(cacheName).then(cache => cache.put(requestClone, responseClone));
                    return netRes;
                });
            })
    );
});

可以看到,service worker在安裝時就緩存了三個資源文件,如果下次該Origin下有頁面對這三個資源發起請求,則會被Fetch事件攔截,然后直接用緩存返回。如果對其他資源發起請求,則會使用網絡資源作為響應,並把這些資源再次存儲起來。

可以看到僅用幾十行代碼就完成了一個非常強大的緩存控制功能,你還可以對特定的幾個資源做自己的處理,取決你想怎么控制你的資源。目前還有一個問題尚待解決,那就是如果資源更新了,緩存該怎么辦?目前有兩種方法可以做到:

  1. 更新sw.js文件,一旦瀏覽器發現安裝使用的sw.js是不同的(通過計算hash值),瀏覽器就會重新安裝service worker,你可以在安裝激活的過程中清空之前的緩存,這樣瀏覽器就會使用服務器上最新的資源。
  2. 對資源文件進行版本控制,就像我上面的例子一樣你可以用style-2.css來代替style-1.css,這樣service worker就會使用新的資源並緩存它。當然版本號不應該這么簡單,最好是使用文件的內容+修改時間+大小的hash值來作為版本號。

以上兩種方法都是可靠的,第一種方法的可靠性由瀏覽器保證,第二種方法則是已經久經考驗,目前大多數網站的靜態資源更新策略都是用的類似於第二種方法的版本控制。這兩種方法通常會混在一起使用,因為你在調整資源的版本號的時候,必須要更新sw.js中資源列表,導致sw.js文件本身就修改。

還有個問題需要注意,那就是sw.js本身也會被HTTP緩存策略緩存。通過對sw.js文件名進行版本控制,可以避免因為service worker安裝文件被緩存而導致資源更新不及時的問題。

4. Service Worker的緩存延伸應用

前面說過,service worker的出現並不是單純的為解決精細化控制瀏覽器緩存問題的。它能充當代理服務器這一能力(通過攔截所有請求實現),能夠實現HTTP緩存無法實現的功能:離線應用。因為在HTTP緩存策略下,如果一個資源過了服務器規定的到期時間,則必須要發起請求,一旦網絡連接有問題,整個網站就會出現功能問題。而在service worker控制下的緩存,能夠在代碼中發現網絡連接問題並直接返回緩存的資源。這種方式返回的響應對於瀏覽器來說是透明的,它會認為該響應就是服務器發送回來的資源。

借助於上述能力以及service worker帶來的推送能力,基於Web的應用已經能夠媲美原生應用了。谷歌將這種Web應用稱為PWA(Progressive Web Application)。

隨着Web應用的功能越來越強大,安卓和IOS上套殼應用越來越多,最近微軟也宣布win 10 上UWP應用可以采用PWA模式開發。至此跨平台應用開發的主流技術變得越來越清晰起來,業界在經歷了Java-SWT,QT,Xamarin的嘗試之后,HTML+CSS+Javascript這套始於瀏覽器的技術,已經成為跨平台應用開發的主流技術。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM