生产环境中的 Service Worker

纵向屏幕截图

摘要

了解我们如何使用 Service Worker 库打造快速、离线优先的 Google I/O 2015 Web 应用。

概览

今年的 Google I/O 2015 Web 应用由 Google 开发者关系团队根据 Instrument 的朋友(编写了精彩的音频/视频实验)的设计编写而成。我们团队的使命是确保 I/O 大会 Web 应用(我用其代号 IOWA)充分展现现代 Web 的一切。完整的离线优先体验是我们必备功能列表的重中之重。

如果您最近阅读本网站上的任何其他文章,无疑曾遇到过 Service Worker,并且听到 IOWA 的离线支持严重依赖于 Service Worker,您也不会对此感到惊讶。为了满足 IOWA 的实际需求,我们开发了两个库来处理两种不同的离线用例:sw-precache 自动预缓存静态资源,sw-toolbox 用于处理运行时缓存和回退策略。

这些库可以相互补充,使我们能够实现高效的策略,使 IOWA 的静态内容“shell”始终直接从缓存提供,而动态或远程资源则通过网络提供,并在需要时回退到缓存或静态响应。

使用 sw-precache 进行预缓存

IOWA 的静态资源(其 HTML、JavaScript、CSS 和图片)为 Web 应用提供了核心框架。在考虑缓存这些资源时,有两个特定要求非常重要:我们希望确保大多数静态资源已缓存,并保持最新状态。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 脚本。这会自动触发 Service Worker 更新流程,在该流程中,系统会缓存新资源并完全清除过时的资源。任何具有相同 MD5 哈希的现有资源都会保留原样。这意味着,之前访问过网站的用户只会下载最少量的变更资源,与整个缓存过期相比,您可以获得更高效的体验。

在用户首次访问 IOWA 时,系统会下载和缓存与其中一个 glob 模式匹配的每个文件。我们努力确保仅预缓存呈现网页所需的关键资源。次要内容(例如音频/视频实验中使用的媒体或会话演讲者的个人资料图片)刻意未预缓存,而是使用 sw-toolbox 库来处理对这些资源的离线请求。

sw-toolbox,可满足我们的所有动态需求

如前所述,预先缓存网站离线工作所需的每项资源并不可行。有些资源太大或者不经常使用,不值得使用,而其他资源是动态的,例如来自远程 API 或服务的响应。但是,请求未预缓存并不意味着它必须生成 NetworkError。利用 sw-toolbox,我们能够灵活地实现请求处理程序,为某些资源处理运行时缓存,并为其他资源处理自定义回退。我们还使用它来更新之前缓存的资源,以响应推送通知。

以下是我们基于 sw-toolbox 构建的一些自定义请求处理程序的示例。通过 sw-precacheimportScripts parameter(可将独立 JavaScript 文件拉取到 Service Worker 的作用域内),您可以轻松地将它们与基础 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 中将失败请求排入队列,并结合主网页中的逻辑,检查 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 小时,因此,每次 Service Worker 启动时,我们都会尽最大努力尽快重放这些请求。

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();

推送通知着陆页

Service Worker 不仅负责处理 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 这样规模的项目时,都会遇到一些问题。下面列出了我们遇到的一些问题,以及我们处理这些问题的方法。

内容过时

每当您规划缓存策略时,无论是通过 Service Worker 还是使用标准浏览器缓存来实施,都需要权衡利弊,即尽快提供资源与提供最新资源。通过 sw-precache,我们为应用的 shell 实施了积极的缓存优先策略,这意味着我们的 Service Worker 在返回网页上的 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。由于底层模板文件是静态的且没有变化,因此我们没有触发 Service Worker 更新流程,本来需要从服务器更新 YouTube 视频的动态响应最终成为了许多用户的缓存响应。

要避免此类问题,请确保您的 Web 应用具有结构化,以便 shell 始终处于静态且可安全地预缓存,同时任何修改 shell 的动态资源都将独立加载。

缓存预缓存请求

sw-precache 请求预缓存资源时,只要它认为文件的 MD5 哈希未更改,它就会无限期地使用这些响应。这意味着,请务必确保对预缓存请求的响应是最新的,而不是从浏览器的 HTTP 缓存中返回的响应。(可以,在 Service Worker 中发出的 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);
            });
          });
        });
      }
    });

注意额外的查询参数!

当 Service Worker 检查缓存的响应时,它会使用请求网址作为键。默认情况下,请求网址必须与用于存储缓存响应的网址完全匹配,包括网址的搜索部分中的所有查询参数。

这最终给我们在开发过程中带来了问题,当时我们开始使用网址参数来跟踪流量的来源。例如,我们在点击我们的某条通知时打开的网址中添加了 utm_source=notification 参数,并在我们的 Web 应用清单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 Web 应用中的 Service Worker 集成可能是目前为止实际部署的最复杂的实际用例。我们期待 Web 开发者社区能够使用我们创建的 sw-precachesw-toolbox 工具,以及我们正在介绍的技术来支持您自己的 Web 应用。Service Worker 是一种渐进式增强功能,您可以立即开始使用。当将其用作结构合理的 Web 应用的一部分时,它的速度和离线优势对用户来说是显著的。