生产环境中的 Service Worker

纵向屏幕截图

摘要

了解我们如何使用服务工作器库让 2015 年 Google I/O 大会 Web 应用快速且离线优先。

概览

今年的 2015 年 Google I/O 大会 Web 应用由 Google 的开发者关系团队编写,并采用了 Instrument 团队的设计,他们编写了出色的音频/视频实验。我们团队的使命是确保 I/O 网络应用(我将其称为代码名称 IOWA)展示现代网络可以执行的所有操作。完全离线优先体验是必须具备的功能之一。

如果您最近阅读过本网站上的任何其他文章,肯定会遇到 Service Worker,因此您不会惊讶地发现 IOWA 的离线支持在很大程度上依赖于它们。受 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 会生成一个基本服务工件脚本,其中包含每个预缓存资源的唯一 MD5 哈希。每当现有资源发生更改或添加新资源时,系统都会重新生成服务工作器脚本。这会自动触发 Service Worker 更新流程,其中会缓存新资源并清除过时资源。所有具有相同 MD5 哈希值的现有资源都会保持不变。这意味着,之前访问过该网站的用户最终只会下载一小部分更改后的资源,与整个缓存全部过期相比,用户体验会高效得多。

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

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

如前所述,预缓存网站离线运行所需的每个资源是不可行的。有些资源太大或使用频率太低,不值得缓存;有些资源是动态的,例如远程 API 或服务的响应。不过,请求未预缓存并不一定会导致 NetworkError。借助 sw-toolbox,我们可以灵活地实现请求处理脚本,以便为某些资源处理运行时缓存,并为其他资源处理自定义回退。我们还使用它来响应推送通知,更新之前缓存的资源。

下面是基于 sw-toolbox 构建的自定义请求处理程序的几个示例。我们可以通过 sw-precacheimportScripts parameter 轻松将这些文件与基本服务工作器脚本集成,该方法会将独立的 JavaScript 文件拉入服务工作器的范围内。

音像实验

对于音频/视频实验,我们使用了 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,我们为应用的 shell 实现了积极的缓存优先策略,这意味着我们的服务工件在返回网页上的 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 哈希,并且仅提取哈希已更改的资源。这意味着资源几乎会立即在页面上可用,但这也意味着,某项内容一旦缓存,就会一直保持缓存状态,直到在更新后的 Service Worker 脚本中分配给它一个新的哈希。

在 I/O 大会期间,我们遇到了此行为方面的问题,因为我们的后端需要动态更新大会每一天的直播 YouTube 视频 ID。由于底层模板文件是静态的,不会发生变化,因此我们的服务工件更新流程未触发,而原本应是服务器针对更新后的 YouTube 视频发送的动态响应,最终却变成了许多用户的缓存响应。

您可以通过确保 Web 应用的结构,使其始终保持静态且可安全地预缓存,同时修改 shell 的任何动态资源都独立加载,从而避免此类问题。

对预缓存请求进行缓存破坏

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

注意额外的查询参数!

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

这最终导致了我们在开发过程中遇到问题,因为我们开始使用网址参数来跟踪流量来源。例如,我们向点击某个通知时打开的网址添加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 Web 应用中的服务工件集成可能是迄今为止部署的最复杂的实际用例。我们期待 Web 开发者社区使用我们创建的工具 sw-precachesw-toolbox,以及我们介绍的技术来为您自己的 Web 应用提供支持。Service Worker 是一种渐进式增强功能,您可以立即开始使用。如果将其作为结构合理的 Web 应用的一部分使用,对于用户来说,速度和离线功能方面的优势会非常明显。