Service Workers 與離線緩存

DanFitchett 7年前發布 | 13K 次閱讀 瀏覽器 前端技術

第一次聽到 Service Workers 這個詞還是在去年 Google 來安利 Angular 2 的時候,那時就覺得很驚艷,想搞一搞,但是因為沒把網站升級成 https 一直拖到現在。 不久前 ,把網站升級成了 https,終于可以搞一發了。

本篇主要包含以下內容:

  • What's Service Workers?

  • 小試 Service Workers

  • 調試 Service Workers

  • 通過 postMessage 與主窗口通信

  • 為應用添加離線緩存

  • Service workers 的生命周期與更新

當然,還是先來看看 Service Workers 究竟是什么?

What's Service Workers?

Service Workers 是谷歌 chrome 團隊提出并大力推廣的一項 web 技術。在 2015 年,它加入到 W3C 標準,進入 草案階段 。W3C 標準中對 Service Workers 的解釋太細致,相對而言,我更喜歡 MDN 上的解釋,更簡練,更易于理解。

Service workers essentially act as proxy servers that sit between web applications, and the browser and network (when available). They are intended to (amongst other things) enable the creation of effective offline experiences, intercepting network requests and taking appropriate action based on whether the network is available and updated assets reside on the server. They will also allow access to push notifications and background sync APIs. - MDN

簡單翻譯一下:Service workers 基本上充當應用同服務器之間的 代理服務器 ,可以用于攔截請求,也就意味著可以在離線環境下響應請求,從而提供更好的離線體驗。同時,它還可以接收服務器推送和后臺同步 API。

那么,這項技術的瀏覽器支持情況是什么樣,還是來看一眼 Can I use?

可以從看到,Chrome 和 Firefox, Opera 都已經支持 Service Workers,底下的備注也寫到 Edge 在開發中,Safari 也考慮支持。至于 IE, 船長都跳船了 。看了 PC 端,再來看看移動端。移動端的支持率并不盡如人意,不過在安卓 4.4 之后,安卓原生瀏覽器,以及安卓版的 Chrome 都已經開始支持 Service Workers。

說句題外話,突然發現在 Can I use 中選擇導入我國數據時,竟出現了 UC 和 QQ 瀏覽器的支持情況,口以口以:+1:...

言歸正傳,在真正開始使用 Service Workers 之前,還有幾點要注意:

  1. Service Workers 基于 Https,這是硬性條件

  2. 每個 Service Worker 都有自己的作用域,它只會處理自己作用域下的請求,而 Service Worker 的存放位置就是它的最大作用域

  3. Service Workder 是 Web Worker 的一種,它不能夠直接操作 DOM

Github 上有一個 非常棒的資源 ,它用圖片的方式展示了 Servic Workers 的一些核心要點。

搞定這些基礎就可以正式開搞了...

小試 Service Workers

和其他 worker 一樣,service worker 有一個獨自的文件。由于之前所提到的 service worker 只能作用在自己存放位置之下的文件,所以,一般在應用根目錄下存放 service worker 文件。

首先,先寫一個最簡單的來看看瀏覽器是不是支持,以及能否正確地安裝并運行 service worker。

// service-worker.js
const _self = this;

console.log('In service worker.');

_self.addEventListener('install', function () {
    console.log('Install success');
});

_self.addEventListener('activate', function () {
    console.log('Activated');
});

雖然,service worker 是 web worker 其中的一種,但它有些不同,它有自己的注冊方式。

// ServiceWorkerService.js
const SERVICE_WORKER_API = 'serviceWorker';
const SERVICE_WORKER_FILE_PATH = 'service-worker.js';

const isSupportServiceWorker = () => SERVICE_WORKER_API in navigator;

if (isSupportServiceWorker()) {
    navigator
        .serviceWorker
        .register(SERVICE_WORKER_FILE_PATH)
        .then(() => console.log('Load service worker Success.'))
        .catch(() => console.error('Load service worker fail'));
} else {
    console.info('Browser not support Service Worker.');
}

重啟程序之后,你應該就能在控制臺中看到 Load service worker Success. 。然而,卻沒有另兩句的輸出,難道加載失敗了?但是,控制臺不是顯示加載成功了么?不要擔心,程序沒有出錯,只是 service worker 中的日志信息有它自己的輸出位置,而并非輸出在主日志之中。

接下去,先來看看如何調試 service worker。

調試 Service Workers

在 Chrome 中,service worker 的信息顯示在 Application -> Service Workers 中,就像這樣

里面會顯示注冊的 service worker,以及它當前的狀態。還能通過切換最上面的選項來模擬不同的網絡環境,測試在不同環境下 service worker 的響應,它們分別是:

  • Offline: 離線

  • Update on reload: 加載時更新

  • Bypass for network: 使用網絡內容

回到之前的問題,如何查看 service worker 之中的日志哪?只需點擊圖中的 inspect 鏈接,它會彈出另一個開發者窗口,在里面可以查看 service worker 的日志。是不是覺得需要那么多步有點麻煩,別擔心,Chrome 已經替我們解決了這個煩惱。重新刷新頁面后,Chrome 的開發者工具中已經能夠查看 service workers 的信息了,比如:在 console 選項卡勾選 Show all messages 就能顯示 service workers 中控制臺的信息;在 source 選項卡也能看到 service workers 的代碼,當然也可以打斷點啦~

在 firefox 中,默認會將 service worker 中的日志輸出到主控制臺中,但要打開 service worker 的調試器就有點麻煩了。有兩種方法查看,一個是在地址欄中輸入 about:debugging#workers ,另一種就是通過菜單欄中選擇 Tools -> Web Developer -> Service Workers 。

雖然,已經將日志輸出到主控制臺了,可這里就有個疑問了,主頁能不能獲取 service workers 中的信息哪?答案是肯定的,那就是通過 postMessage 。

通過 postMessage 與主窗口通信

和 web worker 一樣,service worker 與主窗口通訊也需要通過 postMessage ,但它的語法又有些許不同。

首先,是主頁面給 service worker 發消息。

// ServiceWorkerService.js
const sendMessageToSW = msg => navigator.serviceWorker.controller && navigator.serviceWorker.controller.postMessage(msg);

if (isSupportServiceWorker()) {
    const sw = navigator.serviceWorker;

    sw.register(SERVICE_WORKER_FILE_PATH)
        .then(() => console.log('Load service worker Success.'))
        .catch(() => console.error('Load service worker fail'))
        .then(() => sendMessageToSW('Hello, service worker.'))
        .catch(() => console.error('Send message error.'));
} else {
    console.info('Browser not support Service Worker.');
}

可以看到, postMessage 方法并不在 worker 實例下,而是在 serviceWorker 下的 controller 對象下。這里需要注意一下,當 service worker 還沒有注冊成功時, navigator.serviceWorker.controller 對象的值是 null ,所以,在調用 postMessage 之前需要確保 controller 對象已經存在。在 service worker 這邊就沒有什么區別了

// service-worker.js
_self.addEventListener('message', function(event) {
    console.log(event.data);
});

是不是很簡單?不過,反過來 service worker 給主頁面發消息就要復雜一點了。在 service worker 里發送信息需要通過 Client 對象的 postMessage 方法。獲取 Client 的方法有很多,比如,剛從主頁面發來的消息,事件的來源就是一個 Client 對象,即 event.source 。不過,這只能向來源發消息,但如果你開了幾個網頁,或者不是通過主頁消息發來的該怎么辦哪?方法還是有的,在 service workers 中可以通過 clients 來獲取所有的頁面對象或其他的 service workers。

// service-worker.js
_self.clients.matchAll().then(function(clients) {
    clients.forEach(function(client) {
        client.postMessage('Service worker attached.');
    })
});

不過,如果你發出一個消息需要等到另一方的返回的消息做處理,上述的辦法就做不到了。這時就需要建立一個通道來處理了,修改一下之前的 sendMessageToSW 方法。

// ServiceWorkerService.js
const sendMessageToSW = msg => new Promise((resolve, reject) => {
    const messageChannel = new MessageChannel();
    messageChannel.port1.onmessage = event => {
        if (event.data.error) {
            reject(event.data.error);
        } else {
            resolve(event.data);
        }
    };

    navigator.serviceWorker.controller && navigator.serviceWorker.controller.postMessage(msg, [messageChannel.port2]);
});

這樣信息發送出去后會返回一個 promise ,然后就可以優雅地鏈式調用了。

// ServiceWorkerService.js
if (isSupportServiceWorker()) {
    const sw = navigator.serviceWorker;

    sw.register(SERVICE_WORKER_FILE_PATH)
        .then(() => console.log('Load service worker Success.'))
        .catch(() => console.error('Load service worker fail'))
        .then(() => sendMessageToSW('Hello, service worker.'))
        .then(console.log)
        .catch(() => console.error('Send message error.'));
} else {
    console.info('Browser not support Service Worker.');
}

了解了如何在瀏覽器中調試 service workers 和與主頁面通信這些基礎之后,就可以搞一些正真功能性的東西,比如創造 service workers 最初的動機——提供更好的離線體驗。

為應用添加離線緩存

為應用添加緩存的方式有很多,但能夠提供 離線 緩存的,據我所知,那就只有 service workers 一家了。這就好比已經安裝了的應用,無論是否有網絡連接都可以隨時打開使用(google 所推的 PWA 最終目的就是這個)。你可能會懷疑,聽起來這么高大上實現起來會不會很復雜?然而并沒有,使用 service workers 為應用添加離線緩存還是相當簡單的。

就如同文章開頭 MDN 中所提到的,service workers 可以充當應用與服務器之前的代理服務器,它通過監聽 fetch 事件來捕捉自己作用域下發出的網絡請求,并通過 event.respondWith 來返回請求結果,過程中可以對返回結果做任何的修改(所以必須 https 啊)。

// service-worker.js
const handleFetchRequest = function(request) {
    return fetch(request);
};

const onFetch = function(event) {
    event.respondWith(handleFetchRequest(event.request));
};

_self.addEventListener('fetch', onFetch);

上面這段代碼就是捕獲請求最基本的方式,然后直接將請求發送出去,并將請求的結果返回,沒有做其他額外的操作。如果,你這時觀察控制臺的網絡請求,會發現所有請求的 size 都不再是原先的文件大小或來自緩存,而是 from ServiceWorker 。

接下去,就來給應用添加離線緩存。既然,所有的請求都是手動發出的,而且能夠拿到返回的結果,那么,緩存這些結果就變得輕而易舉了。

不過,這里要先講另一個知識點—— Cache Storage 。它作為 service worker 的一部分寫在 草案中 。通過它,我們可以方便地把請求,以及請求結果一同緩存起來。了解了 Cache Storage ,那就把上面的代碼改一下,讓它能夠緩存請求。

// service-worker.js
const handleFetchRequest = function(request) {
    return caches.match(request)
        .then(function(response) {
            return response || fetch(request)
                    .then(function(response) {
                        const clonedResponse = response.clone();

                        caches.open(CACHE_NAME)
                            .then(function(cache) {
                                cache.put(request, clonedResponse);
                            });

                        return response;
                    });
        });
};

這里主要修改了如何處理請求的方法,先判斷這個請求是否已經被緩存過了,緩存過了就直接返回結果,沒有的話就去請求,并把結果添加到緩存中,以便下次請求來時可以直接返回。

離線緩存就這樣添加好了,來看看效果怎么樣。這就要用到之前調試時所提到的模擬不同環境,不記得的童鞋可以往上翻一翻。(提示關鍵詞:控制臺, Application , Service Workers , Offline )這里模擬離線環境,設置好后再刷新頁面。

Awesome~:grin:

雖然已實現了離線緩存,但是,使用 Cache Storage 還需要注意以下幾點:

  1. 它只能緩存 GET 請求;

  2. 每個站點只能緩存屬于自己域下的請求,同時也能緩存跨域的請求,比如 CDN,不過無法對跨域請求的請求頭和內容進行修改

  3. 緩存的更新需要自行實現;

  4. 緩存不會過期,除非將緩存刪除,而瀏覽器對每個網站 Cache Storage 的大小有硬性的限制,所以需要清理不必要的緩存。

上面的代碼并沒有做緩存的清除和更新,所以,還要更新一下。同時,通過給跨域請求添加 {mode: 'cors'} 屬性來使請求支持跨域,從而拿到響應頭信息。

const HOST_NAME = location.host;
const VERSION_NAME = 'CACHE-v1';
const CACHE_NAME = HOST_NAME + '-' + VERSION_NAME;
const CACHE_HOST = [HOST_NAME, 'cdn.bootcss.com'];

const isNeedCache = function(url) {
    return CACHE_HOST.some(function(host) {
        return url.search(host) !== -1;
    });
};

const isCORSRequest = function(url, host) {
    return url.search(host) === -1;
};

const isValidResponse = function(response) {
    return response && response.status >= 200 && response.status < 400;
};

const handleFetchRequest = function(req) {
    if (isNeedCache(req.url)) {
        const request = isCORSRequest(req.url, HOST_NAME) ? new Request(req.url, {mode: 'cors'}) : req;
        return caches.match(request)
            .then(function(response) {
                // Cache hit - return response directly
                if (response) {
                    // Update Cache for next time enter
                    fetch(request)
                        .then(function(response) {

                            // Check a valid response
                            if(isValidResponse(response)) {
                                caches
                                    .open(CACHE_NAME)
                                    .then(function (cache) {
                                        cache.put(request, response);
                                    });
                            } else {
                                sentMessage('Update cache ' + request.url + ' fail: ' + response.message);
                            }
                        })
                        .catch(function(err) {
                            sentMessage('Update cache ' + request.url + ' fail: ' + err.message);
                        });
                    return response;
                }

                // Return fetch response
                return fetch(request)
                    .then(function(response) {
                        // Check if we received an unvalid response
                        if(!isValidResponse(response)) {
                            return response;
                        }

                        const clonedResponse = response.clone();

                        caches
                            .open(CACHE_NAME)
                            .then(function(cache) {
                                cache.put(request, clonedResponse);
                            });

                        return response;
                    });
            });
    } else {
        return fetch(req);
    }
};

升級之后,還是有緩存先拿緩存,這樣比較快,但依舊會在后臺發出請求,如果返回合法的請求,就更新 cache 中的值,那么,下次訪問時就是這次訪問返回的結果了。

service worker 的 install 和 activite 事件對象都包含一個 waitUntil 方法,方法接受一個 promise,當 promise 被 resolve 后才會繼續執行到下一個狀態。如果,想要強制更新緩存,就可以通過這個方法在 service worker 激活時除舊版本緩存。

// service-worker.js
const onActive = function(event) {
    event.waitUntil(
        caches
            .keys()
            .then(function(cacheNames) {
                return Promise.all(
                    cacheNames.map(function(cacheName) {
                        // Remove expired cache response
                        if (CACHE_NAME.indexOf(cacheName) === -1) {
                            return caches.delete(cacheName);
                        }
                    })
                );
            })
    );
};

_self.addEventListener('activate', onActive);

這樣請求的緩存就能隨時更新了,不過,你可能會和我有同樣的疑問——那 service workers 怎么更新呢?

Service workers 的生命周期與更新

事實上,service workers 的更新并不需要我們操心,只要 service workers 文件有任何一點的修改,瀏覽器就會立即裝載它。然而,它還是有需要注意的地方,不然也就不值一提了。

雖然,瀏覽器立即裝載它,但它并沒有立即生效,這和它的生命周期有關。下面這張圖來自 Service Workers 101 ,非常形象地展示了 service workers 的生命周期。

先看圖的右邊,它展示了 service workers 的 3 種狀態: Installing , Waiting 和 Active ;左邊是 service workers 的生命周期,兩者結合在一起,直觀地展現了在 service workers 不同的生命周期時,service workers 所處的狀態。可以看到, install 與 activate 2 個時間中間,service workers 是處于 Waiting 的狀態。

回到剛才提到的 service workers 更新,瀏覽器雖然會立即裝載最新的 service workers,但只是讓它 install ,并進入 Waiting 的狀態,而并沒有立即 activate 。只有當用戶將瀏覽器關閉后,重新打開頁面時,舊的 service workers 才會被新的 service workers 替換。不過,圖中也有提到,可以在 install 事件中 self.skipWaiting 方法來跳過等待,直接進入 activate 狀態。同樣的,可以在 activate 事件中調用 self.clients.claim 方法來更新所有客戶端上的 service works。

 

 

來自:https://segmentfault.com/a/1190000008491458

 

 本文由用戶 DanFitchett 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!