摘要
瞭解我們如何使用服務工作者程式庫,讓 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 或服務的回應。不過,即使要求未預先快取,也不一定會導致 NetworkError
。sw-toolbox
提供了彈性的要求處理常式,方便我們處理某些資源的執行階段快取,以及處理某些資源的執行階段快取。我們也用它來更新先前快取的資源,以便回應推播通知。
以下列舉幾個以 sw-toolbox 為基礎建構的自訂要求處理常式範例。您可以透過 sw-precache
的 importScripts parameter
,輕鬆將這些檔案整合至基礎服務 worker 指令碼,這項指令碼會將獨立的 JavaScript 檔案拉入服務 worker 的範圍。
音訊/影像實驗
在音訊/影像實驗中,我們使用 sw-toolbox
的 networkFirst
快取策略。所有符合實驗網址模式的 HTTP 要求都會先針對網路提出,如果傳回成功的回應,系統就會使用 Cache Storage API 將該回應儲存起來。如果在網路無法使用時提出後續要求,系統會使用先前快取的回應。
由於快取會在每次成功的網路回應傳回時自動更新,因此我們不必特別為資源版本化或到期項目。
toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);
講者個人資料圖片
就講者個人資料圖片而言,我們的目標是顯示特定揚聲器的映像檔之前快取版本 (如果有的話),如果無法使用,則改為顯示網路以擷取圖片。如果網路要求失敗,我們會使用預先快取的一般預留位置圖片做為最後的備用方案,因此這個圖片一律會可供使用。這是處理可用通用預留位置取代的圖片時常用的策略,只要串連 sw-toolbox
的 cacheFirst
和 cacheOnly
處理常式,就能輕鬆實作。
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-precache
和 sw-toolbox
,以及我們說明的技術,為自己的網頁應用程式提供動力。Service Worker 是一種漸進式增強功能,您可以立即開始使用。如果 Service Worker 是正確結構化的網頁應用程式一部分,使用者就能享有速度和離線的好處。