實際工作環境中的 Service Worker

直向螢幕截圖

摘要

瞭解我們如何使用服務工作者程式庫,讓 Google I/O 2015 網頁應用程式快速且以離線為優先。

總覽

今年的 Google I/O 2015 網頁應用程式是由 Google 開發人員關係團隊所編寫,而是根據我們好友在 Instrument 撰寫的設計,他編寫了酷炫的影音實驗。我們的團隊致力於確保 I/O 網路應用程式 (我會以其代號 IOWA 稱之) 能展示現代網路的所有功能。完整的離線優先體驗是我們必備功能清單中的首要項目。

如果您最近曾閱讀本網站上的其他文章,那麼您肯定曾遇到服務工作者,因此您應該不會感到意外,因為 IOWA 的離線支援功能極為依賴服務工作者。基於 IOWA 的實際需求,我們開發了兩個程式庫,用於處理兩種不同的離線用途:sw-precache 用於自動預先快取靜態資源,以及處理執行階段快取和備用策略的 sw-toolbox

這些程式庫可互相補足,讓我們能夠實作高效策略,在這種策略中,IOWA 的靜態內容「殼層」一律會直接從快取提供,而動態或遠端資源則會從網路提供,並在需要時改用快取或靜態回應。

使用 sw-precache 進行預先快取

IOWA 的靜態資源 (HTML、JavaScript、CSS 和圖片) 可提供網頁應用程式的核心殼層。在考慮快取這些資源時,有兩項特定要求非常重要:我們希望確保大部分的靜態資源都已快取,且保持最新狀態。sw-precache 就是以這些需求為設計考量。

建構時整合

sw-precache 與 IOWA 的 gulp 建構程序,我們會使用一系列 glob 模式,確保產生 IOWA 使用的所有靜態資源的完整清單。

staticFileGlobs: [
    rootDir + '/bower_components/**/*.{html,js,css}',
    rootDir + '/elements/**',
    rootDir + '/fonts/**',
    rootDir + '/images/**',
    rootDir + '/scripts/**',
    rootDir + '/styles/**/*.css',
    rootDir + '/data-worker-scripts.js'
]

其他方法 (例如將檔案名稱清單硬式編碼至陣列中,並記得每次檔案變更時要提升快取版本號碼) 發生錯誤的機率太高,尤其是考量到我們有多名團隊成員在程式碼中進行檢查。沒有人想在手動維護的陣列中遺漏新檔案,導致離線支援功能無法運作!在建構期間整合,我們就能放心修改現有檔案和新增檔案。

更新快取資源

sw-precache 會產生基本服務工作站指令碼,其中包含每個預先快取資源的專屬 MD5 雜湊。每當現有資源變更或新增資源時,系統都會重新產生服務工作者指令碼。這會自動觸發服務工作站更新流程,並在其中快取新資源,並清除過時的資源。任何具有相同 MD5 雜湊的現有資源都會維持原樣。也就是說,先前造訪過該網站的使用者只會下載變更後的資源最小集合,因此比起整個快取集合一口氣過期,使用者可享有更有效率的體驗。

使用者第一次造訪 IOWA 時,系統會下載並快取符合其中一個 glob 模式的每個檔案。我們已盡力確保只友善載入網頁所需的重要資源。次要內容 (例如 影音實驗中使用的媒體,或工作坊講者個人資料圖片) 並未預先快取,而是使用 sw-toolbox 程式庫來處理這些資源的離線要求。

sw-toolbox,滿足所有動態需求

如前所述,光是讓網站必須離線運作的所有資源都無法預先快取。有些資源太大或不常使用,因此不值得儲存,而其他資源則是動態資源,例如遠端 API 或服務的回應。不過,即使要求未預先快取,也不一定會導致 NetworkErrorsw-toolbox 提供了彈性的要求處理常式,方便我們處理某些資源的執行階段快取,以及處理某些資源的執行階段快取。我們也用它來更新先前快取的資源,以便回應推播通知。

以下列舉幾個以 sw-toolbox 為基礎建構的自訂要求處理常式範例。您可以透過 sw-precacheimportScripts parameter,輕鬆將這些檔案整合至基礎服務 worker 指令碼,這項指令碼會將獨立的 JavaScript 檔案拉入服務 worker 的範圍。

音訊/影像實驗

音訊/影像實驗中,我們使用 sw-toolboxnetworkFirst 快取策略。所有符合實驗網址模式的 HTTP 要求都會先針對網路提出,如果傳回成功的回應,系統就會使用 Cache Storage API 將該回應儲存起來。如果在網路無法使用時提出後續要求,系統會使用先前快取的回應。

由於快取會在每次成功的網路回應傳回時自動更新,因此我們不必特別為資源版本化或到期項目。

toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);

講者個人資料圖片

就講者個人資料圖片而言,我們的目標是顯示特定揚聲器的映像檔之前快取版本 (如果有的話),如果無法使用,則改為顯示網路以擷取圖片。如果網路要求失敗,我們會使用預先快取的一般預留位置圖片做為最後的備用方案,因此這個圖片一律會可供使用。這是處理可用通用預留位置取代的圖片時常用的策略,只要串連 sw-toolboxcacheFirstcacheOnly 處理常式,就能輕鬆實作。

var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';

function profileImageRequest(request) {
    return toolbox.cacheFirst(request).catch(function() {
    return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
    });
}

toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
                    profileImageRequest,
                    {origin: /.*\.googleapis\.com/});
工作階段頁面上的個人資料圖片
來自工作階段頁面的剖析圖片。

使用者時間表更新

IOWA 的一項重要功能,就是允許已登入的使用者建立並維護他們打算參加的工作坊時間表。一如您所預期的,工作階段是透過 HTTP POST 要求對後端伺服器進行更新,而我們也投入了一段時間,設法在使用者離線時處理狀態修改要求的最佳方式。我們採用了結合 IndexedDB 中排隊失敗要求的做法,並搭配主要網頁中的邏輯,檢查 IndexedDB 中的排隊要求,並重試找到的任何要求。

var DB_NAME = 'shed-offline-session-updates';

function queueFailedSessionUpdateRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, request.method);
    });
}

function handleSessionUpdateRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedSessionUpdateRequest(request);
    });
}

toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
                    handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
                        handleSessionUpdateRequest);

由於重試是從主頁面的內容進行,因此我們可以確定重試包含一組新的使用者憑證。重試成功後,我們會顯示訊息,讓使用者知道先前排入佇列的更新已套用。

simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
    var replayPromises = [];
    return db.forEach(function(url, method) {
    var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
        return db.delete(url).then(function() {
        return true;
        });
    });
    replayPromises.push(promise);
    }).then(function() {
    if (replayPromises.length) {
        return Promise.all(replayPromises).then(function() {
        IOWA.Elements.Toast.showMessage(
            'My Schedule was updated with offline changes.');
        });
    }
    });
}).catch(function() {
    IOWA.Elements.Toast.showMessage(
    'Offline changes could not be applied to My Schedule.');
});

離線 Google Analytics

同樣地,我們也實作了處理程序,將所有失敗的 Google Analytics 要求排入佇列,並在網路可用時嘗試重播這些要求。透過這種方式,離線不代表失去 Google Analytics 提供的洞察資料。我們在每個排隊中的請求中新增了 qt 參數,並將其設為自首次嘗試請求以來經過的時間長度,確保 Google Analytics 後端能收到正確的事件歸因時間。Google Analytics 正式支援qt 值上限為 4 小時,因此我們會盡力在服務工作者每次啟動時,盡快重播這些要求。

var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;

function replayQueuedAnalyticsRequests() {
    simpleDB.open(DB_NAME).then(function(db) {
    db.forEach(function(url, originalTimestamp) {
        var timeDelta = Date.now() - originalTimestamp;
        var replayUrl = url + '&qt=' + timeDelta;
        fetch(replayUrl).then(function(response) {
        if (response.status >= 500) {
            return Response.error();
        }
        db.delete(url);
        }).catch(function(error) {
        if (timeDelta > EXPIRATION_TIME_DELTA) {
            db.delete(url);
        }
        });
    });
    });
}

function queueFailedAnalyticsRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, Date.now());
    });
}

function handleAnalyticsCollectionRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedAnalyticsRequest(request);
    });
}

toolbox.router.get('/collect',
                    handleAnalyticsCollectionRequest,
                    {origin: ORIGIN});
toolbox.router.get('/analytics.js',
                    toolbox.networkFirst,
                    {origin: ORIGIN});

replayQueuedAnalyticsRequests();

推播通知到達網頁

服務工作者不僅負責處理 IOWA 的離線功能,還可支援推播通知,讓我們可以通知使用者書籤工作階段的更新內容。與這些通知相關聯的到達網頁會顯示更新後的工作階段詳細資料。這些到達網頁已在整體網站中快取,因此已可離線運作,但我們需要確保該頁面上的工作階段詳細資料保持最新狀態,即使在離線狀態下也一樣。為此,我們使用觸發推播通知的更新內容,修改先前快取的工作階段中繼資料,並將結果儲存在快取中。無論是線上還是離線,下次開啟工作階段詳細資料頁面時,系統都會使用這項最新資訊。

caches.open(toolbox.options.cacheName).then(function(cache) {
    cache.match('api/v1/schedule').then(function(response) {
    if (response) {
        parseResponseJSON(response).then(function(schedule) {
        sessions.forEach(function(session) {
            schedule.sessions[session.id] = session;
        });
        cache.put('api/v1/schedule',
                    new Response(JSON.stringify(schedule)));
        });
    } else {
        toolbox.cache('api/v1/schedule');
    }
    });
});

注意事項

當然,沒有人能保證在進行 IOWA 規模的專案時不會遇到任何問題。以下是我們遇到的部分問題,以及解決方法。

內容過時

無論您是要透過服務工作站或標準瀏覽器快取執行快取策略,都必須在盡快提供資源之間進行取捨,而不是提供最新資源。透過 sw-precache,我們為應用程式的殼層實作了積極的快取優先策略,也就是說,服務工作者在傳回頁面上的 HTML、JavaScript 和 CSS 之前,不會先檢查網路是否有更新。

幸好,我們可以利用服務工作站生命週期事件,偵測頁面在載入後是否有新內容。偵測到更新的服務工作者時,我們會向使用者顯示浮動式訊息,告知他們應重新載入網頁,才能查看最新內容。

if (navigator.serviceWorker && navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.onstatechange = function(e) {
    if (e.target.state === 'redundant') {
        var tapHandler = function() {
        window.location.reload();
        };
        IOWA.Elements.Toast.showMessage(
        'Tap here or refresh the page for the latest content.',
        tapHandler);
    }
    };
}
最新內容彈出式通知
「最新內容」浮動視窗。

請確保靜態內容為靜態!

sw-precache 會使用本機檔案內容的 MD5 雜湊,並僅擷取雜湊已變更的資源。這表示資源幾乎會立即在頁面上提供,但也表示一旦某項內容快取後,就會持續快取,直到在更新的服務工作者指令碼中指派新的雜湊值為止。

我們在 I/O 大會期間遇到有關這個行為的問題,這是因為後端需要為會議中的每一天動態更新直播影片的 YouTube 影片 ID。由於基礎範本檔案是靜態且未變更,因此服務工作者更新流程並未觸發,而原本應是伺服器更新 YouTube 影片的動態回應,最後變成許多使用者的快取回應。

如要避免這類問題,請確保網頁應用程式的結構可讓殼層保持靜態,並可安全地預先快取,而任何修改殼層的動態資源則可獨立載入。

快取破壞預先快取要求

sw-precache 要求預先快取的資源時,只要系統認為檔案的 MD5 雜湊未變更,就會無限期使用這些回應。也就是說,確保預先快取要求的回應是最新的,而非從瀏覽器的 HTTP 快取傳回,就顯得格外重要。(是的,在服務工作者中提出的 fetch() 要求可以使用瀏覽器 HTTP 快取中的資料回應)。

為確保預先快取的回應是直接從網路取得,而非瀏覽器的 HTTP 快取,sw-precache 會自動在每個要求的網址後方附加快取破壞查詢參數。如果您未使用 sw-precache,但採用快取優先回應策略,請務必在自己的程式碼中執行類似的操作

要解決快取破壞問題,建議您將用於預先快取的每個 Request快取模式設為 reload,這樣就能確保回應來自網路。不過,截至本文撰寫時,Chrome 不支援快取模式選項。

支援登入和登出

IOWA 可讓使用者使用 Google 帳戶登入並更新自訂活動時間表,但這也意味著使用者之後可能會登出。快取個人化回應資料顯然是一個棘手的議題,而且並非總是只有一種正確做法。

由於查看個人時間表 (甚至是離線) 是 IOWA 體驗的核心,因此我們決定採用快取資料是合適的做法。使用者登出時,我們會確保清除先前快取的工作階段資料。

    self.addEventListener('message', function(event) {
      if (event.data === 'clear-cached-user-data') {
        caches.open(toolbox.options.cacheName).then(function(cache) {
          cache.keys().then(function(requests) {
            return requests.filter(function(request) {
              return request.url.indexOf('api/v1/user/') !== -1;
            });
          }).then(function(userDataRequests) {
            userDataRequests.forEach(function(userDataRequest) {
              cache.delete(userDataRequest);
            });
          });
        });
      }
    });

請留意額外的查詢參數!

服務工作者檢查快取的回應時,會使用要求網址做為索引鍵。根據預設,要求網址必須與用來儲存快取回應的網址完全相符,包括網址的 search 部分中的所有查詢參數。

這最終導致我們在開發期間遇到問題,因為我們開始使用網址參數來追蹤流量來源。舉例來說,我們針對點選通知時開啟的網址新增 utm_source=notification 參數,並在 start_url 中為網頁應用程式資訊清單使用 utm_source=web_app_manifest。先前與快取回應相符的網址,在附加這些參數後,會顯示為未命中。

這部分由 ignoreSearch 選項解決,可在呼叫 Cache.match() 時使用。很抱歉,Chrome「尚未」支援 ignoreSearch,即使確實如此,這也無妨。我們需要的方法包括忽略「部分」網址查詢參數,並將其他有意義的參數納入考量。

我們最終擴充 sw-precache,在檢查快取相符項目之前移除部分查詢參數,並允許開發人員透過 ignoreUrlParametersMatching 選項自訂要略過的參數。以下是基礎實作方式:

function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
    var url = new URL(originalUrl);

    url.search = url.search.slice(1)
    .split('&')
    .map(function(kv) {
        return kv.split('=');
    })
    .filter(function(kv) {
        return ignoredRegexes.every(function(ignoredRegex) {
        return !ignoredRegex.test(kv[0]);
        });
    })
    .map(function(kv) {
        return kv.join('=');
    })
    .join('&');

    return url.toString();
}

這項異動對您的影響

Google I/O 網頁應用程式中的服務工作者整合功能,可能是目前已部署的實際用途中最複雜的一種。我們期待網頁開發人員社群使用我們建立的工具 sw-precachesw-toolbox,以及我們說明的技術,為自己的網頁應用程式提供動力。Service Worker 是一種漸進式增強功能,您可以立即開始使用。如果 Service Worker 是正確結構化的網頁應用程式一部分,使用者就能享有速度和離線的好處。