實際工作環境中的 Service Worker

直向螢幕截圖

摘要

瞭解我們如何使用 Service Worker 程式庫,在 2015 年 Google I/O 大會網頁應用程式推出速度,並支援離線使用。

總覽

今年的 Google I/O 2015 網頁應用程式由 Google 的開發人員關係團隊根據 Instrument 編寫的夥伴設計,編寫出精彩的音訊/視覺實驗。我們的團隊任務是確保 I/O 網頁應用程式 (也就是產品代號 IOWA) 可完整呈現出新式網路的所有功能。

如果您最近曾閱讀本網站的其他任何文章,就表示您非常遇到服務工作人員的到來,也不必擔心,IOWA 的離線支援非常仰賴他們。為因應 IOWA 的實際需求,我們開發了兩個程式庫來處理兩種不同的離線用途:sw-precache 可自動預先快取靜態資源,而 sw-toolbox 則用於處理執行階段快取和備用策略。

這些程式庫能彼此相輔相成,並讓我們實行一項成效極佳的策略,也就是一律直接從快取提供 IOWA 的靜態內容「shell」,並從網路提供動態或遠端資源,並在必要時提供快取或靜態回應的備用方案。

使用 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 會產生基本 Service Worker 指令碼,其中包含每個要預先快取的資源專屬的 MD5 雜湊。每次現有資源變更或新增資源時,系統就會重新產生 Service Worker 指令碼。這會自動觸發服務工作站更新流程,在此流程中快取新的資源,並清除過期的資源。任何擁有相同 MD5 雜湊的現有資源都會保持原樣。這表示在僅下載最少組變更資源的使用者之前,曾造訪網站的使用者所產生的體驗,比整個快取過期的「全面」更有效率。

使用者首次造訪 IOWA 時,系統會下載並快取與其中一個 glob 模式相符的每個檔案。我們致力確保只對轉譯網頁所需的重要資源預先快取。我們刻意透過 sw-toolbox 程式庫處理這些資源的離線要求,並將次要內容 (例如音訊/視覺實驗中使用的媒體或工作階段講者的個人資料圖片) 視為非預載。

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

如前所述,我們無法預先快取網站必須離線運作的每個資源。某些資源過大或使用率不高,以致於無法帶來效益,而其他資源則是動態的,例如遠端 API 或服務的回應。不過,即使要求不會友善載入,並不代表就必須產生 NetworkErrorsw-toolbox 讓我們可以靈活地導入要求處理常式,用於處理某些資源的執行階段快取,以及為其他資源處理自訂備用項。我們也會透過此記錄來更新先前快取資源,以回應推播通知。

以下列舉幾個在 sw-toolbox 基礎上建構的自訂要求處理常式範例。可以透過 sw-precacheimportScripts parameter 輕鬆整合這些服務與基本 Service Worker 指令碼,以便將獨立的 JavaScript 檔案提取到 Service 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 中排入佇列的失敗要求,以及主要網頁中查看索引

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 前,不會檢查網路是否有更新。

幸運的是,我們能夠利用 Service Worker 生命週期事件來偵測頁面載入後,何時還有新內容可用。偵測到更新的 Service Worker 時,我們會向使用者顯示浮動式訊息,讓使用者知道應重新載入頁面來查看最新內容。

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 雜湊,並且只會擷取雜湊變更的資源。這表示資源幾乎會立即顯示在頁面上,但也表示快取內容之後,系統會一直快取,直到在更新的 Service Worker 指令碼中指派新的雜湊為止。

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

如要避免發生這類問題,請確保您的網路應用程式是結構化,使殼層一律是靜態的,並且可以安全地預先快取,同時修改殼層的任何動態資源則會單獨載入。

快取清除預先快取要求

sw-precache 要求預先快取資源時,只要認為檔案的 MD5 雜湊並未變更,就會無限期使用這些回應。這意味著,確保預先快取要求的回應是全新要求,而非從瀏覽器的 HTTP 快取傳回,顯得格外重要。(可以,在 Service Worker 中發出的 fetch() 要求可以使用瀏覽器的 HTTP 快取資料回應)。

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

更簡潔的快取清除解決方案,就是為預先快取至 reload 的每個 Request 設定快取模式,如此可確保回應來自網路。不過,截至本文撰寫時間為止,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);
            });
          });
        });
      }
    });

請留意額外的查詢參數!

Service Worker 檢查快取回應時,會使用要求網址做為金鑰。根據預設,要求網址必須與用來儲存快取回應的網址完全相符,包括網址的「搜尋」部分中的所有查詢參數。

這在開發過程中導致我們使用網址參數來追蹤流量來源,因此造成問題。舉例來說,我們在點選其中一則通知時開啟的網址將 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 網頁應用程式中的 Service Worker 整合功能,可能是部署至目前最複雜的實際使用情況。我們很期待能運用我們打造的 sw-precachesw-toolbox 工具,以及我們介紹的技術來支援自家網頁應用程式,向網頁程式開發人員社群尋求協助。服務工作處理程序是漸進式增強,您可以立即開始使用。當以正確結構化的網頁應用程式搭配使用時,速度和離線優勢對使用者來說至關重要。