渐进式-Web-应用开发实例-全-
渐进式 Web 应用开发实例(全)
原文:
zh.annas-archive.org/md5/162e1266ca9f0812d6595dc1e62fe6f8译者:飞龙
前言
渐进式网络应用(PWAs)标志着用户体验交付的新时代。现在,每个主要浏览器和平台都支持 PWAs,消除了许多以前仅限于原生应用的缺失功能。如果您是一位从事应用前端开发的工作者,您需要了解渐进式网络应用是什么,它们的优点以及如何有效地构建现代 Web 应用。
您将学习基本 PWA 要求,如 Web 清单文件以及 HTTPS 是如何通过高级服务工作者生命周期和缓存策略工作的。本书涵盖了 Web 性能优化实践和工具,以帮助您持续创建高质量的渐进式网络应用。
本书面向对象
如果您是一位希望创造最佳用户体验的 Web 开发者和前端设计师,那么这本书是为您准备的。如果您是一位了解 HTML、CSS 和 JavaScript 的应用开发者,那么这本书将帮助您利用您的技能开发渐进式网络应用,这是应用开发的未来。
本书涵盖内容
第一章, 渐进式网络应用简介,解释了渐进式网络应用是什么以及它们提供的优势。
第二章, 使用 Web 清单创建主屏幕体验,介绍了 Web 清单文件,并解释了浏览器如何使用它来设计 PWA 安装后的主屏幕和启动体验。
第三章, 使您的网站安全,解释了为什么 HTTPS 是现代 Web 的要求以及它是如何工作的。
第四章, 服务工作者 - 通知、同步以及我们的播客应用,介绍了服务工作者、Fetch API 以及实现推送通知。
第五章, 服务工作者生命周期,展示了服务工作者是如何安装和更新的,以及如何管理这个过程。
第六章, 掌握缓存 API - 在播客应用程序中管理 Web 资源,深入探讨了服务工作者缓存 API 的工作原理。
第七章, 服务工作者缓存模式,回顾了您可以在应用程序中使用的一些常见缓存模式。
第八章, 应用高级服务工作者缓存策略,应用了带有失效技术的缓存策略。
第九章, 优化性能,解释了什么是 Web 性能优化,并介绍了一些您可以用来测量页面加载时间以改进渐进式网络应用的工具。
第十章,服务工作者工具,回顾了四个有助于使开发和管理渐进式 Web 应用更简单、更一致的实用工具。
为了充分利用这本书
-
任何开发或负责应用程序前端或用户体验的技术方面的人都会从这本书中受益。
-
假设您具备现代网络开发的中等背景。
-
了解常见的 JavaScript 语法很重要,因为服务工作者是 JavaScript 文件。
-
由于许多现代工具依赖于 Node.js,因此建议您具备基本了解。
-
源代码在 GitHub 上管理,这需要一些使用 Git 源代码控制的知识。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择支持选项卡。
-
点击代码下载和勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR/7-Zip(适用于 Windows)
-
Zipeg/iZip/UnRarX(适用于 Mac)
-
7-Zip/PeaZip(适用于 Linux)
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Progressive-Web-Application-Development-by-Example。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有来自我们丰富的图书和视频目录的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”
代码块按照以下方式设置:
function renderResults(results) {
var template = document.getElementById("search-results-
template"),
searchResults = document.querySelector('.search-results');
任何命令行输入或输出都按照以下方式编写:
npm install -g pwabuilder
粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请发送电子邮件至 questions@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一情况。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,我们将不胜感激,如果您能提供位置地址或网站名称。请通过链接至材料内容与我们联系 copyright@packtpub.com。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packtpub.com.
第一章:渐进式网络应用简介
在 3.28 亿活跃 Twitter 用户中,超过 80%是移动用户。Twitter 知道他们需要他们的移动体验更快、更可靠、更有吸引力。他们选择在 2017 年 4 月将他们的默认移动体验作为渐进式网络应用(PWA)推出,名为 Twitter Lite。
他们的目标很简单,即更快的加载时间、更高的参与度和更低的数据消耗。在将一般活动与非渐进式网络应用版本进行比较时,他们实现了所有三个目标:
-
每次会话页面增加 65%
-
发送推文数量增加 75%
-
跳出率降低 20%
“Twitter Lite 现在是使用 Twitter 最快、最经济、最可靠的方式。该网络应用的性能与我们的原生应用相媲美,但所需的设备存储空间比 Twitter for Android 少 3%以下。”
——Twitter Lite 的工程负责人 Nicolas Gallagher
这只是在线公司从 PWA 中获得回报的一个例子。这本书应该作为起点,为您提供创建第一个 PWA 所需的基本知识和信心。
在这本书中,您将学习如何构建一个 PWA,它将准备好投入生产使用。如果您还没有创建过 PWA,您将在本章的第一部分学习如何制作一个简单的 PWA。
本章将涵盖 PWA 的基本原理以及它们相对于经典网站和原生应用的优势。您还将了解如何将现有的 2048 游戏网络应用升级为 PWA。您将学习如何向应用添加 web manifest 和 service worker,使用 localhost 网络服务器启用 PWA 功能。
本章的目的是让您对 PWA 的工作原理有一个大致的了解,为什么您想要提供 PWA,并为您提供轻松创建具有基本功能的 PWA 的技能。
本章将涵盖以下要点:
-
PWA 的目的
-
PWA 优势
-
PWA 的基本技术要求
-
三大主要用户体验 PWA 目标
-
如何升级现有网站并在本地运行
我们所熟知的网络已经进入了第三个十年。在这段时间里,网络经历了许多变化和增强。虽然网络拥有一些伟大的超级能力,但它也有一些限制,阻碍了它提供与原生应用相当的用户体验。
PWAs 是一种将原生浏览器技术应用于创建消费者希望添加到主屏幕的 Web 解决方案的方式。同时,新的 Web API 正在进步,以填补 Web 和原生应用之间功能差距的额外空白。
PWA 的伟大之处在于,现有的网站可以轻松升级以获得 PWA 状态。这可以在浏览器和平台上解锁新功能,提升任何网站。
大约十年前,当苹果公司发布 iPhone 时,不仅互联网受到了冲击,桌面计算也受到了影响。这标志着移动优先计算新时代的到来。那个时代的网络技术没有准备好应对从桌面到手持设备的快速转变。
转向以移动为先的世界需要不仅仅是响应式设计技术;它需要一套新的原生 API、功能和编码技术。HTML、CSS 和 JavaScript 规范以及浏览器在过去十年中不断发展,以满足客户端应用程序的消费预期。
今天,我们拥有非常丰富的原生 API 和浏览器,从地理位置到语音输入和相机操作,几乎什么都能做。有客户端平台旨在为开发者提供一个丰富、以移动为先的画布,以创作引人入胜的用户体验。
除了优秀的原生 API 之外,浏览器还在服务工作者、Web 清单和开始要求 HTTPS 以启用现代 API 方面添加了新功能。这三个技术特性是成为 PWA 的核心要求。然而,制作一个优秀的 PWA 的艺术远不止于此。这种艺术需要一种不同的网络开发方法。
在这本书中,我们将探讨优秀 PWA 的需求,以及如何将新网站和现有网站升级为 PWA。在这个过程中,我们将学习如何利用新的功能,如 IndexedDB、多媒体和 Fetch API,为我们的应用程序增加价值。
随着本书的深入,您将学习如何使用服务工作者进行缓存、推送通知和后台同步。下一章深入探讨了 Web 清单文件。第三章,让你的网站更安全,涵盖了升级到 HTTPS 的细微之处。
本书将技术和经验需求分解,以便您创建一个优秀的 PWA,并通过三个示例应用程序来展示这一点:
-
第一个应用程序是一个简单的游戏,2048。2048 大约三年前非常流行,我仍然觉得它非常上瘾。尽管它是一个简单的游戏,但它将展示网络如何与常见的原生应用程序在同等水平上竞争。
-
接下来,我们将创建一个相册网站,并了解如何使用服务工作者缓存来创建一个即点即用、在有或没有网络的情况下都能运行的应用程序。该应用程序将与许多流行的播客播放器,如 iTunes 和 Stitcher 相媲美。
-
最后一个应用程序是一个消费者活动门票应用程序。该应用程序将展示高级服务工作者技术,如缓存失效。我还会介绍您可以使用哪些工具来验证您的应用程序,并帮助您为质量和一致性构建它们。
所有源代码均可在 GitHub 上找到,本书中提供了链接。您可随意克隆和分叉这些仓库。根据需要制作本地副本并修改它们。我很乐意看到您如何增强演示应用程序。
为什么我们需要一种新的方式来构建网站
当苹果公司在 2007 年发布 iPhone 时,他们最初打算使用 HTML 来构建应用程序。他们提供了一个初始平台来创建网络应用程序。然而,Mac 开发者们呼吁提供更好的本地应用程序解决方案,苹果公司做出了回应。苹果公司这样做的前提是收取应用程序收入的 30%,并控制通过封闭的 App Store 分发的应用程序。
封闭的 App Store 通过引入第三方守门人而违反了网络的开放性。这导致苹果公司审查您的应用程序时产生一层延迟。审查过程可能导致您的应用程序被审查或拒绝进入。App Store 提供的一个优势是为消费者提供一种安全和可信的感觉。
为了使 App Store 模式对苹果公司更有吸引力,他们决定对原生应用程序收取高额的提成。作为回报,苹果公司处理所有应用程序的支付和分发基础设施。然而,网络在从消费者那里收取金钱以及分发问题上并没有遇到问题。
信用卡商户账户通常收取交易额的 2%到 3%。托管已成为一种廉价的商品,对于大多数网站来说,每月的费用通常为 10 美元或更少。
网络遇到的下一个感知问题是性能。性能问题在移动设备上被放大。与桌面电脑相比,智能手机和平板电脑的 CPU 性能较弱。尽管更多移动设备使用 WiFi,但在发达世界,蜂窝连接仍然不可靠。
当 iPhone 首次发布时,与今天我们所经历的相比,网络仍然非常静态。到那时,网络还没有动画和动态内容这一平台。
在过去十年中,随着单页应用程序和许多大型框架的兴起,丰富的用户体验在网络上变得司空见惯。这些变化在很大程度上是由消费者从许多原生应用程序中期望的用户体验所驱动的。
许多开发者尝试通过黑客手段在移动设备上模仿本地应用程序体验。这导致了一些进步,也带来了一些不良体验和编码实践。
大多数不良体验都是由于对可用的 API 及其使用方式缺乏了解。糟糕的编码技术也造成了比预期价值更多的问题。
我看到的一个常见错误是在浏览器中应用服务器端架构。虽然这超出了本书的范围,但重要的是要注意,为了获得良好的现代 Web 用户体验,您可能不得不放弃对网站开发的先入为主的观念。
2012 年,马克·扎克伯格在 Tech Crunch 活动上的一个采访中,展示了如何误解使用 Web 平台和能力差距的典型案例。您可以查看以下链接获取文章:tcrn.ch/2hwN6HF
Facebook 试图将网络作为其主要平台,但由于许多工程错误和浏览器/硬件限制,他们失败了。在那个时刻,他们转向原生应用作为主要焦点,并从此创建了一个非常大的、封闭的数据和互动社区。
正如您将在本书后面看到的那样,Facebook 在移动原生应用空间中占据主导地位。这为其他人获得屏幕时间留下了很少的空间。
这就是 PWA 如何使企业和组织能够更深入地与消费者互动。这本书旨在为您提供创建 PWA 的工具和知识,以更少的金钱和努力触及消费者。网络拥有原生应用无法触及的几个超级能力。现在,随着原生 API 的兴起,网络超越了原生应用。
真实世界的 PWA 示例
Flipkart,印度次大陆的亚马逊,在 PWA 这个术语首次提出时就接受了它。在许多方面,他们是正确实施 PWA 的典范。
Flipkart 的消费市场几乎完全由在糟糕的移动连接上的客户组成。他们的移动设备存储空间有限,可能或可能没有可靠的 3G 连接。实际上,63%的用户是通过 2G 访问网站的。一个快速加载且在网络缺失时也能工作的客户端应用体验为 Flipkart 带来了商业优势。
Flipkart PWA(developers.google.com/web/showcase/2016/flipkart)是由一支小型工程师团队在 42 天内创建的,他们在这一过程中投入的小额投资通过提高转化率 70%获得了巨大的回报。以下是他们发布的一些关键性能指标:
-
与 Flipkart lite 相比,用户在网站上的时间比之前的移动体验长,3.5 分钟比 70 秒
-
在网站上花费的时间增加 3 倍
-
40%更高的重新参与率
-
通过“添加到主屏幕”功能到达的用户的转化率提高 70%
-
数据使用量降低 3 倍
The Weather Channel 的移动使用量超过 50%来自网络。触及全球消费者是一个优先事项。网络提供了一个可靠的渠道来触及每个人,这通常意味着低功耗设备。重新参与和及时信息(如风暴预警)的提供也非常重要。
The Weather Channel(developers.google.com/web/showcase/2016/weather-channel)创建了一个 PWA,通过推送通知提供与原生应用相匹配的体验。这次升级使他们的团队能够触及 178 个国家,并在提高加载速度的同时提供天气预报:
-
这个 PWA 现在在 62 种语言和 178 个国家可用
-
加载时间提高 80%
-
基于此次成功的全球测试,团队将在 2017 年将 PWA 扩展到其美国网站
兰蔻(developers.google.com/web/showcase/2017/lancome)将其移动网络存在重建为 PWA,转化率提高了 17%。随着他们跟踪移动网络使用情况,超过桌面,兰蔻看到他们的转化率下降。在考虑原生应用后,他们决定投资于网络是正确的选择。
他们确定客户不太可能下载原生应用,也不太可能经常使用它。他们知道必须正确地建立网络存在,因为这样做可以产生更多回报。他们决定从头开始重建他们的网络存在,作为一个 PWA。
总体收益:
-
页面交互时间缩短 84%
-
转化率增加 17%
-
跳出率降低 15%
-
移动会话增加 51%
iOS 改进:
-
iOS 上的移动会话增加 53%
-
iOS 上的跳出率降低 10%
推送通知收益:
-
有 8%的消费者在点击推送通知后进行购买
-
推送通知的打开率提高 18%
-
通过推送通知恢复购物车的转化率提高 12%
如果您担心尚未支持 PWA 技术的浏览器,请注意 iOS 的统计数据。兰蔻(Lancôme)并不孤单;几乎每个采用 PWA 的公司都报告了在 iOS 上类似的改进。稍后,您将看到如何通过 polyfill 缓存和添加到主屏幕的体验在您的应用中实现类似的结果。
这些只是几个已经采用 PWA 并报告了收益的主要品牌的样本。还有许多更多的小型企业也在改进,因为他们正在构建客户想要使用的网络体验。好消息是,您今天就可以使用本章中的示例开始增强您现有的网站。
什么是 PWA?
两年前,一位谷歌 Chrome 工程师Alex Russell发布了定义 PWA 的里程碑式博客文章。您可以在以下链接中查看文章:bit.ly/2n1vQ2r
通过这篇博客文章,Alex 宣布网络现在可以与原生应用并驾齐驱。但这不仅仅是通过服务工作者添加的原生能力,当涉及到构建网站时,添加到主屏幕的启发式方法也很重要。
另一位谷歌 Chrome 工程师 Chris Wilson 表示,渐进式 Web 应用是关于您用户体验质量的一种新的思维方式。
Chrome 团队和其他浏览器希望您理解的是,用户体验是您网站或应用最重要的部分。浏览器为您提供构建优秀应用的基础,但让这些体验变得生动还是取决于您。
我倾向于认为,与原生应用程序开发者相比,网络开发者存在信心问题。仍然存在这种观念,即原生应用程序统治一切。然而,这并不完全正确。正如我们稍后将会看到的,可访问的网络页面比原生应用程序要多得多,与原生应用程序相比,你的网站品牌有更大的成长空间。
原生应用程序有其用途,而这种用途正开始变得过时。前 Opera 公司负责人布鲁斯·劳森,一个在移动设备上非常受欢迎的浏览器,表示(http://bit.ly/2e5Cgry)原生应用程序是一种过渡技术。
这是一个非常大胆的声明,将网页与原生应用程序进行比较。但这确实值得思考。通常有许多过渡技术最终导致了真正的可消费产品。
例如,Netflix 最初是通过邮寄 DVD 来发货的。我敢肯定你今天仍然可以这样做,但 Netflix 的大多数会员只是流式传输和下载视频内容来观看。DVD 只是公司起步和与一个非常忠诚的客户群建立关系的过渡技术。
分发那些 DVD 的费用对他们来说太高,以至于他们无法将其作为主要分销渠道。随着技术的进步,宽带接入的增加,Netflix 能够摆脱过渡分销技术,专注于最初的目标,即让视频和电影进入全球会员的客厅。
同样,移动设备是一个全新的平台,用于构建应用程序体验。就像桌面计算一样,它从原生应用程序开始,而网页最终赢得了它们的青睐。当移动技术出现时,网页也赢得了桌面,并且是以一种大规模的方式出现的。
PWA 标志着对网页的重新工具化,使其成为一个以移动设备为先的平台。你的应用程序可以运行得更快,离线工作,并请求用户允许它们出现在主屏幕上。我们以前从未能够在网页上以这种水平部署这些体验。
应用程序高峰
智能手机用户总是寻找他们认为有用的应用程序进行下载。如果你足够幸运,让消费者下载了你的应用程序,那么如果他们觉得使用起来麻烦或难以操作,他们很可能在第一次使用后就将其删除。
根据尼尔森的一项研究(www.nielsen.com/us/en/insights/news/2015/so-many-apps-so-much-more-time-for-entertainment.html),平均成年人每月使用的应用程序不到 30 个。随着时间的推移,不使用应用程序会导致未使用的应用程序被清除。
几项研究估计,大约 10%的应用程序使用足够频繁,可以保留。这意味着即使你的应用程序被下载,它最终也可能被删除,并且可能永远不会被使用。
品牌在广告上花费 8-12 美元以换取单个应用程序的下载。这意味着真正的客户获取成本大约是 80-120 美元。祝你好运,希望你能收回这笔费用。
苹果和谷歌应用商店宣称拥有 200 万或更多的应用程序。一些过时的研究显示,近 60%的应用程序从未被下载。
苹果最近通过强制执行其应用程序指南要求的第 4.2.6 节,将成功的门槛提高得更高。这一节赋予他们根据自己意愿拒绝和删除应用程序的权力。他们一直在大量清除他们认为不符合这些任意指南的应用程序。
为什么消费者停止下载应用程序了?空间,无论是物理空间还是时间空间。手机和平板电脑的磁盘空间是有限的。现在许多应用程序需要 100 MB-1 GB 的空间。尽管销售了一些 128 GB 的 iPhone,但典型的 iPhone 容量是 32 GB。在个人照片、视频和音乐之后,几乎没有空间留给应用程序。
尽管我们已经成为一个似乎永远离不开手机屏幕的社会,但一天中仍然只有那么多时间。市场分析师密切关注我们如何使用屏幕时间。孩子们观看视频和玩愚蠢的游戏。成年人生活在社交媒体中,Facebook 和 Snapchat 占据了他们的手机。成年人也会玩一些愚蠢的游戏,比如 2048。
在这些应用商店的前五名应用程序中,Facebook 拥有三个。平均成年人每天在 Facebook 的世界中花费超过 2 小时来查看照片和视频。短信正被 Facebook Messenger 取代。千禧一代沉迷于 Instagram,不停地分享、点赞和评论照片和视频。
Facebook、Facebook Messenger 和 Facebook 拥有的 Instagram 是前三大移动应用程序。它们后面是 YouTube、SnapChat 和 Gmail。其中两个由 Google 拥有。在这些应用程序之后,分布曲线下降到几乎为零。
我们,作为移动消费者,已经习惯了使用习惯,并发现对应用程序的需求已经过去了。
安装一个应用程序,即使它是免费的,也需要八个步骤,每个步骤都会使最初的兴趣基础减少 20%。亚马逊实施一键购买的原因是为了消除摩擦并增加销售额。
网络相对无摩擦。你点击一封电子邮件中的链接,或者搜索一些内容,点击最佳感知结果,几秒钟内你就下载或安装了你需要的网页。几乎没有摩擦,几乎没有使用设备资源。
与给定月份的应用程序使用分布相比,平均消费者访问超过 100 个网站。这大约是他们应用程序分布的 20 倍。这意味着通过网络与客户互动的机会比原生应用程序更多。
网络满足了两个重要的消费者需求:最小资源投资。所需的时间或磁盘空间非常少。实际上,当他们清理设备以便分享更多视频到 Instagram 时,他们不需要卸载你的网站。
这就是 PWA 之所以重要的地方。公司希望他们的图标出现在消费者的设备上。这象征着一种关系,并有望增加销售额或其他参与度统计数据。当品牌参与对消费者来说成本低廉时,他们更有可能采取步骤让你成为他们日常生活的一部分。
浏览器提供了参与平台,但你仍然需要满足它们的要求。这正是本书将要教授的内容。
PWA 特性
不要将 PWA(渐进式网络应用)误认为是特定技术。它更多的是一个营销术语,描述了使用现代平台功能来提供一定质量体验的方式。如果没有良好的用户体验,技术本身并不重要。
Chrome 团队确定了 PWA 应该提供的四个体验因素:
-
快速
-
可靠
-
引人入胜
-
集成
研究表明,如果网站加载时间超过 3 秒,53% 的用户将放弃该网站。服务工作者缓存使得页面加载时间几乎瞬间完成,但它不能使动画更快。这需要了解 CSS 动画和可能需要 JavaScript 的知识。
出现卡顿或屏幕跳动现象的应用程序被称为“janky”。例如,如果你曾经加载过网页,比如几乎任何新闻网站,当你开始阅读时内容上下跳动,你就知道我在说什么了。这是一个非常差的用户体验,但可以通过适当的编码实践轻松避免。
在本书的后面部分,你将学习关于 RAIL(响应、动画、空闲、加载)和 PRPL(推送、渲染、预缓存和懒加载)模式。这些是 Chrome 团队提供的编码最佳实践,因为他们了解浏览器的工作原理。他们和其他浏览器厂商都希望网络对每个人都是可用的。
浏览器厂商正在提供指导,以帮助开发者创建能够在客户主屏幕上占有一席之地的网络应用程序。这种指导从以移动性能优先的方法开始。
消费者需要对应用程序有信心,并且他们需要知道该应用程序是可靠的。这意味着当需要时它应该能够正常工作。为了实现这一点,一个网络应用程序应该在设备在线、离线以及介于两者之间的任何状态下都能加载。
服务工作者缓存在浏览器和网络之间提供了一个代理层。这使得网络成为一个渐进式增强。它还引入了程序员必须掌握的新一类编程。
在本书的后面部分,你将学习不同的缓存策略以及如何在不同的场景中应用它们,以使网站能够正常运行。
服务工作者为网络开发者打开了一个新的机会层,他们可以添加提高性能、参与度和数据管理的有价值功能。服务工作者被设计为可扩展的,以便未来可以添加更多功能。目前,支持缓存、推送通知和背景同步,但在 W3C 工作组中还在讨论许多其他功能。
推送通知让你能够在任何时候与消费者建立联系,从而增加参与度。如前所述,天气频道和兰蔻都通过推送通知增加了参与度。
背景同步是一个你现在可以用来在网络不可用的情况下运行应用的通道。当连接恢复时,你可以无缝地与服务器同步,而不会打扰用户。甚至当你的应用正在同步时,他们的手机可能还在口袋里。
一个网络应用需要足够吸引用户,让他们愿意将其作为永久设备上的固定功能。一旦你的网络应用满足了最低的技术要求——网络清单、注册的服务工作者带有 fetch 事件处理器,并通过 HTTPS 提供服务——浏览器就会触发原生提示,让用户将网络应用添加到他们的主屏幕。随着本书的深入,你将更深入地了解这一体验。
网络清单、HTTPS 和服务工作者需要不同的专业知识才能有效执行。在我看来,它们的复杂性从后者开始增加。这就是为什么拥抱 PWA 通常被称为一段旅程。这是一件你可以,也应该分步骤实施的事情。
PWA 优势
我曾向你展示了网络相对于原生应用的优势,但这些优势如何提升网络在过去的原生应用之上的地位呢?
让我们借鉴 Chrome 团队的一个演示。一个 XY 图可以显示网络和原生应用之间的差异。垂直轴代表功能。x 轴代表覆盖范围。所定义的覆盖范围是指发现和快速访问应用或网站有多容易:

多年来,原生应用一直享有能力优势。它们拥有紧密的本地平台 API 和钩子,使得原生应用能够做网络未设计去做的事情。例如,原生推送通知允许品牌在应用未打开的情况下向客户发送消息。
然而,应用被苹果、安卓甚至微软生态系统的封闭应用商店所限制。这使得寻找应用变得非常困难。许多估计显示,每天需要花费 8 到 12 美元才能获得一个应用的下载。
如我之前提到的,网络本身并没有准备好适应这种向移动端的转变。已经有一些 API,例如地理位置和某些网络通知功能。这些 API 并不一定与它们的原生对应物处于同一水平。
开发者对许多现代 API 缺乏认识。不幸的是,这种知识和信心的缺乏导致网站没有充分利用这些功能。
十年前,响应式设计还不存在。然而,今天,我们不仅有 CSS 媒体查询和大量的响应式 CSS 库,而且浏览器还内置了响应式图片。现在,网站可以为所有屏幕尺寸提供布局和下载适当大小的图片,而无需进行疯狂的破解。
与其原生版本相比,网站总是很容易被发现。你可以在任何媒体渠道中宣传一个域名,人们知道如何加载它。搜索引擎比应用商店要复杂得多,提供了一个简单的界面来查找几乎所有东西。搜索引擎相对于应用商店的一个大优势是能够深度索引网页内容。
搜索引擎索引网站深处的页面和每个网站数以千计的网页。应用商店只能提供一个下载应用的入口点。你唯一能控制的“页面”是一个销售页面。在这个页面上,你需要在不让客户体验你的内容和体验的情况下推销你的应用。触达能力和一直以来的网络最大超级力量:

如图表所示,网络不仅在平等的基础上与原生应用程序并肩,而且在大多数情况下超过了原生应用程序。当然,仍然会有一些边缘情况,原生应用程序是最佳选择,但随着浏览器添加新功能,这些情况正在逐渐减少。
PWA 技术要求
至少,要成为 PWA,有三个核心技术要求。一个网站必须有一个 Web 清单文件,使用 HTTPS 提供服务,并且必须注册一个具有 fetch 事件处理器的服务工作者。你将在未来的章节中深入了解这些要求的每一个。
Web 清单驱动着添加到主屏幕的体验。HTTPS 在你的应用程序和浏览器之间提供了一层安全性和信任。服务工作者提供了可扩展的后骨架构,用于在用户界面之外的线程上执行事件驱动功能。
PWA 也应使用应用程序外壳或常见的 HTML 和 CSS。这是 Chrome 最常见的应用,它被用于网站上几乎每个页面。如果你有任何单页应用程序的经验,你应该理解应用程序外壳是什么。
应用程序外壳
典型的应用程序外壳通常包含一个页眉、一个主要内容区域和一个页脚。当然,这可能会根据应用程序和网站而有所不同。2048 游戏不同,因为它只有一个网页:

在单页应用程序中,应用程序外壳很常见,因为它们可以在浏览器中动态渲染标记和数据。对于 PWA 来说,情况并不一定如此。单页应用程序之所以如此受欢迎,是因为它们能够创建更接近原生的过渡体验,因为请求新页面时没有请求延迟。
由于 PWA 可以本地缓存,这并不意味着你需要一个真正的应用程序外壳。如果应用程序采用先缓存策略,页面可以在毫秒级加载,通常小于 100 毫秒。这在人类心智中感觉是瞬间的。
这并不意味着你不应该识别应用程序外壳。服务器和构建渲染引擎可以使用应用程序外壳和一系列布局来创建服务器托管标记。你将接触到这一点,以便在我们构建照片库和播客应用程序时工作。
几年前,一个流行的应用程序是一个简单的游戏,叫做 2048。目标是合并带有数字的方块,最终总数达到 2048。方块以 2 的倍数编号。你可以合并相邻的具有相同值的方块,以创建一个新方块,其值为它们的总和。

我在这个游戏上浪费的时间比我愿意承认的要多。它很容易玩,而且非常上瘾。这是一种很好的内啡肽和游戏体验的结合。
幸运的是,GitHub 上有许多开源的类似版本可用。其中一些是网络应用程序。我敢打赌,通过应用商店分发的原生版本可能是包裹在原生外壳中的网站,是一种混合应用程序。
我选择了一个流行的仓库来为这本书进行分支。2048 网络应用程序简单,是展示如何制作一个优秀的 PWA 示例的完美候选:
源代码
原始应用程序源代码可在 GitHub 上找到(github.com/gabrielecirulli/2048)。你可以克隆仓库并在浏览器中打开它来玩游戏。只是提前警告,它很容易上瘾,可能会分散你学习如何创建 PWA 的注意力。
我在我的 GitHub 个人资料中分支了这个仓库(github.com/docluv/2048)。我的版本添加了清单、图标、服务工作者,并对代码进行了一些升级,以提高应用程序的性能并利用新的 API 和浏览器功能。原始代码编写得很好,但这是为了三年前的浏览器功能。
随意 star、clone 和 fork 我的 GitHub 仓库,以根据你的喜好进行定制。本书中创建的最终应用程序的运行版本可在网上找到(2048.love2dev.com/)。
应用程序的代码结构
让我们回顾一下游戏源代码的结构。我喜欢这个项目,因为代码简单,展示了在浏览器中使用少量代码可以完成多少事情。
有三个资源文件夹:js、meta和style。它们包含渲染和执行游戏所需的 JavaScript 文件、图像和样式表。
你还会注意到一个node_modules文件夹。我使用grunt connect添加了一个本地网络服务器,这是一个 node 模块。原始游戏如果直接在浏览器中加载index.html文件,则运行正常。然而,由于安全限制,没有网络服务器,服务工作者无法工作。我将在稍后详细介绍这一点。
在根级别,只有少数几个网络应用文件:
-
index.html -
manifest.json -
sw.js -
favicon.ico
2048 代码的优点在于它只需要一个 HTML 文件。manifest.json和sw.js文件添加了我们追求的 PWA(渐进式网络应用)功能。favicon.ico文件是浏览器为地址栏加载的图标:

将 node 模块添加到项目中
原始仓库是一个独立游戏,这意味着它不需要网络服务器来执行,只需要一个浏览器。你可以右键点击index.html文件,选择在您喜欢的浏览器中打开它。在注册服务工作者之后,你仍然可以这样做,可能不会注意到任何区别。但是,如果你打开浏览器控制台(F12 开发者工具),你很可能会看到一个错误。
这种错误可以归因于服务工作者(service worker)的要求。服务工作者,就像大多数浏览器支持的新的 API 一样,需要 HTTPS 协议。这一要求提高了默认的安全级别,并使浏览器对您的网站所有权有最低程度的信任。
服务工作者规范放宽了对 localhost 地址的要求。localhost 是一种常见的引用本地机器的方式,通常是开发环境。由于你不太可能自己攻击自己,浏览器通常会允许你做你想做的事情——除了当你直接从文件系统中打开文件时。
当使用 localhost 加载资源时,浏览器正在发起一个传统的网络请求,这需要一个网络服务器来响应。这意味着你,作为本地机器的用户,已经费尽心思启动了一个本地网络服务器。这不是普通消费者知道如何做的事情。
从文件系统打开的文件是不同的。任何人都可以发送一个index.html文件给你,该文件加载了令人恐惧的代码,旨在窃取你的身份或更糟的是展示无穷无尽的猫视频!通过不尊重直接文件系统,访问浏览器正在保护你免受恶意服务工作者脚本注册的影响。信任 localhost 网络服务器可以通过避免注册 localhost SSL 证书的繁琐过程来简化开发。
您可以运行各种本地网络服务器。近年来,我更倾向于使用 node connect,我将它作为一个 Grunt 任务执行(love2dev.com/blog/make-a-local-web-server-with-grunt-connect/)。因为 connect 是一个节点模块,您可以直接从命令行或自定义脚本中启动它。还有针对您喜欢的任务运行器,如 Gulp 等等的模块。此外,node 是跨平台的,所以每个人都可以使用 connect。
如果您熟悉安装节点模块,您可以跳过这一部分。如果 node 和 connect 对您来说是新的,本节将作为一个简单的入门指南,帮助您在本地机器上运行本书中的所有示例应用程序。
加载节点模块的第一步是从 www.npmjs.com/ 或其他新兴的包管理网站安装它们。如果您喜欢,可以通过命令行来管理,或者您可以在 package.json 文件中定义所需的模块。
您可以在此处阅读有关 package.json 格式的更多信息(docs.npmjs.com/files/package.json)。就我们的目的而言,grunt 和 grunt-contrib-connect 模块是 devDependencies。如果您这是一个节点应用程序,您也可以定义一个依赖项部分。
Grunt 是一种在几年前获得流行并仍然是我的首选任务运行器。任务运行器,似乎每周都有一个新版本,帮助您将可重复的任务组织成可重复的食谱。我使用 Grunt 和自定义节点脚本来构建和部署我的 PWA。将您的任务运行器视为一个命令行控制面板来管理您的应用程序:
{
"name": "2048",
"version": "1.0.0",
"description": "2048 Progressive Web App",
"author": "Chris Love",
"private": true,
"devDependencies": {
"grunt": "*",
"grunt-contrib-connect": "*"
}
}
Grunt 和 Grunt connect 模块都是节点包,必须下载才能执行。package.json 文件为 npm 提供了配置,以便它可以管理您的包。这样,您就可以快速在任何机器上设置您的项目,而无需将节点依赖项作为源代码的一部分进行维护。
如果您已经克隆了示例仓库,您会注意到节点模块被排除在源代码之外。这是因为它们不是应用程序本身的一部分。它们是一个依赖项,npm 帮助您重新创建所需的开发环境。
要安装这些包,您需要打开命令行并切换到您的源代码文件夹。接下来,您必须执行以下命令:
>npm install
这将启动 npm 安装过程,下载您的模块及其依赖链。完成后,您将拥有运行或构建应用程序所需的一切。
接下来,您需要创建一个 gruntfile.js 文件。这是您告诉 Grunt 您想运行哪些任务以及如何运行它们的地方。如果您想了解使用 Grunt 的详细信息,请访问他们的网站(gruntjs.com/):
module.exports = function (grunt) {
grunt.loadNpmTasks('grunt-contrib-connect');
// Project configuration.
grunt.initConfig({
connect: {
localhost: {
options: {
port: 15000,
keepalive: true
}
}
}
});
};
由于我们只使用 connect 模块,2048 的 Gruntfile 非常简单。你需要告诉 Grunt 加载 connect 模块,然后在initConfig函数中注册任务。
2048 是一个非常简单的应用程序,它将我们的定制化保持在最低限度。我随意选择了端口 15000 来提供服务,并选择保持keepalive开启。你可以定义许多选项。更多详细信息可以在grunt-contrib-connect npm 页面找到(www.npmjs.com/package/grunt-contrib-connect)。
剩下的唯一任务是启动 connect 网络服务器。这是通过命令行完成的。如果你在执行 npm install 时仍然打开了命令行,你可以重用它。如果没有,重复打开命令行并切换到项目文件夹的过程:
>grunt connect
Running "connect:localhost" (connect) task
Waiting forever...
Started connect web server on http://localhost:15000
执行grunt connect,你应该会看到前面的示例输出。请注意,命令会继续执行。这是因为它是一个服务器,正在监听端口 15000 的请求。你无法在这个提示符下执行其他命令。
现在,你可以在浏览器中通过在地址栏输入http://localhost:15000来加载 2048 游戏。
添加清单文件
添加 Web 清单文件应该是升级现有网站的第一步。你可以在几分钟内创建你网站的清单文件。在工具章节中,我将回顾一些可以帮助自动化此过程的在线资源。
注册 PWA 的 Web 清单需要在 HTML 的head元素中添加一个特殊的链接元素。以下代码显示了如何注册 2048 清单文件:
<head>
….
<link rel="manifest" href="manifest.json">
</head>
如果你熟悉引用样式表,这个语法应该看起来很熟悉。区别在于rel属性值是 manifest。href值指向清单文件。你可以自由命名它,但 manifest 是最常见的名称。
下一章将详细介绍清单文件。你可以参考项目的manifest.json文件来查看 2048 游戏的配置。它包含应用程序的名称、默认 URL、主要颜色以及一个图标图像引用数组。
添加服务工作者
接下来,你需要注册一个服务工作者。这是在所谓的客户端代码中完成的,即你习惯于编写的 JavaScript。服务工作者在 UI 的独立线程中执行。我认为它是一个后台进程。你仍然需要为网站注册服务工作者。
对于像这个例子这样的简单注册,我首选的方法是在我网站标记的底部添加一个脚本块。首先,检测是否支持服务工作者。如果支持,那么尝试注册网站的服务工作者。如果浏览器不支持服务工作者,则跳过注册代码以避免出现异常。
注册是通过调用 navigator.serviceWorker.register 函数来完成的。它接受一个参数,即服务工作者文件的路径。我将在后面的章节中回顾更多关于这个问题的规则。
注册函数返回一个承诺。你可以添加代码来记录成功的注册,如下所示:
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function
(registration) { // Registration was successful
console.log('ServiceWorker registration successful with
scope: ', registration.scope);
}).catch(function (err) { // registration failed :(
console.log('ServiceWorker registration failed: ',
err);
});
}
</script>
我们将在第五章 服务工作者生命周期中开始深入了解关于服务工作者细节。为了帮助你理解示例代码,让我介绍一些服务工作者基础知识。服务工作者是完全异步的。如果它们不需要,它们会进入空闲或睡眠状态。当操作系统或浏览器触发事件时,它们会唤醒或完全启动。
所有逻辑执行都是事件的结果。你必须注册事件处理程序来执行你的服务工作者逻辑。2048 服务工作者为安装、激活和获取事件注册了事件处理程序。
2048 游戏服务工作者在安装事件中预缓存了整个应用程序。你将在第六章 掌握缓存 API – 在播客应用程序中管理网络资产中了解更多关于缓存策略的内容。现在,我们将缓存应用程序,使其始终可用,没有任何网络通信:
self.addEventListener("install", function (event) {
console.log("Installing the service worker!");
caches.open("PRECACHE")
.then(function (cache) {
cache.addAll(cacheList);
});
});
2048 服务工作者在安装事件中缓存资产。应用程序资产在服务器工作者代码中的一个数组中定义。缓存 API 提供了一个接口,用于一个专门为持久化响应对象而设计的特殊存储。我将把细节推迟到后面的章节中:
var cacheList = [
"index.html",
"style/main.css",
"js/keyboard_input_manager.js",
"js/html_actuator.js",
"js/grid.js",
"js/tile.js",
"js/local_storage_manager.js",
"js/game_manager.js",
"js/application.js"
];
服务工作者也有激活和获取事件处理程序。在触发添加到主屏幕功能之前,必须注册获取事件处理程序。
当浏览器从网络请求资产时,获取事件会被触发。这可能是一个图像、样式表、脚本、AJAX 调用等等。event 参数包含请求对象,可以用来检查你的缓存以查看资产是否可用:
self.addEventListener("fetch", function (event) {
event.respondWith(
caches.match(event.request)
.then(function (response) {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
没有获取事件处理程序,你的应用程序无法离线工作。没有要求处理程序捕获任何请求,只需注册即可。这是离线功能的最小检查。
在示例中的获取事件处理程序中,所有缓存都会被查询以查看是否存在与请求匹配的现有条目。如果存在,则返回本地缓存的版本。如果不存在,则请求会被传递到网络。
就这样;恭喜!你的网站现在至少在你的本地机器上是一个 PWA:

在这一点上,在 Chrome 中加载 2048 本地站点应该会显示添加到主屏幕的提示。如果没有,请重新加载页面一次或两次,并将焦点应用到浏览器标签页上。如果你仍然没有看到提示,请检查控制台中的任何错误消息并相应地进行调试。
摘要
在本章中,你通过更新一个基本的游戏网站学习了 PWAs 的基础知识。我们还回顾了渐进式网站是什么以及为什么它们被创建。
在下一章中,你将了解更多关于主屏幕提示体验的细节以及如何制作一个合适的 Web 清单文件。
第二章:使用网页清单创建主屏幕体验
进阶式网页应用让网站感觉就像原生应用一样。对于企业利益相关者来说,这给了他们使用免费应用商店来吸引客户的机会。对于真实用户来说,这意味着他们经常访问的网站可以无缝安装。无论哪种方式,这都是一个通过提供更好的用户体验和自然地将品牌图标放置在用户最重要的位置(即他们的主屏幕)来增加参与度的营销机会。
每个平台(操作系统和浏览器)都以自己的方式实现主屏幕和应用程序的启动,但大多数都涉及某种形式的书签过程和由网页清单文件驱动的打开体验。
Chrome for Android 将安装的 PWA 放置在应用架中,并允许在设备设置中将 PWA 像原生应用一样进行管理。Microsoft 利用 Windows Store,并为未来的版本制定了一个免费商店安装流程。Apple 仍在探索他们将如何实现这些体验,但他们正在基于他们的遗留体验进行构建。
本章将介绍如何创建网页清单文件来描述你的应用程序给平台,以及你如何可以通过编程方式提示用户将 PWA 添加到他们的主屏幕。你将看到我在本章中多次提到添加到主屏幕的过程,但它只是一个指代更多内容的名称。添加到主屏幕这个术语已经逐渐成为描述 PWA 如何在用户设备上安装的默认方式。
事实上,情况更为复杂,因为目前还没有官方的共同规范来定义这个过程。在 Android 上,你将应用图标添加到主屏幕,由于这是 PWA 首次被采用的地方,因此术语就是这样产生的。如今,每个浏览器和平台都以不同的方式处理这个过程。即使是 Android 上的独特浏览器,其做法也与 Chrome 有所不同。在撰写本章时,Microsoft Edge 要求你通过 Windows Store 来安装进阶式网页应用,但即使是这一点也在变化中。
随着本章的深入,你将看到这一概念如何应用于不同的浏览器和平台,并学习如何使用网页清单文件来向平台描述你的 PWA。
本章将涵盖以下主题:
-
网页清单文件
-
添加到主屏幕的过程是如何工作的
-
如何使用遗留功能来Polyfil添加到主屏幕体验
为什么将应用添加到主屏幕很重要
重新参与是原生应用程序相对于网站所享有的关键优势。它们图标出现在用户的主屏幕和应用架上,提供了快速、直观的品牌体验访问。这是微妙的,但那个图标是客户与品牌关系的持续视觉提醒。
浏览器已经为我们提供了内置机制,多年来我们可以使用收藏夹来书签网站,但这些列表已经变成了杂乱无章的混乱,我们经常忘记它们。我们还可以将书签添加到桌面、开始菜单,甚至任务栏,但这个过程是手动的,大多数消费者都不知道它的存在。
更现代的浏览器已经开始记录你经常访问的页面,并在你打开新标签页时提供这些常见目的地的书签。这是一个在不要求用户书签网址的情况下提高用户生产力的例子。
这些书签并不能提供渐进式网络应用添加到首页体验的相同原生体验。Android 上的 Chrome 通过将所有已安装的 PWA 作为 WebAPK 来提供最先进的 PWA 安装优势,引领了这一领域。
WebAPKs 是一种技术术语,表示 Android 上的 Chrome 会在安装过程中将渐进式网络应用打包成 APK(Android 可执行文件)以实现几乎原生的应用升级。由于它们无法访问像原生 Android 应用那样的 Android 特定 API,因此它们仍然有限。
然而,如果你将你的 PWA 提交到 Windows Store,并且客户从 Windows Store 安装它,那么你的渐进式网络应用就是一个原生应用。它将享受与原生应用在 Windows 上相同的所有好处和能力,包括文件系统访问和与 Cortana 等功能的集成能力。
在客户首页上获得位置的能力很重要。原生和 Web 应用都有机制,但两者都有摩擦,这降低了成功率并增加了成本。你必须使用 6-8 个步骤来诱导潜在客户在移动平台上安装你的应用。2012 年,加博尔·塞勒估计,每个步骤都会消除 20%对安装你应用感兴趣的移动用户(blog.gaborcselle.com/2012/10/every-step-costs-you-20-of-users.html)。这意味着对于 6 步安装过程,只有 26%的用户保留,如下面的图表所示。如果步骤有 8 步,这个数字将下降到不到 17%:

当然,用户只有在知道如何在/能够找到你应用商店的情况下才会开始应用安装过程。这意味着你的公司必须投资时间和金钱来驱动流量和品牌知名度。最近的研究表明,这将在 iOS 上花费 8-14 美元,在 Android 上稍微少一些。
然而,只需为每次点击支付几便士,Facebook、按点击付费(PPC)或横幅广告活动就能将相同的参与度带到网站上。更好的是,如果你的页面有一个良好的、自然的 SEO 档案,你可以免费驱动大量目标流量!然而,在客户的首页上获得一个位置并不容易。这是因为它不是一个明显的过程。
回顾到原始 iPhone 发布时,第三方应用直到 6 个月后才可用。在 WWDC 上,史蒂夫·乔布斯宣布了第三方应用解决方案 HTML5 + AJAX (www.apple.com/newsroom/2007/06/11iPhone-to-Support-Third-Party-Web-2-0-Applications/):
"开发者可以创建看起来和表现就像 iPhone 内置应用一样的 Web 2.0 应用,并且可以无缝访问 iPhone 的服务,包括打电话、发送电子邮件以及在谷歌地图中显示位置。使用 Web 2.0 标准创建的第三方应用可以在不损害其可靠性和安全性的情况下扩展 iPhone 的功能。"
随着这一声明,苹果公司提供了一种简单且有点黑客式的方法来驱动 iOS 上的主屏幕体验。非标准技术需要为每个页面添加 iOS Safari 特定的 META 标签,并拥有适当大小的主屏幕图片。
使你的 PWA iOS 网络应用具备功能
当苹果推出 iOS 时,原始的应用推荐是使用 HTML5、CSS3 和 JavaScript 来创建丰富的客户端用户体验。苹果没有移除 Web 应用支持,并且随着时间的推移增强了一些功能。iOS Web 应用体验是由添加到网页HEAD中的自定义元数据驱动的。
大部分苹果元数据已成为现代 Web 清单规范的模式。在创建 Web 清单规范之前,Android 上的 Chrome 集成了对苹果元数据的支持,以驱动类似的经验。
当你的网站包含苹果特定的 META 标签、相应的图标,并且用户已将你的网站添加到他们的主屏幕时,iOS 上的 Web 应用体验被触发。
你需要的第一件东西是一个 png 文件作为默认的主屏幕图标。文件应命名为apple-touch-icon.png,并且应位于你网站的根目录中。
单个页面可以有一个独特的图标,在HEAD中的link引用:
<link rel="apple-touch-icon" href="/custom_icon.png">
更好地是,为不同的屏幕尺寸和密度指定图标。平台将确定哪个图标最适合设备。如果没有指定图标,则搜索根目录中带有apple-touch-icon前缀的图标:
<link rel="apple-touch-icon" href="touch-icon-iphone.png">
<link rel="apple-touch-icon" sizes="152x152" href="touch-icon-ipad.png">
<link rel="apple-touch-icon" sizes="180x180" href="touch-icon-iphone-retina.png">
<link rel="apple-touch-icon" sizes="167x167" href="touch-icon-ipad-retina.png">
当提供了所需的元数据后,你必须诱导用户启动 iOS 添加到主屏幕的过程。这始于他们按下 Safari 的分享图标:

这触发了 Safari 分享菜单,其中不仅包含分享 URL 的选项:它还包含书签图标,并将网站保存到主屏幕,如下面的截图所示:

与主屏幕图标类似,启动屏幕图像也可以指定。将其视为启动画面。这是通过一个 LINK 元素和一个对启动图像的引用来完成的。如果没有指定图像,则使用上次启动应用时的截图:
<link rel="apple-touch-startup-image" href="/ meta/apple-touch-startup-image-640x920.png">
应用程序标题通过另一个 META 标签设置
这与我在网络清单部分将详细讨论的名称和短名称清单属性相似。如果没有提供 META 值,则使用title元素值:
<meta name="apple-mobile-web-app-title" content="2048 PWA">
检查以下截图中的输出:

接下来,您应该控制您的 Web 应用如何呈现给用户。iOS 允许您在浏览器中启动应用或以独立模式启动。独立模式移除了浏览器,但保留了屏幕顶部的 iOS 状态栏。以下代码展示了这一点:
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
在独立模式下,没有地址栏,因此您可能需要调整您的 UI 以允许客户复制 URL 并返回到之前的屏幕。导航问题与本地应用程序设计师协商的问题相同,每个应用程序都有不同的要求。
状态栏也可以使用apple-mobile-web-app-bar-style值进行样式化。此值仅在您的应用以独立模式启动时使用。您可以将默认的灰色更改为黑色或半透明黑色。
不幸的是,您无法将颜色主题化以匹配您的应用程序主题或完全隐藏它:

如果您已按照苹果的规范完成所有操作,当用户启动 2048 游戏时,它应该占用整个屏幕,如前一个截图所示。
苹果可能在 iOS 上为与网络品牌建立更亲密的关系铺平了道路,但他们的方法从未成为通用标准。随着 W3C 的标准化,这种变化发生在渐进式 Web 应用时代,这是一个元数据格式,用于向平台描述您的 Web 应用。
网络清单规范
网络清单描述了渐进式 Web 应用,使用元数据和 JSON 格式。浏览器解析清单文件以创建添加到主屏幕的图标并启动体验。
现在,不再需要在每个页面的HEAD中添加额外的元数据,浏览器可以加载一个包含使用 JSON 格式化的标准属性和值的外部文件。
网络清单规范(w3c.github.io/manifest/)为浏览器提供了一些建立添加到主屏幕体验的指南。浏览器如何实现这一体验是开放的,为创造性开辟了途径。我将在审查清单文件后更详细地介绍这一主题。
引用网络清单文件
网络清单文件必须在文档的 HEAD 中引用,如以下代码所示:
<head>
....
<link rel="manifest" href="manifest.json">
</head>
清单应该使用application/manifest+json MIME 类型提供服务。这是一个重要的设置,因为它经常被忽视。
您应该研究如何在您选择的 Web 服务器中定义或添加 MIME 类型。
许多服务器默认根据文件类型阻止对文件的请求。这通常导致清单文件返回 404 或 403 类型状态代码。我在 PDF 文档需要被服务时看到类似的问题被提出。您可能需要与您的网络管理员或 devops 团队协调,以确保您的服务器配置正确。
不要使用您的服务工作者缓存网络清单文件。您可能无法在不更新服务工作者的情况下更新文件。它们应该保持解耦。
网络清单属性
拥有您应用程序的外观对于确保最佳用户体验至关重要。每个应用程序都有独特的用例,消除了渐进式 Web 应用程序“一刀切”的想法。虽然大多数应用程序将想要复制全屏的原生应用程序体验,但有些将想要保持可见的地址栏。标准化的清单文件为品牌所有者提供了一个与浏览器通信的渠道,以提供最佳品牌体验。
清单应包含一系列属性,包括name、short_name、description、icons、orientation、颜色和默认页面。这些用于主屏幕和启动体验。
最小化的清单属性列表如下:
-
name -
short_name -
description -
icons -
orientation -
theme_color -
background_color -
start_url
您可以在清单中指定其他官方属性,但它们的使用案例有限。我还想指出,由于文档使用 JSON,这是一种可变的数据结构表示法,因此它是可扩展的,一些浏览器正在尝试专有属性。如果您使用非标准属性,请不要担心——您不会破坏其他浏览器,因为它们只是忽略那些值。
有两个名称属性;name和short_name。short_name用于主屏幕图标和其他空间受限的地方。在允许空间的地方,使用name属性。
这是在 2048 应用中前四个属性的外观:
{
"name": "2048",
"short_name": "2048",
"description": "[provide your description here]",
"start_url": "/",
...
}
start_url定义了当选择主屏幕图标时加载的初始 URL。这消除了用户从深度链接(如新闻文章)将 PWA 添加到主屏幕的情况。在过去,图标将是该文章的书签,而不是主页。
start_url可以是应用范围内的任何 URL。它不需要是公共主页;它可以是特殊的 PWA 主页。您还可以使用查询字符串值从服务器驱动额外的跟踪和动态行为。
接下来,icons属性是一个包含icon对象的数组,这些对象定义了图标的 URL、MIME 类型和尺寸:
"icons": [
{
"src": "meta/2048-logo-70x70.png",
"sizes": "70x70",
"type": "image/png"
},
...
{
"src": "meta/2048-logo-600x310.png",
"sizes": "600x310",
"type": "image/png"
}
],
虽然支持不同的图像类型,但我建议使用.png,因为 Chrome 至少需要一张 144 x 144 像素的.png图像。你应该至少包含四个图标,其中至少一个是 144 x 144 像素,但 192 x 192 像素更好。在第十章,“服务工作者工具”中,我将向你展示如何使用www.pwabuilder.com/来帮助你自动化创建完整图像集的过程。
我的经验法则是包含一打或更多的图标变体,以应对潜在的平台需求和机会。Windows Live Tiles 宽度可达 600 像素,可以缩小到小于 70 像素宽。
在创建图标时使用一些艺术指导也是一个好主意。有些标志在小尺寸下效果不佳。如果你将图标添加到主屏幕并发现难以定位,那么你的客户可能也会如此。
启动画面图像是从图标数组中绘制的。Chrome 会选择与设备 128dp 最接近的图像。标题直接从名称成员中提取。使用名为background_color的属性指定背景颜色。
以下图像显示了 Flipkart.com 网站的颜色和标志图标如何用于在加载 Web 应用程序时创建简短的启动画面:

使用数组引用你的 PWA 图标作为 URL。数组中的每个项目都是一个描述图标的对象。包括 src URL、sizes 属性和 MIME 类型。我建议使用.png 文件,因为 Chrome 目前要求此格式。
控制启动样式
清单具有平台用于了解如何启动应用程序的属性。display属性允许你控制 Chrome 浏览器如何渲染。默认值是browser,它将在浏览器中启动 PWA,带有 Chrome。
minimal-ui选项将 PWA 作为应用程序启动,但带有最小的一组导航 UI。
standalone模式将 PWA 作为全屏应用程序启动。应用程序占据大部分屏幕,但一些浏览器元素,如状态栏,可能会被渲染。查看以下代码以了解属性:
"display": "fullscreen",
"orientation": "landscape",
fullscreen模式在无任何浏览器元素的全屏和应用模式下启动应用程序。对于最终用户来说,感觉就像他们打开了一个原生应用程序。
当前支持的显示值如下:
-
fullscreen:在全屏中启动应用程序。 -
standalone:类似于全屏,但可能有一个可见的系统 UI。 -
minimal-ui:向独立视图添加一些最小浏览器导航 UI 组件。 -
browser:在浏览器中以常规网页形式打开 PWA。 -
orientation:此属性定义应用程序渲染的角度。主要选择是横屏和竖屏。值应该是自解释的。不,你不能以 45 度倾斜渲染你的应用程序! -
完整的定位选项如下:
-
any -
自然 -
`横屏` -
`竖屏` -
竖屏-主要 -
竖屏-次要 -
横屏-主要 -
横屏-次要
-
theme_color和background_color用于表示应用并提供默认背景颜色。这两种颜色之间的区别在于它们的应用方式:
"background_color": "#fff",
"theme_color": "#f67c5f",
背景颜色指的是BODY元素的默认背景颜色。这通常在网站的 CSS 中设置。如果没有设置,则默认回浏览器默认值。今天,事实上的背景颜色是白色,但在早期,它是灰色。
theme_color定义了操作系统用于可视化的颜色。这包括任务切换体验。每个平台都提供不同的用户体验,与如何展示应用程序相关,因此主题颜色的应用会有所不同。
如果你的应用程序使用的是从右到左的语言,你可以使用dir属性来指定。然后这个方向会被应用到name、short_name和description字段。
lang属性与dir相关,因为它指定了网站使用的语言。它也应用于文本属性。值应该是一个标准的语言标签(developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang),由 2 或 3 个字符代码后跟一个可选的子标签组成,例如,en-US或en-GB。
如果你恰好有一个提供在 PWA 中不可用功能的原生应用程序,你可以使用prefer_related_applications字段来指示其可用性,并将其设置为 true 或 false。与related_applications值一起使用,以提示如何安装原生应用程序:
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.love2dev.2048",
"id": "com.love2dev.2048"
}, {
"platform": "itunes",
"url": "https://itunes.apple.com/app/2048-pwa/id123456789"
}]
Chrome 最近增加了对清单范围属性的支持,这增加了对 PWA 及其链接页面的渲染方式的更多控制。我将在 WebAPK 或改进添加到主屏幕体验部分中稍后回顾 Chrome 如何具体使用此属性。
scope属性定义了 Web 应用程序的上下文或被认为是渐进式 Web 应用程序一部分的 URL 范围。平台可以根据需要使用它,但共识是,如果用户在范围内导航,浏览器将根据清单的display属性渲染 PWA。任何超出此范围的导航都会导致页面使用完整的 Chrome 浏览器渲染。
支持 Apple Safari 网络清单
自从苹果发布了 iOS 11.3 和 Safari 13 更新以来,对网络清单规范的基本支持被包括在内。当前的使用和支持存在一些限制:
-
主屏幕图标仍然引用自
apple-touch-icon -
透明图标不受支持
-
不支持 3D 触控菜单
-
没有启动画面
-
无法锁定方向
-
不正确地支持
fullscreen和minimal-ui
苹果还有工作要做,以便完全支持使用 web 清单,但这是一个开始。我相信在接下来的几个月里,我们应该会看到支持得到改善。浏览器供应商面临的一个挑战是转向支持提供功能的新方法。
从提供用户体验的 10 年旧方式(如触摸图标和移动 Web 应用功能)迁移到不同的机制是困难的。如果他们做得太快,他们可能会破坏许多网站,这是所有浏览器供应商都害怕的事情。因此,预计过渡将是渐进的。
我还想指出,PWA 支持,特别是与服务工作者相关,在许多原生应用使用的 webview 中尚未得到支持。这也意味着任何混合应用程序作为 PWA 将无法访问这些功能,包括服务工作者。
在 iOS 上的伪浏览器,如 Chrome、Edge 和 Firefox,也不支持任何渐进式 Web 应用功能。这些浏览器使用 webview 来渲染页面,而不是它们自己的引擎。因此,目前它们也受到限制。
好消息是,Safari 支持所有主要平台上的所有主要浏览器,并且现在支持基本的 web 清单消费。
验证 web 清单文件
web 清单是一个简单的 JSON 文档,但很容易出错或忘记某些内容。如果您的网站没有正确注册清单文件,您将需要调试问题。幸运的是,有一些资源可以帮助您验证文件。
Google 托管了一个简单的在线验证器(manifest-validator.appspot.com),您可以在其中输入 URL 或直接将清单代码粘贴到页面中。它将解析您的清单并告知是否存在问题:

nodejs Web 清单验证器(github.com/san650/web-app-manifest-validator)是一个可以包含在您的自动化测试工作流程中的模块,用于验证清单文件。它已经有一年多了,所以如果您使用的是较新的清单功能,您可能需要分叉项目并更新它。请记住,清单规范尚未最终确定,并且随着时间的推移可能会发生变化。
这些工具并非只有这些。还有一些其他 node 模块,以及 Lighthouse 和 Sonar。我将在第十章,“服务工作者工具”,以及 PWA Builder 中介绍这些工具,PWA Builder 可以生成您的清单。
Chrome 改进的添加到主屏幕体验
在 2017 年某个时候,Chrome 团队宣布了对 PWA 安装体验的更改,称为改进的添加到主屏幕体验。当时,这不仅仅关于自动提示,但这已经成为了变化的一部分。它更多地与 PWAs 在 Android 上的行为有关,它更像是原生应用程序。
这些变化是多方面的,始于 Web 清单的作用域属性。这个属性相对较新,但允许浏览器知道如何限制源(域名)上的 PWA 功能。
当你设置作用域值为/时,你是在告诉平台,渐进式 Web 应用的能力适用于源内的所有路径。这并不总是如此,尤其是在较大的网站和企业应用中。通常,这些网站被分割成不同的应用。
如果你将作用域更改为/hr/,那么只有/hr/文件夹下的 URL 将包含在 PWA 的作用域内。这意味着这些 URL 将根据 Web 清单文件配置打开。不在/hr/文件夹内的 URL 将在浏览器中正常打开。
当使用 Chrome 在 Android 上安装 PWA 时,它会自动创建一个未签名的 WebAPK,这使得 PWA 成为一个原生应用。在 WebAPK 中,会创建一个 Android 清单文件,其中包含 intent 过滤器。
Intent 过滤器告诉 Android 如何打开源内的 URL。对于 PWA,这意味着应用将根据清单配置启动,或者如果在其作用域之外,则直接在浏览器中打开。
这里是这些 intent 过滤器在 WebAPK 中的样子:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="2048.love2dev.com"
android:pathPrefix="/" />
</intent-filter>
pathPrefix的值会改变以匹配 Web 清单的作用域值:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="love2dev.com"
android:pathPrefix="/hr/" />
</intent-filter>
这些变化并没有停止在 Android 上,因为最近的更新也已经应用于 Chrome OS,并且不久的将来也将应用于桌面 Chrome。谷歌正在用渐进式 Web 应用替换 Chrome OS 应用,为平台上的先前 Web 应用提供类似的功能。
Chrome 还将更多添加到主屏幕体验的功能引入桌面。然而,这会因操作系统而异,因为每个平台都有不同的用户期望。
好消息是,如果你制作了好的渐进式 Web 应用,你将直接受益于这些变化。
添加到主屏幕体验
出现自动提示访客将你的渐进式 Web 应用添加到他们的主屏幕上是非常令人兴奋的。在过去,Chrome 最终会显示一个提示安装渐进式 Web 应用,但最近这已经改变了。触发提示的规则仍然有效,但现在只触发beforeinstallprompt事件。
用户提示触发的机制是每个浏览器可以选择不同路径的地方。一些要求在 Web 清单规范中定义,但体验被留给浏览器根据需要实现。
目前,Chrome 拥有最成熟的过程。他们建立了以下标准来自动触发添加到主屏幕体验:
-
有一个 Web 应用清单文件:
-
一个
short_name(用于主屏幕) -
一个名称(用于横幅)
-
一个 144 x 144 .png 图标(图标声明必须包含 image/png 的 MIME 类型)
-
Astart_url加载
-
-
在你的网站上注册了 service worker:
-
有 fetch 事件处理器
-
Fetch 事件处理器不能是一个空操作函数,它必须做些什么
-
通过 HTTPS(使用 service worker 的要求)提供服务
-
至少访问两次,两次访问之间至少有五分钟
-
-
FireFox、三星和 Opera 有类似的要求。FireFox 将在 Android 上触发体验,但不在桌面版上。你可以允许桌面版上的体验,但它被隐藏在标志后面。
这些浏览器通常在浏览器的地址栏中提供简单的视觉提示。以下是 Android 上的 Firefox 如何显示指示器:

注意它如何使用带有+的房屋图标来表示该网站可以安装。在其右侧,你也会看到一个 Android 轮廓标志。小 Android 头像表示有应用可用。在这种情况下,它正在检测我从 Chrome 安装的 PWA,它创建了一个 WebAPK。
Chrome 添加到主屏幕体验
在渐进式 Web 应用体验方面,Chrome 无疑是当之无愧的领导者。他们应该如此,因为他们创造了这一概念。他们也有时间对这个概念进行实验,看看什么有效,什么无效,以及消费者和开发者有什么期望。
这导致他们不断改进流程,使得在 Android 上安装应用时 Chrome 会创建一个 WebAPK,将应用提升到与原生应用相似的水平。最近,他们扩展了这一功能到 Windows 和 ChromeOS,并计划很快在 macOS 上实现。
这里,你可以看到我 Windows 开始菜单中最近安装的一些渐进式 Web 应用:

那么,WebAPK和增强型添加到主屏幕体验究竟是什么?
我已经解释过了:Chrome 将渐进式 Web 应用打包到 apk 包中,这种打包方式被称为WebAPK。如果你不熟悉 Android 原生应用开发,所有的资源都打包在一个名为 apk 的单个文件中。
简单来说,这只是一个包含应用资源的 zip 文件。Windows 使用 appx 格式做类似的事情。Chrome 创建 WebAPK 时所做的类似于使用 Cordova 从网站生成原生应用。
Chrome 团队决定创建一个重复的通道来维护并给予 PWAs 类似的控制,因为采用这种混合方法的原生应用效率最高。他们首先在 Chrome 57 中推出了这一功能。原生 Android 应用与已安装的渐进式 Web 应用之间的主要区别是无法访问平台 API。
应用看起来就像设备上安装的任何 Play Store 应用一样。图标可以放置在主屏幕上,在应用托盘上可见,并且可以通过 Android 平台设置进行管理。
这里是如何在 Android 应用管理界面中呈现 2048 PWA 的:

当 Chrome 在其他平台上实现此功能时,您可能会或可能不会发现相同级别的控制。例如,您仍然无法从 Windows 的控制面板中管理已安装的 PWA。
应用程序使用与网页相同的存储设置。清除域的存储也将清除已安装 PWA 的存储。这意味着将删除 cookie 和缓存内容。
另一个好处是当 Web 清单文件更新以引用新的图标或更改名称值时。这将更新主屏幕图标。
Google 并没有将此功能据为己有。他们为其他用户代理(浏览器)提供了文档和参考以实现类似的功能(chromium.googlesource.com/chromium/src/+/master/chrome/android/webapk)。这意味着我们可能会看到 Firefox、Samsung Internet、UC 浏览器等很快实现类似的功能。
您添加到主屏幕的责任
在 2018 年的 Google I/O 上,宣布 Chrome 在 Android 上将不再包含自动添加到主屏幕的提示。相反,您有责任创建用户体验。最终,Chrome 团队决定更符合其他浏览器供应商构建体验的方式。
清单规范需要时间来定义添加到主屏幕体验的骨架规则和最小要求。而不是将所有浏览器限制在相同的规则下,该规范定义了可以作为添加到主屏幕提示算法一部分使用的 安装信号。
提示序列应尊重一定的隐私考虑,并在发出提示之前等待文档完全加载。该过程还应允许用户检查应用程序名称、图标、起始 URL、来源和其他属性。还建议允许最终用户修改一些值。例如,更改他们主屏幕上的应用程序名称。
这就是 beforeinstallprompt 事件发挥作用的地方。这是您将用户引导至安装您的渐进式 Web 应用的正确信号的钩子。
当满足触发添加到主屏幕体验的启发式条件时,将触发此事件。但不是来自 Chrome 的原生或内置提示,您需要在事件触发后适当时机提示用户。
他们为什么改变这个?我并不是 100% 确定,尽管我个人认为这是一个好主意,有助于推广 Web 应用程序的安装。但这有点侵扰性,并且不符合其他最佳实践。例如,当我们在这本书的后面部分查看启用推送通知时,您不应该自动打扰访客启用通知。
在请求下一阶段的交往之前,应该有一些小小的求爱过程。我并不喜欢用这个比喻,但到了这个阶段,它已经变得很经典了;你不能只是走到每一个漂亮的女孩面前,就请求她们嫁给你。这是一个更长的过程,其中必须赢得双方的信任。
请求访客将你的图标添加到他们的主屏幕上,并不完全等同于婚姻,但更像是请求他们开始稳定交往或独家约会。
要使用beforeinstallprompt事件,在你的网站 JavaScript 中添加一个事件监听器回调:
var deferredPrompt;
window.addEventListener('beforeinstallprompt', function (e) {
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later.
deferredPrompt = e;
showAddToHomeScreen();
});
我需要审查几个移动部件。首先是事件对象(e)有两个独特的属性,platforms和userChoice。platforms是一个数组,表示用户是否可以安装原生应用或渐进式 Web 应用。userChoice属性解决了一个承诺,表示用户是否选择了安装应用。
在这个代码中使用的另一个元素是deferredPrompt变量。这个变量是在事件处理程序外部声明的,因此可以在稍后使用,在这种情况下是在showAddToHomeScreen逻辑中。
在这个例子中,showAddToHomeScreen方法会在事件触发时立即被调用,但更好的做法是将操作推迟到适当的时间。想想一个正在执行重要应用任务的用户。突然的安装应用提示将会是一个令人困惑的干扰。如果你将提示推迟到操作完成,这将对你和用户都有好处。
showAddToHomeScreen方法显示一个特殊的覆盖层,请求用户安装应用:
function showAddToHomeScreen() {
var a2hsBtn = document.querySelector(".ad2hs-prompt");
a2hsBtn.style.display = "flex";
a2hsBtn.addEventListener("click", addToHomeScreen);
}
我在 2048 应用中添加了一个简单的覆盖层,当可见时它会向上滑动。查看以下截图:

一旦接受提示,用户将看到原生添加到主屏幕的提示,如下面的截图所示:

最后,addToHomeScreen方法利用了我们保存在beforeinstallprompt事件处理程序中的deferredPrompt变量。它调用提示方法,显示前面截图所示的内置对话框:
function addToHomeScreen() {
...
if (deferredPrompt) {
// Show the prompt
deferredPrompt.prompt();
// Wait for the user to respond to the prompt
deferredPrompt.userChoice
.then(function (choiceResult) {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the A2HS prompt');
} else {
console.log('User dismissed the A2HS prompt');
}
deferredPrompt = null;
});
}
}
然后该方法使用userChoice方法根据选择执行任务。在这里,我只是将选择记录到控制台。你可以持久化一个表示状态的令牌,或者级联额外的逻辑来执行其他任务。
我认为这是一个启动感谢或入职体验的好机会。
2048 应用是一个非常简单的添加到主屏幕体验。你可以扩展这个功能来教育用户或告诉他们添加你的应用到他们主屏幕上的好处。Flipkart 有一个很棒的教育序列,解释了如何安装应用以及为什么应该这样做。这是一个很好的体验模式,也是他们成功使用渐进式 Web 应用的原因之一。
禁用主屏幕提示
beforeinstallprompt事件也可以用来抑制 Chrome 的自动提示。这次,就像打破默认表单提交一样,调用preventDefault函数并返回 false:
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
return false;
});
这将在 Chrome 中阻止该行为。目前,我不知道如何在其他平台上抑制提示,因为它们还没有支持beforeinstallprompt事件。
跟踪主屏幕安装
一旦显示主屏幕安装提示,用户可以选择将 PWA 添加到他们的主屏幕,或者忽略提示。企业应该跟踪和衡量所有可能的事情,以做出更好的决策。了解有多少主屏幕安装以及客户安装他们的 PWA 的速率,可以提供对他们的营销和技术投资的洞察。
Chrome 支持beforeinstallprompt事件,可以用来跟踪这项活动。你可以添加一个处理程序到这个事件,并记录每个用户的选项:
window.addEventListener('beforeinstallprompt', function(event) {
event.userChoice.then(function(result) {
if(result.outcome == 'dismissed') {
// They dismissed, send to analytics
}else {
// User accepted! Send to analytics
}
});
});
你可以将用户的选项 POST 到你的分析系统中。这可能是一个自定义 API 到你的内部分析,甚至可以绑定到你的第三方服务,如 Google Analytics。
beforeinstallprompt是 Web 清单规范的一部分,但在撰写本书时,它只由 Chrome 支持。希望其他浏览器很快会添加支持。
不支持beforeinstallprompt的浏览器也可以提供反馈。Web 清单的start_url可以设置为特殊起始 URL,或者追加到默认 URL 的自定义查询字符串值。你需要添加逻辑到你的日志分析器中,以跟踪这种行为。除了知道你有多少主屏幕安装之外,你还可以跟踪用户启动你的 PWA 的次数,以及那些没有安装你的 PWA 的用户。
在 iOS 和其他旧浏览器上多文件化主屏幕体验
开发者和企业主经常问的一个问题是,如何在 iOS 和像 Internet Explorer 这样的旧浏览器上启用渐进式 Web 应用功能。虽然这些浏览器中所有功能都不能被破解,但大部分是可以的。
当 iPhone 发布时,最初的应用模型是网页。他们为 Web 应用创建了一个高级体验,包括添加到主屏幕的体验。不幸的是,他们没有制作自动提示体验。谁知道如果开发者没有呼吁原生应用模型,今天这个体验可能会多么先进。
我们可以做的仍然是利用这种能力,并使用 Matteo Spinelli 的添加到主屏幕库(cubiq.org/add-to-home-screen)与 Apple 的指南相结合。这样做可以让你的 Web 应用从用户的首页启动,无论是否有 Chrome。这在上面的屏幕截图中有展示:

避免重复加载主屏幕提示非常重要,除非需要,不要加载“添加到主屏幕”库。我发现确定是否需要 polyfil 的最简单方法是通过使用功能检测服务工作者支持。我选择这样做是因为支持服务工作者的浏览器有一些添加到主屏幕的体验。这在未来可能或可能不会保持不变,所以如果情况发生变化,请准备好更改标准。
不深入细节,我喜欢在页面加载时动态加载 JavaScript 引用。这个过程涉及一系列功能检测,以 polyfil 各种要求,如 Promises 和 Fetch API:
if (!'serviceWorker' in navigator) {
//add to homescreen polyfil
scripts.unshift("js/libs/addtohomescreen.min.js");
}
您可以在Jake Archibald 的文章中了解更多关于动态加载脚本的信息**(www.html5rocks.com/en/tutorials/speed/script-loading)。
您还需要动态添加添加到主屏幕样式表。这次,在文档的HEAD中添加一个功能检测脚本:
<script>
if ('serviceWorker' in navigator) {
// add addToHomeScreen CSS
cssLink = document.createElement("link");
cssLink.id = "addToHomeScreen";
cssLink.rel = "stylesheet";
cssLink.type = "text/css";
cssLink.href = "css/libs/addtohomescreen.css";
document.head.appendChild(cssLink);
}
</script>
自从 iPhone 发布以来,用户就可以安装这样的 Web 应用,但这个过程是手动的,并且对最终用户和开发者来说都 largely unknown。缺乏自动提示一直是该功能的关键缺失组件。它创造出的体验似乎是 Chrome 团队和其他平台模仿以显示渐进式 Web 应用主屏幕提示的模型。
Matteo 的库只提示用户并开始手动过程,但用户仍需完成一些不太直观的额外步骤。新的原生添加到主屏幕过程有一个伪自动管道可以集成。我认为添加到主屏幕库可以作为设计您体验的良好参考,所以花时间看看它是值得的。
你应该 polyfil 响应缓存吗?
请求缓存也可以使用 IndexedDB 进行 polyfile。然而,现在大多数浏览器都支持服务工作者和缓存,我认为这种方法并没有明智地利用资源。在移动使用之外,Internet Explorer 是主要没有服务工作者支持的浏览器。在撰写本书时,IE 应该主要用于企业,当他们的业务线应用程序尚未升级到现代标准时。
这意味着可能只有极少数潜在用户会在没有服务工作者支持的浏览器中打开您的 PWA。当这种情况发生时,可以说他们很可能是某种桌面电脑,并且拥有可靠的网络连接。
尽管我使用早期实现的客户端资产缓存开发了数百个应用程序,但我已经正式从我的推荐中弃用了这种方法。
微软 Edge 和 Internet Explorer
当 Windows 8 发布时,微软悄悄地发布了他们所谓的托管 Web 应用(HWA)的支持。这些是引用有效 web 清单文件的网站,并通过 HTTPS 提供服务。
HWA 是渐进式网络应用的早期前身。明显的区别是无需服务工作者要求,这是预料之中的,因为服务工作者这个概念当时还没有被创造出来。
要成为 HWA,你需要为你的应用程序创建一个包含清单文件和公共 URL 引用的.appx文件。然后,你将 HWA appx 提交到 Windows Store,消费者可以从商店安装 HWA。
成为 HWA 的优势在于这些网络应用可以完全访问所有 Windows 平台 API,就像任何原生应用一样。它们拥有这种特权的原因是,一旦安装,它们就构成了商店,并且是完整的应用程序。
主要区别在于 UI 组件和业务逻辑都是网页。这让你能够立即更新应用程序,而无需经历所有移动应用商店都面临的审核延迟。
在许多方面,这就像是传统原生应用和 Chrome 在 Android 上支持的 WebAPK 功能之间的结合。
微软甚至创建了一个名为 Manifoldjs 的在线工具,以帮助进行 HWA 创建和提交过程。近年来,Manifold 已经重新设计,并有了新的名字,PWA Builder(pwabuilder.com)。
今天,PWA Builder 可以将任何公共网站转换为渐进式网络应用,并提供资源将其提交到 Windows Store,同时还能编译适用于 Apple 和 Google Play 商店的 Cordova 应用。
如果你有所疑问,Windows Store 中已经有很多 HWA 和 PWA 了。Twitter 和 Pandora 是一对知名 Windows 渐进式网络应用。事实上,Twitter 正在逐步淘汰所有原生应用,未来将全部转向 PWA。
我将在第十章第十章,服务工作者工具中详细介绍 PWA Builder。相信我,你不会想跳过这一章,因为 PWA Builder 和我们所提到的其他工具都已经成为了我的 PWA 工作流程的基石。
今天,Microsoft Edge 支持服务工作者,这意味着 HWA 的概念已经演变为渐进式网络应用的消费。同样的过程适用于商店提交,你仍然拥有完整、原生的应用功能。
Windows 8 和 Internet Explorer 也支持将网络应用固定到开始屏幕的本地 Live Tiles。当 Edge 和 Windows 10 发布时,Live Tile 支持并未被包含在内。但这并不意味着你不能将网站添加到开始菜单。
在 Microsoft Edge 中,用户可以通过右上角的...图标打开菜单。这会显示一个包含许多选项的菜单,其中一个是将此页面固定到开始菜单。另一个选项是将页面添加到任务栏:

如你所知,在 Windows 7 时代,Internet Explorer 支持丰富的锁定网站功能。最近,对锁定网站的支持已经恢复。就像 iOS 一样,你可以通过 meta 标签自定义此体验:
<meta name="application-name" content="2048" />
<meta name="msapplication-square70x70logo" content="meta/2048-logo-70x70.png" />
<meta name="msapplication-square150x150logo" content="meta/2048-logo-152x152.png" />
<meta name="msapplication-wide310x150logo" content="meta/2048-logo-310x150.png" />
<meta name="msapplication-square310x310logo" content="meta/2048-logo-310x310.png" />
<meta name="msapplication-TileColor" content="#ECC400" />
锁定网站仍然可以通过 Internet Explorer 使用,但随着企业升级到 Windows 10,作为主要浏览器的 Internet Explorer 使用率正在迅速下降。这并不意味着你应该跳过锁定网站的元数据。目前我仍然包括它。
我不想将这些解决方案放在负面空间,因为它们都是将网络平台推进以提供更好用户体验的良好首次尝试。也许你可以看到这些尝试如何作为现代网络清单规范参考的依据。
现在,随着 Microsoft Edge 发布了服务工作者,团队正忙于研究他们的添加到开始菜单(我的术语,不是他们的)将是什么样子。他们在 2018 年 Build 大会上提供了一些早期原型,但在撰写本书时,还没有确定的东西。
我最好的猜测是在夏末或初秋,我们可能会在 Redmond 的年度 Edge 开发者峰会上看到一些更具体的内容。
无需 Polyfils 即可享受好处
即使你没有 polyfil 添加到主屏幕的行为,你的网络应用在 iOS 和其他非 PWA 平台上也会看到用户参与度的提升。许多公司正在公开分享他们在各种渐进式网络应用案例研究中的改进。
在线航空预订服务 Wego 报告称,在 iOS 上的转化率提高了 50%,会话时间延长了 35%。Mynet 页面浏览量增加了 15%,iOS 上的跳出率降低了 23%。兰蔻的 iOS 会话增加了 53%。这些都是积极的渐进式网络应用案例研究的小样本。
这些公司正在享受 iOS 上 PWAs 的好处,因为从本质上讲,正确架构的网站性能更佳。此外,创建渐进式网络应用迫使你将客户放在首位,而不是开发者。当你这样做时,你会创造更好的用户体验,这直接关联到关键性能指标的改善。
遵循渐进式网络应用指南迫使你提供跨所有平台工作的以用户为中心的体验。
在 Chrome 中测试添加到主屏幕的体验
没有测试添加到主屏幕的体验,开发者的体验将不完整。Chrome 添加了工具,允许你查看你的网页清单文件是如何被解释的,并手动触发提示。
通过使用 F12 启动 Chrome 的开发者工具并选择应用程序标签页。有许多选择可以帮助你调试渐进式网络应用的各个方面。在应用程序下,有一个“清单”选项。这将显示你的网页清单文件属性,包括每个图标。这是一个快速确定你的清单是否被正确解释的方法,如下面的截图所示:

还有一个链接可以手动触发添加到主屏幕体验。查看以下截图:

点击开发者工具中的链接将触发添加到主屏幕提示。请看以下截图:

测试您的最佳方式是将您的渐进式 Web 应用部署到云端,并启用 HTTPS。在支持 PWA 的浏览器中打开您的网站,并尽可能遵守规则以触发添加到主屏幕提示。
在多个设备上测试您的网站始终是一个最佳实践。我建议至少有一部 iPhone、一部 Android 手机和一台配备多个浏览器的桌面电脑。拥有这些真实用户体验可以让您有信心,您部署的 PWA 按预期工作。
摘要
由于 Web 清单和添加到主屏幕体验,原生和渐进式 Web 应用之间的界限变得非常模糊。主屏幕不再仅限于原生应用;Web 被所有平台所欢迎。
今天,大多数浏览器都为渐进式 Web 应用提供了高级应用体验,尽管苹果公司尚未采用渐进式 Web 应用标准,但他们却是第一个将 Web 体验转变为应用体验的公司。开发者和企业需要采用并实施丰富的添加到主屏幕功能。
触发添加到主屏幕体验是提升您的网络存在感的第一步。
即使用户尚未将您的 PWA 添加到他们的主屏幕,您仍然可以利用渐进式 Web 应用的功能。然而,在我们深入探讨服务工作者之前,让我们看看如何将 SSL 添加到您的网站上。
下一章将介绍安全性或 HTTPS 的使用,这是使应用成为渐进式 Web 应用的三项主要技术要求之一。
第三章:使您的网站安全
现在没有任何网站使用 HTTPS,这令人惊讶。过去保护网站很困难,但近年来大多数安全障碍都已被消除。这些障碍包括价格、技术要求和性能担忧。
要成为渐进式网络应用,网站必须通过 HTTPS 提供服务。服务工作者位于 HTTPS 之后,因为它们在后台运行,旨在执行可能在不受保护的情况下损害隐私的任务。
减少和消除障碍后,由于搜索引擎排名的提高、浏览器中的视觉提示以及 HTTPS 背后的门控 API,搜索引擎和浏览器增加了 HTTPS 对消费者的可见性:

这意味着每个网站现在都有从 HTTP 迁移到 HTTPS 的激励措施。但仍然有一些问题您需要解决,以确保 HTTPS 策略的成功。
在本章中,您将学习以下主题:
-
什么是 SSL/TLS 和 HTTPS
-
常见反对意见,迁移到 HTTPS 的理由
-
HTTPS 迁移策略
SSL 历史
安全套接字层(SSL)证书代表了大多数网络和互联网交易中信任的基础。当谈到 SSL 和 HTTPS 时,信任是关键词。当网站使用 SSL 时,浏览器和服务器之间的通信是加密的,但为了获得 SSL 证书,您必须与颁发机构建立一定程度的信任。
要启用 SSL,您必须在服务器上安装证书。证书由证书颁发机构(CA)颁发。如今,有许多证书颁发机构,很难一一列举。您应该寻找最适合您需求的最佳提供商。在本章中,我将讨论其中的一些。您还将了解不同类型的证书以及 CA打包的附加功能。在不久的过去,Network Solutions 是唯一一个可以购买证书的权威机构。
他们不仅是镇上唯一的游戏,您还得处理大量的繁琐手续。如果他们不喜欢您的文件,他们会拒绝您。个人购买证书几乎是不可能的,因为域名所有权需要与注册企业绑定。
这种有限的可用性导致了年度证书的高价。平均博客、企业或组织从未考虑过使用 SSL,因为成本问题。这限制了 SSL 的使用,仅限于传输敏感信息(如信用卡和银行账户号码)的网站,因为这些原始障碍。
证书成本不仅限于年度证书成本——托管安全网站是昂贵的。因为网络技术尚未发展,SSL 限制为每个 IP 地址一个域名。这意味着网站需要支付专用 IP 地址的费用,通常还需要支付专用 Web 服务器的费用。如果您想要加密,每月 4.99 美元的共享托管计划不是选项。
自那时起,HTTPS 的故事已经改变。有许多免费和低成本的证书授权机构,消除了年度成本障碍。HTTP 协议和 Web 服务器技术也得到了发展。今天,您可以使用不同的证书和主机头(域名)在同一 IP 地址上托管多个网站。
服务器名称指示(SNI)于 2003 年被添加到 TLS 规范中(en.wikipedia.org/wiki/Server_Name_Indication)。这允许服务器在同一 IP 和端口号上使用 TLS 托管多个域名。最初,服务器在 HTTP 连接建立后或 TLS 握手后管理主机头名称的转换。
2003 年的 TLS 规范变更使得客户端将域名作为 TLS 协商的一部分包含在内。现在,Web 服务器可以使用它们的内部主机头表来确定所需的网站。
TLS 是如何工作的?
TLS 是一种加密协议,它建立在 TCP 之上,有时也建立在 UDP 之上。因为它位于传输层之上,它允许链中更高层的协议保持不变,例如 HTTP。
该协议隐藏了通过线路发送的实际数据。攻击者只能看到与之连接的端口、域名和 IP 地址。他们还可以跟踪传输的数据量。
一旦建立了 TCP 连接,客户端(通过浏览器或其他用户代理客户端应用程序)将启动 TLS 握手。客户端通过提出一系列问题开始 TLS 对话:
-
它运行的是 SSL/TLS 的哪个版本?
-
它想使用哪些加密套件?
-
它想使用哪些压缩方法?
客户端选择客户端和服务器都支持的 TLS 协议的最高级别。同时也会选择压缩方法。
一旦建立了初始的 TLS 连接,客户端会请求服务器的证书。证书必须得到客户端或客户端信任的权威机构的信任。证书授权机构的例子包括 Network Solutions、GeoTrust、Let's Encrypt 和 Amazon。
证书验证完成后,将交换加密密钥。密钥取决于所选择的加密方式。一旦密钥交换完成,客户端和服务器就可以执行对称加密。
客户端通知服务器,所有未来的通信都将是加密的:

客户端和服务器执行最终验证,其中客户端的 MAC 地址由服务器验证。服务器从客户端接收一个初始认证消息,该消息被解密并发送回客户端以进行验证。
加密密钥为每个连接生成,并基于认证消息。假设握手成功完成,客户端和服务器现在可以安全地通信。
客户端和服务器之间的安全 TLS 连接至少具有以下属性之一:
-
由于对称密码学用于加密传输的数据,这是连接安全的原因。共享密钥的协商既安全又可靠(协商的密钥对窃听者不可用,攻击者无法在协商过程中修改通信内容而不被检测到)。
-
公钥密码学用于验证通信方的身份。认证过程可以是可选的,但通常对于服务器来说是必需的。
-
为了防止在传输过程中数据未被检测到的丢失或更改,每个传输的消息都包含使用消息认证码进行的信息完整性检查。
什么是 HTTPS?
HTTPS 是 SSL/TLS 中的 HTTP。TLS 为两个主机(Web 服务器和浏览器)之间的双向二进制数据通信建立了一个安全隧道。HTTP(超文本传输文本协议)是客户端和服务器之间用来互相通信的通信协议:

因此,可以将 HTTP 想象成管道内的水。管道是 TLS 加密,而水是数据。HTTPS 与水类比的主要区别在于 HTTPS 是双向的,而管道不是。
还有其他通信协议支持 TLS,例如 WebSockets(WSS)、电子邮件(SMTPS)和 FTP(FTPS)。
HTTPS 优势
当有人询问 SSL 或 HTTPS 时,你首先想到的,通常是加密。这是使用 HTTPS 的一个很好的理由,但不是唯一的原因,甚至不是最重要的原因。
HTTPS 给我们提供了三个安全特性:
-
身份:证书证明服务器是真正的服务器
-
机密性:只有浏览器和服务器可以读取它们之间传递的数据
-
完整性:发送的数据是对方接收到的数据
身份
当你安装 SSL 证书并启用 HTTPS 时,你是在告诉全世界他们可以信任你的网站身份。一旦客户端和服务器之间建立了这个安全的通信通道,双方都可以确信对话是期望的对象。
客户端和服务器通过验证彼此的身份来建立通信通道。证书用于验证这个身份。每次对话都需要一个只有客户端和服务器知道的令牌。
机密性
没有 HTTPS,你的连接可能会被中间人攻击所劫持。浏览器中的地址可能会告诉你它是你期望加载的域名,但实际上,它可能是中间的坏人。
让我们先定义不同的场景:
通常,当你使用 HTTP 连接到一个网站时,对话是明文的。一般来说,对话不包含任何敏感信息。但坏人可能会窃听你的流量,并利用他们找到的信息做坏事,如下面的图像所示:

当你使用公共 Wi-Fi 时,这种情况会加剧。这些网络对于免费连接到互联网来说很棒,但对于个人安全来说却很糟糕。
一旦窃听者确定了你的会话,他们可能会拦截对话并将你重定向到他们的服务器。现在,你与目标网站共享的任何信息都会发送到坏家伙的服务器,如下面的图片所示:

虽然有些复杂,但它发生的次数比你想象的要多。
让我们改变场景,让所有相关人员都在使用 HTTPS。现在所有通信都是加密的。坏家伙唯一能看到的是你访问的域名(s),甚至不是那些域名上的 URL,如下面的图片所示:

客户端和服务器之间的连接无法被劫持。如果恶意行为者试图劫持会话,客户端和服务器都知道有问题,对话就会结束。
完整性
因为连接是加密的,第三方演员无法篡改数据,所以每一端都知道数据是有效的。这是因为中间人攻击被阻止了。
你不仅需要担心坏人。第三方内容,如广告,可能会在 HTTP 的任何地方注入响应中。例如,ISP 或你当地的咖啡馆可能会修改请求和响应,将你重定向到不同的服务器或更改你查看的内容。
HTTPS 保证了客户和服务器对会话包含真实数据的信心。当使用 HTTP 时,无法保证客户端或服务器接收到的数据是正确的。
浏览器正不遗余力地向客户表明 HTTPS
你有没有注意到浏览器地址栏中的那些挂锁?你可能注意到了,但你直到觉得有点不对劲时才真正考虑它。近年来,浏览器逐渐提高了普通消费者的用户体验:

很快,Chrome 将开始明确显示页面包含密码或信用卡字段的情况。如果这些字段存在且不是通过 HTTPS 提供的,它们将显示一个大红色的视觉警告,提示客户该页面不安全。
最近,Chrome 在隐身模式下加载使用 HTTP 的网站时开始显示警告,如下面的图片所示:

Chrome 并非唯一在网站未使用 HTTPS 提供服务时增加视觉提示的浏览器:Microsoft Edge、FireFox 和 Opera 都已宣布计划增加视觉提示。这当然会导致越来越少诈骗成功,但也会减少合法的商业转化,因为它们忽视了 HTTPS 的应用。
搜索引擎优化
谷歌和其他搜索引擎已经公开表示,他们认为 HTTPS 网站比信息相同但不安全的网站更具权威性。原因有两个。首先,普通网络冲浪者会更信任 HTTPS 网站而非非 HTTPS 网站。这可能只是一个简单的博客,也可能是一个巨大的银行——无论网站是什么,对安全的感知都至关重要。
独立调查显示,HTTPS 与更高的排名之间存在关联(见backlinko.com/search-engine-ranking)。这很有道理,因为搜索排名信号与更好的用户体验高度相关。HTTPS 是一个用户体验因素,因为它向消费者传达了信任。
因此,即使你的网站不处理敏感信息,你也应该实施 HTTPS,以增强访客对你品牌和搜索引擎排名的信心。
第二个原因是,搜索引擎推动企业和组织实施 HTTPS 是为了验证所有权。没有某种所有权验证,你无法安装合法的 TLS 证书。证书颁发者会发送一封电子邮件,根据域名的 WHOIS 记录触发验证过程。当你注册域名时,你必须提供真实的联系信息,包括一个有效的电子邮件地址。
恶意分子倾向于使用虚假或错误的联系信息注册域名,这样他们就无法被追踪。通过要求 HTTPS,搜索引擎表明对网站所有权有一定的信任。
随着网络向 HTTPS 作为默认方式发展,垃圾网站将越来越少。恶意分子不会轻易获得 SSL 证书。
已不再具有成本障碍
自从 SSL 开始以来,证书就伴随着成本。通常,这是一个年度成本。在过去(大约 15 年前),证书通常每年花费 100 到 500 美元。你可以把它想象成年度营业执照。事实上,为了完成证书请求,你通常需要提供业务或组织的证明。颁发过程也是耗时耗力的。通常需要 3-14 天才能获得证书。颁发机构有一支评估每个证书请求及其相关文件的员工。对于一个数字平台来说,这是一个非常古老的过程。
虽然企业对每年 100 美元的网站费用并不在意,但普通小型企业却会感到压力。对于每一家企业,都有成千上万家小型企业。除了传统的小型企业,还有数百万的企业、博客、论坛和其他实体,它们的网站几乎没有或没有收入。它们几乎无法证明其托管费用的合理性。以那样的价格,HTTPS 根本不可行。
另一个你可能没有考虑到的成本是 IP 地址。最初,SSL 需要一个专用的 IP 地址。尽管有数百万个可能的 IP 地址,但仍然不够,甚至远远不够。IP 地址的有限供应也提高了 HTTPS 的价格。这可能会给托管一个网站的成本每年增加 100 美元或更多。今天,这种情况已经改变。现在,证书映射到域名。这消除了这种税收。
今天,HTTPS 的要求和成本都已经放宽。有许多低成本证书提供商。实际上,您可以从亚马逊或 Let's Encrypt(letsencrypt.org)免费获得证书。Let's Encrypt 分享的最新统计数据表明,已颁发超过 5000 万张证书。
到目前为止,我提到了 SSL,但这个名字已经不再完全准确了。传输层安全性(TLS)是今天使用的正确术语。安全协议随着时间的推移不断进化。SSL 最初是由 Netscape 创建的,现在由 AOL 拥有。
为了避免潜在的法律问题,TLS 最初于 1999 年在 RFC 2246 中制定(见tools.ietf.org/html/rfc2246)。名称变更背后的主要意图是将加密协议从 Netscape 中分离出来,使其更加开放和自由。
在一般对话中,SSL 和 TLS 是可以互换的,因为大多数人都会明白你的意思。
你实施的协议版本受限于你的服务器平台。因为 TLS 1.2 现在已经非常成熟,所以很难找到一个不支持版本 1.2 的服务器平台或浏览器。但 Qualys 有一些建议:
“SSL/TLS 家族中有五种协议,但并非所有都是安全的。最佳实践是使用 TLS v1.0 作为你的主要协议(确保在配置中缓解了 BEAST 攻击),如果服务器平台支持,则使用 TLS v1.1 和 v1.2。这样,支持较新协议的客户端将选择它们,而不支持的客户端将回退到 TLS v1.0。你绝对不能使用 SSL v2.0,因为它是不安全的。”
幸运的是,当你创建 TLS 证书时,协议版本由你处理。在本章的后面部分,我将介绍在 AWS 和 Let's Encrypt 创建证书的步骤,这两个都是免费服务。
现代 API 需要 HTTPS
我们目前处于技术发展的一个阶段,新的高价值 API 和功能正在快速添加。这包括服务工作者和 HTTP/2,两者都需要 HTTPS。WebRTC 和现在的地理位置也都需要 HTTPS。任何处理个人信息的 API 要么是,要么很快就会在 HTTPS 后面受到限制(www.chromium.org/Home/chromium-security/deprecating-powerful-features-on-insecure-origins)。
虽然这些 API 可以在没有 HTTPS 的情况下工作,但安全性将这些特性包裹在信任之中。想想看——平台让你集成得越深,它们对你的应用程序的要求就越高。
要求使用 HTTPS 确保最小程度的安全和信任,因此足以让潜在的平台相信你不会做坏事。
HTTPS 可以比 HTTP 快得多
一些旨在使网站更快的技术的确只与 HTTPS 一起工作。一个例子是称为 HTTP/2 的协议增强。通过 HTTP 与 HTTPS 测试来查看这一功能如何发挥作用(详情见www.httpvshttps.com/)。
当我第一次看到研究显示 HTTPS 更快时,我承认我持怀疑态度。我看到加密在网络瀑布中增加了多少时间。幸运的是,管理浏览器和网络堆栈的人们为我们做了好事,并平滑了许多导致 SSL 比非 SSL 慢的问题,如下面来自谷歌的 Adam Langley 的引言所示:
"在我们的生产前端机器上,SSL/TLS 占 CPU 负载不到 1%,每个连接不到 10 KB 的内存,以及网络开销不到 2%。许多人认为 SSL/TLS 会占用大量的 CPU 时间,我们希望前面的数字有助于消除这种看法。"
由于 HTTP/2 通过单个连接多路复用请求,因此只需完成一次 TLS 握手。这减少了检索资产和服务器负载的时间。现在客户端和服务器只需要执行一次握手和加密周期。
HTTP/2 的目标是通过消除 HTTP/1.1 规范中的不足来提高性能。HTTP/2 通过多路复用响应、压缩头部、优先处理响应以及允许服务器端推送来实现这一点,如下面的图像所示:

所有浏览器都支持 HTTP/2,大多数服务器也是如此。Windows 2016 是最后一个支持 HTTP/2 的主要操作系统。由于它是相对较新的,因此在网络上没有很多生产部署。
HTTP/2 不会改变任何 HTTP 语义。状态码、动词和其他此类短语是我们多年来一直在使用的,因此不会破坏现有应用程序。
标准浏览器强制执行的默认实现是 HTTP/2 over TLS。虽然技术上可以使用 HTTP/2 而不使用 TLS,但浏览器不允许这种做法。
单个连接和 TLS 握手与 HTTP/2 提供的其他性能优势相结合,意味着 TLS 始终比 HTTP 快。
HTTPS 的采用
根据 2017 年 8 月的 Qualys 调查(见www.ssllabs.com/ssl-pulse/),他们分析的网站中有 60.2%配置了正确的 SSL。值得注意的是,他们的调查仅限于 138,672 个最受欢迎的网站,这只是目前数亿个网站中的一小部分样本。
HTTP 档案报告称,前 500,000 个顶级网站中有 64%使用 HTTPS(httparchive.org/reports/state-of-the-web#pctHttps),如图所示。好消息是,两项调查都显示出更多网站使用 SSL 的积极趋势:

与过去不同,现在每个网站都应该使用 HTTPS 而不是纯文本 HTTP。通过 HTTPS 保护你的网站不仅仅是关于安全,它还关乎建立信任。当你实施 HTTPS 时,你不仅增加了一层安全:你的客户可以看到你对安全的承诺,并更愿意与你品牌做生意。大多数消费者和非技术人员都理解 HTTPS 的目的。他们不关心技术实现,他们关心的是信任。当你实施 HTTPS 时,你正在消除客户可能有的压力,如果你不使用 HTTPS。SSL 增加了你的整体用户体验价值,这就是为什么搜索引擎和浏览器都在推动每个人使用 HTTPS。有几个原因说明为什么你应该在每个网站上使用 HTTPS。
不同类型的 SSL 证书
SSL 证书可以分为两种类型:验证级别和受保护域名。
证书可以颁发给多个域名,甚至通配符域名。但由于扩展验证规范,这些证书只能颁发给单个域名,而不是多个或通配符域名。这是因为验证过程的敏感性。
根据你选择的确认身份的方法,存在几种不同类型的 SSL 证书。这三个级别在复杂性上递增,并且在验证证书所需的信息类型上有所不同。
域验证证书
域验证 SSL 证书(DV 证书)是最基本的,验证与 SSL 证书注册关联的域名。为了验证 DV SSL 证书,所有者(或具有管理员权限的人)通过电子邮件或 DNS 批准证书请求。
电子邮件接收者通过确认收件并发送确认给提供商来证明他们对域的行政权限。此外,他们可能需要为相关域配置某些 DNS 记录。订购和验证 DV 证书的过程可能需要几分钟到几小时。
这应该是最常用的证书类型,因为任何网站都可以快速安装它们,成本极低或没有成本。如果你的网站可能成为钓鱼或其他欺诈形式的攻击目标,你可能需要投资于需要更多身份验证的证书:

浏览器将通过绿色锁形图标在视觉上指示使用适当 HTTPS 的服务网站。域验证证书是实现这一状态所需的最基本证书。
以下截图显示了 Firefox 如何显示证书信息。这是我的当前 Love2Dev.com 证书,一个域名验证证书:

你可以看到它只列出了通用名称(域名),而将组织和组织单位留空。
组织验证证书
组织验证 SSL 证书(OV 证书)验证域名所有权,以及证书中包含的组织信息,如名称、城市、州和国家。验证过程与域名验证证书类似,但需要额外的文件来证明公司的身份。由于公司验证过程,订单可能需要几天的时间。
提供更多公司信息,从而向最终用户传达更多信任。大多数消费者永远不会检查证书的详细信息,但它可以帮助传达更多信任。
扩展验证 SSL 证书
扩展验证 SSL 证书(EV 证书)比 DV 和 OV 证书需要更多的公司数据验证。需要验证域名所有权、组织信息以及法律证明和文件。由于扩展验证过程,订单可能需要几天到几周的时间。
浏览器中的绿色地址栏包含公司名称是扩展验证证书的显著特征,并且是扩展验证证书的直接回报。它实际上不是一个绿色栏,但你的组织名称以漂亮的绿色字体列在地址栏中,表明这是一个受信任的网站,如下面的截图所示:

将扩展验证证书与前面的示例进行比较,组织和组织单位字段具有属性,如下面的截图所示:

OV 和 EV 证书类型并不反映加密级别的提高;相反,这些反映了网站域名所有者/公司的更严格验证。一个好的规则是,你网站管理的敏感数据越多,你应该使用的验证级别就越高。
如果你使用的是组织或扩展验证证书,请确保你在证书到期前开始续订流程。这些证书由于需要仔细审查,因此发放需要花费数天甚至数周时间。如果你的证书到期,你可能会在没有适当安全保护的情况下度过数天。
如何获取和安装 SSL 证书
每个服务器平台都有自己的步骤来生成证书请求和安装已发行的证书。但常见的步骤包括以下内容:
-
生成证书签名请求(CSR)
-
从 CA 订购 SSL 证书
-
从 CA 下载中间证书
-
在服务器上安装中间证书
今天可以选择多个证书颁发机构,例如 GeoTrust、DigiCert、Symantec 和网络解决方案。你可以比较他们的价格以及他们提供的证书类型,以找到最适合你的解决方案。我们将在本章后面回顾不同类型的证书。
传统上,你从你的 web 服务器软件或管理面板生成一个未签名的密钥。这通常是一个包含加密字符串的文件。你将此文件作为订单过程的一部分提交给 CA。
一旦验证过程完成,CA 将颁发证书,另一个文件。然后你的 web 服务器允许你为该网站安装证书。
今天,这个过程已经变得更加自动化。许多人将其作为一个包含在 web 服务器控制面板中的自动功能。许多人包括自动的 LetEncrypt.org 证书。
WordPress (wordpress.com/)是采用 HTTPS-only 政策的最大玩家。他们在 2017 年使用内置的 Let's Encrypt 工具将他们托管的所有网站升级到 HTTPS。
亚马逊 AWS 为 Cloud Front 和他们的网络负载均衡器服务提供免费的证书。这些是域名验证证书,处理大约需要 30 秒;这是自 90 年代中期以来 SSL 世界发展到一个新高度的又一光辉例子。
将网站迁移到 HTTPS
无论你有一个新网站还是现有网站,你应该有一个系统来确保你的网站正确地实现了 HTTPS。安装证书只是这个过程的开端。你必须确保网站的不同方面都正确地引用 HTTPS。
这包括你页面中的链接,处理指向你网站的链接,以及你的分析和搜索引擎配置文件。
即使你的网站使用 HTTPS,任何包含 HTTP 内容的页面的 HTTP 部分都可能被攻击者读取或修改。当一个 HTTPS 页面包含 HTTP 内容时,它被称为混合内容。你可能认为页面是安全的,但它并不是因为混合内容。
当一个页面包含混合内容时,浏览器会有视觉提示来提醒用户该状态不安全。你不能仅仅依靠在地址栏看到 https 来信赖——寻找绿色的锁。以下截图显示了浏览器栏中的不安全 URL:

这张图片显示,尽管网站是通过 HTTPS 加载的,但微软的主页并不安全。它包含了对不安全资产的引用。以下截图是可能引起问题的示例:

迁移你的网站不仅仅是清理资产引用。本节应该是一个指南或清单,帮助你为你的网站定义一个 HTTPS 迁移策略。
作为额外的好处,这个列表可以作为你完整网站就绪检查单的一部分,因为它包括了一些经常被忽视的最佳实践。
审计网站中的任何 HTTP://链接引用
无论你有一个单页网站还是包含数百万个 URL 的网站,你都需要审计每一页的外部http://链接引用。这包括锚点标签以及你文档头部可能有的任何链接标签。
所有第三方托管资产,如 CSS、JavaScript 库、图像和字体在公共 CDN 上,以及第三方服务,如Google Analytics(GA)**,都是 HTTP 引用的主要目标。这些通常被忽视,因为它们不是由企业“拥有”的。
到现在为止,任何值得信赖的第三方服务都提供 HTTPS 支持。如果它不支持,你可能需要要求它们提供 HTTPS,或者如果它们拒绝,你可以寻找新的提供商。
与前面的 Microsoft 示例一样,你也应该确保你网站上所有对资产的引用要么是 HTTPS,要么根本不带协议。这将更新网站并准备好任何潜在的未来协议:
//:c.microsoft.com/trans_pixel.aspx
最佳实践是使所有引用无协议。与其将所有引用更改为https://,不如将它们更改为//。这种方法更具前瞻性,因为它使任何可能演变的未来通信协议更容易适应。
当使用无协议引用时,用户代理将其应用于资产或外部链接的主要协议。这样,用户应该有一个更无缝的浏览体验。
审计内容和数据
Google 建议你应该始终使用 HTTPS 协议引用外部 URL,以向排名引擎发送一个明确的信号,表明你的网站有 HTTPS。
内容,如博客或一般文本,通常通过内容管理界面由非开发者输入。这意味着原始内容或网站数据以某种形式保存在数据库中,并且不会成为源代码审计的一部分。
你可以爬取你的整个网站并生成报告。这可以通过简单的 node 模块或其他工具完成。同样,你也应该在网站持久化数据上运行脚本以查找外部 HTTP 引用。
关键是要审计你的整个网站以识别这些潜在问题。每个网站都将需要某种形式的定制审计和更新流程。我建议你尽可能自动化;这样,你可以快速更新并重复这个过程作为你构建的一部分。
更新内容后,部署(最好是到测试服务器)并审计结果。很可能会错过一些引用,需要解决这些问题。纠正这些问题并再次尝试,直到通过测试。
更新社交媒体链接
我想强调社交媒体链接作为前一步的常见例子。所有社交媒体网络都使用 HTTPS。由于大多数网站将社交媒体配置文件作为网站主要布局的一部分进行链接,因此这些应该是你首先更新的链接。
由于这些链接通常包含在网站页眉、页脚或两者中,它们会传播到每个页面。从源代码的角度来看,这个 app-shell 文件是一个单独的文件,在这些审计中,它通常会被忽略。
当全局更新某些内容,例如用于引用链接的协议时,你需要审计你的源代码以及你的内容。这包括每个原型文件,例如主布局文件和子布局文件。
配置服务器自动将 HTTP 重定向到 HTTPS
旧链接和自然消费者倾向是通过 HTTP 引用 URL。你的 Web 服务器应该配置为向用户代理发送一个 301 重定向,告诉他们永久加载 HTTPS 地址。
301重定向是一个永久地址更改。你正在告诉用户代理他们正在寻找的地址不再有效,而应转到新的地址。通过将 HTTP 重定向到 HTTPS,你实际上在告诉全世界不再请求不安全的内容。
这个过程因 Web 服务器而异,因此请查阅你平台的文档以获取更多指导。大多数服务器可以通过简单的设置来完成此操作。
如果你正在使用内容分发网络,并且你应该为任何消费者网站这样做,你应该能够在你的 CDN 配置中配置此重定向。
301重定向通过接收用户代理的请求并在服务器的响应中包含一个 301 头来实现。例如,对www.example.org的 HTTP 请求看起来如下:
GET / HTTP/2
Host: www.example.org
服务器返回一个包含永久位置的301响应,如下面的代码所示:
HTTP/2 301 Moved Permanently
Location: https://www.example.org/
你还应该在更改网站路由时配置适当的 301 重定向。当从 HTTP 迁移到 HTTPS 时,你正在更改网站中的每个路由。
在网站管理工具中添加并验证所有域名协议组合
在网站管理工具中添加和验证所有域名协议组合是另一个常被忽视的迁移任务。如果你对搜索引擎排名认真,你将使用 Google 和必应的网站管理工具正确注册你的网站。
关于这两个网站管理平台的详细信息超出了本书的范围。
最佳实践是注册你的网站可能被引用的所有四种方式。你应该为你的主域名和 www.别名注册 HTTP 和 HTTPS 版本。如果你没有全部注册,你的网站将不是完全注册的,可能会遇到一些搜索引擎排名问题。
定义一个规范 HTTPS 链接
你可能还会忽视的另一个 SEO 实践是定义规范链接。Google 和其他搜索引擎使用这个信号来了解内容的原始来源。在这种情况下,任何使用 HTTP URL 的引用都将被视为 HTTPS 版本的重复。以下代码为例:
<link rel="canonical" href="http://example.com/foo">
你应该更新如下:
<link rel="canonical" href="https://example.com/foo">
这样可以避免重复内容的惩罚,在这种情况下,这会稀释链接流量。通过定义canonical链接,您告诉搜索引擎将排名权威导向何处。
将 Google 分析更新为默认使用 HTTPS
您应该进行的另一个更新涉及您的分析服务。由于 GA 是最常见的消费者服务,我将演示如何更新 GA。
在当前的 GA 仪表板中,菜单底部有一个 ADMIN 选项,如下面的截图所示:

这将打开您网站的行政界面。中心列的属性有一个属性设置选项。选择它:

在属性设置面板中,将默认 URL 更改为使用 HTTPS 协议并保存:

更新网站地图和 RSS 源到 HTTPS
就像您更新网站源代码和内容一样,您也应该确保您的sitemal.xml和 RSS 源已更新以使用 HTTPS。这些文件被搜索引擎和当然,任何订阅您 RSS 源的人使用。它们充当您网站的一个已知目录。
您网站地图中的链接被认为是搜索引擎的权威来源。您 RSS 源中的链接传播到订阅者,因此是许多活跃访客进入您网站的方式。确保以最佳方式引导他们进入非常重要。
更新您的 robots.txt 文件
如果您的robots.txt文件包含完整的 URL,您也应该更新这些。一般来说,如果您允许和拒绝蜘蛛访问,您包括相对路径。您最有可能有完整引用的文件是您的sitemap文件:
User-Agent: *
Disallow:
Sitemap: https://example.com/sitemap.xml
虽然不太常见,但一些网站为搜索引擎蜘蛛维护一个disavow文件。他们这样做是为了避免负面链接的惩罚。此文件也应更新为使用https://,这加强了网站协议配置文件与搜索引擎的联系。
摘要
正如您所看到的,HTTPS 的实施很重要,但确实需要您的一些勤奋来正确配置您的网站。随着 TLS、证书和 HTTP 的进步,之前阻碍网站实施 HTTPS 的障碍已被消除。
进步式网络应用需要 HTTPS,因为它提供了更高的用户体验。它们还需要一个注册的服务工作者,这也需要 HTTPS。HTTPS 解锁了现代网络的功能;没有它,您的网站将被限制在更小的功能集。
由于 TLS 确保客户端和服务器之间的对话不会被中间人篡改,并且因为窃听被减轻,所有网站都应该采用 HTTPS。您正在为您的客户提供一层信任,并为网络提供的最新功能打开大门。
第四章:服务工作者 – 通知、同步以及我们的播客应用
在第一章,“渐进式 Web 应用简介”中,您了解到网络在移动时代的表现不足,以及为什么渐进式 Web 应用可以使您的网站达到与原生选项相当甚至更好的能力。服务工作者是渐进式 Web 应用最重要的部分,因为它们是应用的骨架。
网络清单和主屏幕图标增强了与客户建立关系和控制启动体验的能力。服务工作者在页面加载时以及页面未加载时都允许程序化地增强用户体验。
服务工作者位于浏览器和网络之间,充当代理服务器。它们提供的不仅仅是缓存层;它们是一个可扩展的骨干:

对于程序员来说,服务工作者是 JavaScript 文件,这使得它们对大多数 Web 开发者来说都很熟悉。这些脚本更像是 Node.js 模块,而不是网页脚本。它们在 UI 服务工作者之外的一个单独线程上执行,因此它们无法访问 DOM。
服务工作者编程模型比您可能用于用户界面的模型更功能化。将服务工作者编程与无头节点比较是有意义的,因为服务工作者编程用于执行计算,许多任务传统上是为 Web 服务器保留的。
服务工作者与 UI 脚本也有所不同,因为它们是完全异步的。这意味着一些 API,如 XHR 和 localStorage,不支持。相反,您应该使用 Fetch API 和索引数据库(IDB)来与 API 和持久化数据连接。当浏览器支持服务工作者时,它们也必须支持 promises,提供自然的异步接口。
关于XMLHttpRequest的注意事项,当从客户端发起时,请求会通过服务工作者。您不能从服务工作者中发起 XHR 请求。
服务工作者被设计成一个可扩展的平台,允许随着时间的推移添加额外的 API。缓存是规范中详细说明的唯一扩展 API。原生推送通知和后台同步是服务工作者启用的一些额外 API 示例。未来,您可以期待添加更多 API。
缓存使得离线和即时资产加载成为可能。我认为描述这个功能的最佳术语是浏览器中的代理服务器。高级服务工作者几乎像是一个完整的 Web 服务器堆栈。一个精心制作的服务工作者可以承担目前分配给 ASP.NET、Node Express、Ruby 等任务的渲染责任。
上一章介绍了如何为您的网站添加 HTTPS。如果您之前还没有被这个概念说服,现在应该会了。服务工作者也需要 SSL。服务工作者需要 SSL 的主要原因是为了启用需要更高信任级别的 API 和其他功能。
已启用的强大服务工作者可能被用于邪恶目的。除了需要 SSL 之外,它们的作用域也限制在单个域。它们没有能力操纵域之外的内容。
这是一件好事,因为第三方脚本无法注册服务工作者并对您的网站造成破坏。
表面上看,这可能看起来很简单,但掌握服务工作者需要一定的技巧。以下章节旨在为您提供创建能够增强任何网站的服务工作者所需的基础知识。
在本章中,您将学习:
-
服务工作者线程模型
-
服务工作者浏览器支持
-
示例播客应用程序的工作原理
-
fetch API 简介
-
如何创建基本服务工作者
-
服务工作者生命周期
-
服务工作者缓存基础
-
如何使用推送通知
-
背景同步编程简介
服务工作者线程
服务工作者在 UI 之外自己的上下文或线程中运行。因为服务工作者线程与 UI 分离,所以它无法访问 DOM。
服务工作者的作用域是事件驱动的,这意味着平台(根据您的观点,是浏览器或操作系统)启动服务工作者。当服务工作者启动时,它是以响应某个事件为目的,例如网页打开、推送通知或其他事件。
进程保持活跃足够长的时间以服务需求。这个时间由浏览器决定,并且因平台而异。服务工作者规范中没有定义固定的时间。
在与正常 JavaScript 不同的上下文中运行为服务工作者提供了许多优势。首先,服务工作者脚本不会阻止 UI 渲染。您可以使用此功能将非 UI 工作卸载到服务工作者。我们将在后面的章节中看到一个示例,展示如何使用客户端模板在服务工作者而不是 UI 线程中渲染标记。
这为您提供了将任务分离到更合适的线程中的方法。现在,您可以在响应 API 调用时执行计算,如数学或渲染标记,然后将结果返回到 UI 线程以更新 DOM。
服务工作者可以通过消息 API 与 UI 线程通信。您可以在线程之间传递文本消息。您还可以在它们到达 UI 线程之前修改来自服务器的响应。
像推送通知这样的功能之所以可行,是因为服务工作者在自己的上下文中执行。服务工作者可以响应操作系统触发的事件而启动,而不是因为页面加载。
在过去,基于 Web 的推送通知可以通过使用 Web 工作者来实现。这些工具很棒,但只有在浏览器打开时才会执行。服务工作者与之不同,因为操作系统可以因为外部刺激而启动它们。唯一真正的要求是客户端设备应该处于开启状态。
服务工作者浏览器支持
服务工作者是一项相对较新的技术,因此人们常常会问:使用服务工作者是否安全?我们真正想问的是,有多少浏览器支持服务工作者?
好消息是所有主要的浏览器都已经发布了基本的服务工作者支持。Chrome 一直是领导者,因为他们在很大程度上负责启动这一概念并管理规范。这项技术得到了包括微软、Firefox、三星和 Opera 在内的其他浏览器厂商的热烈支持。
截至 2018 年春季,所有现代浏览器都已向普通消费者发布了更新,至少支持服务工作者缓存功能。当然,旧浏览器不会支持服务工作者。但随着消费者升级手机和笔记本电脑,它们的用量正在减少。
微软 Edge 服务工作者支持
在 2017 年 9 月的 Edge Web Summit 上,微软宣布他们将在标志后发布服务工作者支持。目标是解决实现中的任何错误,在向普通消费者发布支持之前。
2018 年春季,随着 Windows RS4 的发布,服务工作者的支持被推向了普通消费者。
Safari 服务工作者支持
如果您不熟悉苹果如何宣布 Web API 支持,他们不会这样做。新功能会悄悄发布,至少在大多数情况下,留给开发者去发现。
在一个令人惊讶的发布中,苹果在 2018 年 3 月更新了 Safari,以支持服务工作者:

Safari 服务工作者支持存在一些限制。目前,它们不支持原生推送通知或后台同步。我不认为这些缺失的功能是避免在您的应用程序中集成它们的原因。记住,渐进式 Web 应用是关于利用可用功能的。您仍然可以创建可行的解决方案来绕过这些功能。
服务工作者是否准备好了?
杰克·阿奇博尔德维护一个 GitHub 网站,跟踪每个主流浏览器对服务工作者相关功能的支持(jakearchibald.github.io/isserviceworkerready/),称为is service worker Ready*。
该网站有行,专注于每个主要服务工作者功能和它们的要求,以及每个浏览器的图标。灰度浏览器图标表示支持尚未发布。背景为黄色的图标表示已发布部分支持。绿色背景表示对功能的完全支持:

如您所见,主要的浏览器厂商都支持服务工作者(service workers)。
现在所有现代浏览器都支持服务工作者,你可能认为没有必要担心不支持服务工作者的浏览器。但仍有相当一部分浏览器会话使用 Internet Explorer 和遗留的 Android 浏览器。这意味着你可能需要考虑回退选项。
Polyfilling 更旧的浏览器
Polyfills 是你可以按需引用的库或回退,用于向不支持现代功能的浏览器添加对现代特性的支持。并非所有现代特性都可以通过 Polyfills 实现,但许多可以。有些不是直接的 Polyfills,而是利用其他 API 来创建所需体验。
好消息是你可以将服务工作者缓存 Polyfill 到一定程度。这可以通过 IndexedDB 实现。你需要额外的 JavaScript 层来管理网站的资产和 API 调用。我们将在高级缓存章节中涉及此技术。
除了使用 IndexedDB 缓存资源外,你还可以使用 appCache 作为离线和资源缓存的回退。
推送通知无法通过 Polyfills 实现,但你可以利用替代的通知媒介。短信文本和 Web Worker 通知可以为业务与客户互动提供方式。
播客应用程序
本章向您介绍不同的服务工作者概念和功能,如服务工作者生命周期、缓存和推送通知。为此,我们将构建一个简单的播客应用程序。此应用程序将展示如何处理服务工作者生命周期和响应缓存:
最基本的播客应用程序需求包括:
-
快速加载
-
搜索播客
-
收藏播客
-
播放音频文件
-
持久化剧集以供离线收听
应用程序需要一些服务器端的支持,对于我们来说,这将是一组我从公共来源获取的快照数据。这些数据是项目仓库的一部分 (github.com/docluv/PWAPodcast),因此你可以重新创建应用程序。
你将学习如何注册服务工作者、服务工作者生命周期的基本知识、服务工作者缓存的工作原理以及如何使用 IndexedDB 持久化数据。你还将看到如何利用服务工作者缓存和 IndexedDB 持久化 MP3 媒体文件。
播客应用程序源代码按文件夹组织,这些文件夹与每个章节的进度相关联。根文件夹包含定义运行本地 Web 服务器的 Grunt 任务等常见文件,就像 2048 应用程序一样。
在每个章节的文件夹中,有用于资产(如 CSS、JavaScript 和图像)的文件夹。每个路由都有一个包含单个 index.html 文件的文件夹。这允许应用程序使用无扩展名的 URL。数据文件存储在 API 文件夹下。
应用程序由以下页面组成:
/ (home)
/podcasts/
/podcast/{slug}
/episode/{slug}
/search?q={term}
/later/
每个页面都是一个静态页面,作为网站构建过程的一部分预先渲染。背后的逻辑超出了本书的范围。
应用程序的数据来自 iTunes API。它确实需要一些数据处理才能在应用程序中使用。如果您想使用或研究这些数据,我已经将这些原始数据文件包含在 GitHub 仓库中。
苹果的数据模型需要转换为应用程序。而不是建立一个 Web 服务器来托管正式的 API,数据存储在一系列 JSON 文件中。应用程序将根据需要引用这些文件。
服务工作者位于应用程序的根目录中。这个文件是本章的核心,我们将花费大部分时间在每个章节中修改它。它将演示服务工作者生命周期和基本缓存概念。
您可以尝试完成的版本,请访问podcast.love2dev.com:

PWA Podstr 的首页
首页包含用户订阅的播客列表。这是通过 API 调用检索列表来填充的。然后使用Mustache在浏览器中渲染。所有 API 数据都是一组 JSON 文件,因此不需要编写杂乱的代码来连接和建立数据库:

播客页面显示了播客的详细信息以及最近集锦的列表。播客数据也是通过 API 调用检索的:

集锦页面列出了集锦的标题和描述。它包括一个播放 mp3 文件的 AUDIO 元素。同样,页面在从服务器检索数据后进行渲染:

Fetch API
回到 1996 年,Internet Explorer 引入了iframe元素,作为在网页中异步加载网页内容的一种方式。在接下来的两年里,这个概念发展成了我们现在所知道的XMLHttpRequest对象的第一种实现。
当时,它被称为XMLHTTP,并首次在 Internet Explorer 5.0 中发布。不久之后,Mozilla、Safari 和 Opera 都发布了我们现在称之为XMLHttpRequest的实现。
到目前为止,网页是静态的,当用户在同一个站点内从一个页面导航到另一个页面时,需要整个页面重新加载。
在 2004 年,谷歌开始在 Gmail 和 Google Maps 中广泛使用我们现在称之为AJAX的技术。他们向我们展示了如何利用浏览器对服务器的请求以及如何根据服务器的有效载荷来操作 DOM。这通常是通过调用返回 JSON 数据的 API 来完成的。
就像任何技术一样,随着它的使用,实施者在处理使用过程中暴露的问题时会感到沮丧。为了应对新的用例和问题,技术会更新,通常是通过发布新版本。
有时,这些更新如此重大,以至于新的技术、产品或实现取代了第一个版本。
XMLHttpRequest提供了一种机制来对服务器进行异步调用,但它基于十年前网络和浏览器的工作方式。
今天,网络在许多方面都得到了扩展。我们现在普遍支持的一个特性是 JavaScript Promises。我们也对可以使用异步调用服务器的内容类型有了更深入的了解,这些内容类型是我们当初在 AJAX 最初被指定时没有考虑到的。
介绍 Fetch
在确定了XMLHttpRequest对象的常见限制之后,Fetch API 被标准化,以提供一种新的、经过深思熟虑的方式来实现异步 HTTP 请求。
Fetch API 是制作 AJAX 调用的一种全新的方式。它被创建出来是为了解决我们开发者为了处理XMLHttpRequest限制而进行的许多黑客式和绕道工作。主要区别在于,当使用 Promises 时,Fetch 是异步的。
它最初在 2016 年春季开始看到浏览器实现,现在所有现代浏览器都广泛支持。如果你还没有开始使用 Fetch 来制作异步 HTTP 请求,你应该尽快开始迁移。
Fetch 与XMLHttpRequest对象区别的三个关键特性是更简单的语法、原生的 promise 支持和能够操作请求和响应:

使用 Fetch API
由于 AJAX 已经成为驱动 DOM 操作的一种流行方式,让我们看看如何使用 Fetch 来实现这一点。这将会是一个稍微人为构造的例子,其中 Podcast 的标志被获取并设置到相应的IMG元素中:
var logo = document.querySelector('.podcast-logo');
fetch("…/600x600bb.jpg").then(function(response) {
return response.blob();
}).then(function(logoBlob) {
var objectURL = URL.createObjectURL(logoBlob);
logo.src = objectURL;
});
如果你熟悉如何组合XMLHttpRequest,这个例子看起来应该非常干净和简单。你首先会注意到,唯一需要的参数是一个 URL。这是 fetch 的最简单形式。
这段代码做的是同样的事情,但使用了XMLHttpRequest对象:
var xhr = new XMLHttpRequest();
xhr.open("GET", "…/600x600bb.jpg", true);
xhr.overrideMimeType('text/plain; charset=x-user-defined');
xhr.send(null);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4){
if ((xhr.status == 200) || (xhr.status == 0)){
var logo = document.querySelector('.podcast-logo');
logo.src = "data:image/gif;base64," +
encode64(xhr.responseText);
}else{
alert("Something misconfiguration : " +
"\nError Code : " + xhr.status +
"\nError Message : " + xhr.responseText);
}
}
};
这并不完全干净,也不是异步的。这个例子相当简单。在大多数情况下,AJAX 请求需要更多的复杂性。
fetch方法返回一个 promise,它解析一个响应对象。这代表的是响应,而不是我们想要的图片。通过调用 blob mixin 来访问图片,它也返回一个 promise。
Blob 是图片,然后可以使用URL.createObjectUrl函数将其字节转换为可用的图片格式,并将其应用于图片的 src 属性。
虽然这个例子是人为构造的,但它展示了 Fetch API 的多个方面,你应该熟悉这些方面。API 提供了一个简单的表面来发起请求,但它允许你实现非常复杂的请求处理逻辑。
除了fetch方法外,API 还指定了请求、响应和头对象。还有一个 body mixins 集合,用于操作不同的响应类型。
你不仅可以向fetch方法传递 URL,还可以传递一个组合的请求对象。请求对象包含进行网络请求的值。
请求构造函数有两个参数,即 URL 和一个可选的选项对象。你也可以提供一个现有的请求对象,这听起来可能有些奇怪,但随着你对服务工作者学习的深入,你会意识到这将成为一个常见的起点。
选项参数可以包含以下属性:
-
method:HTTP 请求方法:GET、POST、PUT、DELETE等。 -
headers:自定义请求头,可以是头对象或对象字面量。 -
body:你想要添加到请求中的任何主体:一个Blob、BufferSource、FormData、URLSearchParams、USVString或ReadableStream对象。 -
mode:请求模式,例如,cors、no-cors、same-origin或navigate。默认是cors。 -
credentials:你想要用于请求的请求凭据:omit、same-origin 或 include。 -
cache:类似于 Cache-Control 头中使用的属性。这告诉浏览器如何与本地缓存交互。
其他不太常见的属性是 cache、redirect、referrer 和 integrity。
我们可以使用前面的示例并扩展它以使用自定义请求对象:
var logoHeaders = new Headers();
logoHeaders.append('Content-Type', 'image/jpeg');
var logoInit = { method: 'GET',
headers: logoHeaders,
mode: 'cors',
cache: 'default'
};
var logoRequest = new Request("…/600x600bb.jpg", logoInit);
fetch(logoRequest).then(function(response) {
return response.blob();
}).then(function(logoBlob) {
logo.src = URL.createObjectURL(logoBlob);
});
你应该注意,fetch 方法只有在出现网络错误时才会拒绝。当抛出异常时,网络无法访问,例如,当设备离线时。它不会因为非 2XX 状态码而失败。
这意味着你必须验证响应是良好的、未找到、重定向还是服务器错误。你可以构建一个健壮的逻辑树来处理不同的状态码。如果你只需要响应良好的请求,你可以使用response.ok属性。
如果响应的状态是 200-299,即良好,那么 ok 属性为真。否则,为假。
你应该以不同的方式处理异常和具有状态码的响应。例如,如果一个响应的状态码是 403,那么你可以重定向到一个登录表单。404 状态应该重定向到一个未找到页面。
如果出现网络错误,你可以触发适当的视觉响应,如错误消息或触发应用程序的离线模式体验。
响应对象
fetch 方法解析响应对象。这与请求对象类似,但有几个区别。它代表对请求的服务器响应。
响应对象具有以下属性:
-
headers:头对象 -
ok:指示状态是否在 200-299 范围内 -
redirected:指示响应是否来自重定向 -
status:HTTP 状态码,例如,200 表示良好 -
statusText:对应代码的状态消息 -
type:响应类型,例如,cors 或 basic -
url:响应 URL -
bodyUsed:一个布尔值,指示主体是否已被使用
你还应该了解以下几种方法:
-
clone:创建响应对象的副本 -
arrayBuffer:返回一个解析为arrayBuffer的承诺 -
blob:返回一个解析为 blob 的承诺 -
formData: 返回一个解析为formData对象的承诺 -
json: 返回一个解析正文为 JSON 对象的承诺 -
text: 返回一个解析正文为承诺
之前的示例展示了如何使用 blob 方法创建图像。网络应用程序更常见的任务是从 API 获取数据。通常,响应是文本,包含 JSON。
在播客应用程序中,一个常见的用户任务是在播客和剧集之间进行搜索。我们的播客应用程序在布局标题中集成了搜索表单。它绑定了一些逻辑来调用 API 并返回一组结果。
Podstr 应用程序使用单个 JSON 文件作为示例搜索结果。这样做是为了我们不需要构建服务器端搜索基础设施。生产应用程序将有一个更正式的设置。
搜索结果格式化为包含两个数组,一个是匹配的播客列表,另一个是匹配的剧集列表:
[
podcasts: [
{…}
],
episodes: [
{…}
]
}
搜索结果通过在模板上渲染结果来在搜索页面上显示。检索结果是通过使用 JSON 方法的 fetch 请求完成的:
var searchResults = document.querySelector('.search-results');
fetch("api/search?term={term}").then(function(response) {
return response.json();
}).then(function(results) {
renderResults(results);
});
renderResults 函数将结果对象通过 Mustache 模板运行,并将渲染的标记分配给 search-results 元素:
function renderResults(results) {
var template = document.getElementById("search-results-
template"),
searchResults = document.querySelector('.search-results');
searchResults.innerHTML =
Mustache.render(template.innerHTML, results);
}
如果你不太熟悉 Mustache,它是一个极简模板引擎。渲染方法接受一个 HTML 模板,并将 JSON 对象合并以生成标记。如果你想了解更多关于使用 Mustache 的信息,请访问 GitHub 页面 (github.com/janl/mustache.js/)。
搜索页面展示了如何动态组合页面进行 API 调用。这在今天的网络应用程序中很常见。我们不再来回走动,让服务器根据操作(如提交表单)渲染新的标记。
相反,我们迁移到了一个演变成我们通常称为的单页应用程序的模式。页面不再是静态体验,我们可以动态地更改它们。
能够在不进行完整往返的情况下向服务器发起 API 调用。Fetch 使得这成为可能,并且比之前更简单。模板库如 Mustache 使得在客户端渲染标记变得简单。
如果你熟悉 jQuery 的 ajax 方法,你会注意到与 fetch 的一些相似之处。但也有一些关键的区别。
从 fetch() 返回的承诺不会在 HTTP 错误状态上拒绝,即使响应是 HTTP 404 或 500 错误。相反,它将正常解析(ok 状态设置为 false),并且只有在网络失败或任何阻止请求完成的情况下才会拒绝。
默认情况下,fetch 不会从服务器发送或接收任何 cookies,如果网站依赖于维护用户会话(要发送 cookies,必须设置 credentials 初始化选项),则会导致未认证的请求。
服务工作者 fetch
服务工作者依赖于 promises 和异步 API。这消除了在服务工作者中使用XMLHttpRequest等平台功能。服务工作者依赖于浏览器支持 promises 和 fetch API。
对 fetch 的基本理解是服务工作者编程所需的基本技能。服务工作者允许你在网络请求发送到网络之前拦截所有网络请求。这是通过添加一个 fetch 事件处理器来完成的:
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then((response) =>{
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
事件处理器接收一个单一的FetchEvent对象。你需要了解FetchEvent对象的两个成员,它们是request和respondWith。
请求属性是正在发送到网络的请求对象。respondWith方法限制了 fetch 事件处理器。它保持事件处理器打开,直到响应准备好。该方法还需要返回一个响应对象。
服务工作者 fetch 事件允许你拦截对网络的请求。这种力量允许你调查请求并返回一个缓存的响应,组合一个自定义响应,或者返回一个网络响应。我们将在服务工作者缓存章节中介绍如何使用这种力量的方法。
在旧版浏览器中 polyfill fetch
Fetch 和其他现代 API 得到了主流浏览器的广泛支持。然而,仍然有足够的用户在使用较旧的浏览器。许多企业仍然要求员工使用过时的 Internet Explorer 版本。许多消费者对旧手机感到满意,不升级他们的设备或更新软件。
这意味着我们需要使我们的网站适应这些潜在的情景。幸运的是,许多 API 可以用 JavaScript 库进行 polyfill。Fetch 和 promises 是现代功能,可以轻松地进行 polyfill。
就像我们检测服务工作者支持一样,我们也可以检测 fetch 和 promise 支持。如果这些功能不受支持,那么我们可以加载一个 polyfill。重要的是这些 polyfills 需要按照依赖顺序加载,其中 promise 之后是 fetch,然后是任何特定于网站的代码:
var scripts = ["js/libs/jquery.small.js",
"js/libs/index.js",
"js/libs/collapse.js",
"js/libs/util.js",
"js/app/app.js"
];
if (typeof fetch === "undefined" || fetch.toString().indexOf("[native code]") === -1) {
scripts.unshift("js/polyfill/fetch.js");
}
if (typeof Promise === "undefined" || Promise.toString().indexOf("[native code]") === -1) {
scripts.unshift("js/polyfill/es6-promise.min.js");
}
这是一种从 HTML5 Rocks 文章中借用的异步加载脚本的技巧(www.html5rocks.com/en/tutorials/speed/script-loading/#disqus_thread)。大多数时候,polyfills 是不需要的,但对于那些需要 polyfill 的情况,你需要控制脚本加载的顺序。
这种技术使用一系列脚本 URL,并遍历它们,将每个添加到 DOM 中,同时保持依赖顺序。
由于 polyfills 不是总是需要的,它们只有在必要时才添加。这是通过检查原生支持来确定的。在示例代码中,promise 和 fetch 都被检测到。如果不支持,则将它们添加到脚本 URL 数组中,并在其他脚本之前添加。
承诺也被检查,因为 fetch 依赖于 promise 支持。Podstr 应用程序只需要可能使用 fetch 和 promise polyfill。
但可能有许多 API polyfill 您的应用程序可能需要。HTML5 跨浏览器 polyfill(github.com/Modernizr/Modernizr/wiki/HTML5-Cross-browser-Polyfills )存储库是一个寻找更多资源的好地方。您可以使用相同的功能检测技术根据需要添加它们。
创建服务工作者外壳
在第一章,“渐进式 Web 应用简介”中,我们创建了一个基本的 service worker,它预先缓存了 2048 游戏资源。在本章和接下来的章节中,我们将更深入地探讨 service worker 的细节。
服务工作者在其整体生命周期中经过几个阶段。服务工作者被注册。一旦脚本被加载,它就会触发“安装”事件。此时,服务工作者不控制客户端(浏览器标签页)。
当服务工作者被清除以控制客户端上下文时,激活事件被触发。在此之后,服务工作者完全活跃并控制任何活跃的客户端,无论是标签页还是后台进程。
合适的服务工作者利用事件生命周期来管理数据,如缓存的响应,以设置服务工作者上下文。
播客应用程序从包含安装、激活和 fetch 事件处理器的简单服务工作者脚本开始:
self.addEventListener('install', (event) => {
//install event handler
});
self.addEventListener('activate', (event) => {
//activate event handler
});
self.addEventListener('fetch', (event) => {
//fetch event handler
});
随着这些章节的进展,我们将填充代码以使用每个这些处理器。这些更新将展示服务工作者生命周期、常见的缓存技术以及其他重要的服务工作者概念。
服务工作者生命周期
服务工作者遵循一个已知的生命周期,允许新的服务工作者在不干扰当前服务工作者的情况下准备自己。生命周期是为最佳用户体验而设计的。
当服务工作者被注册时,它不会立即控制客户端。有一些规则旨在最小化由于代码版本差异而导致的错误。
如果新的服务工作者在期望前一个版本逻辑时控制了客户端上下文,可能会出现问题。尽管服务工作者在单独的线程上操作,但 UI 代码可能依赖于服务工作者逻辑或缓存资源。如果新版本破坏,用户的用户体验可能会受到影响。
生命周期旨在确保在会话期间,作用域内的页面或任务始终由同一服务工作者(或没有服务工作者)控制:

生命周期包括注册、安装和激活步骤。安装和激活事件可以绑定处理器以执行特定任务。
生命周期还包括服务工作者更新和注销。这两个任务可能不会经常使用,但开发者仍然应该熟悉它们的工作方式。
每个阶段都可以用于不同的处理阶段来管理服务工作者、缓存资源和可能的状态数据。下一章将详细介绍生命周期以及如何利用每个阶段来提高应用程序的性能并使其更容易管理。
你将学习如何注册、更新和删除服务工作者。你还将了解服务工作者的作用域和服务工作者客户端的定义。本章还将涵盖安装和激活事件,以便你可以添加代码来管理服务工作者的缓存和活动状态。
缓存
最重要的渐进式网络应用程序功能之一是能够在离线状态下工作并即时加载。服务工作者缓存实现了这种超级功能。在过去,网站可以在离线状态下运行,甚至可以通过appCache获得一些性能优势。
服务工作者缓存取代了appCache并提供了更好的程序化接口。AppCache因其难以管理和维护而臭名昭著。
当你的页面引用了appCache清单文件并且有注册的服务工作者时,服务工作者管理缓存,而appCache被绕过。这使得服务工作者缓存成为从appCache的渐进式增强,并且使用它们是安全的。
通过启用缓存,服务工作者使网络成为渐进式增强。因为服务工作者缓存 API 非常底层,它要求开发者应用自定义逻辑来管理网络资源如何被缓存和检索。
这为在你的应用程序中应用不同的缓存策略留下了很大的空间。第六章,“掌握缓存 API - 在播客应用程序中管理 Web 资源”,深入探讨了你需要掌握的核心服务工作者缓存概念。
使用推送通知
商业已经使用推送来吸引客户,即使他们的应用程序没有打开,也已经大约十年了。为什么不呢?研究表明,与品牌参与度和收入直接相关的数据相当令人印象深刻,这些数据与微小的中断有关。
例如,谷歌分享了以下内容:
-
通过推送通知访问的用户时间增加了 72%
-
通过推送通知到达的会员平均消费增加了 26%
-
+50% 在 3 个月内重复访问量增加
这些值都指向品牌和产品经理为什么喜欢推送通知的原因。不幸的是,直到最近,网络一直被排除在这个派对之外。许多企业选择通过原生应用程序的麻烦来发送推送通知。
Push API 为网络应用程序提供了从服务器接收消息推送的能力,无论网络应用程序是否在前台,或者当前是否在用户代理上加载。
在您的应用程序中实现推送通知需要基于服务器的服务,通常是云基础服务,如 Google Cloud Messenger 或 AWS Pinpoint。有众多提供商可供选择。
不要担心您的推送提供商。Web 推送通知基于 IETF 标准,即使用 HTTP 推送的通用事件交付(tools.ietf.org/html/draft-ietf-webpush-protocol-12)。请确保您的提供商符合标准,您应该不会遇到任何问题。
在撰写本书时,Chrome、Firefox、Samsung Internet 和 Opera 目前都提供了推送通知支持。Microsoft Edge 正在推出支持。苹果尚未发布 Safari 支持的时间表。
重要的一点是,每个浏览器或用户代理都独立于其他浏览器。如果客户从多个浏览器加载您的网页,每个浏览器都会注册一个服务工作者。如果每个浏览器还创建了推送通知订阅,用户可能会接收到多个通知。
这使得在您的应用程序服务逻辑中管理订阅逻辑变得很重要。这超出了本书的范围。作为生产逻辑的一部分,在尝试为推送通知注册用户之前查询您的服务器是一个好主意。有几种选项可以处理这种潜在情况,您需要确定最适合您应用程序的方案。
如果您的品牌也有提供推送通知的原生应用,那么它们也将是单独的订阅。这意味着您应该尽可能跟踪客户是否已经在设备上接收到了通知,以避免重复发送消息。
实现推送通知
在本节中,您将学习实现推送通知的一些基础知识:
-
如何订阅和取消订阅用户的推送消息
-
如何处理传入的推送消息
-
如何显示通知
-
如何响应用户点击通知
代码是 Podstr 应用程序的一部分。我不会介绍如何设置推送提供商,因为它们差异很大,且易于更改其管理界面。这会创造一个流动的环境,只会给读者和潜在提供商带来困惑。此外,单独强调一个提供商可能会产生不希望的偏见。大多数提供商都有当前的文档和 JavaScript SDK,可以帮助您创建服务器端环境。
如果您想建立自己的推送服务,Google Chrome 团队的马特·高恩(Matt Gaunt)已经发布了一个您可以克隆的示例服务器(github.com/web-push-libs/web-push)。这可能作为一个不错的测试服务,但我不会将其视为生产级别的服务。
对于我们的目的,Chrome 开发者工具提供了足够的功能来触发客户端逻辑和体验。您可以在注册的服务工作者详情右侧找到一个链接来模拟推送事件:

此链接触发一个带有简单负载的模拟推送消息:来自 DevTools 的测试推送消息。Podstr 应用程序将使用此事件来触发一个我们可以显示给用户的关于新播客剧集的消息。
设置推送通知
要启用推送通知,您需要遵循几个步骤。第一步是检测浏览器是否支持推送。如果是的话,然后您可以继续:
navigator.serviceWorker.register('/sw.js').then(function (registration) {
if ("PushManager" in window) {
//push is supported
}
});
由于推送是在注册服务工作者之后配置的,因此您可以在服务工作者注册后检查其支持情况。就像检查服务工作者支持一样,您可以检查窗口对象是否包含对PushManager的引用。
PushManager有三个方法来管理用户的订阅状态。getSubscription方法返回一个解析为PushSubscription对象的承诺。如果用户已订阅,则订阅是一个对象,否则为 null。
如何在您的应用程序中呈现推送通知的状态取决于您。我个人的建议是,如果浏览器不支持推送通知,则隐藏任何可见的队列,因为这会混淆消费者。
大多数网站会简单地提示用户允许发送推送通知。发送通知的能力受用户批准的限制。当您尝试启动推送通知订阅过程时,浏览器会显示是或否的对话框。
允许用户选择退出推送通知也是一个好的实践。这可以在应用程序设置或配置页面上完成。Podstr 应用程序有一个设置页面,其中包括管理推送通知的选项:

管理推送通知订阅的代码将在本节后面介绍。您可以为用户提供一个界面,无论是作为被动选项,如配置页面,还是通过通知主动提供。
随着 Android 的发展,Chrome 和其他可能的 Android 浏览器将自动将添加到homescreen的渐进式 Web 应用程序转换为WebAPKs。这些是享受与商店应用几乎同等地位的本地应用程序。它们应该具备的一个功能是通过平台设置应用程序在应用程序中管理推送通知的能力,但您永远不应该依赖这是关闭通知的唯一方式。
例如,Twitter 已经采用了渐进式 Web 应用程序并可以发送通知。我打开了它,但发现它只将通知推送到我的手机上的单个账户(Scott Hanselman)。虽然我喜欢 Scott,但我期待有更多的多样性。
我花了很长时间才发现如何管理 Twitter 通知。我找到了如何在 Chrome 中更快地阻止该网站通知的方法:

Twitter 有许多需要强大后端来管理的通知选项。Podstr 应用程序使用开或关的选择。如果这是一个生产应用程序,我会随着时间的推移构建出更多的逻辑,就像 Twitter 所做的那样。
管理用户的订阅
在示例代码中,如果订阅对象不存在,将调用subscribeUser函数。将服务工作者注册对象作为唯一参数传递:
registration.pushManager.getSubscription()
.then(function (subscription) {
if(subscription === null){
subscribeUser(registration);
}
});
pushManager的订阅方法有一个单一参数,即一个具有两个属性的对象,userVisibleOnly和applicationServerKey。
订阅函数在用户已授予发送通知的权限并且浏览器向推送服务发送请求后返回一个解析的承诺。
作为订阅函数工作流程的一部分,用户代理需要提示用户许可。如果被拒绝,承诺将因NotAllowedError而拒绝。您应该始终为订阅调用包括一个 catch 处理程序。
根据推送通知规范:
用户代理不得在未经用户明确许可的情况下向网络应用提供推送 API 访问权限。用户代理必须通过用户界面为subscribe()方法的每次调用获取同意,除非之前的权限授予已被持久化,或者存在预安排的信任关系。超出当前浏览会话的权限必须是可撤销的。
userVisibleOnly属性是一个布尔值,表示推送通知是否始终对用户可见。目前,您必须将此属性设置为 true,浏览器才会允许您订阅用户:
“如果开发者请求使用静默推送(无需触发用户可见的 UI 变化的能力)的功能,我们目前会拒绝这一请求,但未来我们计划引入一个权限来启用此用例”

因此,目前您需要向最终用户显示一条消息,即使不需要用户反馈。例如,可以通过推送通知触发缓存策略来更新网站的缓存资源。在这些场景中,您将显示一个通知来提醒用户更新。
applicationServerKey属性也称为 WEBPUS-VAPID(自愿的网络推送应用服务器标识tools.ietf.org/html/draft-ietf-webpush-vapid-04)。此值来自您的推送服务提供商。它将是一个相当长的随机字符和数字的字符串。
该值应该是 base 64 URL 安全编码。urlB64ToUnit8Array函数将其转换为UInt8Array,这是订阅函数所期望的。urlB64ToUnit8Array是您可以在 Podstr 源代码中找到的实用函数。
用户代理应仅接受包含已订阅的applicationServerKey的通知。根据规范,这是一个建议,浏览器会遵守这一建议:
function subscribeUser(registration) {
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
})
.then(function (subscription) {
updateSubscriptionOnServer(subscription);
console.log('User is subscribed.');
})
.catch(function (err) {
console.log('Failed to subscribe the user: ', err);
});
}
一旦创建订阅,就不能更改。您必须从初始订阅中退订,并使用新选项创建新的订阅。例如,如果您应该更改您的推送服务,客户端将需要一个新的applicationServerKey:

就像服务工作者中的所有事物一样,订阅函数返回一个承诺(promise)。如果没有异常,它将解析为一个PushSubscription对象。此对象包含有关订阅的各种值,这些值可能有助于构建更稳健的用户体验或管理逻辑。
属性(所有为只读):
-
endpoint:订阅端点 -
expirationTime:除非订阅有过期时间,否则为 null -
options:创建订阅时使用的选项的回声 -
subscriptionId:订阅 ID
方法:
-
getKey:表示客户端公钥的ArrayBuffer -
toJSON:订阅属性的 JSON 表示 -
unsubscribe:启动订阅者退订过程
在成功订阅后调用的updateSubscriptionOnServer函数通常用于更新服务器。然而,对于我们的目的,它用于回显订阅对象属性:
{
"endpoint": "https://fcm.googleapis.com/fcm/send/cRWeyfFxsE0:APA91bE8jAnZtPTHSG5pV9hNWtTdS_ZFZT7FTDEpDEYwf8f_FvwuiLc6YDtxNigxKzyHhj9yzbhlJ4zm3M3wt0w1JPYQ41yhF38yeXKhYVp_TFauMr_RnINOKiobCTCYIgj_X0PWlGQF",
"expirationTime": null,
"keys": {
"p256dh": "BO0FEUNPej_U984Q-dVAvtv1lyIdSvOn01AVl5ALu8F-GPA7lTtZ8QfyiQ7Z12BFjPQLvpvypMrL4I6QqHy2wNg=",
"auth": "lDiFiN9EFPcXm7LVzOYUlg=="
}
}
处理推送通知
在消费者确认推送通知订阅后,您可以向用户代理发送消息。当消息发送到设备时,它将确定应用消息的服务工作者并触发推送事件。
服务工作者需要一个推送事件处理程序,该处理程序接收一个事件参数。事件对象包含消息数据。
服务器可以在消息中发送任何文本。这取决于服务工作者解析消息并触发适当的工作流程。
PodStr 仅管理新播客的剧集通知。但零售商可能想要推广销售或提醒客户完成订单。您可能发送给客户的提示实际上没有限制。
后者消息可能不是一个简单的字符串——它可能是一个字符串化的 JSON 对象。如果您需要处理 JSON 对象,您需要测试它以查看它是否是有效的对象,然后再回退到字符串。您可能有的消息类型越多,您需要的事件处理程序逻辑就越复杂。
为了演示目的,Podstr 推送事件处理程序将创建一个包含有关新剧集详细信息的options对象。这包括剧集标题和播客标志:
{
"title": "CodeWinds - Leading edge web developer news and training | javascript / React.js / Node.js / HTML5 / web development - Jeff Barczewski",
“description”: “episode description here”,
"image": "http://codewinds.com/assets/codewinds/codewinds-podcast-1400.png"
}
推送消息由核心serviceWorkerRegistration对象的扩展处理,这些扩展在注册过程中或getRegistration方法中获取。我们感兴趣的方法是showNotification方法。
此方法有两个参数,一个标题和一个选项对象。标题应是一个描述通知的字符串。Podstr 应用程序将使用剧集的标题。
选项对象允许您配置通知,可以是几个属性的组合。
通知对象:
-
actions:一个对象数组,将显示用户可以选择的动作按钮。 -
badge:当没有足够空间显示通知本身时,表示通知的图像的 URL。 -
body:要显示在消息中的字符串。 -
dir:通知的方向;可以是 auto、ltr 或 rtl。 -
icon:通知图标的 URL。 -
image:包含要显示在通知中的图像 URL 的字符串。 -
lang:必须是有效的 BCP 47 语言标签,用于通知语言。 -
renotify:如果通知使用标签进行重复显示,则可以将此设置为 true 以抑制振动和可听通知。 -
requireInteraction:在较大屏幕上,如果此值为 true,则通知将保持可见,直到用户将其关闭。否则,Chrome 以及我假设的其他浏览器将在 20 秒后将通知最小化。 -
tag:一个 ID,允许您在需要时查找和替换通知。这可以通过调用getNotifications方法来完成。 -
vibrate:一个包含振动序列的数字数组。例如,[300, 100, 400]将振动 300 毫秒,暂停 100 毫秒,然后振动 400 毫秒。 -
data:这是一个开放字段,您可以按需填充。它可以任何数据类型,如字符串、数字、日期或对象。
action 属性允许您向通知添加一个或多个动作按钮。您可以在 notificationClick 事件处理器中处理此选择。
动作对象具有以下属性:
-
action:一个DOMString,用于标识要在通知上显示的用户动作。 -
title:一个包含要显示给用户的动作文本的DOMString。 -
icon:[可选] 包含要显示与动作一起的图标的 URL 的字符串:

Podstr 服务工作者在通知事件的 data 字段中查找一个简单的 JSON 对象。它解析对象并构建一个通知对象。
在尝试解析文本时要小心,因为它可能不是一个 JSON 对象。处理这种情况的最佳方式是将解析方法和相关逻辑包裹在 try catch 语句中。这不是最好的场景,但现在是处理有效和无效 JSON 对象解析的唯一方法:
try {
var episode = JSON.parse(event.data.text());
const title = episode.title;
const options = {
body: episode.description,
icon: 'img/pwa-podstr-logo-70x70.png',
badge: 'img/pwa-podstr-logo-70x70.png',
image: episode.image,
vibrate: [200, 100, 200, 100, 200, 100, 200],
actions: [{
action: "listen",
title: "Listen Now",
icon: 'img/listen-now.png'
},
{
action: "later",
title: "Listen Later",
icon: 'img/listen-later.png'
}]
};
event.waitUntil(self.registration.showNotification(title,
options));
}
catch (e) {
console.log('invalid json');
event.waitUntil(self.registration.showNotification("spartan
obstacles", {
body: 'Generic Notification Handler',
icon: 'img/pwa-podstr-logo-70x70.png',
badge: 'img/pwa-podstr-logo-70x70.png',
vibrate: [200, 100, 200, 100, 200, 100, 200]
}));
}
如果通知包含纯文本,则显示一个通用通知。
showNotification 方法在用户的设备上显示消息。该函数返回一个解析为 NotificationEvent 的承诺。
将 showNotification 方法包裹在 waitUntil 函数中可以保持事件处理器打开,直到承诺解析,这样服务工作者就不会终止。
NotificationEvent 对象有两个属性:通知和动作。通知是创建通知时使用的通知对象的副本。如果通知中有一个或多个动作按钮,动作值是通知对象中定义的动作对象的动作属性。
在我们的例子中,这个值将是 listen 或 later。你可以使用这个值来触发不同的响应流程。如果用户选择 listen,你可以直接进入剧集页面并开始播放剧集。如果他们选择 later,你知道要下载剧集的 mp3 文件并将其持久化到缓存中:
self.addEventListener('notificationclick', function (event) {
if(event.action === "listen"){
listenToEpisode(event.notification);
}else if(event.action === "later"){
saveEpisodeForLater(event.notification);
}
event.notification.close();
});
notification.close 方法可以编程关闭一个通知。
显示推送通知只需这些步骤。记住,处理推送通知的所有代码都在服务工作者中处理。目前,浏览器要求你在处理通知时显示一个可见的消息。这并不意味着通知需要用户交互。
推送通知可以触发在服务工作者中执行的操作逻辑,如更新缓存。如果你需要响应,你可以配置操作按钮并处理最终用户的选项。
取消推送通知订阅
我们需要实现的唯一推送通知任务是取消订阅的方式。这可以通过 pushManager 完成。
在我深入探讨取消用户推送通知订阅的细节之前,我想看看我们如何为用户提供一个界面来管理他们的订阅状态。
我更喜欢在网站的设置、配置页面或部分中包含一个管理界面。例如,Twitter PWA 有一个详细的推送通知配置体验。它有一个高级页面,包含链接到四个子页面,每个页面都提供了对不同通知方面的更精细控制。
它们被分组为过滤器或偏好设置。在过滤器组中,还有一个复选框来启用质量过滤器,这是一个非常高级的设置。
推送通知在自己的页面组中管理。它们有代码来检测网站是否启用了推送通知,如果是,则给用户一个选项来启用推送。一旦他们启用了推送,他们就可以根据活动类型定制他们的通知。
默认选项可能会导致发送大量通知。所以,如果你像我一样,花时间减少通知的数量。
Twitter Lite 应用可以作为详细推送管理界面的参考。幸运的是,Podstr 应用保持其通知简单。为了我们的目的,我们将提供一个界面来开启或关闭通知:

可以开启或关闭通知,这会触发客户端逻辑来管理订阅。应用程序必须根据用户切换选择来管理 subscribeUser 和 unsubscribeUser。
这就是为什么有单独的订阅和取消订阅方法。在我深入处理切换 UI 的代码之前,让我们回顾一下 unsubscribeUser 方法。
就像 subscribeUser 方法一样,unsubscribeUser 方法使用服务工作者的 pushManager.getSubscription 方法来获取当前订阅的引用(如果有的话)。
如果存在当前订阅,则调用订阅对象的取消订阅方法。取消订阅返回一个解析为布尔值的承诺,指示是否已取消订阅:
function unsubscribeUser(registration) {
return registration.pushManager.getSubscription()
.then(function (subscription) {
if (subscription) {
return subscription.unsubscribe()
.then(function(success){
console.log(“user is unsubscribed ”, success);
});
}
})
.catch(function (error) {
console.log('Error unsubscribing', error);
});
}
如果服务工作者未注册,则任何相关的推送通知订阅都将被停用。
当通知订阅在应用程序的控制之外发生变化时,pushsubscriptionchange 事件在服务工作者中触发。您可以为该事件添加事件处理器来按需处理更改。
订阅状态可以通过消费者或服务自动更改。如果订阅已经过时,服务可能会删除订阅。在这种情况下,您可以创建一个自动重新订阅过程来续订订阅。
如果您正在重新订阅通知订阅,则必须使用与初始前端 JavaScript 中使用的页面相同的选项进行。您可以通过访问事件对象中的 oldSubscription 对象来访问之前的选项。
处理推送订阅变更
如果由于订阅过时而自动删除订阅,pushsubscriptionchange 事件特别有用。这可能会发生,因为许多推送服务出于安全和因不活跃而限制订阅的生存期。
就像身份验证令牌一样,推送订阅可以无缝续订,无需涉及用户。这就是您可以在服务工作者中为推送订阅做的事情。
pushsubscriptionchange 事件包含一个 oldSubscription 对象,其中包含原始订阅的详细信息。它们可以用来创建一个新的订阅:
self.addEventListener('pushsubscriptionchange', e => {
e.waitUntil(registration.pushManager.subscribe(e.oldSubscription.options)
.then(subscription => {
// TODO: Send new subscription to application server
}));
});
这为您节省了在会话之间持久化值的麻烦。现在,您可以在不干扰最终用户的情况下,轻松地在服务工作者中重新订阅用户。
背景同步
服务工作者缓存允许网站离线渲染。但只有在您有页面和资源在缓存中可用时才有所帮助。如果您需要在离线状态下发送数据或获取未缓存的页面,您能做什么呢?
这就是背景同步可以帮助的地方。它使您能够注册一个请求,当设备重新上线时将得到满足。
背景同步在设备在线时在后台执行异步任务。它通过建立一个队列,当设备能够连接到互联网时立即满足请求。
背景同步的工作方式是您使用与 SyncManager 注册的标签放置一个网络请求。平台负责检查设备是否在线或离线。
如果无法发出请求,同步将请求放入该标签的队列中。背景同步定期检查发出请求的能力,但不会过多到耗尽您的电池或消耗过多的 CPU 周期。
后台同步模型可能需要一种新的方式来组织您的应用程序代码。为了正确使用同步编程,您应该将所有请求从任何事件触发器中分离成独立的方法。
例如,您不会在按钮点击事件处理器中直接进行获取调用,而是会调用一个从事件处理器获取资产的方法。这允许您更好地在后台同步注册中隔离调用。
Podstr 应用程序允许客户选择要离线收听的播客剧集。这需要用户选择剧集,应用程序将下载音频文件并存储以供离线播放。
当然,应用程序必须在线才能下载剧集。您可能还希望限制在设备连接到 WiFi 而不是蜂窝网络时下载大文件,如音频文件。
首先,让我们看看如何使用后台同步来注册一个请求:
if ("sync" in reg) {
reg.sync.register('get-episode');
}
由于后台同步非常新,目前许多浏览器还不支持它。这应该会在不久的将来改变。例如,Edge 在服务工人标志后面提供了支持。
为了安全起见,在使用之前应该进行功能检测。这可以通过检查服务工人注册对象是否支持 "sync" 来完成。如果是这样,那么您可以注册请求;否则,您可以像普通请求一样发出请求。
同步请求是在您的 UI 代码中注册的,而不是在服务工人中。服务工人有一个同步事件处理器,负责处理网络请求。
后台同步就像一个基于云的消息平台。您不是直接放置请求,而是向一个队列或缓冲区发送消息,该队列或缓冲区可以被放置请求和响应双方访问。
在我们的示例中,Podstr 将离线剧集的请求存储在 IDB 中。这是因为它是一个异步数据存储,可供客户端代码和服务工人使用。我不会在本章中详细介绍它是如何工作的,因为我在第六章 掌握缓存 API - 在播客应用程序中管理网络资产 中会详细介绍,当我们深入探讨缓存时。
要使后台同步工作,您首先需要在 IDB 队列中放置一个消息。当服务工人收到处理稍后收听的剧集的同步事件(get-episode)时,它会检查 IDB 队列中的剧集并获取每个文件。
您可以通过调用同步的 register 方法来注册请求。此方法接受一个简单的标签名称。这个名称会被传递给服务工人的同步事件处理器。
服务工人注册一个单独的同步事件处理器。每次后台同步事件触发时,处理器都会接收到一个 SyncEvent 对象。它包含一个 tag 属性,通过提供的 tag 值来识别事件:
self.addEventListener('sync', function (event) {
if (event.tag == 'get-episode') {
event.waitUntil(getEpisode());
}
});
在这个例子中,你可以看到它在调用getEpisode函数之前正在检查标签的值。getEpisode函数触发了检索收听稍后队列中剧集并为其离线持久化下载所需的任务。
你应该注意这个方法被包裹在waitUntil中。这是为了在执行后台任务时保持事件处理器处于活跃状态。下载一集播客可能需要几分钟,你不想服务工作者进程终止。
在这个例子中,播客剧集将在后台同步事件触发时下载。这意味着请求可能不会立即启动,但它们将会被下载。
奖励是,即使浏览器中没有加载 Podstr 页面,这些下载也会发生。此外,下载在后台线程中发生,释放了 UI 从这项繁琐的任务中。现在,消费者可以自由地在应用程序中导航,无需等待音频文件下载。
摘要
服务工作者令人兴奋,为开发者提供了一个构建丰富、引人入胜的用户体验的新环境,同时使用后台活动。本章介绍了不同的服务工作者概念和 Podstr 应用程序。
你现在已经看到了如何利用推送通知和后台同步来建立互动,即使客户没有查看你的应用程序。
在接下来的章节中,你将看到服务工作者生命周期和缓存的工作方式。到本节结束时,你将拥有一个简单的播客应用程序,展示如何使用服务工作者缓存、推送通知和后台同步来创建与使用网络技术构建的流行原生应用程序相媲美的用户体验。
第五章:服务工作者生命周期
服务工作者生命周期是您必须掌握的最重要的概念之一,以便创建适当的服务工作者。服务工作者学科的这个部分通常被忽视,导致在 Stack Overflow 等网站上出现许多问题和挫败感。但掌握服务工作者生命周期可以使您无缝注册和更新服务工作者。
我认为开发者忽视了生命周期,因为它直到他们遇到由于不了解服务工作者生命周期而造成的障碍时才变得明显。大多数开发者感到困惑的问题是服务工作者何时变得活跃。
服务工作者遵循一个已知的生命周期,允许新的服务工作者在不干扰当前服务工作者的情况下准备自己。生命周期旨在提供最佳的用户体验。
当服务工作者被注册时,它不会立即接管客户端。有一些规则旨在最小化由于代码版本差异而导致的错误。
如果一个新的服务工作者刚刚接管了客户端的上下文,如果客户端或页面期望的是之前的版本,可能会出现问题。尽管服务工作者在单独的线程上运行,但 UI 代码可能依赖于服务工作者逻辑或缓存资源。如果新版本破坏了前端,用户体验可能会受到影响。
生命周期旨在确保在整个会话期间,处于范围内的页面或任务由同一个服务工作者(或没有服务工作者)控制:

生命周期包括注册、安装和激活步骤。安装和激活事件可以绑定处理程序,以便它们执行特定任务。
生命周期还涵盖了服务工作者更新,可能是最重要的生命周期步骤,以及注销。这两个任务可能不常用,但开发者仍然应该熟悉它们的工作方式。
每个阶段都可以用于不同的处理阶段来管理服务工作者、缓存资源和可能的状态数据。本章将详细介绍生命周期以及每个阶段如何被用来使您的应用程序性能更优、更易于管理。
本章将涵盖以下主题:
-
注册服务工作者
-
服务工作者客户端
-
更新服务工作者
-
服务工作者作用域
-
服务工作者更新
-
服务工作者事件
当服务工作者被注册时,脚本会被下载并安装。在这个时候,它不会接管任何活跃的客户端,包括注册服务工作者的页面。这是设计上的,以确保客户端体验不会因为服务工作者代码的变化而中断。
当服务工作者变为活跃时,它会声明或控制工作者范围内的任何客户端。由于可能存在控制客户端的先前工作者,新版本不会自动接管。如果两个版本之间存在逻辑差异,这可能会导致各种问题。
为了避免潜在的错误状态,服务工作者规范倾向于谨慎行事。你可以在安装事件处理程序中调用skipWaiting函数,使新版本变为活跃状态。在调用skipWaiting时,你可能仍然需要声明活跃的客户端:
self.addEventListener('install', event => {
self.skipWaiting();
event.waitUntil(
// caching etc
);
});
如果你使用skipWaiting,最好在你进行任何预缓存活动之前调用该方法,因为它们可能需要一段时间。这就是为什么预缓存逻辑被封装在waitUntil方法中。
waitUntil方法在任务完成处理之前保持事件处理程序打开。想想看,直到每个人都上或下电梯,你才关闭电梯门。如果你有扩展处理,服务工作者将不会关闭。
如果服务工作者长时间空闲,活跃的服务工作者将被终止以减少 CPU 负载和其他可能消耗的资源。这是好事,因为持续运行的服务工作者会耗尽你的设备电池。
警惕,如果你强制新的服务工作者变为活跃,你需要确保它不会破坏你的应用程序。当用户体验中断并显示错误消息时,用户不喜欢这种情况。
一个最佳实践是执行某种测试以验证应用程序的完整性。你可能还希望警告用户应用程序已被更新,可能鼓励手动刷新。
永远不要在没有警告访客的情况下自动刷新页面,因为这可能会造成困惑。消息 API 可以用来与用户通信以协调更新。
如果在服务工作者安装过程中出现任何错误,注册将失败,其生命周期将结束。安装后,服务工作者可以变为活跃。一旦活跃,它可以响应如fetch之类的功能事件。
安装处理程序期间的一个常见错误是缓存。addAll方法可能会收到 404 未找到的响应。当这种情况发生时,addAll方法会抛出异常。由于安装事件无法确定错误严重性或上下文,它会回滚。服务工作者永远不会安装。这在下图中表示为红色错误块。
你可以捕获异常并优雅地处理它们。你仍然可以发出单独的请求并缓存这些结果。这需要更多的代码,但可以为你提供一些对单个请求导致服务工作者安装失败的防护。你还可以确保即使有一个失败,好的响应也会被缓存。
下面的流程图演示了核心生命周期,但不会可视化服务工作者如何变为活跃:

注册服务工作者
服务工作者必须从网页中进行注册。这通常在一个常规的 UI 脚本中完成。在调用 register 方法之前,你应该检测浏览器是否支持服务工作者。如果支持,navigator 对象将有一个 serviceWorker 属性:
if ('serviceWorker' in navigator) {
}
如果浏览器支持服务工作者,您就可以安全地注册您的服务工作者。serviceWorker 对象有一个 register 方法,因此您需要提供一个服务工作者脚本的 URL 引用:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
// Registration was successful
});
}
serviceWorker.register("sw path"[, options]) 函数接受两个参数。第一个是服务工作者的路径。此路径相对于网站源或根文件夹。
第二个参数是可选的,它是一个包含注册选项的对象。目前,唯一可用的选项是 scope。使用对象是为了允许对 register 函数进行未来的修改。
scope 选项是一个字符串引用,指向服务工作者允许控制的相对于网站根目录的路径。在以下示例中,正在为人力资源部门注册一个服务工作者。相同的代码可以从网站的根域名或 hr 子文件夹中使用,因为所有路径都是相对于网站根目录的:
navigator.serviceWorker.register('/hr/sw.js', {scope: '/hr/'})
.then(function (registration) {
// Registration was successful
console.log('ServiceWorker registration successful with scope: ', registration.scope);
});
您可以从网站的任何路径注册服务工作者,作用域仍然限制在服务工作者实际所在的位置。这意味着您也可以从人力资源应用程序中注册营销和财务服务工作者。然而,人力资源部门无法操纵那些应用程序中的任何内容,反之亦然。
脚本可以存储在其指定作用域的任何级别或以上。例如,如果您的应用程序的所有服务工作者都位于网站的根目录中,它们将需要不同的 scope 值:
navigator.serviceWorker.register('/sw-hr.js', {scope: '/hr/'})
.then(function (registration) {
// Registration was successful
console.log('ServiceWorker registration successful with scope: ', registration.scope);
});
上述示例演示了如何将人力资源部门的客服人员存储在域的根目录中。将作用域设置为 /hr/ 限制了其作用域仅限于 hr 文件夹及其以下。
对作用域的误解是新服务工作者开发者最常见的错误之一。第一步是接受服务工作者与我们过去二十年来编写的客户端 JavaScript 是不同的。
您应该努力将服务工作者脚本与传统的客户端脚本文件分开。将您注册的服务工作者文件放在应用程序的根目录中。您仍然可以从其他文件夹导入脚本,这使您能够在应用程序作用域之间重用代码。
作用域是一个有价值的特性,可以防止由于外部服务提供商或客户可能访问的网站而发生的糟糕事情。您可以将它视为隔离您的逻辑业务单元的一种方式。它也是保护您的应用程序免受潜在安全威胁的一种方式,如果您的域上的一个应用程序被破坏。
默认情况下,服务工作者的作用域限于脚本所在的文件夹。服务工作者不允许控制位于更高文件夹级别或同级文件夹中的托管页面。
服务工作者客户端
我在本章中多次提到了服务工作者客户端。明显的定义是打开网站页面的浏览器标签页。虽然这将在大多数情况下成立,但它不是唯一的客户端类型。
由于服务工作者在浏览器标签页的 UI 线程之外执行,它们可以为多个客户端提供服务。这包括多个标签页、推送通知和后台同步事件。后两者是没有传统用户界面的客户端。
服务工作者规范说明(w3c.github.io/ServiceWorker/#service-worker-client-concept):
"服务工作者客户端是一个环境。"
它继续定义了一系列潜在的客户类型。服务工作者客户端的概念设计不是为了考虑明显的浏览器标签页,而是任何可能触发服务工作者事件的进程。目前,这包括推送通知和后台同步事件。随着更多功能被标准化以使用服务工作者基础设施,未来是开放的。
以下截图显示了 Chrome 开发者工具列出了来自同一网站的三不同标签页:

每个标签页都是一个唯一的客户端。你可以点击任何客户端右侧的焦点链接,立即显示相应的浏览器标签页。
以下代码允许你检查当前作用域下的所有服务工作者注册:
navigator.serviceWorker.getRegistrations()
.then(function(registrations){
registrations.forEach(function(registration) {
console.log("registration: ", registration.scope);
}, this);
});
你可能会问,为什么当只能为一个作用域注册单个服务工作者时,还有getRegistrations方法?getRegistrations函数返回域内所有已注册服务工作者的列表。
getRegistration方法以类似的方式工作,但只返回当前作用域的已注册服务工作者:
navigator.serviceWorker.getRegistration().then(function (registration) {
if (registration) {
console.log("registration: ", registration.scope);
}
});
getRegistration方法有一个可选参数,你可以指定一个 URL,它将返回控制 URL 作用域的服务工作者注册。例如,如果你提供了/marketing/,则registration.scope将返回{domain}/marketing/,前提是你已为该作用域注册了服务工作者。
服务工作者注册对象
注册服务工作者会在用户代理维护的服务工作者注册表中创建一个条目。当你注册或调用getRegistration或getRegistrations方法时,它们会返回匹配的注册对象(们)的引用。
注册对象包含以下成员:
属性
-
scope:服务工作者的作用域 -
installing:如果服务工作者正在安装,它返回一个ServiceWorker对象,否则返回 undefined -
waiting: 如果服务工作者正在等待,它返回一个ServiceWorker对象,否则返回 undefined -
active: 如果服务工作者是活动状态或正在激活,它返回一个ServiceWorker对象,否则返回 undefined -
navigationPreLoad: 返回服务工作者preLoadManager的引用 -
periodicSync: 返回服务工作者PeriodicSyncManager的引用,用于后台同步 -
pushManager: 返回服务工作者pushManager的引用 -
sync: 返回服务工作者syncManager的引用
方法
-
update: 以编程方式检查服务工作者更新,绕过缓存 -
unregister: 以编程方式移除服务工作者 -
getNotifications: 返回一个解析为服务工作者通知数组的承诺 -
showNotifications: 显示由标题标识的通知
事件
onupdatefound: 任何有新服务工作者时都会触发
以下是如何处理updatefound事件的示例。当它触发时,注册的安装属性中应该有一个serviceworker对象。在这里,serviceworker对象(newWorker)已经对其状态属性进行了查询:
reg.addEventListener('updatefound', () => { // A wild service worker
// has appeared in reg.installing!
const newWorker = reg.installing;
console.log("newWorker.state: ", newWorker.state);
// "installing" - the install event has fired, but not yet complete
// "installed" - install complete
// "activating" - the activate event has fired, but not yet complete
// "activated" - fully active
// "redundant" - discarded. Either failed install, or it's been
// replaced by a newer version
newWorker.addEventListener('statechange', () => {
// newWorker.state has changed
console.log("service worker state change");
});
});
这个事件可以用来执行一系列逻辑,为客户端更新做准备,包括通知用户重新加载浏览器以利用新版本。
update和unregister方法将在第六章中介绍,掌握 Cache API – 在播客应用程序中管理 Web 资源。让我们花点时间看看推送通知是如何工作的。
更新服务工作者
以下图表显示了服务工作者在替换周期中经历的序列。第一个图表显示了如何注册新的服务工作者,并使其与现有服务工作者并存。新的服务工作者不是活动的,而是在等待所有活动客户端关闭:

一旦客户端关闭,初始服务工作者死亡,新的服务工作者开始其新的活动角色:

在新的服务工作者变为活动状态后,它是唯一存活的服务工作者:

服务工作者作用域
如前所述,服务工作者限制在单个域。域是您网站的地址,例如podcast.love2dev.com/。这是一个安全特性。限制服务工作者被称为服务工作者作用域。这防止外部脚本对你的网站进行恶意操作。
想象一下,如果你的客户也访问了你的竞争对手的网站,该网站安装了一个服务工作者。如果没有对服务工作者作用域的限制,他们可能理论上会操纵你的内容或监视你和你客户的私人数据。
事实上,第三方脚本不能从你网站上的页面注册服务工作者。这应该阻止外部脚本和服务提供商使用服务工作者与你的域名结合使用。
服务工作者限于原始域名,并且也限于其物理位置所在的文件夹。这意味着你可以在网站域名内的任何子文件夹中注册服务工作者。子脚本将控制来自其文件夹及其以下的任何请求。
如果另一个服务工作者在较低文件夹中注册,那么它将控制从该文件夹向下,依此类推。另一种思考服务工作者控制范围的方式是向下,而不是向上。位于子文件夹中的脚本不会对网站根目录触发的事件做出响应。
注意你的服务工作者文件的位置。常见的做法是将 JavaScript 文件存储在/js文件夹下。这对于传统的 UI JavaScript 来说是可行的,但当服务工作者文件存储在js文件夹下时,往往会引起混淆。最佳实践是将服务工作者放在你网站的根文件夹或其控制的范畴的根文件夹中。
范畴决定了哪些页面受服务工作者控制。一旦页面受服务工作者控制,来自该页面的所有 HTTP 请求(无论请求 URL 如何),都将触发服务工作者的 fetch 事件。
大多数情况下,这意味着你的服务工作者位于你域名网站的最高文件夹中。但有许多场景下情况并非如此。大型网站和公司内部网站通常是不同、隔离应用的集合。
在有不同应用岛屿的架构中,每个应用都可以有自己的服务工作者。例如,一个企业可能有 HR、财务和营销的兄弟网站。每个都可以有单独的服务工作者。不同的服务工作者彼此隔离,不能访问其他应用的范畴。
这些服务工作者可以在网站根域名内的任何位置注册。这意味着你可以从另一个范畴注册子应用的服务工作者。每个服务工作者仍然限于其所在的文件夹及其以下。
以下截图显示,可以为单个网站注册四个服务工作者,每个控制自己的范畴:

服务工作者更新
更新服务工作者文件也是一个复杂的概念。有几个因素决定何时更新你的服务工作者。更新周期只有在浏览器确定有新的服务工作者文件可用时才会开始。
一旦服务工作者注册,浏览器在确定是否有新版本可用时,会像对待任何其他文件一样对待该文件。它向服务器发出请求,这触发了已知的周期。
首先,是本地浏览器缓存(不是 service worker 缓存)中的文件。如果有可用的本地版本且尚未过时,它将被检索。接下来,请求通过网络发送到服务器。如果服务器响应 304,这意味着浏览器拥有最新版本。如果文件没有更改,则不会启动 service worker 更新周期。如果有新版本,则更新 service worker。
基本更新流程的一个例外是内置的防止大 Cache-Control 头值的保护措施。如果服务工作者在过去 24 小时内没有更新,浏览器将始终从服务器检索服务工作者。
Cache-Control 头告诉浏览器在浏览器存储中保留文件副本的时间长度。对于大多数资产,你希望缓存时间较长,例如一年,因为它们不经常更改。这可能导致你的应用程序无法更新的糟糕情况。
对于静态资产,如样式表和图像,常见的做法是使用由文件哈希生成的值来命名它们,并给它们分配一个非常长的生命周期。这意味着任何更新都会使用新的文件名并触发新的请求。你当然可以使用这种策略与 service workers 一起使用。
如果你使用相同的 service worker 文件名,那么你应该设置一个短的生命周期。这可以从几分钟到几小时不等。超过 24 小时的内容将被浏览器忽略。
如果浏览器在过去 24 小时内没有检查新的 service worker 版本,它将强制进行服务器端检查。这被添加到规范中作为安全预防措施,以防你部署了一个可能导致重大问题的 service worker,而你无法通过编程方式强制更新。
这种情况可能会发生,如果你为你的网站中注册了 service worker 的页面指定了较长的缓存时间,并且对 service worker 脚本也做了同样的处理。这意味着你可能会经历的最坏情况是从有缺陷的 service worker 安装之日起整整一天。
这不是解决糟糕问题的最佳方案,但至少有一个终极的安全措施来帮助你。如果你发现自己处于这种情况,你仍然可以立即部署更新,而尚未安装有缺陷版本的用戶将免受影响。
Service worker 事件
服务工作者事件有两种类型:核心和功能。核心消息是使服务工作者成为服务工作者基本要素的。功能事件可以被视为对中央服务工作者骨干的扩展:
核心事件:
-
Install
-
Activate
-
消息
功能事件:
-
fetch -
sync -
push
这些事件中的每一个都可以用来触发处理。安装和激活事件是生命周期的一部分。在第七章,服务工作者缓存模式(Chapter 7),我们将深入研究不同的缓存模式。安装和激活事件对于管理预缓存资源和清理你的缓存模型非常有用。
当注册新的服务工作者时,安装事件立即触发。激活事件在服务工作者变得活跃时触发。这意味着任何现有的服务工作者都会被新的服务工作者替换。
当客户端使用 postMessage 方法发送消息时,会触发消息事件。
功能性事件是对外部动作的响应而触发的。我们已经探讨了推送和后台同步。在第六章,掌握 Cache API – 在播客应用程序中管理网络资源,我们将回顾 Fetch API 的工作原理,并开始探讨缓存策略。
摘要
服务工作者生命周期看起来很简单,直到你开始与它们一起工作。了解生命周期如何执行有助于你理解服务工作者的状态。
服务工作者(service worker)的生命周期旨在帮助我们避免在升级过程中可能破坏应用程序的情况。可以注册一个新的服务工作者,但需要等待任何现有的客户端关闭。当安全时,你可以使用 skipWaiting 方法允许新的服务工作者立即接管控制。
更复杂的应用程序也可能有多个具有不同作用域的服务工作者。这允许大型应用程序在不同子应用程序之间实现隔离控制。
现在你已经了解了如何使用服务工作者和服务工作者生命周期,在下一章中,你将看到如何使用 Fetch 和 Cache API 使 Podstr 应用程序离线工作,并将剧集保存到任何地方、任何时间都可以收听。
第六章:掌握 Cache API - 在播客应用中管理 Web 资源
最重要的服务工作者超级能力是使用本地响应缓存,使网络可选。服务工作者可以做到这一点,因为它们可以拦截网络请求并检查在将请求传递到网络之前是否有缓存过的响应。它们还可以被编程来缓存任何网络响应以供将来使用。这允许网站可能立即加载,无论网络状态如何,这也是说您的 Web 应用可以在离线状态下工作。
这种超级能力依赖于两个较新的平台特性,Fetch 和 Cache API。在向 Podstr 应用程序添加缓存之前,我们需要了解 API 的详细信息。
您第一次在第四章,服务工作者 – 通知、同步以及我们的播客应用中看到 fetch,但那只是一个简单的介绍。在我们深入使用 Cache API 之前,我们将深入探讨 Fetch API 及其支持对象。在了解这些 API 的详细信息后,我们可以开始积累技能,构建缓存策略,并使我们的 Web 应用更加健壮,成为离线渐进式 Web 应用。
本章将涵盖以下主题:
-
Fetch API 的工作原理
-
Request、Response、header对象以及其他 Fetch API 特性 -
Cache API 的工作原理
使用 Fetch API
我们已经在第四章中看到了 Fetch API 的使用,服务工作者 – 通知、同步以及我们的播客应用,所以让我们快速回顾一下。Fetch 是XMLHttpRequest的现代替代品。它是异步的,依赖于承诺,并提供了一个更流畅的接口来管理动态请求。您可以通过创建自定义的Request和header对象来自定义请求。服务工作者依赖于 Fetch 来发起网络请求。
fetch()方法接受两个参数,即您请求的 URL(或一个request对象)和一个options对象。此方法返回一个promise对象。
与您可能习惯的 AJAX 请求方式相比,Fetch 通常不会在 HTTPS 状态码上抛出异常,只有当请求出现网络问题时,这通常表明是平台或硬件问题,才会抛出异常。来自服务器的任何响应,即使它是错误状态码,如 404:页面未找到或 500:服务器错误,仍然被视为成功的请求。
这是因为浏览器无法从应用程序的角度判断请求的成功与否。您需要负责验证响应,这在第一个代码示例中已经展示,演示了一个基本的 fetch 请求并检查了成功的响应。如果响应状态不是 200(良好响应),则会记录错误信息。
另一个区别是 cookie 的管理方式。Fetch 不会向服务器发送 cookie。这意味着如果你依赖于基于 cookie 的认证令牌,你将需要在fetch的初始选项中使用credentials属性。
fetch方法接受两个参数,一个request对象或 URL 和一个可选的init对象。第一个参数要么是一个有效的 URL,要么是一个request对象。如果提供了 URL,fetch会创建一个默认请求来调用网络。request对象将在下一节中介绍。
如果未提供init参数或未设置属性,则使用默认值。init对象可以包含以下属性:
-
method: HTTP 动词;GET、POST、PUT、DELETE 等。 -
headers: 向你的请求添加自定义 HTTP 头。它们可以是header对象或对象字面量的一部分。 -
body: 传递给服务器的任何数据。它可以是一个Blob、Buffer、FormData、QueryString或String对象。 -
mode:cors、no-cors或same-origin。 -
credentials:omit、same-origin或include(必需)。它表示如何处理认证 cookie。 -
cache:default、no-store、reload、no-cache、force-cache或only-if-cached。 -
redirect:follow或manual。 -
referrer:no-referrer, 客户端(默认),或一个 URL。 -
referrerPolicy:no-referrer、no-referrer-when-downgrade、origin、origin-when-cross-origin或unsafe-url。 -
keepalive: 通过在响应交付后保持连接来提高性能。
当你只提供一个 URL 或一个 URL 和一个init对象时,fetch方法会根据这些参数创建一个请求。当你提供一个自己的request对象时,它包含这些值。Fetch API 的一个酷特性是它能够创建一个自定义的request对象。这意味着你可以在你的服务工作者中拦截一个请求并将其转换成不同的请求。
请求对象
Request构造函数与fetch方法具有相同的两个参数,一个 URL 和一个可选的初始化对象。以下代码修改了fetch示例以使用自定义的request对象:
var myRequest = new Request("./api/podcast/" + id + ".json");
fetch(myRequest).then(function(response) {
if (response.status !== 200) {
console.log('Status Code: ' + response.status);
return;
}
return response.json();
}).then(function(response) {
console.log(data);
});
你可以做的不仅仅是根据 URL 创建一个request对象。你可以使用各种选项来构建请求。虽然大多数请求是简单的 GET 请求,但很多时候你需要构建一些自定义的内容。request对象为你提供了灵活性,以便进行这些请求。
以下示例展示了如何管理一个潜在的跳转情况,其中你已更改了你的主图片文件夹:
self.addEventListener("fetch", function (event) {
event.respondWith(
var re = /img/S+/g;
if(re.test(request.url)){
request = new Request(request.url.replace("img", "images"),
{
method: request.method,
headers: request.headers,
mode: request.mode,
credentials: request.credentials
});
}
return fetch(request)
.then(function (response) {
//process response
})
);
});
当然,还有许多其他可能需要修改请求在发送到网络之前的情况。记住,如果你创建了一个自定义请求并缓存了响应,你需要在检查缓存之前修改请求。
处理跨域请求
您可能还需要处理来自不同域的 URL。由于潜在的安全漏洞,对这些 URL 的访问通常受到保护。您可以使用跨域资源共享(CORS)来访问这些 URL,这是一个额外的层,它依赖于头部来控制访问。
服务器负责告知浏览器是否允许浏览器访问资源。CORS 是一种协议,它使得这些跨源请求成为可能。实际上,完成一个跨源请求需要两个请求。第一个是预检请求(developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests),服务器告诉浏览器请求已被批准。之后,再进行原始请求。
除非将mode属性设置为cors,否则 Fetch 不会进行预检请求。CORS 请求的其余部分在response对象中处理。我将在后面的部分中介绍这一点。
有四种不同的请求模式:cors、no-cors、same-origin和navigate。您不会使用navigate,因为它仅用于在页面之间导航。因此,它是由浏览器创建的。
默认模式是same-origin,它限制了请求到same-origin。如果发起对外部域的请求,则会抛出错误:
var myRequest = new Request("./api/podcast/" + id + ".json",
{mode: "cors"});
fetch(myRequest).then(function(response) {
if (response.status !== 200) {
console.log('Status Code: ' + response.status);
return;
}
return response.json();
}).then(function(response) {
console.log(data);
});
no-cors请求限制了请求方法的类型为 GET、HEAD 和 POST。当使用no-cors时,服务工作者在修改请求和访问响应属性方面受到限制。
您仍然可以使用no-cors从不同的源请求资源,但响应类型有限。例如,您可以获取一张图片,但对响应的处理能力有限。这些被称为不透明请求。
管理请求凭证
默认情况下,fetch 不会将 cookies 附加到请求中。这对安全和性能都有好处。在大多数场景中,访问 API 确实需要基于 cookie 的认证。对于这些场景,您需要将凭证属性设置为same-origin或include。默认值是omit。
通过将credentials选项设置为include或same-origin,请求将包含认证 cookies。include值会触发request对象为任何目标源包含凭证,而same-origin将凭证限制在same-origin:
var myRequest = new Request("./api/podcast/" + id + ".json",
{
mode: "cors",
credentials: "include"
});
fetch(myRequest).then(function(response) {
if (response.status !== 200) {
console.log('Status Code: ' + response.status);
return;
}
return response.json();
}).then(function(response) {
console.log(data);
});
控制响应的缓存方式
另一个重要的请求选项是缓存属性。该属性控制浏览器如何使用其自身的缓存。由于服务工作者提供缓存,您可以编程方式控制浏览器缓存,但这可能显得有些冗余,并可能导致一些不期望的响应。
默认的缓存值不会改变任何东西;浏览器在发起网络调用之前会检查其自身的缓存,响应是基于默认缓存规则的最佳响应。
然而,通过将请求缓存选项设置为另一个值,您可以强制浏览器绕过或更改它使用浏览器缓存的方式。选项如下:
-
default -
no-store -
reload -
no-cache -
force-cache -
only-if-cached
由于服务工作者缓存提供了优于浏览器缓存的缓存资产方法,我倾向于使用它,并可能想要从管道中移除浏览器的缓存。在这种情况下,您希望将缓存属性更改为 no-store:
var myRequest = new Request("./api/podcast/" + id + ".json",
{
mode: "cors",
credentials: "include",
cache: "no-store"
});
fetch(myRequest).then(function(response) {
if (response.status !== 200) {
console.log('Looks like there was a problem. Status Code: ' +
response.status);
return;
}
return response.json();
}).then(function(response) {
console.log(data);
});
头部对象
在构建客户端和服务器之间的独特请求和响应时,自定义请求头部非常重要。请求头部属性是一个 Headers 对象。Header 对象被 request 和 response 对象使用。
Headers 是客户端和服务器之间通信关于请求和响应额外信息的一种方式。可以将它们视为正在发送和接收的数据的元数据。
例如,当响应包含 gzip 压缩数据时,Content-Encoding 头部会通知浏览器,以便它可以解压缩主体。在返回压缩响应之前,服务器会查找相应的头部,例如 accept-encoding,告诉它客户端可以接受压缩响应。
Headers 对象管理头部列表。成员方法提供了管理与请求或响应相关联的头部的能力。
添加头部
头部可以在构造函数中添加,也可以通过 append 方法添加。以下示例使用 Headers 构造函数:
var httpHeaders = {
'Content-Type' : 'image/jpeg',
'Accept-Charset' : 'utf-8',
'X-My-Custom-Header' : 'custom-value'
};
var myHeaders = new Headers(httpHeaders);
头部也可以使用 append 方法添加:
var myHeaders = new Headers();
myHeaders.append('Content-Type', 'image/jpeg');
myHeaders.append('Accept-Charset', 'utf-8);
myHeaders.append('X-My-Custom-Header', 'custom-value');
添加头部的另一种方式是使用 set 方法:
var myHeaders = new Headers();
myHeaders.set('Content-Type', 'image/jpeg');
myHeaders.set('Accept-Charset', 'utf-8);
myHeaders.set('X-My-Custom-Header', 'custom-value');
append 和 set 方法之间的区别在于后者会覆盖任何现有的值。当 append 方法将头部值添加到头部列表中时,应该使用 append 方法来处理允许多个值的头部。
一个多值头部示例是 Cache-Control。您可能需要设置许多组合来向不同的客户端和中间件提供指令。
例如,使用我的 CDN 管理 HTML 资产的缓存的最佳方式是将它们标记为私有,生存时间为 3600 秒。您还可以包括 CDN 缓存的值,可能是 300 秒。这意味着我的 CDN 将在 300 秒后自然失效,减少我强制更新的需求。
使用 append 方法可能需要最多三次调用:
myHeaders.append('Cache-Control', 'private');
myHeaders.append('Cache-Control', 'max-age=3600');
myHeaders.append('Cache-Control', 's-max-age=300');
set 方法写入最终值,覆盖任何之前的值:
myHeaders.set('Cache-Control', 'private, max-age=3600, s-max-age=300');
头部是一个复杂的话题,如果您需要深入了解特定的头部及其值,我建议寻找更多资源。维基百科是一个很好的起点(en.wikipedia.org/wiki/List_of_HTTP_header_fields),其页面提供了一个非常详尽的列表,包括详细信息以及到规范的进一步链接。
你可以管理的头部数量是有限的。有些头部仅限于浏览器,有些仅限于服务器,这意味着不允许更改它们。
如果你尝试添加或设置一个无效的头部,将会抛出异常。
访问头部值
get 方法返回一个特定的头部值:
Var cacheHeader = myHeaders.get('Cache-Control');
//returns 'private, max-age=3600, s-max-age=300'
entries 方法返回一个迭代器,你可以用它来遍历所有头部。每个条目都是一个简单的数组,第一个条目是头部键名,第二个成员是值:
// Display the key/value pairs
for (var header of myHeaders.entries()) {
console.log(header[0]+ ': '+ header[1]);
}
keys 方法也提供了一个迭代器,但只返回头部名称的列表:
// Display the keys
for(var key of myHeaders.keys()) {
console.log(key);
}
相反,你可以从 values 方法获取值的列表。这个方法的问题在于值与它们的键没有直接关联:
// Display the values
for (var value of myHeaders.values()) {
console.log(value);
}
你可以通过调用 has 方法来检查头部是否存在:
myHeaders.has(name);
可以使用 delete 方法删除头部:
myHeaders.delete(name);
受保护的头部
头部有一个守卫属性。这个标志指示头部是否可以被操作。如果你尝试操作一个守卫设置为不可变的头部,将会抛出异常。
这些是可能的守卫状态:
-
none: 默认 -
request: 用于从请求中获取的头部对象的守卫(Request.headers) -
request-no-cors: 用于从使用模式no-cors创建的请求中获取的头部对象的守卫 -
response: 自然地,对于从响应中获取的头部(Response.headers) -
immutable: 主要用于 ServiceWorkers;渲染一个Headers对象
实际控制如何操作不同头部的规则非常详细。如果你想了解更多关于这些细节的信息,我建议阅读 Fetch 规范(fetch.spec.whatwg.org/)。
体混合
Request 和 Response 对象都有一个 body 属性。这实际上是一个混合函数或类,它实现了体接口。体包含一个数据流,具有根据类型检索内容的方法。
每个 body 方法读取流并将其转换为所需的格式。流被完全读取,并返回一个 promise,解析为格式化数据。
你已经看到了如何使用 json() 方法来读取 JSON 格式的数据。还有 text、blob、formData 和 arrayBuffer 方法。每个方法都将体解析为相应的格式。
要回顾如何使用 JSON 格式的数据,让我们看看如何在 Podstr 应用程序中检索搜索结果:
function fetchSearch(term) {
fetch("api/search.json?term=" + term)
.then(function (response) {
if (response.status !== 200) {
console.log('Status Code: ' + response.status);
return;
}
return response.json();
}).then(function (results) {
renderResults(results);
})
.catch(function (err) {
console.log('No CORS Fetch Error :-S', err);
});
}
注意 json() 混合函数是如何作为响应的方法提供的。这是因为每个体混合函数都实现了 Body 接口,并被添加到响应对象中。
混合函数返回一个承诺,解析为 JSON 对象。记住,你不能直接使用体混合函数的返回值,因为它们返回一个承诺。你需要在一个承诺处理程序中处理格式化的数据,这就是 then 方法。
JSON 可能是现代 API 使用最广泛的格式,但有时你会检索其他格式——最简单的是纯文本。
获取纯文本看起来几乎与获取 JSON 相同。而不是使用 json mixin,使用text mixin:
fetch("api/simple.txt")
.then(function (response) {
if (response.status !== 200) {
console.log('Status Code: ' + response.status);
return;
}
return response.text();
})
.then(function(result){
renderResult(result);
})
.catch(function (err) {
console.log('Fetch Error :-S', err);
});
以下示例展示了如何获取一个音频文件(ogg格式)并将数据缓冲到AudioContext对象中:
source = audioCtx.createBufferSource();
fetch('./viper.ogg')
.then(function (response) {
return response.arrayBuffer();
})
.then(function (buffer) {
audioCtx.decodeAudioData(buffer, function (decodedData) {
source.buffer = decodedData;
source.connect(audioCtx.destination);
});
});
到目前为止,我们已经看到了如何使用响应主体,但你也可以在请求中设置主体。一个常见的场景是提交表单数据。
这个例子展示了如何将联系表单作为 JSON 对象进行 POST。方法设置为'post',并提供了自定义头。自定义头告诉服务器你正在发送一个带有Content-Type头的 JSON 格式主体。你也在告诉服务器(通过Accept头)你期望返回一个 JSON 对象。
表单在serializeForm方法中被序列化或转换为 JSON 对象(未显示):
fetch("/api/contact/, {
method: 'post',
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json; charset=UTF-8'
},
body: JSON.stringify(serializeForm())
}).then(function (res) {
if (res.status >= 200 && res.status < 400) {
// Success!
return res.json();
} else {
console.log('Status Code: ' + response.status);
return;
}
}).then(function (resp) {
//process the response
});
你也可以使用FormData对象(developer.mozilla.org/en-US/docs/Web/API/FormData)提交原始表单。确保你提交到的 API 可以处理表单数据:
var form = document.querySelector("[name=contact-form]");
fetch("/api/contact/, {
method: 'post',
body: new FormData(form)
}).then(function (res) {
if (res.status >= 200 && res.status < 400) {
// Success!
return res.json();
} else {
console.log('Status Code: ' + response.status);
return;
}
}).then(function (resp) {
//process the response
});
你需要了解的关于主体的最后一个方面是bodyUsed属性。这可以用来检查你是否还能使用主体。主体只能读取一次。
主体是一个流,它是一组只能向前读取的数据。尝试多次读取主体会导致抛出异常。
如果你需要多次读取或使用一个主体,你应该克隆请求或响应。一个常见的场景是当服务工作者获取一个 URL 并缓存响应的同时返回响应给客户端。你将在下一章中看到更详细的说明:
fetch(request)
.then(function (response) {
var rsp = response.clone();
//cache response for the next time around
return caches.open(cacheName).then(function (cache) {
cache.put(request, rsp);
return response;
});
})
响应对象
response对象几乎与request对象相同。它是在请求从服务器收到答案时创建的。响应对象也有头和主体,与request对象相同的对象类型。
响应通常由平台在从服务器收到响应时创建。然而,你可以创建一个新的响应。这更常见于你想要转换响应时。
我处理的一个场景是从一个返回扁平数据的旧 API 中获取数据。这些扁平数据需要被转换成多维度的 JSON 对象。为了避免重复进行这个计算密集型过程,我将数据转换并缓存了转换后的响应。
响应构造函数有两个参数,一个 Body 对象和一个初始化对象。我们已经在第四章中讨论了 Body 对象,服务工作者 – 通知、同步和我们的播客应用。响应初始化对象与request对象略有不同。
与request对象不同的响应对象属性如下:
-
status:响应的状态码,例如,200 -
statusText:与状态码相关联的状态消息,例如,OK -
headers:你想要添加到响应中的任何头信息,包含在 Headers 对象或ByteString键/值对的字面量对象中(参见 HTTP 头信息以获取参考)
只有三个属性,一个是 Headers 对象。其他两个属性指示 HTTP 状态。有一个 code、status 和 text 值,statusText。
响应属性
大多数响应对象属性与请求对象类似,最值得注意的是 Body 及其混合。
isRedirected属性指示响应是否是重定向(HTTPS 状态码301或302)响应的结果。
类型属性是只读的,告诉我们响应是否有网络错误(错误)、不透明(带有 no-cors 请求的跨源,cors,基本上表示成功的响应)。
url属性是任何重定向后的最终响应 URL。
验证成功的响应
如我之前提到的,如果响应状态不是 200,fetch 不会抛出异常或返回错误。相反,只要 fetch 收到响应,它就不会失败。仍然取决于你如何查询响应状态来确定你想要如何处理响应。
如果状态码是 200-299,则ok属性为 true,否则为 false。这可以是一个快速验证请求成功的方法。但请小心,有时你可以收到缓存、跨源响应的 0 状态码。
在实践中,许多请求可以有一系列成功的状态码。HTTP 状态码按 100s 分组。200-299 表示成功的响应,而 300-399 表示重定向,并伴随新的地址。400表示 URL 不可访问,404是最著名的。其他资源不可访问的原因与授权或访问资源的权限有关。最后,500 范围内的任何内容都表示存在服务器端问题。
制定策略来处理不同的状态对于应用程序的成功至关重要。检查response.ok值可能不会给出完整的故事。我发现 Chrome 为存储在缓存中的外部源资源返回等于 0 的状态。响应仍然是成功的,但将你的逻辑限制为检查response.ok会导致过多的网络请求。
现在你已经了解了 Fetch API 的细节,是时候开始学习 Cache API 的工作原理了。Cache API 依赖于 Fetch API,因为它缓存请求-响应对。
缓存响应
我们已经能够使用 Web 存储和 IndexedDB 存储内容,包括数据和网站资源好几年了。使用这两种方式中的任何一种都需要一个库或自定义逻辑来管理网站资源,这也是为什么服务工作者规范包括一个专门的缓存 API。
缓存接口为请求/响应对象对提供了一个管理存储机制。这些对象也被称为可网络访问的资源,但你可以将它们视为仅仅是文件或 API 响应。
与 IndexedDB 和 localStorage 相比,缓存 API 具有天然的优势,因为它被设计为通过 Request 和 Response 对象来抽象、持久化这些资产。这意味着你可以使用比 localStorage 提供的不仅仅是单个键值来引用资产。
除了提供一个管理接口来存储资产外,它还允许你将这些资产组织成组或缓存。随着我们继续阅读剩余的章节,你将看到如何使用这种能力来隔离资产,以便更新和失效变得更容易。
与其他浏览器存储服务一样,缓存由浏览器按来源或域名管理。这阻止第三方访问存储在你网站上的内容。你的整体存储配额也与来源相关联,并在各种存储服务之间分配。
可用空间的大小因设备而异,与可用磁盘空间成正比。就像所得税一样,管理你的配额允许的规则因可用空间的大小而异。
Ali Alabbas 在微软边缘开发者峰会上分享了以下幻灯片,解释了管理存储配额的规则:

如果空间变得有限,浏览器将任意地从不同的存储提供者中清除存储的数据。除非你管理这个过程,否则你无法保证你的资产已经保留在缓存中。
缓存 API 是服务工作者规范的一部分,因为它是使你的渐进式 Web 应用离线工作的基础。如果没有保存资产以供即时访问的能力,你的网站将无法离线工作。
缓存对象
Caches 对象代表一组命名缓存及其成员,用于与这些缓存交互。该接口提供了打开、创建、迭代和删除单个缓存的方法。你还可以在所有命名缓存中匹配缓存的响应,而无需查询每个缓存。
caches.open
caches.open 方法返回一个与提供的名称匹配的缓存的引用。这个引用是一个 Cache 对象,在后面的章节中会详细介绍。如果不存在与名称匹配的缓存,则会创建一个新的缓存。这返回一个承诺;解决缓存对象允许你在该缓存中管理缓存的响应:
caches.open(cacheName).then(function (cache) {
//do something here
});
caches.match
caches.match 方法是一个便利方法,其工作方式与 cache.match 方法类似。它返回一个承诺,该承诺解析为与传递给方法的 Request 对象匹配的第一个缓存的响应。
caches.match 方法的优点在于它处理查询所有命名缓存以找到匹配项的方式。它返回它找到的第一个匹配项。
这意味着如果匹配的响应存储在不同的缓存中,你无法控制找到哪个响应。为了避免匹配到无效响应的场景,你需要确保在缓存更新之前,你的逻辑使过时的缓存响应无效:
return caches.match(request)
.then(function (response) {
return response;
})
caches.has()
如果你需要检查一个命名缓存是否存在,has 方法返回一个承诺,解析为 true:
caches.has(cacheName).then(function(ret) {
// true: your cache exists!
});
caches.delete()
delete 方法搜索与提供的名称匹配的缓存。如果找到一个匹配的缓存,它将被删除。返回一个承诺,如果找到匹配的缓存则解析为 true,如果没有找到则解析为 false:
Cached.delete(cacheName).then((ret)=>{ console.log(cacheName + " deleted: " + res});
你不总是需要添加代码来处理响应。通常,你会记录活动。如果没有删除缓存,最可能的原因是缓存不存在,在这种情况下,你可能没有什么可担心的。
你还应该注意,如果你删除了一个缓存,所有缓存的项都会随之删除。
caches.keys()
keys 方法返回一个承诺,包含每个命名缓存的名称(字符串)数组。这个列表可以迭代以进行处理。
以下示例放置在服务工作者激活事件处理器中。它删除了为先前服务工作者版本制作的缓存。这个概念在第五章中有所介绍,服务工作者生命周期:
caches.keys().then(function (cacheNames) {
cacheNames.forEach(function (value) {
if (value.indexOf(version) < 0) {
caches.delete(value);
}
});
return;
})
注意 delete 方法在删除缓存后没有进行任何处理。如果你想记录有关删除缓存的任何问题,可以在这里做。
Cache 对象
Cache 接口是一组用于管理存储响应的方法和属性。你不能创建缓存对象;你必须使用 Caches.open 方法打开对缓存的引用,这将在后面介绍。这个引用是 Cache 对象的一个实例,它让你可以访问它管理的响应。
cache.match()
match 方法有两个参数,一个 request 对象和一个可选的选项对象。如果找到一个匹配的响应,它将返回一个解析的承诺。如果没有找到响应,承诺仍然解析,但作为 undefined。
如果没有找到匹配项,你可以继续执行适当的逻辑,例如将请求转发到网络或返回一个回退响应。
request 对象参数可以是有效的 request 对象或 URL。如果只提供了一个 URL,方法会内部进行隐式转换。提供 request 对象可以让你有更多的控制权,因为不同的响应可能会被请求变体缓存。
例如,相同的 URL 可能对 HEAD 和 GET 方法都有缓存的响应,每个都是唯一的。不同的 QueryStrings 是另一个常见示例,其中相似的 URL 有不同的响应被缓存。
可选的options参数允许你向match方法提供更多关于你想要匹配的特定请求的详细信息。你可以将其视为一种筛选潜在匹配的方法。
options对象有一组潜在的属性,你可以提供值来匹配或筛选。
可用的潜在选项如下:
-
ignoreSearch:表示你是否想使用QueryString。选项是 true 或 false。 -
ignoreMethod:表示你是否想通过request方法进行筛选,例如 GET、POST、DELETE 等。默认情况下,匹配确实使用请求方法来匹配响应。 -
ignoreVary:当设置为 true 时,在检索匹配项时忽略头信息。URL 用作筛选器,这意味着不同头组合的缓存响应可以匹配。 -
cacheName:这限制了匹配响应到特定的缓存名称。多个响应可以跨不同命名的缓存进行缓存,但指定了应该使用哪个缓存。
对于大多数查询,你不会使用这些选项,因为它们高度专业化。ignoreSearch最有可能是被使用的,因为QueryString参数非常常见。服务器通常根据这些值的差异返回不同的响应:
return namedCache.match(request).then(function (response) {
return response;
});
cache.matchAll
与match方法类似,matchAll()方法接受请求和选项(可选)参数。该方法返回一个承诺,解析出一个匹配响应的数组。与 Caches 对象上的相同方法不同,它只返回特定命名的缓存中的匹配项。
matchAll和match之间的主要区别在于,match方法返回它匹配的第一个响应。
当你需要使用路由而不是特定 URL 来匹配响应时,matchAll方法很有用。例如,如果你想获取所有播客的横幅图像列表,你可以这样做:
caches.open("podcasts").then(function(cache) {
cache.matchAll('/images/').then(function(response) {
response.forEach(function(element, index, array) {
//do something with each response/image
});
});
});
缓存添加和 addAll
如果你想要检索一个资产并立即缓存响应,add和addAll方法管理这个过程。add方法等同于以下代码:
const precache_urls = [...]
caches.open(preCacheName).then(function (cache) {
return cache.addAll(precache_urls);
})
add和addAll方法都有单个参数:一个request对象。像match方法一样,你也可以提供一个有效的 URL,方法会将它转换为request对象。
两种方法都返回一个承诺,但不会解析一个值。只要没有异常发生,请求被制作和缓存,你就可以使用解析来继续应用程序的工作流程。如果有异常,你可以适当地捕获和处理它。
在安装和激活生命周期事件中,add方法很有用,因为你可以提供要预缓存的 URL 列表。你不受这些场景的限制,因为它们可以在任何时候调用。
在大多数由 fetch 事件处理器启动的缓存策略中,它们并不有用。我们将在下一章中介绍许多这些策略。在处理实时 fetch 时,通常需要网络响应来渲染页面。这些方法不返回对响应的访问权限。
cache.put
尽管你可能认为不是这样,add 方法并不允许你直接缓存响应。如果你从 fetch 调用中收到网络响应,你需要显式地处理其缓存。这就是 put 方法为你提供所需控制以缓存响应并允许在缓存过程中并行使用响应的灵活性所在。
正如你将在下一章中了解到的那样,有许多不同的缓存策略,在网络请求解析后,网络响应会被缓存。
put 方法有两个参数,request 和 response。这些与在 Fetch 部分中提到的相同的 Request 和 Response 对象。
put 方法使用这两个对象以键值对的方式对页面资源进行目录化,有点像 localStorage,但专门用于缓存页面资源。
put 方法返回一个承诺,但不解析任何值。
如果你需要使用响应,你应该在缓存之前克隆它。响应体只能使用一次,但 response 对象有一个 clone 方法可以创建副本。我通常缓存副本并返回原始的,但这并不重要。
以下代码演示了如何获取资源,克隆响应,缓存克隆,并返回原始响应:
fetch(request).then(function (response) {
var rsp = response.clone();
//cache response for the next time around
return caches.open(cacheName).then(function (cache) {
cache.put(request, rsp);
return response;
});
});
删除缓存项
你还负责定期清除缓存条目。浏览器不会为你使缓存失效。如果可用空间变得有限,它可能会清除项目。然而,我并不担心这种场景。相反,你需要有一个计划来删除缓存资源,这样你就可以控制一个项目被缓存的时间长度。
管理或使缓存资源失效的最佳方式是应用一组规则来控制你的缓存。第八章,应用高级 Service Worker 缓存策略,更详细地介绍了失效策略。在你达到那个水平之前,了解如何删除资源或删除整个缓存是很重要的。
delete 方法返回一个承诺,如果找到并删除了匹配的响应,则解析为 true,否则返回 false:
cache.delete(request,{options}).then(function(true) {
//your cache entry has been deleted
});
此方法有两个参数,一个请求和一个可选的选项对象。这些选项与在 match 方法中使用的是相同的。这是因为如果你使用这些选项有不同的请求变体,你可以缓存对 URL 的多个响应。
cache.keys
缓存对象的 key 方法返回一个解析为存储在缓存中的请求数组的承诺。该方法有两个可选参数,请求和选项。这些与我们在其他方法中看到的相同类型。
当提供这些参数时,keys方法的工作方式与match和matchAll方法非常相似。
可以使用键或请求的数组来更新您的缓存。例如,您可以循环遍历并删除所有匹配项,或者可能进行静默的背景更新:
caches.open(cacheName).then(function(cache) {
cache.keys().then(function(keys) {
keys.forEach(function(request) {
fetchAndUpdateCache(cacheName, request);
});
});
})
键按插入顺序返回。如果您想通过首先删除最旧的配对来管理缓存失效,这可能会很有用。我们将在后面的章节中回顾一些缓存失效策略。您将看到如何实现这一点,以及一些其他策略。
摘要
在本章中,我们看到了 Fetch 和 Cache API 对于服务工作者的重要性。为了充分利用服务工作者,您需要能够拦截和操作请求以及服务器响应。因为服务工作者依赖于异步函数(Promises),所以您必须使用 Fetch,作为XMLHttpRequest的新替代品。
Cache API 为浏览器提供了一种新的存储介质,这种介质高度专门化,适用于请求/响应对。该 API 是一个丰富的平台,提供了对网络资源的最大控制量。
您的目标是使用最佳的逻辑和平台资源来使您的网站快速加载并离线工作。现在您已经了解了 Fetch 和 Cache API 的工作原理,是时候开始构建您能构建的最好的缓存系统了。
在下一章中,我们将回顾不同的缓存模式,并开始了解它们如何应用,以便您可以确定在您的应用程序中应该采用哪种策略。
第七章:服务工作线程缓存模式
互联网很棒,直到你离线或连接不良。然后,当你等待一个似乎永远不会出现的页面加载时,它变成了一种徒劳的行为。最终,请求超时,浏览器显示一条消息告诉你你已离线——Chrome 以其可爱的离线恐龙而闻名。
大多数网络流量来自智能手机,其中许多连接是通过蜂窝连接(GPRS)完成的。当蜂窝网络工作良好时,它们很棒,但通常无法保证有一个干净的网络连接。
即使在美国,可靠的 LTE 网络也不是无处不在。在我家附近有几个地方我没有任何蜂窝信号。想象一下在欠发达地区可能会是什么样子。
这就是服务工作线程和缓存 API 能帮到你的时候。这两个功能的组合使你可以使网络成为可选的。服务工作线程有几个事件,你可以利用这些事件在浏览器中构建一个网络服务器。
本章将涵盖以下主题:
-
服务工作线程缓存的工作原理
-
常见的服务工作线程缓存策略
服务工作线程缓存的工作原理
服务工作线程位于浏览器和网络之间。通过添加一个fetch事件处理器,你可以确定如何处理请求。所有网络请求都通过服务工作线程的fetch事件处理器:

这为你提供了一个钩子,或者说是将逻辑注入工作流程的方法,以拦截请求并确定响应如何以及在哪里返回。
使用服务工作线程(Service Worker),你可以做以下事情:
-
将请求传递给网络,传统方法
-
返回缓存响应,完全绕过网络
-
创建自定义响应
当网络失败时,你可以编程服务工作线程从缓存返回一个响应,即使它是一个回退响应。因为服务工作线程可以从缓存返回响应,如果你的页面被缓存,它们可以立即加载:

在前面的图表中,服务工作线程被编程为拦截所有网络请求,并可以从缓存或网络返回响应。
由于服务工作线程在本地运行,它始终可用。它可以根据条件决定返回响应的最佳方式。
以下图表说明了服务工作线程在浏览器上下文中运行,提供代理来处理请求,即使网络不可用:

当使用服务工作线程且网络不可用时,如果你的网站有有效的缓存响应,它仍然可以正常工作。即使页面没有缓存,你也可以创建一个响应,让客户有相关的内容可以与之互动。在本书的后面部分,我将介绍如何在网络可用时排队用户的操作并更新服务器。
以下图表显示了服务工作线程在网络不可用时如何使用缓存资源:

Service Worker 事件
Service Worker 有几个事件。你可以使用这些事件来管理你的应用缓存。我们已经在第五章,“Service Worker 生命周期”中探讨了如何使用 install 和 activate 事件来预缓存响应。
全明星 Service Worker 事件是 fetch。每次请求网络地址资产(URL)时,都会触发此事件。通过向你的 Service Worker 添加 fetch 事件处理器,你可以拦截所有网络请求,触发一个工作流程来确定如何响应:
self.addEventListener("fetch", event => {
//process request and return best response
});
如你所学 第六章,“掌握 Cache API – 在播客应用程序中管理 Web 资产”,你可以使用 Fetch API 的自定义 request 和 response 对象来检查请求和创建或克隆网络响应。
fetch 事件处理器提供的事件变量有一个请求属性。这个属性是一个 request 对象。你可以使用 request 对象来确定你将如何返回响应。在本章中,你将学习到几个可以应用来使你的渐进式 Web 应用工作更快、离线的缓存策略。
知道如何使用 fetch 事件来最大化应用缓存的使用性能,是提供最佳用户体验的关键。
缓存模式和策略
Service Worker 事件为你提供了通往 Service Worker 生命周期的门户,以便应用你的缓存策略。但这不仅仅是检查是否已缓存有效的响应或将请求传递给网络。你应该有一个计划,说明你的应用将如何使用 Service Worker 缓存、事件和网络来提供最佳体验。
这意味着你需要有一系列常见的模式和策略来构建你的应用程序逻辑。本章的其余部分将回顾你可以用来构建应用程序的常见缓存模式。
策略是指导原则和示例代码的组合,你可以使用它们来构建你的应用程序。随着你继续阅读本书,你将看到这些策略在 PodStr 和 PWA Tickets 应用程序中的应用。
预缓存
PRPL 模式的一个关键方面,我们将在后面的章节中了解更多,就是在应用安装时存储应用资产。当用户访问一个 Web 应用的初始入口点时,会触发一个后台进程,该进程将自动加载后续渲染网站不同方面所需的额外资产。这被称为预缓存,或者为应用缓存进行预优化,以获得未来的更好性能。
Service Workers 使得这种做法易于管理。你可以利用 install 和 activate 事件,以及当 Service Worker 首次触发时。常见的做法是在注册新的 Service Worker 时使用 install 事件来预缓存一系列已知的资产:
self.addEventListener("install", event => {
event.waitUntil(
caches.open(cacheName(PRECACHE_NAME)).then(cache => {
return cache.addAll(PRECACHE_URLS);
})
);
});
你需要了解两种预缓存策略:作为依赖项的预缓存和不作为依赖项的预缓存。
作为依赖项安装
当预缓存应用程序资源时,有些资源你知道它们会被很快或频繁地使用。你可以将这些视为关键任务。尽管初始页面加载或应用外壳加载可能会触发对这些资源的网络请求,导致它们被缓存,但可能还有其他你希望确保在应用程序加载早期过程中被缓存的资源:

这些资源应该作为install事件完成的依赖项进行预缓存。换句话说,这些资源必须在install事件关闭之前完成缓存,这意味着你应该使用event.waitUntil方法保持过程开启。通过这样做,你延迟了任何活动事件的触发,直到这些资源完全被缓存:
self.addEventListener('install', function (event) {
event.waitUntil(
//pre-cache
//on install as a dependency
return caches.open(preCacheName).then(cache =>{
return cache.addAll(PRECACHE_URLS);
});
});
不作为依赖项安装
预缓存不仅限于关键任务资源。你很可能会识别出许多将被常用但不是你应用程序成功关键的任务资源。你仍然可以使用服务工作者的install事件来缓存这组资源,但选择不使它们依赖于事件完成。这被称为无依赖项的预缓存资源。
在这种情况下,你也会触发这些网络资源的预缓存,但你不会在event.wait until调用中返回cache.addAll方法。这些资源仍然会被添加到缓存中,但不会保持install事件开启直到所有资源都被缓存。
这种技术让你能够最小化可能延迟服务工作者安装的预缓存资源的延迟。当你注册新的服务工作者时,你的一个目标就是尽可能快地使其可用。请求可能需要一些时间的资源可能会延迟这个过程。这意味着你的服务工作者不能在所有这些缓存(包括activate事件)完成之前控制任何客户端,如图所示:

当你在install事件中触发这个请求时,事件不会被延迟。请求仍然会被缓存,但它在event循环之外:
self.addEventListener('install', function (event) {
event.waitUntil(
//won't delay install completing and won't cause installation
to
//fail if caching fails.
//the difference is as dependency returns a Promise, the
//no dependency does not.
//on install not as dependency (lazy-load)
caches.open(preCacheNoDependencyName).then(cache =>{
cache.addAll(PRECACHE_NO_DEPENDENCY_URLS);
return cache.addAll(PRECACHE_URLS);
}));
});
在激活时
activate事件是服务工作者生命周期链中的下一个部分,你可以利用它来管理缓存资源。它也可以用来缓存资源,但更常用于执行缓存模型清理。
与关注缓存资源相比,activate事件更适合清理旧缓存。这可以消除可能导致你的应用程序崩溃的版本不匹配问题:

要实现这个策略,你需要有一个可识别的版本缓存命名约定。我建议将版本号添加到所有命名的缓存中。这给你一个简单的模式,你可以匹配它来确定是否应该删除命名的缓存。只是要注意,那些命名的缓存中缓存的任何资源也将被删除。这是可以接受的,因为新的服务工作者版本通常缓存这些资源的更新版本:
self.addEventListener('activate', function (event) {
//on activate
event.waitUntil(
caches.keys().then(function (cacheNames) {
cacheNames.forEach(function (value) {
if (value.indexOf(VERSION) < 0) {
caches.delete(value);
}
});
return;
})
);
});
之前的示例代码遍历所有命名的缓存,并检查缓存是否属于当前的服务工作者。版本变量具有我们正在寻找的版本号模式。我个人的偏好是在服务工作者开始时声明一个 const 值:
const VERSION = "v1",
我在值中添加了一个 v 来进一步表明它是一个版本号,但这更多的是一种心理工具,以迎合我个人的偏好。请随意使用你喜欢的任何版本控制模式。语义版本控制甚至随机的哈希或 GUID 也可以在这里很好地工作。
主要目的是在你的缓存名称中有一个独特的模式,你可以识别出来。创建一个变量是有用的,因为它可以被附加到缓存名称上,使清理更容易管理。
实时缓存
缓存不需要仅限于服务工作者安装事件。虽然你应该预缓存你的常用资源,但可能有许多资源本质上是动态的。在播客应用中的例子包括单个播客和播客剧集页面。
每个播客都包含独特的属性,如标题、描述和标志。每个剧集也有一个独特的标题、描述、可能还有图片,当然还有音频文件。
这些数据非常动态且流动,为创建新页面和页面资源以及更新这些页面和资源提供了许多机会。服务工作者 fetch 事件处理器为你提供了拦截所有网络请求的钩子,这样你可以在连接互联网之前确定这些资源是否已经正确地本地缓存。
这个基本模式让你能够立即加载之前缓存的资源,而无需担心网络连接。正如你将在以下章节中看到的,这个模式有许多变体。
在用户交互时
首个动态缓存策略是对用户交互的响应,例如点击按钮。这不需要由服务工作者 fetch 事件显式捕获,它可以由客户端 JavaScript 来管理。这利用了客户端(浏览器)对客户端 API 的访问:

播客应用的剧集页面有一个按钮,用户可以选择它来将剧集的 MP3 文件存储在离线状态。这是“稍后听”功能的一部分,这是我在 Stitcher 应用中非常喜欢使用的功能!
以下示例代码是一个在用户点击单集页面上的“稍后收听”按钮时执行的函数。它向 API 发起一个 fetch 请求,并将响应缓存到 LISTEN_LATER 缓存中:
function saveEpisodeData(guid) {
var episodeSource = "api/episodes/" + guid + ".json";
fetch(episodeSource)
.then(function (response) {
if (response && response.ok) {
caches.open("LISTEN_LATER").then(
cache => {
cache.put(episodeSource, response);
});
}
});
}
要使此功能完整,可以使用类似的方法来缓存单集的 MP3 文件。你还希望持久化一些可以用来视觉上指示单集已保存以供以后使用的属性。如果用户稍后打开单集页面,你还希望维护一个已保存单集的本地列表。这可以使用 IndexedDB 实现。
在网络响应
当请求从网络返回时,你可以拦截 fetch 过程的这一部分,并根据你的应用程序逻辑进行处理。最常见的事情是在返回副本的同时缓存响应。
这也扩展了按需资产缓存的核心理念。需要注意的是,当你从网络拦截一个请求时,你应该在缓存之前克隆响应。响应只能使用一次:

clone 方法创建响应的深拷贝,允许你对响应进行其他操作。clone 方法最常见的使用是创建一个副本,以便一个可以缓存,另一个可以返回给客户端,如下面的代码所示:
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open(cacheName).then(cache =>{
return cache.match(event.request).then(function (response)
{
return response || fetch(event.request).then(
function (response) {
caches.open(cacheName).then(
cache =>{
cache.put(event.request,
response.clone());
});
return response;
});
});
})
);
});
过期时重新验证
我们基本网络缓存策略的下一阶段是在发起网络请求获取最新版本的同时,向客户端返回之前缓存的版本。
当你需要快速返回响应,但数据的最新性不是最重要的要求时,这种策略非常有用。例如,播客单集的详细信息变化不大,如果有的话。返回缓存的页面及其图像不会意味着用户错过了新鲜数据。以下图显示了此过程中的交互:

但也许你想要确保用户拥有最新的内容。你可以立即返回缓存的响应,同时向网络发起新的请求并缓存该响应。这将替换任何之前缓存的页面数据,以便下次请求页面时使用,如下面的代码所示:
self.addEventListener('fetch', event => {
event.respondWith(
caches.open(dynamicCacheName).then(cache =>{
return cache.match(event.request).then(function
(response) {
var fetchPromise = fetch(event.request)
.then(function (networkResponse) {
cache.put(event.request,
networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
})
})
);
});
在推送通知
用户活动和网络请求并不是唯一可以缓存网络资源的时候:

你也可以使用 push 消息来启动缓存响应,如下面的代码所示:
//On push message
function parsePushMessage(message){
//parse the message
If(message.update){
//notify user of background update process here
caches.open(preCacheName).then(cache =>{
return cache.addAll(message.urls);
});
}
}
self.addEventListener('push', event => {
parsePushMessage(event.data);
});
在这个策略中,push 事件处理程序确定消息操作的类型。如果是更新应用程序的通知,它将启动该过程。前面的代码示例有点过于简化,但它显示了重要的部分。
push 消息体应该包含某种属性,指示应采取的操作。在这种情况下,它触发一个更新工作流程。消息中包含一个属性,urls,它是一个应更新或缓存的全部 URL 的数组。
cache.addAll方法使代码变得简单,因为它将执行所有的fetch请求并为你缓存响应。
你应该始终通知用户已经收到push通知并且应用程序正在更新。在这种情况下,你可能还希望提示用户重新加载应用程序,如果他们目前正在使用它。你可以在更新缓存时检查是否有任何活动客户端,并通知他们更新过程。你将在未来的章节中了解更多关于push通知的内容。
在后台同步
更高级的缓存模式将是使用后台同步 API。我们将在后面的章节中更详细地讨论这个问题。
核心思想是将所有的网络请求都包裹在一个后台同步包装器中。在这里,你会创建一个请求标签,并使其在网络可用时立即处理。
这为使用服务工作者 fetch 添加了一个新的复杂层,但如果需要维护与服务器异步数据集,它可能非常有价值。
如果你连接到网络,任何传递给后台同步 API 的请求将立即按正常方式执行。如果你没有连接,它将被添加到队列中,并在设备恢复连接时执行。以下图像显示了此过程中的交互:

在那个时刻,一个sync事件被触发,这可以用来启动缓存更新,如下面的代码所示:
self.addEventListener('sync', function(event) {
if (event.id === 'update-podcast-XYZ') {
event.waitUntil(
caches.open(DYNAMIC_CACHE_NAME).then(function(cache) {
return cache.add("podcast/xyz/");
})
);
}
});
在这个例子中,为特定的播客创建了一个后台同步标签。如果该标签触发了同步事件,相应的播客详情页面将被添加到动态缓存中。
仅缓存
当网络资源被预缓存时,你可以选择仅使用资产的缓存版本来实现策略。这被称为仅缓存策略。
当你知道一个资源可以被安全地长期缓存时,你可以通过消除不必要的网络通信来减少应用程序中的更多开销。
从缓存中检索这些资源也意味着应用程序可以更快地加载,当然,也可以离线加载。只需确保它们不要变得过于陈旧。
在这里,对网络资源的任何请求都只从缓存中检索,不会使用任何网络响应。这对于长期静态资源,如应用程序的核心 JavaScript 和 CSS 文件,非常有价值。你也可以将其应用于像网站标志和字体文件这样的图像。
如果你正在使用仅缓存策略,我建议在服务工作者执行时自动触发一个常规程序。这个程序应该定期检查新的响应版本,以确保你拥有最新的数据。
此外,请确保这些资源是静态的,否则可能会破坏您的应用程序。如果您选择使它们成为静态资源,我建议在将它们部署到服务器之前,将这些资源添加到服务工作者版本更改中。以下图像显示了此过程中的交互:

仅缓存策略与本章前面讨论的任何预缓存策略搭配良好。预缓存仅缓存资源应确保它们可以从缓存中获取,如下面的代码所示:
self.addEventListener('fetch', event => {
// If a match isn't found in the cache, the response
// will look like a connection error
event.respondWith(caches.match(event.request));
});
在此代码示例中,fetch事件处理程序仅对缓存中的匹配项做出响应。如果不存在,客户端将收到一个“未找到”(404 状态码)响应。
仅网络
仅缓存与仅从网络请求资源的完全相反。在这种情况下,您将确定始终应从服务器请求的网络资源。
应该在变化非常频繁的数据上应用此策略。例如,股票行情应用希望确保立即而不是从本地缓存更新股票价格请求。在这种情况下,过时的数据可能会让您损失一大笔钱。以下图像显示了此过程中的交互:

您应该确定您可能需要访问的任何文件或网络资源的性质,并确保数据始终在更新。您可以拦截这些请求并根据路由和模式应用适当的策略。为此,我建议您在这些资产的 URL 中包含一些唯一标识符,如下面的代码示例所示:
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request));
// or simply don't call event.respondWith, which
// will result in default browser behaviour
});
缓存回退到网络
我看到在服务工作者中最常见的模式是缓存回退到网络。它非常受欢迎,因为检查资产是否存在于您的缓存中意味着它可以立即返回。如果没有,您仍然可以通过网络回退以尽可能快的速度检索它。
任何未预缓存或之前从同一模式缓存的资产都将可访问,前提是您已在线。以下图像显示了此过程中的交互:

我认为这可能是最常用的模式,因为任何未预缓存的资产都可以轻松地适应此模式。播客应用程序使用此模式为所有个人播客和剧集页面。这使得它们可以尽快访问,但我们不希望在事先预缓存每个文件和图像,而仅在需要时。
以下是一个执行此模式的示例:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
在没有缓存网络响应的情况下执行此模式似乎毫无意义。以下代码显示了您应该如何应用此策略;我称之为“缓存回退到网络”,即缓存结果:
self.addEventListener('fetch', event =>{
caches.match(event.request).then(
function (response) {
return response || fetch(event.request).then(
function (response) {
caches.open(dynamicCacheName).then(
cache =>{
cache.put(event.request, response.clone());
});
return response;
}
)
})
});
这是此模式的更完整版本。现在,下次请求资源时,它将来自缓存。但如果之前没有缓存,它可以从网络中检索。
缓存和网络竞速
另一个有趣的缓存模式,即缓存回退到网络模式的变体,是缓存和网络竞速模式。这是您将同时从缓存和网络请求资源的地方。最快的响应获胜。虽然缓存应该是赢家,但它可能并不总是能赢。此模式还给您提供了在无缓存版本的情况下更快地检索网络版本的机会。以下图片展示了此过程中的交互:

这种模式的缺点是,即使不需要,您也总是会发起网络请求。这将增加您的网络流量。但您也可以将其视为确保每次至少有一些最新内容缓存的途径。它也可以被视为“陈旧但正在验证”策略的一种变体。以下代码展示了如何实现缓存和网络竞速模式:
self.addEventListener('fetch', event =>{
promiseAny([
caches.match(event.request),
fetch(event.request)
])
});
// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
return new Promise((resolve, reject) => {
// make sure promises are all
promises = promises.map(p => Promise.resolve(p));
// resolve this promise as soon as one resolves
promises.forEach(p => p.then(resolve));
// reject if all promises reject
promises.reduce((a, b) => a.catch(() => b))
.catch(() => reject(Error("All failed")));
});
};
注意,这里有一个自定义的 promiseAny 函数。这是由于 Promise.race 方法的限制。当使用 Promise.race 方法且提供的任何承诺抛出异常时,整个过程将失败。
此模式依赖于至少有一个承诺解析响应。promiseAny 函数是对 Promise.race 方法的修改,除了它不会在提供的承诺失败时失败。它假设其中一个承诺将成功并返回赢家。
网络回退到缓存
如果您有一个时间敏感的网络资源,您总是希望访问网络,那么您也应该考虑使用网络回退到缓存策略。在这里,您将始终尝试访问网络,但如果网络不可访问,那么您就有内置的选项来检索最近缓存的文件版本。
如果资源的新鲜度至关重要,我建议您通过视觉方式提醒客户与该响应相关的时间。以下图片展示了此过程中的交互:

此模式还可以为任何通过网络无法访问的资产提供回退,我们将在下一个模式中看到。以下代码展示了如何实现网络回退到缓存模式:
self.addEventListener('fetch', event => {
event.respondWidth(
fetch(event.request).catch(function () {
return caches.match(event.request);
})
});
});
通用回退
从网络回退到缓存的下一步是为所有请求提供一个通用回退。当缓存和网络都没有可用响应时,您应该使用此策略。
您可以在 Podstr 应用程序中看到这种模式被用于播客剧集及其相关图片。
这里的技巧是为这些特定的网络资产预缓存一个回退响应。我还建议你通过匹配路由变体而不是单个 URL 来应用这种策略。
在 Podstr 播客应用程序中,播客页面、剧集页面和播客标志都有通用的回退。我识别出这些都是无法预缓存的动态资源。
通常,如果你无法访问资源,这意味着资源未找到或设备离线。我认为设置一些逻辑来区分这两种情况很重要。如果应用离线,你希望以某种方式在视觉上指示这一点,但如果资源确实未找到,你可能希望返回一个略有不同的响应。
我认为 Flipkart 在这方面做得非常出色。当应用离线时,他们会将整个 UI 以灰度显示。这对最终用户来说是一个非常清晰的指示,表明设备处于离线状态,这意味着他们可能无法访问信息,并且他们收到的任何信息可能不是最新的。以下截图展示了这种灰度显示的例子:

如果你收到一个404错误消息,那么你可以返回一个“未找到”页面,并利用这一点。以下图片展示了这种模式涉及到的交互:

可能你会选择将他们引导到相关资源,或者提供网站地图,如下面的代码所示:
self.addEventListener('fetch', event =>{
event.respondWidth(
caches.match(event.request).then(response => {
// Fall back to network
return response || fetch(event.request);
}).catch(function () { // If both fail, show a generic
fallback:
return caches.match("fallback/").then(function(response){
return response;
});
// However, in reality you'd have many different
// fallbacks, depending on URL & headers.
// Eg, a fallback images for podcast logos.
})
);
});
服务工作者模板
关于服务工作者,你应该接受的一个概念是它们可以在浏览器中充当一个网络服务器。传统上,网络服务器使用运行时渲染平台,如 ASP.NET、PHP、Ruby on Rails,以及内容管理系统,如 WordPress、Drupal 和 Joomla!。这些系统更多的是渲染引擎。你可以在服务工作者内部执行 HTML 渲染。
单页应用程序在本十年变得非常流行。它们有效地从服务器接管了渲染过程。如今,预加载应用程序模板很流行,无论你使用 mustache、handlebars 还是更大的框架,如 Angular 和 React。所有这些本质上都是 HTML 渲染系统。服务器端和客户端之间的区别在于渲染发生的位置。由于你可以在服务工作者中拦截网络请求,渲染过程可以从客户端 UI 或服务器移动到服务工作者。
在这种模式中,你很可能会提前预缓存页面或组件模板,并向 API 发起网络请求以获取数据,通常以 JSON 格式返回。当你检索到新的 JSON 后,你将在服务工作者中渲染标记,并将 HTML 返回给客户端。以下图片展示了这种模式涉及到的交互:

我个人的技术是使用 mustache,因为它简单快捷。整体技术稍微复杂一些,但一旦你有一个工作模式,我认为你会发现它更容易实现。
在这个例子中,fetch事件处理程序会寻找对播客剧集路由的任何请求。当发起剧集请求时,它会被拦截并创建一个新的自定义请求。服务工作者不会从服务器请求 HTML,而是创建一个请求到 API 以检索 JSON。
理论上,对 JSON 的请求应该比 HTML 的请求小。较小的数据包应该加载得更快。真正的问题是,这个小请求能否比预渲染 HTML 的请求更快地检索和渲染?这是我无法给出的答案。这将需要您对应用程序页面和 API 进行一些实验,以确定哪种解决方案最好。
对于小块数据,例如剧集页面,服务工作者渲染可能会稍微慢一些。但如果您的页面包含大量可重复的信息——例如在业务线应用程序中经常看到的那种信息——这种技术可以提高您的整体性能。以下代码展示了您如何实现这种模式:
self.addEventListener('fetch', event => {
event.respondWidth(
Promise.all([
caches.match("/product-template.html").then(
response => {
return response.text();
}),
caches.match("api/products/35.json").then(
response => {
return response.json();
})
]).then(function (responses) {
var template = responses[0];
var data = responses[1];
return new Response(Mustache.render(template, data), {
headers: {
"Content-Type": "text/html"
}
});
})
});
});
一旦 API 请求返回服务工作者逻辑,它随后加载内容模板并将其渲染为 HTML。然后,这个 HTML 通过一个自定义的response对象返回给客户端。
使用这种策略的更高级方法是将其与缓存响应结合使用。这样,你只需渲染一次响应,然后缓存它,并在下次检查它是否已缓存。
这种模式可能并不适用于所有场景,但如果您有包含大量重复数据集的页面,并且希望利用任何速度提升,那么它应该被考虑。
我认为这种模式最能带来好处的是动态渲染的标记,其中数据频繁变化。例如 Podstr 应用程序这样的渐进式 Web 应用程序可能不会实现性能提升,但业务线应用程序可以。
摘要
这些策略应该作为您的服务工作者获取和缓存工作流程。之所以有这么多策略,是因为应用程序资产有众多场景。如何应用这些策略取决于您,并且可能需要一些实验来确定每种资产类型最佳策略。
这些策略可能不是您应用程序所需的精确模式,但它们将为您所有的缓存需求提供基础。您可以使用这些作为起点,扩展、混合和匹配以创建最适合您应用程序的最佳策略。
第八章:应用高级服务工作者缓存策略
是时候提升我们的渐进式 Web 应用能力了。到目前为止,你已经学会了如何添加到主屏幕体验、核心服务工作者概念以及如何使你的网站安全。在本章中,我们将深入探讨高级服务工作者概念和全新的渐进式 Web 应用,PWA 门票。
本章将涵盖以下主题:
-
什么是 PWA 门票?
-
如何运行本地、模拟 API 服务器
-
PWA 应用程序架构
-
将实用库导入服务工作者中
-
服务工作者响应管理器
-
高级缓存策略和技术
-
缓存失效技术
什么是 PWA 门票?
PWA 门票应用程序是一个托管服务应用程序的示例,旨在模仿在线购票解决方案。虽然我可以关注这个应用程序的许多方面,但本书专注于消费者应用程序。
以下截图是 PWA 门票首页,显示了客户可以购买门票的即将举行的活动卡片列表。卡片是指用于样式化列表中项的 UI 隐喻:

一个真实的门票服务应用程序将包括一套应用程序,包括管理应用程序和用于验证门票的应用程序。对于这本书,我将专注于消费者客户端体验。
应用程序的消费者版本具有用户登录、资料管理、访问未来事件列表、购买门票的能力以及用户的购票历史。
应用程序本身由实时 API 和静态网页组成,但本章真正关注的焦点是一些高级服务工作者概念。
服务工作者评估每个 fetch 请求并对其进行不同的处理。服务工作者预先缓存关键资源,但也为不同的路由定义自定义规则。
另一个新的高级概念是缓存失效的想法。这是你定义适用于缓存响应的规则,并确定是否应该发起网络请求以及使缓存失效的地方。这是一个重要的概念,因为它让你完全控制你的应用程序、缓存规则,并允许你管理缓存中存储的内容量。
PWA 门票应用程序将展示一些新的概念和策略,这将帮助你创建专业的渐进式 Web 应用,例如以下内容:
-
服务工作者模板
-
缓存失效
-
根据请求的 URL 触发不同的缓存策略
-
使用
importScripts
检查 PWA 门票应用程序
让我们看看应用程序将包含的不同部分。应用程序有八个主要页面区域:
-
首页
-
用户资料
-
活动
-
门票
-
购物车
-
联系方式
-
配置
-
登录
票务和事件都包含两个页面:一个是列表页,另一个是项目详情页。该应用还包含我们的第一个页面,这些页面会向 API 发起 POST 请求,而不仅仅是 GET 请求。这引入了一个新的fetch方法,我们的 service worker 必须正确处理。
首页列出了 10 个即将举行的事件,后面跟着用户购买的票务列表。每个条目都使用 bootstrap 卡片类进行样式化。每个票务卡片还有一个按钮来显示项目的详情。
用户个人资料页面列出了用户的联系信息和一些他们最近购买的票务。它还包含一个更新用户资料的按钮。这将切换视图从只读模式到编辑模式。
应用程序的导航包括事件、票务、个人资料、注销和一个搜索字段:

用户可以输入搜索词,无论他们处于哪个页面,都会自动更新以显示任何匹配的事件,而无需加载新页面。这是通过发起一个 AJAX 调用并在浏览器和 service worker 中渲染结果来实现的。
应用程序的 API 并不代表一个生产质量的搜索功能,但它服务于我们的目的。它将匹配搜索框中输入的任何包含的短语的事件。
事件页面将列出系统中所有可用的未来事件。同样,每个事件都是一个带有查看事件详情按钮的卡片。事件的详情页面显示了更多一些信息,并包括可供购买的票务列表:

一个真正的票务服务应用会提供一种更复杂的方式来查找票务,但我希望为了演示目的保持其简单性。
当用户购买票务时,他们必须确认购买,然后它会被添加到他们的个人资料中。
在 Now 栏中选择票务链接会将用户带到他们购买的票务列表。从这里,他们可以查看任何票务的详情,包括一个二维码。二维码旨在模拟现代电子票务解决方案在进入场地或需要找到座位时 usher 和 gate agent 扫描的内容:

这就是 usher 的应用可以发挥作用的地方。他们可以使用手机扫描二维码以确认票务并允许顾客进入场地。
在这里还有一个联系页面,用户可以向系统管理员提交信息。这主要用于演示如何使用 fetch 和 service worker 来处理帖子消息。
最后,整个应用要求用户进行身份验证。每个页面都会快速验证用户是否已登录,如果没有,则会加载登录页面:

用户通过输入用户名和密码进行登录。凭证被发送到 API 进行验证。API 返回用户的个人资料,这模拟了一个身份验证令牌。身份验证令牌被持久化,并在每次页面加载前进行验证。
用户目前还没有可以选择的个人资料。他们可以使用创建新个人资料链接并将自己添加到系统中。
以下函数是应用程序的 API 调用,用于将用户登录到应用程序:
login: function (credentials) {
return fetch(api +
"users/?userName=" +
credentials.username +
"password=" + credentials.password)
.then(function (response) {
if (response.ok) {
return response.json()
.then(function (token) {
if (token.length > 0) {
return saveAuthToken(token[0]);
}
});
} else {
throw "user tickets fetch failed";
}
});
}
注意,用户名和密码是通过queryString传递给 API 的。我通常不会这样做,但我需要一种与 json-server 一起工作的方式,它似乎没有提供使用 POST 匹配的自定义函数的方式。
您不希望在生产环境中这样做,因为这会暴露凭证。当将凭证作为请求体的一部分发送时,它们被 HTTPS 保护。
PWA 车票应用程序包含一个最小功能集,我认为这将有助于展示前三章中涵盖的概念。这个关于应用程序如何工作的介绍并没有涵盖所有内容。我邀请您克隆源代码(github.com/docluv/pwa-ticket)并在本地运行它。
使用 JSON 服务器作为 API
当您构建现代应用程序时,前端几乎总是通过 API 与数据源进行交互。API 是后端应用程序的入口,可以被任何客户端应用程序消费,例如渐进式 Web 应用程序。
当您不想首先开发整个 API 时,针对 API 进行开发可能会相当棘手。在播客应用程序中,我们只是加载了一个预渲染的 JSON 来模拟 API。播客应用程序只进行了 GET 请求,没有进行任何 POST 请求或尝试更新底层数据模型。
PWA 车票应用程序确实会进行 POST 请求并尝试更新底层数据模型,但与其构建整个基础设施,我找到了一个很好的解决方案:json-server(github.com/typicode/json-server)。这是一个类似于我们用于前几个应用程序的 http-server 的 node 模块。
json-server 的真正优势在于其内置的创建基于 JSON 数据模型的完整功能 API 的能力。您必须像安装任何其他 node 模块一样安装该模块:使用npm install并在您的package.json文件中包含对其的引用。
在您执行服务器之前,您必须创建一个数据源。这只是一个 JSON 文件。而不是手动创建数据和数据模型,我选择编写一个使用 faker 模块(github.com/marak/Faker.js/)的脚本。这也可以使用标准的 NPM 任务进行安装。
faker 是一个相当酷的 node 模块,允许你动态地为你生成大量虚假数据,以便你围绕这些数据构建应用程序。在我看来,这是前端和 Web 开发中更令人烦恼的方面之一,因为你需要大量数据来验证你的应用程序逻辑。然而,创建这些数据需要很长时间。Faker 消除了这个问题。
将 json-server 和 faker 结合使用,可以创建一个非常复杂和深入 API 和数据源。你可以使用这两个模块模拟你潜在 API 和后端的几乎所有方面。
在创建 PWA 票务应用程序的过程中,我多次修改数据模型,试图使其恰到好处。而不是手动编码所有数据,我能够编写一个脚本来从头开始重建数据库。
项目源代码库包括一个名为utils的顶级文件夹。在这个文件夹中,有几个脚本:一个用于生成虚假数据,另一个用于渲染页面。虚假数据脚本利用了 faker 和一些固定数据源的组合。
Faker 具有生成各种类型数据的能力,包括图像。然而,我发现它生成的和使用图像是一个缓慢的、随机的图像生成服务。与其依赖这些图像,我选择固定一组 8 张场馆图像和 8 张肖像图像。这 16 张图像存储在网站的img文件夹下。
你还会注意到一个生成二维码的方法。这也是通过一对 node 模块完成的:一个用于生成二维码图像,另一个用于将图像保存到磁盘。
每张票都会生成一个二维码,代表票的唯一标识符。生成的每个条形码图像都保存在网站的barcodes文件夹中。每个条形码图像是一个.gif文件,并且扩展名附加在其名称之后。
虽然这些 node 模块对于运行 PWA 票务应用程序至关重要,但它们与服务工作者和渐进式 Web 应用程序没有直接关系。我确实想花点时间解释它们是如何工作的,这样你就可以在本地使用源代码。
创建数据库和 API
JSON 服务器允许你通过支持基本的 CRUD 操作而不编写任何代码来托管本地 REST API。该 node 模块通过读取包含完整 JSON 对象的源文件来工作。PWA 票务应用程序依赖于 JSON 数据结构,如下所述:
{
tickets: [],
users: [],
futureEvents: [],
pastEvents: [],
contact: []
}
你可以配置数据源以包含基于提供的参数返回数据的方法。我选择不这样做,以保持事情简单。因此,这解释了为什么使用futureEvents和pastEvents数组,而不是按需创建这些列表。
要执行json-server,使用带有--watch开关的命令行实用程序。watch 开关会导致json-server在源数据文件更新时更新:
json-server --watch db.json
PWA 票据源代码在根目录中有 db.json 数据源文件。服务器创建 RESTful 端点,映射到顶级对象的名称。它还充当静态文件服务器。你只需注意数据文件中的对象和页面之间的重叠路径。
在创建此演示应用程序时,我遇到了使用 json-server 在同一站点重复路由的场景。这迫使我运行两个网络服务器实例:一个用于 API,一个用于网站。
对于基于 localhost 的服务器,你可以指定不同的端口号来运行多个本地服务器。你可以通过在命令行界面添加 -port 开关来定义端口号:
json-server --watch db.json -port 15501
在尝试启动仅静态网络服务器的实例时,我遇到了一些挫折,所以我选择使用 json-server 启动 API,并使用 http-server 启动静态网站。
你可以从命令行运行两个本地网络服务器,每个服务器一个控制台实例,因为它们正在运行服务器:
>npm run api-server
>npm run web-server
在不同的端口上运行 API 服务器的一个优点是它有助于模拟跨域访问,或称为 CORS。
CORS 代表 跨源资源共享,是为了允许浏览器更安全地请求外部域的资源而创建的。它依赖于浏览器使用额外的头信息来管理对外部资源的访问,通常是通过 AJAX 实现的。
服务器会添加 CORS 特定的头信息,以告知浏览器哪些域名可以访问资源。
要检索数据,你可以加载一个与 API 服务器和对象名称相对应的 URI:
http://localhost:15501/users/
此示例 URL 返回一个用户对象的数组:
[
{
"id": "891ad435-41f3-4b83-929b-18d8870a53a4",
"firstName": "Catharine",
"lastName": "Cormier",
"mugshot": "avtar-2.jpg",
"userName": "Clay.Parker",
"password": "93gQtXaB0Tc3JM5",
"streetAddress": "401 Kassulke Square",
"city": "Cronintown",
"state": "Vermont",
"zipCode": "09904-5827",
"email": "Bradly_Fahey56@gmail.com",
"phoneNumber": "400.748.9656 x0600",
"tickets": [...]
}, {...}
]
json-server 提供了更多高级功能,但这应该足以让你了解如何在本地运行站点。
使用 faker
在你可以使用 json-server 托管 API 之前,你需要源数据文件。这正是 faker 模块发挥作用的地方。为真实测试环境创建足够的数据始终是我面临的最大挑战之一。今天,似乎大多数平台都有像 faker 这样的库或工具。
由于我大多数项目都使用 Node.js,因此 faker 是一个突出的强大工具。它只需要一个脚本来生成数据。此脚本位于 /utils 文件夹中的 generate-fake-data.js:

此脚本不仅帮助我生成了文件数据集,还允许我在整体模型演变的过程中不断修改源数据。
此脚本生成随机数量的用户、事件和票据,并将它们随机映射在一起以创建完整的数据库。
我不会详细介绍 faker 可用的所有可能的数据类型。这是脚本生成新用户的方式:
let user = {
"id": faker.random.uuid(),
"firstName": faker.name.firstName(),
"lastName": faker.name.lastName(),
"mugshot": mugshots[mugshot],
"userName": faker.internet.userName(),
"password": faker.internet.password(),
"streetAddress": faker.address.streetAddress(),
"city": faker.address.city(),
"state": faker.address.state(),
"zipCode": faker.address.zipCode(),
"email": faker.internet.email(),
"phoneNumber": faker.phone.phoneNumber()
}
faker 对象具有不同的顶级数据类型,并提供各种方法来生成格式正确、随机的数据。
由 faker 生成的数据值处于正确的或预期的格式。我喜欢它生成的一些文本值。我鼓励你阅读其中的一些,因为它们创造了一些相当幽默的值和组合!例如,通用塑料奶酪。
脚本是自包含的,每次运行时都会创建一个新的数据库。此外,当你使用 -watch 开关启动 json-server 时,API 将自动更新以适应新数据。
数据库的下一个方面仍然是:二维码!
生成二维码
现代票务解决方案更多地关注条形码和二维码,而不是实体票。为了创建逼真的票务应用程序,我需要为每张票创建自定义的二维码。同样,一对 node 模块使这一过程变得非常简单:qr-encode (cryptocoinjs.com/modules/misc/qr-encode/) 和 ba64(www.npmjs.com/package/ba64)。
qr-encode 将字符串转换为多种二维码选项之一。以下代码展示了如何使用 qr 方法生成 dataURI:
let dataURI = qr(id, {
type: 6,
size: 6,
level: 'Q'
});
qr 方法返回一个 base64 编码的数据缓冲区。你仍然需要将其转换为物理文件。这正是 ba64 发挥作用的地方。它将 base64 编码的缓冲区转换为文件:
ba64.writeImageSync(qrCodePath + "/" + id, dataURI);
qrCodePath 指向 public/qrcodes 文件夹的本地路径。脚本将删除现有的二维码图像文件,并在每张票生成时创建新的二维码:

二维码编码了票的唯一标识符,这是一个由 faker 生成的 GUID。这确保了每张票可以通过扫描二维码来识别。
现在数据已经生成,我们也有了服务 API 和网站的方法,我们只需要一件事:网站。
渲染网站
2048 和 Podstr 应用程序基于静态网站。虽然 Podstr 应用程序使用了一些动态渲染的页面,但大部分是预先渲染的。它还有一个创建 HTML 页面的脚本,但这个脚本在演示中并不像 PWA 票务应用程序那样关键。
PWA 票务应用程序有一个脚本,通过结合应用程序外壳和实际页面的单个页面标记来渲染核心页面。这很方便,因为它允许你独立更新应用程序外壳和页面,以及为不同的环境定制渲染脚本。
例如,在部署到生产环境之前,你可能想要捆绑和压缩一些样式表和脚本。正如你将在下一章中看到的,你还将想要使用工具减少资产,例如使用样式。
源标记文件位于网站的 HTML 文件夹中,分别是 /public/html 和 /public/html/pages。渲染脚本会遍历这些文件,并加载定义页面特定配置数据的相应数据文件:
{
"name": "events",
"slug": "events",
"scripts": ["js/app/pages/events.js"],
"css": []
}
PWA 票据应用有一些简单的配置对象。这些属性用于定义每个页面中的组件,如路由或文件夹,用于保存最终渲染的文件。这些属性在渲染管道中使用,以基于通用模板生成最终页面。
脚本是从命令行运行的:
>node render-public
控制台将记录每个页面的渲染过程。
PWA 票据应用比 Podstr 应用更先进,因为大多数页面都是在客户端渲染的,而不是作为一个完整的静态网站。票据应用依赖于客户端渲染的原因是每个页面都与用户的个人资料和购票相关。
本章将探讨这些场景以及服务工作者如何增强整体体验。
PWA 票据的渲染架构和逻辑
我们在本书中较早地介绍了应用壳的概念。快速回顾一下,这是应用使用一个通用的标记文件来管理常见的HEAD和布局功能的地方。然后这个壳与单个页面结合,组成每个页面。
由于最近单页应用的兴起,这个概念得到了广泛的应用。渐进式 Web 应用可以从中受益,但不需要依赖于在 UI 线程中渲染标记。
相反,标记可以在服务工作者中渲染。技术类似:它使用Mustache,一个 JavaScript 模板库,将数据合并到标记模板中。渲染的页面标记返回给客户端。我将在本章稍后回顾这段代码。
这种技术是最终缓存策略的实用应用,即Service Worker 模板化,这在第七章 Service Worker 缓存模式中讨论过。
PWA 票据的 JavaScript 架构
与 Podstr 应用类似,PWA 票据应用结合了第三方库和应用特定的 JavaScript。
下面的截图显示了 JavaScript 文件在源代码中的组织方式:

定制的服务工作者逻辑或工作流程利用不同的库在客户端渲染内容。两个第三方库是用于IndexedDB交互的localForage和用于渲染标记的Mustache。
localForage (localforage.github.io/localForage/) 是另一个IndexedDB包装器。它提供了一个简单的接口,模仿localStorage。主要区别在于localForage支持Promise,使其异步。
有三个polyfills,它们是Object.assign、Promise和Fetch。
每个页面使用一些特定于应用的库:api和push-mgr,以及通用应用模块。每个页面都有一个特定的脚本来驱动用户体验。
应用程序模块处理常见的 UI 组件,包括汉堡菜单切换、注销、搜索和身份验证验证。它还注册了服务工作者并在较高层次上管理推送通知。
由于常见的用户界面很简单,我将这些组件合并到单个脚本中,以保持管理简单。请注意,没有引用 jQuery 或更复杂的框架来驱动 UI。整个应用程序脚本大约有 200 行整齐排列的代码。单个页面脚本要短得多,因此没有很多特定于应用程序的代码。
api.js文件是一个包含与应用程序 API 交互方法的模块。由于应用程序较小,我将所有方法放置在单个模块中。对于更复杂的应用程序,您可能希望重构为独立的模块以使代码更容易维护:
var pwaTicketAPI = (function () {
var api = "http://localhost:15501/",
authToken = "auth-token";
function saveAuthToken(token) {
return localforage.setItem(authToken, token)
.then(function () {
return token;
});
}
return {
//API wrapper methods go here
};
})();
此方法创建了一个全局变量pwaTicketAPI,可以被单个页面控制器模块访问以与 API 交互。
每种方法都封装了对 API 端点的获取调用:
getUser: function (userId) {
return fetch(api + "users/" + userId)
.then(function (response) {
if (response.ok) {
return response.json();
} else {
throw "user tickets fetch failed";
}
});
},
大多数 API 方法执行 GET 请求,但少数方法执行 POST 请求以更新或创建新记录:
updateUser: function (user) {
return fetch({
"method": "POST",
"Content-Type": "application/json",
"body": JSON.stringify(user),
"url": api + "users/"
});
},
每个页面控制器都使用立即调用的函数表达式(IIFE)来隔离页面逻辑与全局作用域:
(function () {
//no need to render if service workers are supported
//unless the service worker is not in control of the page yet.
//test if the loader element exists. If so then fetch the data to //render
if (_d.qs(".loader")) {
pwaTicketAPI.loadTemplate("templates/event.html")
.then(function (template) {
if (template) {
pwaTicketAPI.getEvent(pwaTickets.getParameterByName("id"))
.then(function (event) {
var target = _d.qs(".content-target");
target.innerHTML = Mustache.render(template, event);
});
}
})
.catch(function (err) {
console.log(err);
});
}
})();
每个页面都遵循从 API 获取数据并渲染标记以构建页面的通用模式。大多数页面都有一个带有旋转圆盘的占位符。当渲染标记时,它会替换:
<div class="loader"></div>
主应用程序外壳有一个具有content-target类的main元素。这个类名用作选择元素的参考,并使用动态渲染的文本设置内部 HTML:
<main class="page-content content-target">
<%template%>
</main>
你应该已经注意到了我如何使用_d.qs()来选择目标元素。这是一个简单的实用程序对象,我创建它来消除编写document.querySelector()和相关选择器方法的需要。我不知道你是否和我一样,但我厌倦了到处输入这些内容,而且对于 jQuery 的选择器语法来说有点长:
var _d = {
qs: function (s) {
return document.querySelector(s);
},
qsa: function (s) {
return document.querySelectorAll(s);
},
gei: function (s) {
return document.getElementById(s);
},
gen: function (s) {
return document.getElementsByName(s);
}
};
此实用程序提供了一个简单的简写来选择元素,但无需 jQuery 的开销。
PWA 票据服务工作者架构
2048 和 Podstr 应用程序依赖于单个脚本。PWA 票据应用程序使用更复杂的技巧,如导入库来驱动逻辑。
服务工作者可以使用importScripts方法加载外部脚本。此函数在全局范围内可用,并接受一个 URL 数组。这些是额外的脚本,工作方式类似于 node.js 的require系统:
self.importScripts("js/libs/localforage.min.js",
"js/app/libs/api.js",
"sw/response-mgr.js",
"sw/push-mgr.js",
"sw/invalidation-mgr.js",
"sw/date-mgr.js"
);
前两个脚本也用于客户端代码。localForage是一个IndexedDB包装器,API 脚本管理对 API 和身份验证令牌的访问。Mustache库文件在ResponseManager模块中导入,我将在稍后介绍其用法。
剩余的脚本是一些常见的服务工作者库,用于帮助缓存策略,例如缓存失效和推送管理。每个服务工作者库都包含一个 JavaScript 类,其中包含用于管理缓存策略和生命周期的方法。
导入脚本是将服务工作者逻辑重构为更小单元的绝佳方式,这些单元可以复用且更容易维护。我审查了几个超过 10,000 行代码的服务工作者。每次你有一个大代码文件时,你往往会引入不希望出现的维护问题。
大代码文件首先造成的问题是导航代码。即使有现代开发环境和不错的键盘快捷键,也很容易在代码中迷失方向。如果你曾经浪费时间寻找函数和协调变量,你就知道这是什么感觉。
另一个常见问题是管理团队对代码的访问。当你有两个或更多开发者同时在一个文件上工作时,这会引入太多的代码合并机会。代码合并是我最不喜欢的开发者活动之一,尤其是当其他人编写了另一个版本时。
当创建大文件时,我看到的最后一个问题是缺乏代码复用。当你将代码重构为更小的模块时,它们不仅专注于单一职责,类、模块等还可以在其他应用程序区域或不同应用程序中复用。
这就是为什么我喜欢在服务工作者中导入脚本。2048 服务工作者非常简单,并不需要这种策略。Podstr 应用可以使用importScripts功能,但我选择将其保留到今天。对于 Podstr 的真正生产版本,我必须重构代码以导入不同的脚本。
将脚本导入服务工作者的一个缺点是关于脚本的更新。当服务工作者更新时,它们不会更新。不幸的是,我仍然不清楚这些文件何时会从服务器更新。我读过一些参考资料说导入的脚本应该遵循正常的浏览器缓存或缓存控制失效,而其他人声称这并没有按预期工作。
在规范讨论中,有一些关于这个问题的开放讨论,但到目前为止,我认为还没有采用真正的解决方案。
无论哪种方式,在开发过程中,这个问题可能会非常令人沮丧。你需要频繁更新这些文件,因为代码更新。我发现强制这些文件更新的最佳方式是手动注销服务工作者。
在注销服务工作者后,重新加载页面会再次注册服务工作者,并执行importScripts方法。
目前我在 Chrome 开发者工具中看到的一个与这种技术相关的bug是,每个注销的服务工作者都会在工具中留下痕迹:

您可以关闭工具以重置服务工作者面板。如果您可以忍受向下滚动到当前的活动服务工作者,您可以避免此步骤。我认为这只是一个问题,即当手动注销服务工作者时,开发者工具 UI 没有正确刷新。
importScripts 也可以用于由服务工作者导入的任何脚本。实际上,服务工作者的全局作用域对这些脚本可用。在服务工作者全局作用域中声明的任何变量在脚本中都是可用的。这也适用于从其他导入的脚本导出的任何对象。
节点请求系统与 importScript 方法类似。它们都加载外部脚本以创建您可以在脚本中使用的方法和对象,在这种情况下是服务工作者。
ResponseManager
ResponseManager 包含与第七章([part0152.html#4GULG0-f12cdcca08b54960b3d271452dc7667d])中介绍的一些缓存策略相关的常用逻辑,即 Service Worker 缓存模式。ResponseManager 类包含一组压缩的缓存策略及其对应的方法,用于五种缓存策略:
-
仅缓存
-
仅网络
-
缓存回退到网络
-
缓存回退到网络并缓存响应
-
缓存回退到网络、渲染结果和缓存
这是 ResponseManager 类的定义以及方法签名:
class ResponseManager {
fetchText(url) {...}
fetchJSON(url) {...}
fetchAndRenderResponseCache(options) {...}
cacheFallingBackToNetwork(request, cacheName) {...}
cacheFallingBackToNetworkCache(request, cacheName) {...}
cacheOnly(request, cacheName) {...}
networkOnly(request) {...}
}
cacheOnly 和 networkOnly 方法确实如其名称所暗示的那样,要么只从缓存返回响应,要么只从网络返回响应:
cacheOnly(request, cacheName) {
return caches.match(request);
}
networkOnly(request) {
return fetch(request);
}
cacheFallingBackToNetwork 检查是否有缓存响应,如果没有,则通过网络请求获取响应。响应不会被缓存。
cacheFallingBackToNetworkCache 重复了该逻辑,但会缓存网络响应。
有两个额外的辅助方法,分别是 fetchText 和 fetchJson。这两个方法专门帮助渲染结果策略检索 HTML 模板并从 API 获取 JSON 数据。
fetchText 用于检索 HTML 文件。fetchJSON 通过 API 调用获取数据。fetchAndRenderCache 方法使用提供的选项参数执行 API 调用。
我在之前的章节中介绍了这些缓存策略的核心概念。然而,我确实想回顾一下 fetchAndRenderCache 策略,因为它没有详细说明。
该策略的目标是在服务工作者中动态渲染响应并将其缓存以供下一次请求使用。这在像 PWA 票务应用程序这样的高度动态应用程序中效果很好。
虽然您可以在服务器上为任何应用程序预先渲染所有 HTML 页面,但这可能不如按需渲染高效或成本效益高。在过去,我们依赖于服务器上的运行时渲染系统,如 ASP.NET、PHP 等,以及客户端的大型单页应用程序框架。
无论您如何渲染标记,过程始终相同。您将数据与标记模板合并。您所使用的引擎利用某种合并字段语法,并用源数据中匹配的值替换这些字段。
我更喜欢使用Mustache (mustache.github.io/),因为其语法相对简单:
<div class="card ticket-card" id="{{id}}">
<div class="card-header">
<h5 class="card-title">{{event.title}}</h5>
</div>
<div class="row">
<div class="col-md-6 text-center">
<img class="card-img-top ticket-barcode"
src="img/{{barcode}}"
alt="{{id}}" />
</div>
<div class="col-md-6">
<div class="card-body">
<p class="card-text">{{event.venue}}</p>
<p class="card-text">{{event.date}} - {{event.city}}
{{event.state}}</p>
<p class="card-text">{{id}}</p>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">Section {{section}}</li>
<li class="list-group-item">Row {{row}}</li>
<li class="list-group-item">Seat {{seat}}</li>
</ul>
</div>
</div>
</div>
Mustache的名字来源于使用两个花括号来表示合并字段。渲染方法将 JSON 对象的属性与匹配的字段名合并。它可以使用相同的模板处理单个记录或创建重复的列表。它还支持基本的if...else逻辑和一些其他功能。
PWA 票据应用程序在大多数页面中使用Mustache模板,无论是单个记录还是列表。应用程序模板存储在/templates文件夹中:

JavaScript Mustache库可以在客户端或作为 node 模块使用。我在许多应用程序中在服务器和客户端都使用它。它很棒,因为您可以在应用程序的任何地方使用单个模板,而不用担心渲染库的不同。
如果您正在遵循任何主流 JavaScript 框架,它们都有成熟的服务器端渲染组件。我认为这是一个重要的趋势,因为这些框架已经导致了许多性能问题,并使许多网站无法在搜索引擎中索引。
这些服务器端组件的兴起应该会给这些框架带来更长的生命周期,并帮助他们通过将重逻辑移动到服务器来提供更好的用户体验。这对开发者来说也很好,因为许多开发者已经投入了大量时间学习他们的专有语法。
fetchAndRenderResponseCache方法执行服务工作者级别的运行时渲染。它接受一个包含不同属性的选项参数,这些属性是驱动策略所必需的。
我强烈建议将此方法与一个调用缓存来查看响应是否可用的调用配对。ResponseManager有一个可以使用的cacheOnly方法:
responseManager.cacheOnly(request, runtimeCache)
.then(response => {
return response ||
responseManager.fetchAndRenderResponseCache({...});
});
该方法使用一个 JavaScript 对象作为其唯一参数。它应该具有以下属性:
{
request: //the request that triggered the fetch
pageURL: "url to core page html",
template: "url to the data template",
api: //a method to execute that makes the API call,
cacheName: "cache name to save the rendered response"
}
这些值用于驱动逻辑,并使其足够灵活,可以在任何应用程序的不同页面和组件中重用:
fetchAndRenderResponseCache(options) {
return fetchText(options.pageURL)
.then(pageHTML => {
return fetchText(options.template)
.then(template => {
return pageHTML.replace(/<%template%>/g, template);
});
})
序列中的第一步是检索页面的 HTML。这是通过将pageURL值传递给fetchText方法来完成的。这应该解析页面的核心 HTML。
接下来,使用相同的方法获取模板。这次,将解析后的模板 HTML 注入到pageHTML中。它是通过在 HTML 页面中替换自定义标记/<%template%>/g来做到这一点的。同样,这是为了使页面模板更加灵活。您可以使用模板预渲染整个页面。
我这样做是因为我想让应用程序能够在不支持服务工作者的情况下回退到使用经典客户端渲染。
在这个阶段,你应该已经拥有了页面的完整 HTML,只是还没有用数据来渲染。接下来的步骤是使用提供的方法从 API 中检索数据。这个方法应该返回一个 promise,这意味着你可以直接返回用于调用 API 的 fetch:
.then(pageTemplate => {
return options.api()
.then(data => {
return Mustache.render(pageTemplate, data);
});
API 方法应该将响应解析为 JSON。然后使用pageTemplate和Mustache.render方法渲染数据。这样就创建了我们所需要的最终 HTML!
现在来点酷炫的魔法。逻辑创建了一个新的Response对象并克隆了它。克隆被保存到命名的缓存中,新的响应被返回以便可以用于用户渲染:
}).then(html => {
//make custom response
let response = new Response(html, {
headers: {
'content-type': 'text/html'
}
}),
copy = response.clone();
caches.open(options.cacheName)
.then(cache => {
cache.put(options.request, copy);
});
return response;
});
}
这可能看起来像是一项繁重的工作,但假设 API 调用很快,这可以很快完成。我确实建议预先缓存页面和数据模板标记。预缓存是一个很好的地方来做这件事。
你还可以考虑将这些响应缓存到一个特殊的模板缓存中,这样你就可以应用适当的失效规则,确保它们不会变得过于陈旧。
使用请求方法确定缓存策略
HTTP 的一个神奇之处在于它使用不同的属性来触发操作。HTTP 方法提供了一种描述性的方式来触发响应。有各种可能的 HTTP 方法,其中 PUT、GET、POST 和 DELETE 是最常见的方法。
这些方法对应于创建、检索、更新和删除(CRUD)操作。缓存是一个强大的工具,可以使你的应用程序响应更快,但并非所有响应都应该被缓存。HTTP 方法可以是一个触发适当缓存策略的主要信号。
前两个应用程序,2048 和 Podstr,仅使用 GET 请求。PWA 票据应用程序使用 POST 方法,这些方法不应该被缓存。当用户注册、购买票据、更新他们的个人资料或提交联系请求时,会向 API 发送 POST 请求。
API 响应通常用于确定成功或某种失败状态。这些响应不应该被缓存。如果它们被缓存,你的请求可能不会被发送到服务器:
if (!responseManager.isResponseNotFound(response)
request.method.toUpperCase() === "GET"
request.url.indexOf("chrome-extension") === -1
responseManager.isResponseCacheable(response)) {
//cache response here
}
匹配路由与缓存策略
利用不同的缓存策略涉及到一种触发特定策略以针对不同响应类型或路由的方式。你的应用程序越复杂,你可能需要管理的潜在路由和媒体类型就越多。
这可以通过定义一个由 URL 路由驱动的规则数组来完成。我建议使用正则表达式来匹配路由,尤其是在路由有一个与大量选项的共同基础时。一个很好的例子是一个电子商务网站的产品详情页面。这可能是一个指向预渲染页面的 URL,或者可能涉及一个QueryString值。
对于 PWA 票据应用程序,我将演示如何使用正则表达式定义一个用于活动详情的动态路由,以及另一个用于二维码图像的路由。
QR 码请求触发缓存回退到网络,然后缓存响应模式。事件请求触发服务工作者渲染策略。这涉及一个额外的属性,其中包含驱动策略的值:
routeRules = [
{
"url": /event?/,
"strategy": "fetchAndRenderResponseCache",
"options": {...},
"cacheName": eventsCacheName
},
{
"url": /qrcodes?/,
"strategy": "cacheFallingBackToNetworkCache",
"cacheName": qrCodesCacheName
}
];
而不是有一个复杂的 fetch 事件处理器,你应该将逻辑重构为单独的方法。将事件对象传递给你的处理器:
self.addEventListener("fetch", event => {
event.respondWith(
handleResponse(event)
);
});
通过将请求的 URL 与规则数组中的每个规则的url值进行测试,发生魔法般的事情。这是通过使用 JavaScript 的正则表达式test()方法完成的:
function testRequestRule(url, rules) {
for (let i = 0; i < rules.length - 1; i++) {
if (rules[i].route.test(url)) {
return rules[i];
}
}
}
此方法返回匹配的规则对象。如果没有定义匹配规则,你可以将规则值合并到一个空对象中:
function handleResponse(event) {
let cacheName = getCacheName(event.request.url);
let rule = testRequestRule(event.request.url, routeRules);
rule = rule || {};
switch(rule.strategy){
//
}
}
在识别到匹配规则后,可以使用 JavaScript 的 switch 语句执行策略。responseManager具有每个策略的逻辑。确保传递request对象和目标cacheName:
case "cacheFallingBackToNetwork":
return responseManager.cacheFallingBackToNetworkCache(event.request,
cacheName);
break;
我喜欢将缓存回退到网络缓存的响应作为我的默认策略。通过在 case 表达式中堆叠此策略,代码只需包含一次:
case "cacheFallingBackToNetworkCache":
default:
return
responseManager.cacheFallingBackToNetworkCache(event.request,
cacheName)
.then(response => {
invalidationManager.cacheCleanUp(cacheName);
return response;
});
break;
此方法依赖于配置路由及其相应的缓存策略。这与 WorkBox 方法类似。我将在下一章中探讨 Workbox,这是一个帮助您构建复杂服务工作者的 Node 模块。
缓存失效策略
就像有缓存策略一样,还有你可以采用的缓存失效策略来防止缓存失控。PWA 票据应用程序使用最大项策略来控制缓存的响应数量,但你还可以使用其他策略。
唯一哈希名称和长生存期值
使用哈希值在文件名中是一种流行的技术,可以简化具有长生存期值的资产的更新。这是因为基于文件内容的哈希值意味着算法生成了一个相对唯一的价值。
唯一名称为资产创建一个新的 URL,并分配一个新的 Cache-Control 值给资产。这对于样式表、脚本、图像和其他静态资源都适用。
MD5 哈希值是创建这些唯一值最常见的方式。Node.js 有一个内置的crypto模块,具有 MD5 哈希功能:
function getHash(data) {
var md5 = crypto.createHash('md5');
md5.update(data);
return md5.digest('hex');
}
数据参数是文件的内容。对于样式表或脚本,数据是文件中的文本。摘要是一个可以用来命名文件的唯一值:

这种技术效果很好,但需要相当复杂的构建过程来更新所有引用文件的文件名。我不鼓励在本地开发环境中使用此技术,但对于生产环境,这是一个非常强大的缓存破坏技术。只需记住,您需要更新所有 HTML 文件中的引用,以及可能引用这些唯一文件名的服务工作者或其他文件。
我认为这对许多网站来说可能有点复杂,尤其是在没有支持此技术的正式系统的情况下。如果哈希技术对开发者来说是透明的,并且几乎是自动的,那就最好了。
不幸的是,这并不常见。您还可以利用其他技术,这些技术提供了更细粒度的控制,并且可能对您缓存的数据量有更多的控制。以下技术可以在您的服务工作者中使用,以管理响应的缓存时间。
缓存中的最大项数
一个更简单的缓存失效策略是限制持久项的数量。我称之为最大项失效。
这需要服务工作者逻辑来检查特定命名的缓存中保存了多少项。如果缓存已保存最大数量的响应,则在添加新项之前至少会移除一个响应。
这种策略需要多个命名缓存,这些缓存与不同类型的响应相关联。每个命名缓存可以分配不同的项限制,以管理不同类型的响应。您还可以分配不同的缓存失效策略,这将在稍后讨论。
票务应用程序为事件命名了缓存,这些事件在请求时动态渲染。我随意选择了 20 个事件的限制,以便更容易展示策略。它还有一个名为缓存二维码的缓存,限制为五个响应,这同样有些随意。
您需要为您的应用程序和响应类型选择一个合适的值。记住,您的存储配额是所有不同存储介质的组合,并且根据设备和容量而变化。
我通常为文本响应使用更宽松的值,为像图像这样的二进制文件使用较小的值。直到您知道您的客户如何使用您的网站,您可能需要调整此值。
如果您管理像 Amazon.com 这样的网站,您将能够访问数据,这些数据告诉您用户在平均会话中访问了多少个产品。因此,您可能确保可以缓存那么多的产品页面和相关图像。我可能会缓存他们大部分的观看列表产品和购物车中的所有内容。
每个网站和应用都是不同的,在这些网站中,有独特的页面和数据类型需要不同的缓存限制:
maxItems(options) {
self.caches.open(options.cacheName)
.then((cache) => {
cache.keys().then((keys) => {
if (keys.length > options.strategyOptions.max) {
let purge = keys.length -
options.strategyOptions.max;
for (let i = 0; i < purge; i++) {
cache.delete(keys[i]);
}
}
});
});
}
就像没有限制总项数的神奇数字一样,不是所有的缓存都应该通过最大项数来限制。您还应该考虑基于时间来限制。
使用生存时间清除陈旧的响应
下一个缓存失效策略是基于响应可以缓存多长时间。如果您无法访问 Cache-Control 头,确定响应的缓存时间可能会很具挑战性。
好消息是,Cache-Control 头部并不是确定响应缓存生命周期的唯一方式。当响应被添加到命名缓存中时,会添加一个 "date" 值。你可以使用缓存的日期值来为缓存的响应应用超时规则:
let responseDate = new Date(response.headers.get("date")),
currentDate = Date.now();
if(!DateManager.compareDates(currentDate,
DateManager.addSecondsToDate(responseDate, 300))) {
cache.add(request);
}else{
cache.delete(request);
}
当缓存的响应变得过时时,你可以删除它。下次请求该资产时,默认的缓存策略会触发。
执行 ResponseManager
而不是在服务工作者的 fetch 事件处理器中直接编写一个复杂的程序来获取和缓存响应,你可以使用 ResponseManager。因为缓存策略逻辑包含在模块中,你可以传递请求和 cacheName 来执行:
self.addEventListener("fetch", event => {
let cacheName = getCacheName(event.request.url);
event.respondWith(
responseManager.cacheFallingBackToNetworkCache(event.request,
cacheName)
.then(response => {
invalidationManager.cacheCleanUp(cacheName);
return response;
})
);
});
在这个例子中,响应作为承诺链的结果返回。它还执行缓存的 InvalidatationManager.cacheCleanUp 方法,以确保缓存中不包含太多项目或过时项目。
无效化管理器
无效化管理器是一个特殊的模块,用于处理实现最大项目和生存时间无效化策略。脚本使用 importScripts 方法导入到服务工作者中:
invalidationManager = new InvalidationManager([{
"cacheName": preCache,
"invalidationStrategy": "ttl",
"strategyOptions": {
"ttl": 604800 //1 week }
},
{ "cacheName": qrCodesCacheName,
"invalidationStrategy": "maxItems",
"strategyOptions": { "max": 10 }
}]);
此模块有一个名为 cacheCleanup 的方法,它遍历在构造函数中提供的无效化规则集,如前所述。在遍历规则时,它针对命名缓存执行每个策略。无效化规则是在类实例化时通过传递规则数组定义的。
该类可以处理两种无效化策略,即 maxItems 和 ttl(生存时间)。规则数组中的每个项目都是一个对象,定义了命名缓存、应用于缓存的策略和策略的选项。
ttl 的 strategyOptions 是缓存项目可以保持缓存的最大时间框架。在前面的例子中,preCached 项目可以在一周后才会被清除。一旦被清除,就会发起网络请求,更新资产。
maxItems 的 strategyOptions 有一个 max 属性,它定义了命名缓存可以持久存储的最大缓存项目数。在这个例子中,我选择了一个任意低的数字 10 项来帮助说明原理。
strategyOptions 属性是一个对象,尽管现在每个策略只有一个属性。通过使用对象,它允许以后添加更多属性,并为潜在的未来策略提供不同的属性选项:
cacheCleanUp() {
let invMgr = this;
invMgr.invalidationRules.forEach((value) => {
switch (value.invalidationStrategy) {
case "ttl":
invMgr.updateStaleEntries(value);
break;
case "maxItems":
invMgr.maxItems(value);
break;
default:
break;
}
});
}
cacheCleanUp 方法可以在任何时候调用。它总是在创建新的 InvalidationManger 或你的服务工作者首次唤醒时执行。
这可能不足以满足你的应用程序。你可以根据计时器或项目缓存后定期执行此方法。
maxItems 策略
maxItems 策略限制了可以存储在命名缓存中的项目数量。它通过打开命名缓存,然后使用键方法检索请求数组来实现。
程序随后将存储的项目数量(keys.length)与在此缓存中允许的最大项目数进行比较。如果项目数量超过配额,则计算超出配额的项目数量。
然后执行一个for循环来从缓存中删除第一个项目,并重复此操作,直到删除了要清除的项目数量:
maxItems(options) {
self.caches.open(options.cacheName)
.then(cache => {
cache.keys().then(keys => {
if (keys.length > options.strategyOptions.max) {
let purge = keys.length -
options.strategyOptions.max;
for (let i = 0; i < purge; i++) {
cache.delete(keys[i]);
}
}
});
});
}
您可能想知道为什么不能使用数组的pop或slice方法来删除缓存项。这是因为缓存不提供数组接口,因此没有原生数组方法。
相反,您必须创建一个循环或自定义程序来逐个删除缓存项。
存活时间失效策略
与maxItems策略类似,updateStaleEntries策略打开对命名缓存的引用,并获取缓存请求的列表。这次,必须从缓存中检索单个请求。
这需要通过传递一个请求对象(键)来调用缓存的匹配方法。这将返回带有date头的存储响应。这是在项目添加到缓存时添加的,可以用来确定响应是否已过时:
updateStaleEntries(rule) {
self.caches.open(rule.cacheName)
.then(cache => {
cache.keys().then(keys => {
keys.forEach((request, index, array) => {
cache.match(request).then(response => {
let date = new
Date(response.headers.get("date")),
current = new Date(Date.now());
if (!DateManager.compareDates(current,
DateManager.addSecondsToDate(date,
rule.strategyOptions.ttl))) {
cache.delete(request);
}
});
});
});
});
}
在检索到缓存日期后,可以使用DateManager来测试响应是否已过时或已过期。如果项目已过期,则从缓存中删除。
使用实时资产清单
我用来管理服务工作者缓存的一个更复杂的技术是清单文件。这项技术涉及维护一个包含缓存规则的 JSON 对象的文件:
[{
"url": "/",
"strategy": "precache-dependency",
"ttl": 604800
}, {
"url": "/privacy",
"strategy": "genericFallback",
"fallback": "fallback/",
"ttl": 604800
}, {
"url": "/product/*",
"strategy": "cacheFirst",
"ttl": 1800
},
...
]
要利用这项技术,您需要通过将动态请求的 URL 与提供的路由进行测试来处理这些请求。这可以通过正则表达式来完成。
这个过程与之前定义和触发路由和缓存策略的方式非常相似。请求 URL 会与正则表达式进行测试,以确定要使用的缓存策略:
processDynamicRequest(request) {
var routeTest = new RegExp(this.routes),
result = routeTest.exec(request.url);
if (result) {
var match = result.shift(),
index = result.indexOf(match);
//return request handler. Should be a promise.
return strategyManager[this.strategies[index - 1]](request);
} else {
//default to pass-through
return fetch(request);
}
}
当服务工作者首次实例化时,清单文件会动态加载。我管理清单更新的方式是通过在indexedDB中持久化一系列值:
canUpdateCacheManifest() {
let cm = this,
now = new Date();
//retrieve persisted list of precached URLs
return cm.idbkv.get(this.CACHE_UPDATE_TTL_KEY).then(ret => {
if (!ret) {
return true;
}
return cm.dateMgr.compareDates(ret, Date.now());
});
}
我选择手动控制清单文件的存活时间是为了避免与浏览器缓存相关的潜在问题。您必须设置清单的缓存头,不允许浏览器或代理服务器缓存资源。
我将默认的存活时间设置为 24 小时,就像内置的服务工作者存活时间一样。这可以防止服务工作者频繁加载清单,但又不至于太长,以至于可能与服务端不同步。
票务应用不使用这项技术,但我确实包含了一个示例清单文件和一些可能需要的附加支持模块。我将这项技术保留用于更复杂的应用,并且它确实需要一些维护工作。具体来说,您需要一个工具来保持清单文件更新,我认为这超出了本书的范围。
应该缓存多少?
我之前提到,你需要确定应用程序的数据应该如何缓存。数据类型及其使用方式应作为指导方针,以了解如何在服务工作者中管理。
我还希望你考虑你的用户。并不是每个人都能访问到无限的高速连接或大硬盘。许多智能手机仍然只配备了 8 GB 的存储空间,在操作系统和所有消费者记录的照片和视频之后,几乎没有剩余空间。
只因为你能够缓存整个应用程序,包括图片和数据,并不意味着你应该这样做。尼古拉斯·霍伊兹(nicolas-hoizey.com/2017/01/how-much-data-should-my-service-worker-put-upfront-in-the-offline-cache.html)展示了这种策略可能会对你的应用程序产生负面影响。
你可能想要考虑添加一个用户可以配置应用程序以控制资源持久化的体验。就像我们在推送通知管理中看到的那样,你可能希望添加用户确定缓存多少事件或票据(在示例应用程序中)以及票据二维码可用的时长:

你仍然可以使用本章中涵盖的辅助模块。缓存和失效策略不会改变,只是它们执行操作时使用的设置。
摘要
这是一个非常长的章节,涉及了一些高级概念。你学习了如何根据请求 URL 触发不同的缓存策略,模块化服务工作者逻辑,并采用缓存失效策略。此外,你还可以玩一个新的渐进式 Web 应用程序!
本章涵盖了大量的有用信息和源代码,但这些示例仅应作为你应用程序的基础参考。你不仅应该成为分配给你的应用程序存储的良好管理者,还应该关注用户的数据计划。
在下一章中,我们将继续探讨如何使用服务工作者和缓存来提高应用程序的性能。你还将看到如何使用可用的工具来评估和诊断性能问题,以便你可以制作出更好的应用程序。
第九章:优化性能
性能是用户体验的关键方面之一。事实上,许多专家认为网站性能创造了良好的用户体验。在提供在线体验时,你应该考虑网站性能的不同方面,例如:
-
首次字节时间(TTFB)和服务器端延迟
-
渲染过程
-
交互
渐进式 Web 应用(PWA)的一个主要属性是速度。这是因为人们喜欢页面快速加载,并且对动作或输入的响应更快。制作快速、交互式的网站既是艺术也是科学。
在深入制作快速 PWA 之前,我想定义本章旨在帮助解决的问题:
-
目标:定义关键性能指标以衡量性能
-
指南:定义实现这些目标的方法
-
演示:将这些指南应用于 PWA 票务应用,以便你有一个参考代码和工作流程
本章将深入探讨浏览器如何加载和渲染页面。你还将了解 TCP 的工作细节以及如何利用这些知识创建 1 秒内就能加载完成的页面。
你将学习不同的中级和高级网站性能优化(WPO)技术以及它们如何与 PWA 设计相关联。这些技术被编织进 PWA 票务应用的天然结构中。随着本章的发展,你将学习部分依赖于自动化构建脚本的技巧。这种自动化将延续到下一章,我将回顾不同的工具来帮助你构建 PWA。
WPO 的重要性
DoubleClick 和其他网站已经表明,你只有 3 秒钟的时间。如果感知到的页面在 3 秒内没有加载完成,53%的移动访客会放弃该页面(www.doubleclickbygoogle.com/articles/mobile-speed-matters/)。此外,DoubleClick 报告称,在 5 秒内加载完成的网站,会享受 70%更长的会话时间,35%更低的跳出率,以及 25%更高的广告可见度。
这些例子只是众多案例研究和报告中的一小部分,展示了页面加载和交互对于一个网站成功的重要性。你可以在wpostats.com找到更多统计数据。这也是为什么谷歌不断强调网站性能作为一个关键排名信号的原因之一。
人类心理学是性能的一个重要方面。我们知道大脑如何感知性能,并且可以将这些科学原理与我们网页相关联:
-
0 到 16 毫秒:只要每秒渲染 60 个新帧或每帧 16 毫秒,用户就会将动画感知为平滑。考虑到浏览器开销,这留下了大约 10 毫秒的时间来生成一个帧。
-
0 到 100 毫秒:用户感觉对动作的响应是即时的。
-
100 到 300 毫秒:轻微可感知的延迟。
-
300 到 1000 ms:感觉像是任务自然和连续进展的一部分。
-
>= 1000 ms:用户会失去对任务的注意力。
将这些数字记在心里,因为它们是这个章节的主要基准。本章的目标是将 PWA 票务应用程序修改为在平均 3G 连接下 1 秒内加载完成。
快速加载给你带来竞争优势,因为平均网页在移动设备上加载需要 15 秒(www.thinkwithgoogle.com/marketing-resources/data-measurement/mobile-page-speed-new-industry-benchmarks/),比 2017 年的标准快了 7 秒。这不仅提高了用户的参与度,还改善了当网站性能优化时通常报告的统计数据。这样的网站享有更高的搜索排名。
大多数网络流量来自移动设备。一些企业发现高达 95%的流量来自智能手机。这就是为什么谷歌将他们的主要搜索索引从桌面切换到移动,截止到 2018 年 6 月。
谷歌搜索团队多次表示,速度是一个关键排名因素。他们知道我们想要快速网站,而快速网站提供更好的用户体验,这会让客户满意。他们的目标是提供最佳资源来回答用户的问题,这意味着你需要提供一个快速体验。
最令人不安的统计数据之一是网页大小的增长。到 2015 年,平均网页超过了 2.5 MB 的标记,这比游戏 DOOM 的原始安装盘还要大。因此,网站性能受到了影响。
谷歌的研究发现,关于平均网页大小的以下令人不安的统计数据:
-
79% > 1 MB
-
53% > 2 MB
-
23% > 4 MB
这很重要,因为在良好的 3G 连接下,仅下载 1 兆字节就需要大约 5 秒。这还不是开销的终点,所有资源仍需要被处理,页面也需要被渲染。
如果你考虑谷歌报告的数字,这意味着 79%的网页甚至在初始请求后 5 秒才开始渲染周期!到那时,用户已经跳出的概率是 90%。
减少图片负载大小
许多人将图片视为根本原因,并在一定程度上是如此,但图片并不会阻止渲染。图片可以也应该被优化,这会减少整个页面的尺寸。
优化图像文件大小可以将整体负载大小平均减少 25%。如果页面是 1 MB,那么 25%等于 250 KB 的负载减少。
应该也使用响应式图片。这是你使用srcset图片和尺寸属性,或者使用picture元素来引用适合显示的图片的地方:
<img srcset="img/pwa-tickets-logo-1158x559.png 1158w,
img/pwa-tickets-logo-700x338.png 700w,
img/pwa-tickets-logo-570x276.png 570w,
img/pwa-tickets-logo-533x258.png 533w,
img/pwa-tickets-logo-460x223.png 460w,
img/pwa-tickets-logo-320x155.png 320w"
src="img/pwa-tickets-logo-1158x559.png"
sizes="(max-width: 480px) 40vw,
(max-width: 720px) 20vw, 10vw"
alt="pwa-tickets-logo">
客户端设备视口差异很大。与其试图在每台设备上都非常精确,我建议关注四个视口类别:手机、迷你平板、平板和桌面。我将借用 Twitter bootstrap 项目中的断点,这些断点对应于这些视口。
任何超过不同视口宽度阈值的图像都应该是一个图像数组,在较窄的宽度上保持其宽高比。
CSS 和 JavaScript 的成本
延迟页面加载时间的真正罪魁祸首是 CSS 和 JavaScript 的过度使用。两者都是渲染阻塞的,这意味着当它们被处理时,其他事情无法发生。
记住从服务工作者章节中学到的,浏览器使用单个线程来管理所有渲染任务,包括处理 CSS 和 JavaScript。当这个线程在进行处理时,无法进行任何渲染。
开发人员往往对 CSS 和 JavaScript 对其页面加载的影响一无所知。这通常归结为忘记真实用户使用什么设备加载网页,即手机。
开发人员通常在高性能工作站和笔记本电脑上工作。他们在这些设备上开发时也会加载他们的工作。因此,他们认为他们的页面是瞬间加载的。
这种差异是由于没有网络延迟、高速处理器和充足的内存。在现实世界中并非如此。
大多数消费者使用更便宜的手机,通常是一部 200 美元的手机,而不是一部 1000 美元的 iPhone 或工作站。这意味着功率较低、网络条件受限的设备加载和渲染页面不如桌面快。
为了加剧这些限制,当移动设备使用电池电量时,它们通常会降低处理器速度,甚至关闭核心以减少功耗。这意味着处理 JavaScript 和 CSS 所需的时间会更长。
数份报告已经证明了 JavaScript 对页面加载效果的影响有多大。Addy Osmani发布了一项权威研究(medium.com/dev-channel/the-cost-of-javascript-84009f51e99e),展示了 JavaScript 是如何阻碍页面加载的。
一个常见的误解是主要性能影响是加载脚本或样式表通过网络。这确实有一定的影响,但更大的影响是在文件加载之后。这是浏览器必须在内存中加载脚本、解析脚本、评估然后执行脚本的地方。
“字节对字节,JavaScript 对于浏览器来说比同等大小的图像或 Web 字体更昂贵”
— 汤姆·戴尔
如果你使用浏览器分析工具,你可以通过出现黄色或金色红色来识别 JavaScript 的这个阶段。Chrome 团队称这为一只巨大的黄色蛞蝓:

近年来,单页应用程序(SPAs)变得非常流行。这导致了大型框架的出现,这些框架抽象了原生 API,并为开发者和团队提供了一个可以遵循的架构。
您应该确定 SPA 是否是满足您需求的正确解决方案。SPA 如此受欢迎的主要原因是可以实现无缝的页面转换,类似于原生应用体验。
如果您有服务工作者并利用服务工作者缓存,您可以实现 SPA 提供的所需即时加载和流畅的页面转换。作为额外的好处,您不需要加载那么多客户端 JavaScript。
您也可以学习原生 API 而不是框架抽象,例如 jQuery。例如,document.querySelector 和 document.querySelectorAll 返回对 DOM 元素的引用,比 jQuery 快得多。
我利用的其他原生 API,包括替换了我大部分使用 jQuery 的部分:
-
addEventListener -
classList -
setAttribute和getAttribute
我已经尝试提供一些简单的架构,您可以根据 Podstr 和 PWA 票据应用程序来遵循。不是 SPA 的好处是您可以减少运行页面所需的 JavaScript 数量。
PWA 票据应用程序几乎不依赖 JavaScript。localForage 和 Mustache 账户用于大部分 JavaScript。应用程序和单个页面几乎不依赖脚本。在应用 gzip 压缩后,典型页面所需的 JavaScript 小于 14 KB。
正确的测试设备和仿真
我确实建议拥有现实测试设备。这并不意味着购买 iPhone X、Pixel 2 或三星 9。您应该有一部平均的手机,这在不同地区可能意味着不同的事情。一个基本建议是:
-
北美和欧洲:摩托罗拉 G4:
- 常规 3G 在 Devtools 网络限速中
-
印度和印尼:小米 Redmi 3s
- 良好 2G 在 Devtools 网络限速中
通常情况下,对于网络受限的设备,那些拥有缓慢和差的蜂窝连接的设备,负载很重要。功耗、CPU 和内存受限的设备在解析和评估脚本和样式表时会有更多问题。
当低功耗设备使用受限网络时,这种情况会加剧。这就是为什么您应该将网站架构得好像所有用户都有这种糟糕的组合一样。当您这样做时,您的网站将始终快速加载。
幸运的是,我们有众多工具可用于测量我们页面性能配置文件并改进它们。不同的浏览器开发者工具提供了设备仿真,可以提供对网络和设备限制的合理模拟。WebPageTest 是一个免费在线资源,可以测试真实设备和条件。
需要关注的重点领域包括服务器端因素以及您的页面如何在浏览器中加载。最重要的目标是使您的页面尽可能快地渲染并快速响应。
在本章中,我们将探讨不同的关键性能指标以及您如何应用技术和模式来确保您提供出色的用户体验。我们还将探讨这些技术与渐进式 Web 应用的关系。
使用开发者工具测试较差的条件
关于测试较差的连接和普通用户设备的喜讯是,这可以被模拟。Chrome、Edge 和 Firefox 的开发者工具都包括一些模拟较慢连接甚至低功耗设备的容量。
Chrome 拥有最完善的条件测试工具。在开发者工具中,您需要切换设备工具栏。快捷键是Ctrl + Shift + M,按钮位于左上角。它看起来像一部手机覆盖在平板上:

这会将浏览器标签页改为在框架中渲染页面。框架模拟目标设备的视口。它还在内容框架上方渲染设备工具栏:

设备工具栏由不同的下拉菜单组成,允许您配置想要模拟的设备和连接场景。最左侧的下拉菜单是预配置设备的列表。它包含了一些更受欢迎的设备,并且可以进行自定义。
当您选择一个设备时,宽度和高度值会调整以匹配设备的视口。我喜欢这一点,因为它让我可以接近真实设备,而无需使用真实设备。
我确实建议您准备几部真机来测试您的网站,但这会很快变得昂贵。我个人有几部安卓手机,一部高端的,一部低端的,还有一部 iPhone。目前,我有一部 iPhone 6。我建议购买翻新的硬件或便宜的预付费手机,这些手机在大多数零售商那里大约售价 50 美元。
Chrome 设备模拟器对真实设备的近似足够好,使我能够完成我的响应式设计工作。您应该注意,您仍在使用桌面 Chrome,这并不完全是 Android Chrome,当然也不是 iOS Safari。
设备模拟器还配置了几个流行的安卓平板和 iPad。此外,您还可以创建自己的视口。
您还可以调整缩放。如果内容对于您来说太小,难以微调,这可能会很有帮助:

最后一个选项是带宽。这是最右侧的下拉菜单。它包括模拟离线、中间层和低层连接的选项。他们尽量避免用常见的蜂窝连接来标记这些速度,因为这会使它们面临不精确匹配的问题。
3G、4G 和 LTE 的速度因地区而异,即使在同一国家也是如此。用蜂窝速度来标记这些速度可能会非常误导。
由于绝大多数开发都是在功能强大的本地主机网站上进行的,开发者往往忘记他们的页面是在手机的蜂窝连接上加载的。这导致我们假设我们的页面比实际要快得多。相反,你应该始终尽可能真实地体验你的网站。
我鼓励开发者不要使用 JavaScript 框架的一个主要原因是,在发布前几天体验了 3G 上的移动优先应用程序。每个页面加载大约需要 30 秒。我发现不仅 3G 连接差是一个问题,而且 JavaScript 的数量是瓶颈。
如果我没有在我的 3G 手机上开始使用我们的应用程序,我就不会知道用户体验有多差。当时,浏览器开发者工具没有这些模拟功能,这使得真实设备变得必不可少。所以,感谢这些工具的存在,它们可以为你节省数小时的时间来重新设计你的网站。
我利用这些模拟功能来开发我的网站,特别是对于响应式设计工作。速度模拟帮助我感受到客户可能遇到的问题,这使我能够对他们有更多的同理心。
使用 Lighthouse 进行性能和 PWA 测试
Chrome 包含一个强大的工具来测试你的网站性能以及是否符合渐进式 Web 应用标准。这个工具被称为 Lighthouse。该工具集成到开发者工具的“审计”标签页中,并且可以作为节点模块和命令行实用程序使用:

我将在这里重点介绍如何在开发者工具中使用 Lighthouse,并在下一章中介绍命令行使用。
要执行审计,请按“执行审计...”按钮,如前一个屏幕截图所示。然后你会看到一个对话框,提供了高级配置选项。
Lighthouse 审计有五个区域:
-
性能
-
PWAs
-
最佳实践
-
无障碍性
-
SEO
你可以在所有这些区域运行测试,或者只执行选定区域的测试。Lighthouse 会运行审计并生成一个评分卡和报告:

报告突出了你可以改进页面的具体区域。在先前的屏幕截图中,我只运行了性能审计,你可以看到一些需要改进的具体区域,包括感知速度指数。我还让工具在页面加载时截图,这样你可以看到页面随时间如何渲染。
开发者工具使得在单个页面上运行 Lighthouse 审计变得容易。你可以从任何 Chrome 实例运行它们,但我建议打开一个隐身实例。当你这样做时,你会在一个干净的浏览器中加载页面,没有任何缓存、cookies 或扩展程序。
由于 Chrome 扩展程序与浏览器标签页在同一个进程中运行,它们经常会干扰页面和工具,例如 Lighthouse。我发现一个通常加载速度快且评分高的页面,在包含扩展程序时会受到影响。它们会延迟页面加载,并且通常在页面完成加载后才执行。
完成一次全面审计需要 30-90 秒。这取决于正在执行多少测试以及你网站的响应时间。
运行的性能审计电池非常彻底,不仅涵盖了桌面和高速连接,还使用模拟在 3G 连接上模拟低功耗手机。正是这些条件暴露了你的弱点。
你可以使用报告来定位需要纠正的具体区域,其中许多在本章中都有讨论。
每个测试都有在线文档来解释测试的内容以及你可以采取的行动:developers.google.com/web/tools/Lighthouse/audits/consistently-interactive。
由于 PWA 票务应用程序相当优化,没有太多需要解决的问题。这个测试是在用户认证后的主页上运行的。唯一一个导致延迟的区域是感知速度指数。
这衡量了页面内容加载所需的时间。在这个例子中,我们得到了 47 分,这非常低。这是因为 UI 线程在调用 API 和渲染即将发生的事件和用户的票证时无响应。
我们可以通过将 API 调用和渲染传递给服务工作者或甚至网络工作者来提高这个分数。这将把工作从 UI 线程移到后台线程。这将需要对页面和网站架构进行调整。
另一个建议是使用下一代图像格式,如 WebP 和 JPEG 2000。虽然这些图像格式更高效,但它们并不被广泛支持。这部分是由于它们还很年轻,部分是由于不同用户代理的许可问题。因此,目前我倾向于忽略这些建议,并抱有希望这些格式将在不久的将来得到普遍支持。
你可以使用复杂的解决方案,例如使用PICTURE元素,但我发现这需要比回报所值得的更多管理和责任。
在最近的 Google I/O 上宣布,Lighthouse 版本 3 将很快推出。他们预览了更新的 UI 和一些新的测试。你可以在 Google 开发者网站上了解更多关于这些公告的信息:developers.google.com/web/tools/Lighthouse/v3/scoring。
作为一句忠告,Lighthouse 是一个有偏见的工具。这意味着它会寻找谷歌和 Chrome 团队认为重要的东西。它不是一个你可以根据特定要求添加自定义测试或规则的测试运行工具。
在 2017 年微软 Edge Web 峰会(Microsoft Edge Web Summit)上,他们宣布了一个名为 Sonar(sonarwhal.com)的类似工具。它也是一个节点模块和命令行工具,可以对提供的 URL 进行测试。与 Lighthouse 的不同之处在于可以按需扩展测试套件。你不仅可以添加公开可用的测试或规则,还可以编写自己的。
Sonar 可以执行并确实使用了与 Lighthouse 相同的测试套件,但它允许你添加更多。在撰写本书时,它不像 Lighthouse 那样在 Edge 开发者工具中可用。他们确实提供了一个在线实例,你可以测试公共 URL,当然也可以作为测试套件的一部分本地运行它:
你应该将 Lighthouse 和 Sonar 纳入你的常规开发者工作流程。你可以快速发现不仅性能问题,还包括缺失的渐进式 Web 应用程序要求、最佳实践、基本 SEO 问题和糟糕的服务器配置。
使用 WebPageTest 进行性能基准测试
WebPageTest (webpagetest.org/) 是一个免费的工具,你可以用它来获取网页性能细节。它的工作方式与开发者工具类似,但增加了更多的价值:

它不仅提供了详细的水落石出,还提供了来自不同位置、不同速度和设备的测试。有时,它揭示的真相可能难以接受,但它为你提供了目标区域,以便你可以提高性能:

要进行测试,请访问 webpagetest.org。如果你的网站有一个公共 URL,请在表单中输入它,并选择一个位置、设备/浏览器以及要测试的速度。一旦提交请求,你的网站将使用真实硬件进行评估。一两分钟后,假设它没有被添加到待处理队列中,你将收到一份报告:

就像浏览器开发者工具提供了网络瀑布图一样,WebPageTest 也可以,但提供了更多细节:

我总是检查的一个关键指标是速度指数。这是由 WebPageTest 背后的思想者帕特里克·米南(Patrick Meenan)创建的性能指标,它衡量页面在加载时间内的视觉完整性:

它衡量与最终渲染相比可见的空白区域有多少。目标是尽量减少到完整渲染的时间。速度指数是衡量到第一次交互或感知渲染的时间的一种方法。
一个值得追求的数字是 1,000 或更少。这表明渲染页面花费了 1 秒或更少的时间。为了参考,我评估的大多数页面得分都超过 10,000,这意味着至少需要 10 秒来渲染。这些较差的分数是在宽带连接上,所以对于蜂窝连接来说,这个值要差得多:

可以使用许多高级功能和设置来执行 WebPageTest,包括自定义脚本。您甚至可以在本地或 Amazon AWS 上建立自己的虚拟机。当您有一个隐藏在防火墙后面的企业应用程序时,这非常有用。
关键性能指标
创建快速网站或改进现有网站的第一步是使用工具来衡量您的性能,并了解您需要测量什么。您应该创建一个性能基线,并逐步改进以提升您的性能概况。
在本节中,我将回顾您应该测量的不同指标,为什么需要跟踪它们,以及如何改进它们。
首次字节时间
获取第一个响应字节的所需时间是首次字节时间。这一刻标志着响应下载的开始。最重要的网络请求是文档请求。一旦下载完成,其余的网络资源(如图片、脚本、样式表等)就可以下载。
您可以将首次字节时间过程分解为不同的步骤:
-
浏览器和服务器之间建立连接所需的时间
-
在服务器上检索和可能渲染文件所需的时间
-
将字节发送到浏览器所需的时间
测量首次字节时间最简单的方法是通过按F12或Ctrl + Shift + I打开浏览器开发者工具。每个浏览器的开发者工具都有一个网络标签。在这里,您可以查看页面的水落图。您可能需要刷新页面以生成报告。
我建议执行带前置符号和无前置符号的请求。区别在于以这是您第一次访问网站的方式加载页面,这被称为无前置符号。在这种情况下,浏览器缓存中没有持久化任何内容。您还应该清除或绕过您的服务工作者。
您可以通过进行硬重载来触发无前置符号请求,即Ctrl + F5。
如果您查看以下水落图示例,您会注意到第一个请求,即文档或 HTML 的请求,首先完成。然后浏览器解析标记,识别需要加载的附加资源。这就是那些请求开始的时候。
您应该能够在所有页面上注意到这种模式,即使资产是本地缓存的。这就是为什么初始文档请求和辅助资源之间存在轻微的时间间隔:

带有前置符号的请求假设您之前已经访问过该网站或页面,并且浏览器和可能的服务工作者缓存包含有效的响应。这意味着这些请求是在本地进行的,没有网络活动。理论上,由于缓存的存在,页面应该加载得更快。
水落图由组成页面的每个文件请求组成。您应该能够选择单个请求(在水落图中双击请求)以查看每个步骤花费了多少时间:

Chrome、Firefox 和 Edge 允许你可视化首次字节时间。每个浏览器都有一个计时面板,它将请求的不同部分和时间分配分解开来。它进一步细化这些部分,显示执行 DNS 解析、建立与服务器连接以及服务器发送字节到浏览器所需的时间。
在发起网络请求之前,它会被添加到浏览器队列中。这个队列是浏览器需要发起的请求集合。每个浏览器都决定如何处理这个队列,这取决于可用资源、HTTP/2 与 HTTP/1 支持等因素。
接下来,如果需要,浏览器将触发 DNS 解析。如果设备有缓存域名解析或 IP 地址,则跳过此步骤。你可以通过使用dns-prefetch来加快这一步骤,我将在稍后进行介绍。
浏览器随后发起网络请求。此时,服务器负责发送响应。如果服务器存在任何瓶颈,你应该解决这些问题。
不要忘记 TLS 协商。HTTPS 会有轻微的性能损失,但使用 HTTP/2 时,这种损失通常会被 HTTP/2 提供的额外性能提升所抵消。
你可以通过优化服务器配置来减少首次字节时间。你应该寻找机会通过在内存中缓存响应来减少磁盘 I/O。在 ASP.NET 中,这是通过实现输出缓存来完成的。其他 Web 平台提供类似的功能。
数据库查询是另一个常见的瓶颈。如果你可以消除它们,你应该。评估页面数据,找出可以提前检索的数据。我喜欢创建 JSON 数据文件或在内存中的对象来避免这些昂贵的查询。
这正是 NoSQL、文档数据库如 MongoDB 和 ElasticSearch,以及云服务如 DynamoDB 越来越受欢迎的主要原因。这些数据库设计为预先选择和格式化数据,以便按需使用。这些解决方案帮助 Twitter、Facebook 等热门在线网站快速成长和扩展。
另一种策略是尽可能避免按需渲染。大多数网站都是由 ASP.NET、PHP、Ruby、Node 等服务器进程渲染的。这些都增加了请求过程的负担。通过尽可能预先渲染标记,你减少了这些进程减慢响应的机会。
我尽量在可能的情况下使用静态网站解决方案,因为它们提供最快的响应管道。静态网站的优势在于运行时渲染,因为渲染周期被移除了。你可以创建自己的引擎来预渲染内容,或者使用 Varnish 等工具来管理任务。你不必放弃现有的处理器,而是添加一个静态引擎在顶部来维护静态文件,以便你的页面加载更快。
剩下的唯一摩擦点是网络的速率。不幸的是,这些通常超出了你的控制。路由器、代理和蜂窝塔都可能引起问题。
在这一点上,响应字节开始流入浏览器进行处理。文件越大,通常延迟越长。
PRPL 模式
我们已经研究了首次字节时间和运行时性能问题。确保你的网站表现最佳的最佳方式是实施架构最佳实践。PRPL 模式是为了帮助现代 Web 应用程序实现最佳性能值而创建的。
Google Polymer 团队开发了 PRPL 作为遵循的指南,以帮助网站性能更好。应将其视为可以实现的架构,但它不仅仅是关于技术细节。引用 PRPL 文档:
“PRPL 更多地关乎一种心态和改善移动网络性能的长期愿景,而不是关于特定的技术或技术。”
PRPL 回归到将性能作为任何网站的一等特性的原则。
PRPL 代表:
-
推送初始 URL 路由的关键资源使用
<link preload>和 HTTP/2 -
渲染初始路由
-
预缓存剩余路由
-
按需懒加载并创建剩余路由
尽管 PRPL 是为现代单页应用程序而设计的,但渐进式 Web 应用程序可以从遵循 PRPL 模式中受益。服务工作者是实现 PRPL 模式的有价值工具,因为你可以利用缓存 API 来实现该模式。你只需要调整如何应用不同的原则来提高你应用程序的性能。
PRPL 模式的主要目标是:
-
最短的可交互时间:
-
尤其是在首次使用时(无论入口点)
-
尤其是在现实世界的移动设备上
-
-
最大的缓存效率,尤其是在更新发布后的时间上
-
开发和部署的简单性
使用浏览器提示和服务工作者缓存实现推送
推送的第一个概念依赖于实现 HTTP/2 服务器端推送。我发现这很难配置,因为大多数服务器还没有实现 HTTP/2 推送。
这就是服务工作者可以提供一种我认为更好的解决方案的地方。我们研究了如何实现预缓存,这是使用 HTTP/2 推送的绝佳替代方案。通过使用预缓存,你实际上是在需要之前将这些关键资产推送到浏览器。
记住,你预缓存的资源应该是关键和常见的应用程序资产。这些资产应该反映你可能想要配置 HTTP/2 推送发送的内容。
将服务工作者缓存与预加载资源提示结合可以重现 HTTP/2 推送的大部分功能。浏览器使用预加载提示在代码中遇到资源请求之前初始化资源请求。当与预缓存资源一起使用时,加载过程非常快。
表面上看,像预加载这样的资源提示可能看起来没有提供太多优势。但随着页面组成的复杂化,这些提示可以显著提高页面加载和渲染时间。
浏览器不会在从 HTML 解析或从脚本或样式表发起之前启动请求。
自定义字体文件是一个完美的例子。它们的下载不会开始,直到浏览器解析样式表并找到字体引用。如果文件作为预加载资源提示包含在内,浏览器已经加载或至少开始请求,使文件更快可用:
<link rel="preload" href="css/webfonts/fa-brands-400.eot "
as="font">
...
<link rel="preload" href="js/libs/utils.js" as="script">
<link rel="preload" href="js/libs/localforage.min.js" as="script">
<link rel="preload" href="js/libs/mustache.min.js" as="script">
<link rel="preload" href="js/app/events.js" as="script">
<link rel="preload" href="js/app/tickets.js" as="script">
<link rel="preload" href="js/app/user.js" as="script">
<link rel="preload" href="js/app/app.js" as="script">
指定资源内容类型允许浏览器:
-
优先加载资源
-
匹配未来的请求并重用相同的资源
-
应用资源的内容安全策略
-
设置资源的正确 Accept 请求头
你还可以添加资源 MIME 类型。当你这样做时,浏览器可以在尝试下载文件之前确定它是否支持该资源类型:
<link rel="preload" href="js/app/app.js" as="script" type="application/javascript">
理念是在 DOM 解析触发请求之前使页面或应用程序的资产可用。由于这些资产在服务工作者缓存中可用,它们已经存储在本地,可以立即加载。
你可以在使用 preload 提示的水墨图中注意到差异。你还记得我指出的初始标记加载和早期瀑布中的资产之间轻微的时间间隔吗?
如果你查看以下瀑布图,你会注意到依赖项是在标记加载完成之前启动的:

这是因为浏览器将这些资源与预加载提示相关联。它会在解析完标记后立即开始下载资源,而不是在完整文档解析完毕后。
你还应该注意这些资源的加载速度有多快。这是由于它们使用服务工作者缓存进行缓存。这消除了网络瓶颈,在某些情况下意味着文件甚至在标记完全解析之前就已经加载。
这只是轻微的页面加载优势,并不是重大的改进。每一丝帮助都很重要,因为毫秒很快就会累积起来。
使用应用外壳模型和服务工作者渲染初始路由
由于 PRPL 模式是从单页应用(SPA)的角度设计的,所以它的语言与该架构相关。但正如你在前面的章节中看到的,应用外壳对渐进式网络应用很有用。
即使你没有缓存页面,你也应该至少缓存你的应用程序的标记外壳。这可以作为你的初始渲染,使用户有响应感。同时,你可以从网络上检索任何资产以完成页面。
PWA 票务应用使用服务工作者通过 Mustache 模板和从 API 获取的 JSON 数据来渲染页面。这是如何将应用外壳作为对请求的有效响应返回,并在内容可用时更新内容的示例。
我的规则是提供我此刻拥有的所有内容,并在有更多内容提供时填补空白。这可能包括提供应用程序外壳并在之后替换它,或者一旦可用就注入页面特定的标记。
服务工作者预先缓存重要路由
在本书的这个阶段,应该很明显,一个好的服务工作者预先缓存策略如何适用于 PRPL 预先缓存点,但回顾这个概念永远不会有害。
PRPL 中的第二个 P 代表预先缓存常见路由。这包括 HTML 及其支持文件,如样式、图像和脚本。这正是您服务工作者预先缓存策略应该设计的方式。
重要资源通常是常访问的页面,但也可以是服务工作者中渲染页面所需的标记模板。常见的样式、脚本、图像和其他支持资源应该预先缓存。
懒加载非关键和动态路由
并非每个网站上的每个页面都可以或应该预先缓存。正如您在第五章“服务工作者生命周期”中看到的,您还应该有缓存失效逻辑,以确保您提供最新内容。
动态内容,如可用的票务事件或甚至更新的播客剧集列表,长期缓存是困难的。但您可以为仅等待下载页面所有资源提供更好的体验。
这就是采用一个或多个常见缓存策略有帮助的地方。您还可以将您的渲染策略与应用程序外壳概念结合起来,在资源加载或更新时构建页面。
您还可以根据需要预先缓存和更新常见的支持资源。这是网络低估的力量之一,动态更新渲染的内容及其渲染方式。您不仅可以更新页面中的标记,还可以动态更改样式表和脚本。
正如您也已经学到的,您可以在服务工作者中缓存资源而不会影响 UI 线程。这可以用来预先缓存非关键资源并更新之前缓存的资源。
正如您所看到的,服务工作者缓存使得实现 PRPL 模式非常自然。它缓存资源的能力使得所有四个 PRPL 原则都很容易实现。如果您已经遵循了前几章中的示例和指南,那么您已经看到了如何设计符合 PRPL 的渐进式网络应用程序。
我认为 PRPL 的首要原则是尽可能在客户端缓存您应用程序的资源。这使得网络变得可用,而不是潜在的延迟和不确定性的来源。这正是服务工作者缓存设计的目的:使您的资源接近用户的玻璃。
RAIL 模式
RAIL 模式是 Google Chrome 团队用来定义您应该尝试遵循的许多 WPO 模式之一的缩写。其目标是确保您的用户体验是响应式的:
-
响应:任何输入时响应的速度
-
动画:包括视觉动画、滚动和拖动
-
空闲:后台工作
-
加载:页面达到首次有意义的绘制所需的速度
在 PRPL 模式关注资源加载时,RAIL 关注的是运行时用户体验或资源加载后会发生什么。
该模式旨在以用户为中心,首先关注性能。构成该缩写的四个方面是 Web 应用程序和页面生命周期的不同区域,或者一旦字节加载后会发生什么。
考虑到性能重要性的不同区域:加载、渲染和对动作的响应。不仅仅是页面加载阶段。页面加载是浏览器加载资源有多快,但许多人忘记了它仍然需要处理这些资源并渲染内容。然后,一旦渲染,你可以响应用户交互。
你还需要考虑页面响应点击或触摸的速度有多快。滚动是否平滑?通知是否及时?任何后台活动是否高效?
对普通用户来说,第一个关键因素是页面变得可交互所需的时间,而不是下载文件所需的时间。
在最近的 Google I/O 大会上,团队宣布了 Lighthouse 将报告的新指标,特别是首次内容绘制(FCP)。这是浏览器渲染新页面 DOM 的第一个像素的点。测量从导航开始,到第一个像素渲染的点。
这之所以是关键性能指标,是因为这是用户第一次视觉提示,表明他们的请求操作或导航正在被处理。我喜欢将其翻译为说,这是用户知道页面正在到来,并没有迷失在虚空中,导致他们尝试重新加载页面或放弃。
FCP 可以从 Paint Timing API([w3c.github.io/paint-timing/](https://w3c.github.io/paint-timing/))获得,这是现代浏览器中可用的现代性能 API 之一。
你应该关注的下一个关键绩效指标是交互时间(TTI)。这是页面完全渲染并能够响应用户输入的点。通常,尽管页面看起来已经渲染,但由于后台处理,它无法响应用户。
例如,页面仍在处理 JavaScript,这会锁定 UI 线程,导致页面无法滚动。
RAIL 关注用户;目标不是在特定设备上使网站性能尽可能快,而是让用户感到满意。任何用户与你的内容互动时,你应该在 100 毫秒内给出响应。任何类型的动画或滚动也应该在 10 毫秒内响应。
由于现代网页往往需要进行大量的后台处理,你应该最大化空闲时间来执行这些任务,而不是阻塞交互和渲染。
如果您需要执行任何非 UI 处理,例如数据转换、服务工作者或 Web 工作者提供了一条通道,让您可以将这些过程卸载到后台线程。这使 UI 线程能够专注于 UI 任务,如布局和绘制。这也使 UI 能够立即响应用户交互。
专注于在一秒内交付交互式内容。在蜂窝网络和平均移动设备上实现这一点可能非常困难,但并非不可能。
正如我之前提到的,服务器加载时间并不是您网络性能配置的主要部分,而是创建更大瓶颈的资源处理。这是因为脚本和样式表阻塞了关键渲染路径,导致您的页面部分渲染或看起来像是挂起了。
如果您从未听说过关键渲染路径,它是浏览器用来构建和渲染页面的工作流程:

这些是主要步骤:
-
文档对象模型(DOM)
-
CSS 对象模型(CSSOM)
-
渲染树
-
布局
-
绘制
要构建 DOM,浏览器必须完成以下子步骤:
-
将字节转换为字符
-
识别标记
-
将标记转换为节点
-
构建 DOM 树
与构建 DOM 类似,浏览器遵循一系列类似的步骤来构建 CSSOM 或处理样式:
-
将字节转换为字符
-
识别标记
-
将标记转换为节点
-
构建 CSSOM
对于 CSS 来说,重要的结论就像 JavaScript 一样:它是渲染阻塞的。浏览器必须在渲染之前处理页面的样式。多个大型 CSS 文件会导致 CSSOM 过程重复。样式表越大,这一步骤所需的时间就越长。
一旦 DOM 和 CSSOM 被创建,浏览器然后将这两个组合起来构建渲染树。接下来是布局步骤,为所有页面元素计算所有大小和颜色属性。
最后,像素被绘制到屏幕上。这并不总是“瞬间”的,因为不同的样式和样式组合需要不同数量的处理来渲染。
如何 JavaScript 阻塞管道
DOM 和 CSSOM 过程协同工作,以产生屏幕上显示的内容,但渲染周期中还有第三部分,即处理 JavaScript。JavaScript 不仅是一个渲染阻塞器,还是一个解析阻塞过程。
这是我们将脚本引用添加到 HTML 末尾的主要原因。通过这样做,浏览器有机会在尝试加载脚本之前解析 HTML 和 CSS,这会阻塞解析过程:

当浏览器遇到脚本时,它会停止 DOM 和 CSS 解析器以加载、解析和评估 JavaScript。这也是我努力最小化 JavaScript 大小的原因之一。
您可以采用一些技巧来最小化这种行为。第一个是将您能标记为异步的任何脚本。这会导致浏览器随意加载脚本文件。
起初这听起来很棒,但我发现这通常比实际更乐观。似乎总有一段关键脚本需要在页面渲染时执行。
你可以将所有脚本标记为异步,并在运行你的应用程序时查看是否有任何问题。只需彻底测试,以排除任何边缘情况:
<script src="img/config.js" async></script>
另一种解决方案是将你的脚本压缩后内联到你的标记中。这也可以帮助你的渲染周期。其中一个主要的好处是不需要等待额外的文件下载。
然而,如果你使用 HTTP/2,多路复用功能可能会提供更多好处。使用 HTTP/2 时,通常使用可以单独缓存的较小文件比大型文件包更好。
当你内联脚本时,你的 HTML 大小会增加,这可能会延迟其处理。然而,正如你即将学习的,内联 CSS 非常有用。这是一个测试的问题,看看什么最适合你的页面和应用。
为什么 14 KB 是神奇数字
为了控制网络流量,TCP 实现了一种称为慢启动的模式。它这样做是为了防止网络被请求淹没。详细信息在 RFC 5681 中指定(tools.ietf.org/html/rfc5681)。
协议的工作原理是发送方或发起者发送一个初始的小数据包。当它收到响应后,它会将数据包大小加倍。这种往返会重复进行,直到发送方收到拥塞响应。
初始数据包大小为 14 KB。这种往返是一系列往返。如果你可以将整个页面或响应放入 14 KB 中,那么只需要一个往返就可以完全下载:

在这个例子中,响应大小为 10.5 KB,因此只需要 1 次往返。你应该注意,这个例子没有压缩,这会显著减小大小。这是我们稍后应用资源内联时想要你记住的另一点。
初始 TCP 数据包实际上是 16 KB,但前 2 KB 被保留用于请求头数据。剩余的 14 KB 是内容或数据传输的地方。如果内容超过 14 KB,则启动第二次往返,这次数据包大小加倍至 32 KB。这会一直重复,直到出现网络拥塞消息。
通过将请求限制为单个往返,你可以几乎瞬间加载整个响应。往返次数越多,数据加载所需的时间越长。
内联关键 CSS
当你内联 CSS 时,你消除了检索样式的所需往返,并且它们在解析 DOM 时立即对浏览器可用。这使得这两个关键步骤变得更快。
为了刷新,当浏览器遇到外部样式表时,它会阻止任何渲染,直到样式表完全加载。
如我之前提到的,你希望将页面 CSS 的大小限制仅为渲染页面所需的 CSS。通过仅将样式限制在页面使用的那些,你通常可以将 CSS 的数量减少到几个千字节。
由于实际 CSS 的数量很少,你可以在文档的 head 元素中内联这些样式。现在,浏览器没有外部文件需要下载,只需加载最小量的 CSS。此外,你还有渲染应用外壳所需的临界样式。
PWA 票务应用程序有一个非常标准的应用外壳:header、body 和 footer。每个单独的页面都需要最小量的自定义 CSS 来渲染其内容。
好消息是,有工具可以帮助你识别每个页面所需的 CSS。有多个节点模块可用,但我专注于 UnCSS 模块 (www.npmjs.com/package/uncss)。它是第一个创建来识别所需 CSS 的模块之一。
因为这些是节点模块,你可以将它们包含在构建脚本中。PWA 票务应用程序在项目的 utils 文件夹中有一个名为 render-public.js 的构建脚本。我不会详细介绍脚本的所有细节,但它会在网站的源代码上运行,以生成网站的页面和支持文件。
extractCSS 函数负责提取页面的样式,最小化它们,并将它们注入到 head 元素中。
有额外的节点模块被用来帮助。Cheerio 加载 HTML 并创建一个具有 jQuery API 的对象,就像你在浏览器中使用 jQuery 一样。这使得操作标记更容易。
第二个模块是 CleanCSS。此模块最小化样式,删除不必要的空白,从而使得代码占用更少的空间:
function extractCSS($, callback) {
let options = {
ignore: [".page-content .card", ".page-content .card-
title", ".page-content .ticket-card"],
media: ['@media (max-width:480px)', '@media (min-
width:768px)', '@media (max-width:992px)', '@media (max-
width:1199px)'],
stylesheets: [path.resolve(publicPath,
'css/libs/bootstrap.min.css'),
path.resolve(publicPath, 'css/app/site.css')
],
timeout: 1000,
report: true,
banner: false
},
html = $.html();
let $html = cheerio.load(html);
$html("body").append(templates);
$html("script").remove();
$("script").remove();
//run uncss
uncss($html.html(), options, function (error, output) {
if (error) {
console.log(error);
}
let minCSS = new CleanCSS({
level: 2
}).minify(output);
$("head").append("<style>" + minCSS.styles + "</style>");
callback($);
});
}
UnCSS 有一个很长的配置选项列表,你可以使用这些选项来控制模块的执行方式。我提供了我最常用的设置,例如媒体查询断点和消除横幅注释。
有时,我发现我仍然需要包含一个不应删除的选择器列表:
ignore: [".page-content .card", ".page-content .card-title", ".page-content .ticket-card"]
我还发现,从标记中删除任何脚本引用将有助于该模块。当模块找到脚本时,它会尝试加载并执行它。这是因为 UnCSS 在无头浏览器中执行页面,就像它是一个正常浏览器一样。
UnCSS 可以处理原始 HTML,这就是我使用它的方式,或者通过 URL 或本地路径加载页面。它使用标准的节点回调模式,因此你应该相应地编写代码。
另一件我尝试做的事情是在要处理的 HTML 中注入潜在的内容模板。这应该有助于 UnCSS 确定所需的全部样式,即使它们是动态渲染的。
与 UnCSS 一样,CleanCSS 也使用回调模式。你可以提供过滤后的 CSS,它将返回一个最小化版本。
在这一点上,你可以将最小化的样式注入到 HTML 的 head:
$("head").append("<style>" + minCSS.styles + "</style>");
到目前为止,页面的 HTML 已经包含所有必需的 CSS,内联在标记的 HEAD 中。对于 PWA 技术票据应用程序,典型的页面大小约为 30 KB,这不符合 14 KB 的目标。
幸运的是,我们还没有完成。
静态资源,如 HTML,应该被压缩。你可以使用 gzip 或 deflate 压缩。Brotli 是另一个选项,但并非所有浏览器都支持它。一旦压缩这些文件,它们通常可以减少到大约 8 KB,远远在我们的 14 KB 目标之内!
大多数 Web 服务器都可以配置为按需压缩文本文件。但正如你可以想象的那样,我喜欢将其作为我的部署过程的一部分。这可以通过节点完成,但你应该与你的 DevOps 团队确认,确保这是为你的网站执行的:
Body = fs.createReadStream(src).pipe(zlib.createGzip({
level: 9
}));
确保任何压缩文件都通过设置 Content-Encoding 标头为 gzip 或 deflate 来提供,这样浏览器就知道要解压缩响应。
使用 uglify 压缩脚本
就像 CSS 一样,你也应该最小化 JavaScript 文件。就像我们使用 Clean-CSS 来压缩 CSS 一样,你可以使用 uglify 来对你的 JavaScript 执行相同的操作。我更喜欢将其保留在单独的文件中。
在过去,我还会将多个脚本文件捆绑在一起。HTTP/2 利用请求多路复用来优化内容交付。通过保持每个脚本为单独的文件,你可以利用长期缓存,并在不需要完整下载的情况下进行小幅度修改。
除了最小化脚本外,我还会向你展示如何使用内容上的 MD5 哈希创建唯一的文件名。这将允许你应用非常长的缓存时间,而不用担心浏览器缓存保留过时的副本。这项技术是高级的,确实需要一些规划,当然,还需要一个智能的构建或渲染过程。
有多个 uglifier 节点模块。我选择了 uglify-js 用于 PWA 技术票据应用程序。我挑选这类模块的方式是查看其流行度,同时也会考虑流行的任务运行器,如 Grunt、Gulp 和 WebPack 插件所依赖的。
作为一句警告,uglify-js 不处理 ES6 语法,如 let 和 const,在遇到时会抛出错误。但我警告不要在浏览器中使用 ES6 语法,因为仍然有许多浏览器不支持它,例如 Internet Explorer。
对于构建脚本,我选择创建一个简单的 uglify 模块,以便在整体构建脚本中引用。它引用 uglify-js 并创建一个 uglify 类:
const UglifyJS = require("uglify-js"),
uglifyOptions = {
parse: {
html5_comments: false,
shebang: false
},
compress: {
drop_console: true,
keep_fargs: false
}
},
...;
class uglify {
constructor(src) {}
transformSrc(srcFiles) {}
minify() {}
}
类 constructor 和 transformSrc 方法用于在压缩前设置。它们被设置为允许你传递单个脚本引用或脚本数组以供 uglify 和连接。
就像 UnCSS 一样,uglifier 允许你自定义过程。这就是选项允许你配置模块的地方。为此,我选择了我喜欢的一些简单设置来优化过程:
minify() {
let src = this.transformSrc(srcFiles);
return UglifyJS.minify(src, uglifyOptions);
}
渲染脚本不仅会压缩每个脚本;它还会创建一个唯一的哈希名称:
function uglifyScripts() {
scripts.forEach(script => {
let ug = new uglify(path.resolve(publicPath, script));
let min = ug.minify();
if (min.code && min.code !== "") {
let hashName = utils.getHash(min.code);
fs.writeFileSync(path.join(publicPath,
path.dirname(script), hashName + ".min.js"), min.code);
scriptsObjs.push({
src: script,
hash: hashName + ".min.js"
});
} else {
console.log("uglify error ", min.error);
}
});
}
该文件是通过将脚本内容传递给 nodejs crypto 对象来计算的。crypto 对象使得计算哈希变得简单。在这种情况下,我想要一个 md5 哈希值,所以当调用createHash方法时,你提供'md5'值。
如果你不太熟悉 md5 哈希,它们是一种生成校验和的加密方法,用于验证数据完整性。它们不适合加密,但基于数据提供唯一值。这个唯一值对于创建唯一的文件名很有帮助:
function getHash(data) {
var md5 = crypto.createHash('md5');
md5.update(data);
return md5.digest('hex');
}
唯一性足够好,可以相信脚本文件名不会在应用程序内部重复。构建脚本不仅需要生成唯一的哈希值,还需要将文件以哈希名称保存。它也可以只是重命名源文件。
即使你创建了具有唯一文件名的文件,你仍然需要将其集成到 HTML 文件中。渲染脚本负责这项任务。产品看起来可能像这样:
<script src="img/470bb9da4a68c224d0034b1792dcbd77.min.js"></script>
<script src="img/ca901f49ff220b077f4252d2f1140c68.min.js"></script>
<script src="img/2ae25530a0dd28f30ca44f5182f0de61.min.js"></script>
<script src="img/aa0a8a25292f1dc72b1bee3bd358d477.min.js"></script>
<script src="img/470bb9da4a68c224d0034b1792dcbd77.min.js"></script>
<script src="img/e392a867bee507b90b366637460259aa.min.js"></script>
<script src="img/8fd5a965abed65cd11ef13e6a3408641.min.js"></script>
<script src="img/512df4f42ca96bc22908ff3a84431452.min.js"></script>
<script src="img/bc8ffbb70c5786945962ce782fae415c.min.js"></script>
我还向每个文件添加了.min,因为脚本已经被压缩。这是出于惯例而不是要求。好处是对于像浏览器开发者工具这样的工具,它们理解脚本已经被压缩。Edge 允许你在调试时选择绕过脚本,因为.min被附加到文件名上。
因为每个页面也会为脚本文件设置一个预加载提示,所以这些引用也必须更新:
<link rel="preload" href="js/libs/470bb9da4a68c224d0034b1792dcbd77.min.js" as="script" type="application/javascript">
<link rel="preload" href="js/libs/ca901f49ff220b077f4252d2f1140c68.min.js" as="script" type="application/javascript">
<link rel="preload" href="js/libs/2ae25530a0dd28f30ca44f5182f0de61.min.js" as="script" type="application/javascript">
<link rel="preload" href="js/libs/aa0a8a25292f1dc72b1bee3bd358d477.min.js" as="script" type="application/javascript">
<link rel="preload" href="js/app/pages/bc8ffbb70c5786945962ce782fae415c.min.js" as="script" type="application/javascript">
<link rel="preload" href="js/app/512df4f42ca96bc22908ff3a84431452.min.js" as="script" type="application/javascript">
那么,为什么我要在构建和渲染过程中添加这个复杂的重命名步骤呢?是为了启用较长的缓存时间。这告诉浏览器尝试在内置的浏览器缓存中而不是在服务工作者缓存中本地缓存响应。
推荐的存活时间至少为一年。大多数脚本可以在那个时间范围内更新,并且哈希名称技术为你提供了一个保证的缓存失效技术。其他技术,如附加唯一的QueryString参数,可能并不总是有效。
你可以通过设置cache-control头来设置长时间存活。这需要在你的 web 服务器上完成,这样它就会成为你的 devops 工作流程的一部分:
cache-control: public, max-age=31536000
我不会深入探讨配置 Cache-Control 头部的艺术,但你可以将前面的示例作为参考。脚本、样式表甚至图像等文件都适合使用哈希命名技巧。只需确保更新任何对文件的引用到新名称即可。
使用功能检测条件性地加载 JavaScript polyfills
PWA 票据应用程序使用了许多现代 API,但其中一些在旧浏览器中不受支持。你应该最关注两种浏览器场景:Internet Explorer 和较老的 Android 手机。UC 浏览器是另一个流行的浏览器,它还没有支持所有新功能。
Internet Explorer 是现在已弃用的微软浏览器。唯一支持的是 IE 11,目前仅存在于 Windows 7 和企业中。企业使用许多业务线应用程序,许多都是针对旧的和过时的网络标准创建的。通常,对于他们来说更新或替换这些应用程序是昂贵的。
Internet Explorer 为它们提供了一个遗留浏览器通道,以便它们可以继续运行这些应用程序。然而,当它们升级到 Windows 10 时,它们应该配置这些业务线应用程序,以便在需要时从 Edge 触发 Internet Explorer,而不是作为默认浏览器。
这意味着你应该期望的默认行为是 Edge,而不是 Windows 10 上的 Internet Explorer。然而,人的本性和习惯往往超过推荐的做法,这意味着 IE 仍然是一个非常流行的浏览器。
幸运的是,大多数现代 API PWA 票据都可以使用 polyfills。这就是脚本可以按需加载以实现新 API 的地方。其他 API 可以在功能检测门后面安全使用,就像我们在注册服务工作者之前所做的那样,或者可以信任它们被优雅地忽略。后者是现代 CSS 属性的处理方式。
可以使用功能检测和一种我称之为切换脚本的简单技术按需加载功能 polyfills。
PWA 票据应用程序使用了 4 个 polyfills:
-
Object.Assign -
Promises
-
Fetch API
-
IntersectionObserver
这个技巧依赖于这些脚本引用应用了一个除了 script 之外的其他类型属性。这告诉浏览器,尽管它是一个脚本元素,但 src 不是脚本。当然,src 文件是脚本,但通过将类型设置为其他内容,浏览器不会下载脚本。
toggleScript方法接受一个 ID 来引用 polyfills 的script元素。然后它将脚本的类型从script-polyfil切换到 text/JavaScript。当这个切换发生时,浏览器会下载并处理 polyfills 脚本:
<script type="script-polyfil" id="polyfilassign"
src="img/object.assign.js"></script>
<script type="script-polyfil" id="polyfilpromise"
src="img/es6-promise.min.js"></script>
<script type="script-polyfil" id="polyfilfetch"
src="img/fetch.js"></script>
<script type="script-polyfil" id="polyfilintersection"
src="img/intersection-observer.js"></script>
<script>
//wrap in IIFE to keep out of global scope
(function () {
function toggleScript(id) {
var target = document.getElementById("polyfil" + id);
target.setAttribute("type", "text/javascript");
}
if (typeof Object.assign != 'function') {
toggleScript("assign");
}
if (typeof Promise === "undefined" ||
Promise.toString().indexOf("[native code]") === -1) {
toggleScript("promise");
}
if (typeof fetch === "undefined" ||
fetch.toString().indexOf("[native code]") === -1) {
toggleScript("fetch");
}
}());
</script>
所有这些都取决于是否需要 polyfills。每个 API 或功能支持都可以通过简单的测试来检测。如果测试失败,则该功能不受支持,并调用 toggleScript 方法来加载 polyfills。
你应该在加载任何应用程序特定的代码或可能依赖于这些 API 的任何代码之前放置此代码。
动态加载 polyfills 非常重要,因为这意味着当原生 API 可用时,你会使用它,并且当原生 API 存在时避免加载这些昂贵的文件。
任何需要加载这些 polyfills 的时候,页面加载都会受到影响。然而,我认为这是一个合理的权衡,因为这些旧浏览器一开始运行就会慢,用户可能不会有像使用现代浏览器的人那样的期望。
懒加载图片
由于图片数量和大小,图片可能会延迟你的整体页面加载体验。有不同策略用于优化图片传输。你应该考虑的第一个策略是在页面可滚动部分以下懒加载图片。
这可能是一个非常棘手的技巧来执行。现代 API 可以帮助您,特别是IntersectionObserver (developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) API,它让您能够检测元素何时进入视口。您可以指定元素出现时估计的距离和时间阈值。
IntersectionObserver API 将触发一个事件,让您知道当元素即将被显示时。在这个时候,如果需要,您可以启动图像下载。这意味着您的页面图像不会在初始渲染过程中加载,而是按需加载。这可以在初始页面加载过程中节省宝贵的带宽和网络连接:
lazyDisplay: function () {
var images = document.querySelectorAll('.lazy-image');
var config = {
// If the image gets within 50px in the Y axis, start the download.
rootMargin: '50px 0px',
threshold: 0.01
};
// The observer for the images on the page
var observer = new IntersectionObserver(this.showImage, config);
images.forEach(function (image) {
observer.observe(image);
});
}
代替使用图像的src属性,您可以将图像源指定为数据属性(data-src)。您也应该对srcset属性做同样的事情:
<img class="lazy-image" data-src="img/venue.jpg"
data-srcset=" img/venues/venue-1200x900.jpg 1200vw, img/venues/venue -992x744.jpg 992vw, img/venues/venue -768x576.jpg 768vw, img/venues/venue -576x432.jpg 576vw"
sizes=" (max-width: 577px) 90vw, (max-width: 769px) 45vw, (max-width: 769px) 30vw, 20vw" >
showImage函数处理切换data-src和data-srcset值到相应的src和srcset值。这会导致浏览器在图像即将进入视图之前或按需加载图像:
showImage: function (entries, observer) {
entries.forEach(function (io) {
if (io.isIntersecting) {
var image = io.target,
src = image.getAttribute("data-src"),
srcSet = image.getAttribute("data-srcset");
if (srcSet) {
image.setAttribute("srcset", srcSet);
}
if (src) {
image.setAttribute("src", src);
}
}
});
}
如果您担心旧版浏览器支持IntersectionObserver,请不要担心:有一个 polyfil (github.com/w3c/IntersectionObserver/tree/master/polyfill)。目前,Chrome、Firefox 和 Edge 都原生支持IntersectionObserver。这个 polyfil 允许您在其他浏览器中使用这项技术。
您应该使用功能检测来确定是否需要加载 polyfil 以及之前描述的 polyfil 技术。
PWA 票据应用使用IntersectionObserver模式来懒加载图像。我还想指出这项技术的关键方面,即指定图像的渲染大小作为占位符。
摘要
本章重点介绍了提高您的渐进式 Web 应用性能。您已经了解了您可以衡量的关键性能指标以及测量您应用程序的工具。
一旦您已经确定了需要改进的项目,您就可以着手改进它们,以缩短页面加载时间和响应时间。
您已经获得了一些技巧,并接触到了代码,以帮助您构建页面,提供更好的用户体验。您已经看到了如何最小化每个页面所需的代码量,提高缓存,并减少初始页面负载。
在下一章中,您将看到如何自动化您的渐进式 Web 应用工作流程,以确保您有一个持续表现良好且合格的 PWA。
第十章:Service Worker 工具
随着今天有如此多的选项、步骤和工具,Web 开发已经变得复杂。渐进式 Web 应用(PWAs)需要一个新的模型,但这可能会让错误悄悄进入代码。好消息是,有几个工具可以集成到你的工作流程中,以改善你的应用程序代码的完整性。
在本章中,我将回顾一些我认为非常有价值的工具,这些工具帮助我在发布应用程序之前确保它们达到一定的质量标准。这些工具包括PWABuilder、Lighthouse、Sonar和WorkBox。
Lighthouse 和 Sonar 是你可以用来审计你的网页以确保满足最低标准的linting 工具,包括 PWA、性能、托管和 SEO 要求。PWABuilder 和 Workbox 在构建渐进式 Web 应用的重要方面非常有帮助,包括清单、图标和服务工作者。
作为额外的好处,所有这些工具都可作为 node 模块提供,你可以将其作为脚本的一部分或在命令行中执行。这意味着你可以将许多此功能包含在自动化工作流程和构建过程中。
本章将涵盖以下主题:
-
使用 PWABuilder 构建 PWA 资产
-
使用 Lighthouse 审计网页
-
使用 Sonar 审计网页
-
使用 WorkBox 构建复杂的服务工作者
使用 PWABuilder 构建你的 PWA
PWABuilder 是由 Microsoft Edge 团队构建的渐进式 Web 应用构建工具,公开托管在pwabuilder.com。我喜欢这个工具,因为它可以在不到一分钟的时间内快速构建你需要的资产,将任何网站升级为渐进式 Web 应用。
构建 PWA 组件有三个步骤,包括生成一个 web 清单文件、选择一个简单的 Service Worker 以及下载资产:

让我们详细检查每个步骤。
生成有效的 web 清单文件
一个有效的 web 清单文件是 PWA 的基本要求之一,但你会惊讶地发现,许多网站忽略了这一简单的步骤。其中大多数只是忘记了一个重要的字段,或者提供的值不符合指南,例如长名称和短名称值。
最大的错误是没有包含所有不同浏览器和操作系统所需的最小图标集。
要使用在线工具,你需要提供一个公开的 URL、两种主要的应用程序颜色以及你想要制作应用程序图标的标志或图像:

向导将尝试解析值以提供属性,例如全名、简称和描述。如果你的网站已经有一个 web 清单文件,它将使用那些属性。不用担心:你可以在在线表单中提供或更改任何值,以及指定显示方向和语言,如下面的截图所示:

PWABuilder 向导的下一步是生成一组应用程序图标,在撰写本书时,有超过 100 个图标可供选择。您可以选择一个公开图像的 URL 或上传一个基本图像。图像生成器将创建一组图像,以满足每个主要平台(iOS、Android 和 Windows)的准则。它还涵盖了 Chrome 和 Firefox 的要求。
我最喜欢的功能是它包括在生成的 web 清单文件中对每个图像的正确引用。我无法强调这节省了多少时间以及它如何消除潜在的错误。服务创建完整图像集的事实也意味着您不会错过可能使您的 PWA 在不同平台上无法作为渐进式 Web 应用程序的重要图标大小。查看以下截图:

当您点击“输入图像”输入字段时,您会看到一个表单,以便您可以上传图像。您可以从您的硬盘驱动器中选择一个文件:

当向导完成时,产品是一个包含所有图标和一个包含图标引用的清单文件的包:

构建服务工作者
在上传源图像以制作应用程序图标后,服务工作者步骤会显示。在这里,您可以选择预制的服务工作者。这些都是基本的服务工作者,可以与大多数网站一起工作,以便您开始渐进式 Web 应用的旅程。
有五种初始服务工作者选项:
-
离线页面:使用离线回退页面初始化服务工作者缓存。
-
页面离线副本:当用户访问您的网站上的页面时,它们会被缓存,这使得返回访问变得快速且支持离线。
-
离线副本与离线页面的备份
-
缓存优先网络:在触网之前检查缓存以获取有效响应,这会将网络响应添加到缓存中。
-
高级预缓存:目前处于开发中,但旨在让您对服务工作者安装时缓存的网站资产有更多控制。
下载您网站的 PWA 资产
在选择初始服务工作者后,PWABuilder 提供下载您的 PWA 和原生应用程序打包版本的链接。您可以选择仅下载渐进式 Web 应用程序资产、预打包的 Windows appx 文件以及 Google Play 和 iOS 应用商店的原生应用程序。
在大多数情况下,不需要原生应用程序版本,但如果有需要访问特定平台 API 和集成的需求,它们可能很有用。
您可以点击对应于您所需包的按钮。然后,PWABuilder 将生成一个包含所需资产的包,一个 ZIP 文件。该网站还提供了将框架化代码集成到您的网站和提交应用商店包到各个商店的说明。
这是生成的 web 清单文件的示例:

框架化的 PWA 图像
我认为 PWABuilder 最好的部分是它如何快速生成所有应用程序图标,并正确引用每个图像的网页清单文件。它生成了超过 100 张图像,我不知道您是否也是如此,但我没有时间或耐心去创建那么多变体,更不用说将代码添加到我的网页清单文件中。
这是 PWABuilder 向导的一部分,但您可能已经创建了一个有效的网页清单文件和一个服务工作者,并且只需要一组图标。
这是您可以直接访问图像生成服务的地方 www.pwabuilder.com/imageGenerator。您将看到与向导界面中包含的相同表单。唯一的区别是,一旦您提供了基础图像,您可以直接点击下载按钮以获取仅包含您的 PWA 图标和网页清单 JSON 的文件。
您不仅限于使用在线图像生成器。源代码是一个开源的 ASP.NET 项目,github.com/pwa-builder/App-Image-Generator。您需要建立一个能够服务 ASP.NET 网站的网络服务器,您可以使用 Docker 来实现。我认为这个版本不是基于 .NET Core,这意味着您需要在 Windows 上有一个 IIS 实例。
工具不会修改您网站上的任何文件,因此您仍然需要更新您的页面以注册服务工作者和网页清单文件。您还需要将图标、清单和服务工作者文件复制到您网站文件夹中。
本地运行 PWABuilder
如果您的网站不是公开的,请不要担心:您仍然可以通过本地运行来利用 PWABuilder。实际上,PWABuilder 是微软生产的第二代 PWA 工具。在其之前的生活中,它被称为 Manifold JS,并且仍然使用那个引擎。
Manifold 是一个由 PWABuilder 网站使用的节点模块集合,用于生成您 PWA 所需的文件。Manifold JS 组件可在 GitHub (github.com/pwa-builder) 和 npm (www.npmjs.com/package/manifoldjs) 上找到。
您可以直接从命令行运行 PWABuilder。由于它是一个节点模块,您需要安装 Nodejs,等等。
我建议全局安装 PWABuilder 节点库:
npm install -g pwabuilder
现在,您可以从任何命令行执行 pwabuilder。在以下示例中,我添加了一些选项,一个用于指定目标以保存生成的资产,另一个用于指定生成资产的平台:

如果您想更加雄心勃勃,您可以构建自己的节点脚本来直接执行 PWABuilder 库。您可以克隆 PWABuilder 项目并检查源代码以及命令行实用工具的执行方式,以了解您如何将库的某些部分纳入自己的脚手架流程中。
使用 Lighthouse 审计网页
Lighthouse (developers.google.com/web/tools/lighthouse/) 是由 Chrome 团队管理的一个自动化工具,可以帮助您审核网站以识别需要关注的问题。该工具不仅审核渐进式 Web 应用的要求,还检查许多最佳实践,包括性能、无障碍性和 SEO。
Lighthouse 是一个开源项目,欢迎贡献。
如本书前面所述,Lighthouse 可以通过两种不同的方式执行:
-
内置于 Chrome 开发者工具的审核标签页
-
Node 模块 (
www.npmjs.com/package/Lighthouse)
Chrome 扩展是原始实现,但现在正在逐步淘汰。今天,扩展是多余的,因为 Lighthouse 已经内置到开发者工具中。
Lighthouse 将在 Chrome 中执行给定的 URL,并通过一系列测试。这些测试涵盖了模拟移动蜂窝网络和桌面版本的不同场景。
它在这些不同场景中对 URL 进行一系列测试,寻找潜在问题。它可以为您应用程序的性能以及它如何满足最低要求(如成为 PWA)建立基准。
Lighthouse 生成一个分数/成绩单,列出页面在一系列测试中的表现。在一分钟左右的审计过程中,审核会根据不同条件多次重新加载页面,每次加载都会捕获跟踪信息。页面加载时间、成为交互式页面、离线工作效果等都会被记录。
Lighthouse 审核的主要类别包括渐进式 Web 应用功能、Web 性能优化、无障碍性和一般最佳实践。最近,该工具新增了 SEO 审核部分,但这一功能目前仍有限。
在您运行 Lighthouse 审核后,您将看到一份成绩单。这是五个顶级审核类型分数的示例。这些分数基于 0-100 的评分标准,100 分是完美分数。分数由审核组中每个点的加权分数组成:

每个顶级审核都运行细粒度测试并提供非常详细的报告。Lighthouse 可以生成 JSON 或默认 HTML 格式的报告。您还可以保存开发工具测试期间收集的数据,以便您可以对其进行审查和比较。
HTML 报告会自动突出显示您失败或需要解决的问题区域。它还提供了有用的链接,指向可以帮助您找到解决方案的文档:

不时地,我喜欢从 Chrome 开发者工具中运行 Lighthouse,只是为了看看我在开发过程中的位置。这是一个快速且自动化的工具,可以帮助我了解我可能遗漏了一些微不足道但可能产生可衡量影响的问题。
对于渐进式 Web 应用,它会通过一系列清单项来确保它是一个完整的渐进式 Web 应用。大多数都可以自动测试,但有一些必须手动评估。Lighthouse 报告中的条目应该手动验证。
最明显的两个是 HTTPS 和在服务工作者中的 Web 清单文件,但它还深入到更深的层次。它还会检查你的性能,以及你是否可以在蜂窝连接下在 3 秒内加载。它是通过模拟更慢的网络条件和 CPU 来做到这一点的。
它还会检查确保你有针对不同视口的有效图标,并且你可以提示主屏幕横幅。
所有测试都会运行,模拟 3G 连接并将 CPU 速度降低到默认速度的 4 倍。这部分显然会根据可用 CPU 的不同,从设备到设备地影响你的测试结果。我在一台非常新的 i7 Surface Laptop 上工作,它的运行速度比更成熟的 i3 或 i5 处理器要快得多。你也会看到,当你使用不同 CPU 功率的虚拟机时,会有所变化。
所有这些小事情加在一起,将帮助你了解你的网站是否能够提供良好的用户体验。这不是唯一的测试方法,但它确实为你提供了一个良好的基准,涵盖了你需要审计的大部分领域。
从 Chrome 开发者工具运行 Lighthouse
使用 Lighthouse 最简单的方法是从 Chrome 开发者工具开始。到现在,你应该知道如何打开这些工具。所有桌面浏览器都包含开发者工具,可以通过按 F12 或 Ctrl + Shift + I 来打开。
一旦打开开发者工具,你就有多个选项。Lighthouse 就是其中之一。它被标记为 Audits,并作为最后一个原生标签呈现。如果你像我一样,你可能还有一个扩展程序在工具中放置了一个自定义标签页。它们在 Audits 标签页之后列出:

初始状态下,打开 Audits 标签页时并没有太多内容,只有一个 Lighthouse 对话框。
对话框列出了五个当前的审计类别,以及“运行审计”和“取消”按钮。每个类别都可以打开或关闭。按下“运行审计”按钮开始审计过程:

在审计过程中,你会看到一个新对话框,向你保证审计正在运行,并分享有用的网页开发事实和统计数据以供娱乐。同时,相应的浏览器标签页正在由 Lighthouse 进行测试。
如果你观察浏览器标签页,你会看到它使用内置的模拟工具在不同的虚拟设备和不同的网络条件下加载目标页面,包括离线状态。
根据页面行为和加载情况,不同的测试要么通过要么失败。最后,Lighthouse 会生成一个报告,你可以用它来评估你的页面表现。使用报告来识别页面和网站的各个方面,以进行改进:

审计完成后,您将看到一个格式良好的报告,顶部显示每个类别的总体等级。在分数下方,每个类别列出任何失败的测试或您没有完全达到目标数字但应调查的测试。所有通过测试的测试都分组在一起并折叠在视图之外,但仍然可以查看。
每个测试都应该有一个链接,提供更多详细信息,以单独的“了解更多”链接指示测试内容,并希望提供帮助您改进页面的指导,以便您可以通过测试:

您还应该考虑您正在测试的托管环境。由于您可能没有完整的生产规模 Web 服务器,一些测试将在本地站点上失败。虽然 node http-server 使运行本地 Web 服务器变得容易,但它默认不包含 HTTPS 和 HTTP/2。这使得您的页面在寻找这两个功能的测试中失败。
在这些情况下,由于环境限制,您可以安全地忽略这些测试结果。我确实建议在网站部署到开发、预发布和生产等全规模环境中后进行额外的测试。
记住,当在开发者工具中运行时,您应该将其视为一个重要的分类步骤,以确保您的代码和网站更新能够提升整体网站体验。它们并不是真实用户可能拥有的实际体验,因为网站托管在不同的环境中。也要记住,Lighthouse 模拟的条件只是模拟,可能无法完美反映真实世界的设备或网络条件。
开发者工具 Lighthouse 实现中另一个大问题是无法在切换主要测试类别之外自定义审计。您不能通过自己的测试和类别扩展审计。
这就是直接将 Lighthouse 作为 node 模块运行提供巨大优势的地方。
将 Lighthouse 作为命令行实用程序运行
我真的很喜欢将 Lighthouse 作为 node 模块执行的能力。Lighthouse node 模块依赖于 Chrome Launcher (www.npmjs.com/package/chrome-launcher),这将打开一个 Chrome 实例以执行测试。
这意味着执行 Lighthouse 的机器应该已安装 Chrome。该工具使用完整的 Chrome 实例,因为它需要渲染页面并使用开发者工具来模拟不同的模式。
由于 Lighthouse 是一个 node 模块,您需要使用 npm 或 yarn 安装它。像其他工具一样,我建议全局安装:
>npm install -g Lighthouse
您可以直接通过命令行运行完整的审计,只需执行 Lighthouse 后跟目标 URL:
>Lighthouse https://tickets.love2dev.com
这将启动一个新的 Chrome 实例,并对目标 URL 运行测试套件。完成后,在当前文件夹中创建一个 HTML 报告文件。您可以在浏览器中加载此文件进行审查。它与开发者工具中加载的报告相同。
这是一个将自动测试报告文件加载到 Lighthouse 报告查看器的示例:

您可以自定义 Lighthouse 以执行所需的测试并以您想要的格式报告。以下是我在我项目上使用的一些常见选项:
-
--config-path: 要用于审计的配置文件的本地路径。 -
--output: 报告格式。选项包括 JSON、CSV 和 HTML(默认为 HTML)。您可以指定多个格式。 -
--output-path: 报告的写入位置。如果指定了多个格式,路径将被忽略,但每个格式都根据目标的基本名称保存到当前路径。 -
--view: 一旦写入,就在浏览器中启动 HTML 报告。 -
--block-url-patterns: 强制浏览器忽略某些资源。这对于测试没有第三方脚本的情况很有用。 -
--throttling-*: 在测试期间对网络和 CPU 限制的不同选项,以细粒度控制。 -
--save-assets: 将测试资源如屏幕截图持久化到磁盘。
在此示例中,运行了完整的审计,并以 JSON 和 HTML 格式保存:
>Lighthouse https://tickets.love2dev.com --output json --output html --output-path ./myfile.json
报告以两种输出格式保存在本地。我喜欢这样做,因为 HTML 报告易于阅读,而 JSON 报告易于被第三方或自定义报告解决方案消费。
默认的 Lighthouse 审计可能无法覆盖您需要监控的规则或您想要测试的规则。您也可以创建自己的测试,这些测试需要被包含在内。您可以使用自己的配置来自定义审计。
自定义配置以 JSON 对象的形式编写。以下示例扩展了默认配置,但将测试限制为仅性能和渐进式 Web 应用测试:
{
"extends": "Lighthouse:default",
"settings": {
"onlyCategories": ["performance", "pwa"]
}
}
要使用自定义配置文件,您必须将路径作为命令行开关提供:
>Lighthouse https://tickets.love2dev.com --config-path=./custom.Lighthouse.config.js --output json --output html --output-path ./pwa-tickets.json
最终,您可以控制 Lighthouse 用于运行审计的所有内容,包括收集、测试组、类别以及不同通过如何执行。
自定义配置文件使您能够非常容易地控制 Lighthouse 如何针对您的 Web 应用进行测试,而无需烦恼于扩展命令行选项。这也使得测试非常可重复,并且您可以将配置文件包含在源控制中,以便轻松恢复和审计测试的执行情况。
Lighthouse 和无头测试
最近,Chrome 团队还发布了一个名为 Puppeteer 的工具(github.com/GoogleChrome/puppeteer),它执行一个无头 Chromium 实例。这并不是 Chrome,而是在代码库上的一个变体,许多流行的应用程序如 Visual Studio Code 都是基于这个变体构建的。
无头浏览器可以执行页面,但不能查看页面。因为页面没有可见地渲染,所以一些测试没有运行。在执行使用无头浏览器的审计时,您应该注意这一点。
能够在无头浏览器上使用 Lighthouse 进行测试的能力为将工具集成到不同的工具中打开了机会。例如,WebPageTest、HTTPArchive、Calibre 和其他工具使用 Lighthouse 向他们的报告添加额外的测试点。你可以效仿他们,在你的内部测试和审计工作流程中整合 Lighthouse 节点模块。
由于 Lighthouse 可以作为节点模块从脚本中执行,你可以创建一个自动化测试来测试你的整个网站或网站中一组样本 URL。然后你可以审计结果并找到你需要解决的问题的共同区域。
我确实建议在您网站上的多个 URL 上运行它,因为大多数应用程序都是由许多不同类型的页面组成的。但请注意:限制自动测试的页面数量。每个测试实例都会启动一个新的 Chrome 实例,如果你试图测试整个网站,这可能会迅速失控。
lightcrawler (github.com/github/lightcrawler) 项目是一个你可以用来自动化测试整个网站的潜在工具。Lighthouse Cron (github.com/thearegee/Lighthouse-cron) 允许你使用 cron 作业在预定和随机间隔内审计网站,以收集随时间变化的分数。这些都是 Lighthouse 和类似工具如何被用来提供有价值的测试和性能审计,以使 Web 应用程序更好的几个例子。
我还建议运行多个测试周期以创建比较。每次我运行网站或页面审计时,我都会发现结果变化。有时,就像任何真正的科学实验一样,你会找到一些异常值,这些异常值并不能准确反映整体体验。
例如,测试机器可能正在运行一个高 CPU 后台任务,或者一个消耗磁盘和内存 I/O 的任务,这可能导致性能问题。当对实时网站进行测试时,你也可能遇到超出你控制范围的网络问题。
这些都可能导致测试失败和审计结果不佳。当我看到性能结果不佳时,我通常会再运行 3-5 次测试,看看可能会有什么变化。当我持续看到测试失败或记录不良结果时,我会努力改进问题。如果一个失败的测试是持续的,那么问题是我的网站,而不是网站周围的环境。
在 Node 脚本中运行 Lighthouse
就像 Sonar(将在下一节中介绍)和其他基于节点的实用工具一样,你还可以在自己的 Node 脚本和模块中使用 Lighthouse。你需要创建对Lighthouse和chrome-launcher模块的引用:
const Lighthouse = require("Lighthouse"),
chromeLauncher = require('chrome-launcher');
Chrome 启动器和 Lighthouse 模块都返回承诺。在启动 Lighthouse 之前,您必须创建一个新的 Chrome 实例。
Chrome Launcher 解析对 Chrome 实例的引用。您需要将开发端口号码传递给灯塔模块。这就是灯塔如何与 Chrome 通信以执行测试的:
function launchChromeAndRunLighthouse(url, flags, config = null) {
return chromeLauncher.launch({
chromeFlags: flags.chromeFlags
}).then(chrome => {
flags.port = chrome.port;
return Lighthouse(url, flags, config).then(results => {
// The gathered artifacts are typically removed as they can be
//quite large (~50MB+)
delete results.artifacts;
return chrome.kill().then(() => results);
});
});
}
您还应该将自定义配置提供给灯塔方法。您可以将其留空,并执行默认审计。
当灯塔启动时,会打开一个新的 Chrome 实例,就像从命令行一样。您可以观察您的网站运行所有测试。
当灯塔完成时,它会以目标格式解析结果。
使用灯塔进行持续构建
使用灯塔的另一种方法是将其嵌入到您的构建和部署工作流程中。例如,您可能使用像Travis这样的工具,该工具执行脚本以部署和测试应用程序。这样,您可以在将项目部署到生产服务器之前确保项目通过灯塔审计。
运行需要认证的现代网站(如 PWA 票据应用)存在问题。因为灯塔使用未初始化的环境来执行网页,有时当存储不可用时,持久令牌可能不可用。
在 PWA 票据应用中,这意味着网站会重定向到登录页面。我看到了一些关于解决这个问题的潜在解决方案的提及,但并没有取得太多成功。
最后,我发现的一个很酷的灯塔工具是埃里克·比德尔(Eric Bidel)的得分徽章(github.com/ebidel/Lighthouse-badge)。您可以将徽章作为项目readme文件的一部分包含:

灯塔是一个出色的工具,可以帮助您找到并诊断许多会影响您整体应用程序用户体验的常见问题。它对渐进式 Web 应用程序功能、Web 性能、可访问性和功能行为等方面提供了有价值的见解。正如我们最近所看到的,团队正在不断向灯塔添加新的测试集。SEO 集只是灯塔将要覆盖的新领域的一个起点。
内置工具为您,开发者或任何相关方,提供了快速测试页面以查看您在默认类别中满足预期程度的能力。您应该记住,Chrome 工具执行的默认测试是 Chrome 团队认为重要的,它们可能并不完全符合您的需求。
我确实建议从默认测试开始,以基准测试您网站当前的状态。默认测试是一套很好的基线测试,我认为每个网站都应该将其作为最低标准使用。
直接使用 node 运行灯塔的能力不仅可以让您根据需求定制测试,还让您能够自定义和扩展测试套件。您可以深入研究灯塔文档,了解如何创建自己的测试。
使用 Sonar 审计网页
与 Lighthouse 类似,微软 Edge 团队也发布了一个名为 Sonar 的新网站代码检查工具(sonarwhal.com/)。像 Lighthouse 一样,它是一个开源的节点模块,充当测试框架来在网页上执行规则。
这两个工具提供类似的测试能力,但也提供了一组不同的能力和测试套件。两者都提供了一个默认的起点来在页面上执行一组基本测试。两者都可以使用你自己的配置进行自定义,甚至可以通过自定义测试和报告进行扩展。
与灯塔(Lighthouse)不同,Sonar 并未集成到浏览器开发者工具中。至少目前还没有,我之所以这么说,是因为我可以看到这个工具可能会在某个时刻被集成到 Edge 浏览器中,就像 Lighthouse 在 Chrome 浏览器中那样。
Sonar 还有所不同,因为它可以在 Microsoft Edge、Chrome 或其他测试库中执行测试。例如,Sonar 随带一个 jsdom 解析器。
使用 Sonar CLI
由于 Sonar 不是浏览器的一部分,而是一个节点模块,它需要在自定义节点脚本中执行或者通过使用其命令行界面(CLI)来执行。CLI 是开始使用 Sonar 的最佳起点,就像 Lighthouse 一样,你首先需要安装 Sonar。再次强调,我推荐全局安装:
npm install -g sonarwhal
在你能够运行 Sonar 之前,你需要创建一个配置文件,.sonarwhalrc。你可以手动创建或者使用命令行来初始化配置文件:
> sonarwhal --init
这将启动一个向导,它会问你一系列问题以初始化你的配置。你可以选择一个预定义的配置或者创建一个自定义配置:

目前有 web-recommended 和 progressive-web-apps 两个预定义配置可用。根据你选择的配置,向导将安装所需的任何节点依赖项。我将在稍后介绍 Sonar 组件:

这将生成一个启动配置文件,扩展内置的 web-recommended 包:
{
"extends": [
"web-recommended"
]
}
这包含一个基础配置,包括最小规则集、格式化器和连接器定义。这就是 web-recommended 配置文件的样子。你可以使用以下代码作为参考来查看如何创建自己的配置文件:
{
"connector": {
"name": "jsdom",
"options": {
"waitFor": 5000
}
},
"formatters": [
"summary"
],
"rules": {
"axe": "error",
"content-type": "error",
"disown-opener": "error",
"highest-available-document-mode": "error",
"html-checker": "error",
"http-cache": "error",
"http-compression": "error",
"meta-charset-utf-8": "error",
"meta-viewport": "error",
"no-bom": "error",
"no-disallowed-headers": "error",
"no-friendly-error-pages": "error",
"no-html-only-headers": "error",
"no-http-redirects": "error",
"no-protocol-relative-urls": "error",
"no-vulnerable-javascript-libraries": "error",
"sri": "error",
"ssllabs": "error",
"strict-transport-security": "error",
"validate-set-cookie-header": "error",
"x-content-type-options": "error"
},
"rulesTimeout": 120000
}
我将在稍后演示如何自定义这个配置。
你不仅限于扩展单个配置,因为你还可以扩展多个配置,如以下代码所示:
{
"extends": [
"config1", "config2"
]
}
你不仅限于内置的配置,而且不需要运行初始化向导。你可以创建自己的配置文件,因为它们只是 JSON 文档:
{
"connector": {
"name": "edge"
},
"formatters": ["json"],
"rules": {
"rule1": "error",
"rule2": "warning",
"rule3": "off"
},
"rulesTimeout": 120000
...
}
一旦 Sonar 有了一个配置,你就可以从命令行执行它。像 Lighthouse 一样,你只需要执行 Sonar 然后跟上一个有效的 URL:
> sonarwhal https://tickets.love2dev.com
你可以通过创建一个自定义配置文件来自定义测试。Sonar 将在当前文件夹中查找 .sonarwhalrc 文件。
Sonar 组件
你需要熟悉 Sonar 的五个组件,才能理解这个工具是如何工作的。
配置
这些就是我之前提到的 .sonarwhalrc 文件。这些文件驱动着针对您网站的特定测试或审计。
连接器
连接器是 Sonar 执行的规则与 URL 之间的接口。目前有三种官方连接器可用:
-
jsdom:WHATWG DOM 的 nodejs 实现,这意味着它提供了一个相当不错的引擎来测试页面是如何渲染的。 -
chrome:通过启动 Chrome 并使用其调试协议来执行规则。 -
edge:使用 Microsoft Edge 来执行规则。这需要 Windows 10,因为 Edge 只在 Windows 上运行。
格式化器
格式化器将测试结果打印出来。目前有五个格式化器由 Sonar 团队维护:
-
json:创建一个 JSON 格式的报告 -
stylish:生成一个格式化的表格报告 -
excel:将报告生成为一个 Excel 工作表 -
codeframe:以代码框架风格生成报告 -
summary:以表格格式格式化报告
我个人最喜欢 JSON,因为这样我就可以将其作为原始数据源添加到自定义报告中,或者将其与其他工具(如 Lighthouse 的报告)结合使用,以完成全面的网站审计。
解析器
规则订阅解析器和它们发出的事件以执行测试。它们被设计为专注于特定的资源类型,如 JavaScript 和 web 清单文件。
规则
规则是 Sonar 的核心,因为它们负责对页面进行测试,以查看是否满足所需的准则。默认的 Sonar 安装中包含了许多规则。像 Sonar 的其他部分一样,您可以自由创建自己的规则。
使用 Sonar node 模块自动化网站审计
作为 nodejs 模块和命令行界面的组合,这为您提供了多种方式将 Sonar 集成到您的构建和部署过程中。
与 Lighthouse 不同,没有命令行开关,因为它依赖于配置文件。如果您想执行不同的配置,您需要从目标文件夹运行 sonarwhal,以便加载所需的自定义配置。
使用 workbox 创建复杂的服务工作者
Workbox (developers.google.com/web/tools/workbox/) 是另一个开源项目,可以帮助您创建服务工作者。它由 Chrome 团队维护,但像其他项目一样,我已经审查了那些公开接受贡献的项目。
Workbox 的目标是帮助构建完整的服务工作者或向现有服务工作者添加复杂组件。Workbox 允许您在稳固的基础上构建,因此您可以配置以满足您的特定需求。它让您能够控制如何构建服务工作者。您可以手动向现有服务工作者添加功能,并使用工具从零开始构建服务工作者。
正确配置的服务工作者使用一系列适当的缓存策略。这句话的关键是“正确配置的服务工作者”,正如您现在应该知道的,这并不简单。
Workbox 是一个用于在您的服务工作者中构建缓存组件的工具。该工具专注于提供样板代码,以帮助以下服务工作者区域:
-
预缓存
-
运行时缓存
-
策略
-
请求路由
-
背景同步
-
有用的调试
Workbox 的历史起源于一对现在已弃用的项目,sw_precache和sw_toolbox。sw_precache管理预缓存资源,而sw_toolbox处理动态缓存。Workbox 专注于管理所有缓存和失效策略。
在撰写本书时,Workbox 目前处于 3.2 版本,在过去 18 个月里取得了长足的进步。它的优势在于能够将复杂的代码抽象化,从而让您,开发者,可以专注于配置,在某些情况下,还可以进行定制。
在本章的其余部分,我将专注于更新 PWA 票据应用程序,用 Workbox 替换第八章中涵盖的大部分代码,应用高级服务工作者缓存策略。我在源代码库中创建了一个名为 workbox 的单独分支。
安装 workbox
Workbox 是一个库和 node 模块的集合。要获得工具的全貌,您需要全局安装 node 模块并克隆 GitHub 仓库。克隆库不是必需的,但我建议您这样做,以便您可以研究库的结构:
> npm install -g workbox-cli
Workbox 的 node 模块包括命令行界面,可以帮助您构建服务工作者和 Workbox 组件。我将在解释工具的不同部分时介绍如何使用 CLI。
安装 CLI 后的第一步是运行 Workbox 向导:
> workbox wizard
这将启动一系列关于您应用程序的问题:

这将创建一个配置文件,workbox-cli工具可以在后续步骤中使用。这是在运行时选择默认选项为 PWA 票据应用程序生成的:
module.exports = {
"globDirectory": "C:\Source Code\PWA\pwa-ticket\www\public",
"globPatterns": [
"**/*.{html,css,eot,svg,ttf,woff,woff2,png,txt,jpg,json,js,gif,manifest}"
],
"swDest": "C:\Source Code\PWA\pwa-ticket\www\public\sw.js"
};
CLI 命令使用配置文件中的设置来查找所有匹配globPatterns的文件,并在预缓存数组中创建一个条目。我将在下一节中介绍预缓存列表格式:

我不建议像这个例子那样预先缓存 600 个文件和 11 MB。相反,你应该自定义配置以将列表裁剪到适当的数量。在这种情况下,PWA 票据应用在构建过程中生成了所有票据条形码,因此有数据可以开发。
我会回来展示如何自定义配置文件以最大化您的 Workbox 服务工作者体验。配置文件和 workbox-cli 命令是构建服务工作者的关键,也是使用该库的关键。
您可以自由地以任何您认为合适的方式使用 Workbox 库。配置是为命令行工具准备的。如果您不熟悉 Workbox 的工作方式的不同细微差别,那么我建议您首先从向导开始。
一旦您掌握了 Workbox 库的工作方式,或者至少感到非常舒适,您就可以开始手动集成它。一切从将库导入到您的服务工作者开始。
在前面的章节中,我演示了如何使用 importScripts 在您的服务工作者中引用额外的脚本。您需要使用 importScripts 引用 workbox 库:
self.importScripts("https://storage.googleapis.com/workbox-cdn/releases/3.2.0/workbox-sw.js",
"js/libs/2ae25530a0dd28f30ca44f5182f0de61.min.js",
"js/app/libs/e392a867bee507b90b366637460259aa.min.js",
"sw/sw-push-manager.js"
);
这个例子展示了我是如何将 PWA 票务应用中的一些支持库替换为对 CDN 托管的 Workbox 脚本的引用。该库托管在 Google Cloud CDN 上。
您还可以使用 workbox-cli copyLibrary 命令将库复制到您的网站上。该库不是一个单独的文件,而是一组包含不同 JavaScript 类的多个文件。这些文件都被复制到目标目录中。我在 PWA 票务应用中使用了 /sw 文件夹。
在目标文件夹内创建了一个包含当前 Workbox 版本号的文件夹。要添加对库的引用,您需要引用 workbox-sw.js 文件:
self.importScripts("sw/workbox-v3.2.0/workbox-sw.js",
"js/libs/2ae25530a0dd28f30ca44f5182f0de61.min.js",
"js/app/libs/e392a867bee507b90b366637460259aa.min.js",
"sw/sw-push-manager.js"
);
在之前的 Workbox 版本中,整个库都被加载,这有很多代码。现在已经得到了改进,现在只加载您服务工作者所需的文件,减少了负载和所需的存储。我在这例子中使用的 3.2 版本有 23 个不同的文件或类。
如果您查看 copyFiles 方法创建的文件夹,您会发现更多内容。每个类都有两个版本,分别是生产版和调试版。还有源映射文件。我稍后会向您展示如何切换使用的版本,以及如何告诉 Workbox 如何查找本地模块。
CLI 工具是熟悉使用 Workbox 构建服务工作者的一种好方法,但它实际上只是一个库。在下一节中,我将回顾库的结构,并指导您如何在使用服务工作者时使用 Workbox 来最大化您的体验。
Workbox 结构
当您导入 Workbox 库时,您正在导入根级别模块。该模块加载属于 workbox 命名空间的其他组件,例如:
-
workbox -
workbox.core -
workbox.precaching -
workbox.routing -
workbox.strategies -
`workbox.expiration` -
workbox.backgroundSync -
workbox.googleAnalytics -
workbox.cacheableResponse -
workbox.broadcastUpdate -
workbox.rangeRequest -
workbox.streams
每个库都有自己的 API。有些是高级别的,而有些在或甚至扩展了其他库。路由使用策略类,而这些类可以通过插件进行扩展。Workbox 库可以处理大多数场景,但它们是可配置和可扩展的,允许您根据需要自定义它们。
您不仅限于库中包含的模块。您可以创建自己的组件,并为 Workbox 的功能添加更多价值。您还可以使用它们的模块作为参考来构建自己的定制解决方案。
服务工作者设置
默认情况下,Workbox 使用具有额外日志记录功能的库的调试版本。您可以通过在 setConfig 方法中将调试标志设置为 false 来关闭此功能,并使用生产版本。生产版本更轻量级,这意味着您不会对客户的流量计划造成太大负担。生产代码已压缩,并移除了更重的日志记录功能:
workbox.setConfig({
debug: false
});
默认情况下,Workbox 从 CDN 加载模块。如果您在自己的服务器上托管 Workbox,那么您需要配置 Workbox 从您的服务器加载模块。这需要使用配置对象中的 modulePathPrefix。
要配置 Workbox 使用本地生产版本,setConfig 调用将如下所示:
workbox.setConfig({
debug: false,
modulePathPrefix: "sw/ workbox-v3.2.0/"
});
您可以让 Workbox 管理关于您的服务工作者的一切,包括生命周期事件。如果您希望服务工作者立即变为活动状态,您应该对 skipWaiting(在第五章,服务工作者生命周期) 和 clientsClaim 方法执行此操作:
workbox.skipWaiting();
workbox.clientsClaim();
如果您在服务工作者中集成 Workbox 组件,并且没有将所有生命周期管理外包给 Workbox,那么您仍然可以在安装和活动事件中管理这些,就像您在本书的早期部分学到的那样。
使用 Workbox 进行预缓存
在本书的早期部分,我介绍了在安装事件中预缓存资源的概念。使用的标准模式是创建一个要缓存的 URL 列表,并将其传递给 cache.addAll 方法。当您的应用程序非常稳定且很少更改资源时,这很棒。但如果你只需要更新少量预缓存响应怎么办?
与使用 cache.addAll 方法不同,您需要创建更复杂的程序来检查修订版本与缓存资源之间的差异,并执行更新。
这就是 Workbox 预缓存模块发挥作用的地方。它抽象了管理您预缓存资源所需的复杂逻辑。您仍然可以提供简单的 URL 数组,就像我在前面的章节中演示的那样,并且它会缓存这些资源。但您还有选择包括相应的哈希或修订值,Workbox 可以使用这些值来跟踪资源。
workbox.precaching.precacheAndRoute 方法接受一个数组,该数组可以包含字符串、对象或两者的组合。在这个例子中,我只是将 PWA 票据预缓存列表复制到了该方法中:
workbox.precaching.precacheAndRoute([
"/",
"img/pwa-tickets-logo-320x155.png",
"js/app/512df4f42ca96bc22908ff3a84431452.min.js",
"js/libs/ca901f49ff220b077f4252d2f1140c68.min.js",
//...the remaining URLs to pre-cache
"cart/"
]);
Workbox 在 IndexedDB 中维护一个包含额外元数据的缓存响应索引。它只能在需要时使用此索引来更新预缓存资源。您需要做出的更改是将您的预缓存列表从仅字符串转换为包含 URL 和修订值的对象。
在这里,我已经修改了列表的版本,只显示其中的一些条目:
workbox.precaching.precacheAndRoute([
...
{
"url": "error.html",
"revision": "24d4cb67d5da47a373764712eecb7d86"
},
{
"url": "event/index.html",
"revision": "7127ba50c2b316ccbc33f2fad868c6a7"
},
{
"url": "events/index.html",
"revision": "6702e4d3b1554047e8655696875b396d"
},
{
"url": "fallback/index.html",
"revision": "d03fa5c7471ec4c84ca8bf8fefaddc2b"
},
....
]);
生成修订值最常见的方法是计算文件的哈希值。我在第八章,“应用高级服务工作者缓存策略”中演示了如何生成文件哈希值。你可以将此例程作为构建过程的一部分,或者利用 Workbox CLI 来帮助。
当你运行 Workbox CLI 向导时,它使用全局模式来识别你的网站资产。它创建一个带有分配给相应修订值的哈希值的预缓存文件列表。你有两个选择:让向导构建整个服务工作者,或者让它将预缓存代码注入现有的服务工作者中。
injectManifest 功能允许你使用 Workbox 基础设施注入预缓存代码。这是通过 CLI 和 injectManifest 命令完成的。你需要提供配置脚本的路径:
> workbox injectManifest path/to/config.js
CLI 工具查找以下内容以替换为对 preCacheAndRoute 的调用:
workbox.precaching.precacheAndRoute([]);
此方法允许你在构建过程中修改源服务工作者。它还允许你依赖向导为你创建文件哈希值。
如果你使用包含哈希或修订值的文件命名约定,你可以继续这样做,无需向 Workbox 提供哈希值。对于这些场景,你只需提供字符串引用即可。
一些文件不能在文件名中使用修订值,例如任何 HTML 的路径。更改 URL 会更改地址,这意味着你可能需要配置复杂的 301 重定向规则,或者更糟糕的是,断开资产链接。
在这个例子中,precacheAndRoute 方法接收一个包含字符串、带有修订值嵌入名称的 URL 以及不带修订名称的 HTML 文件对象的数组:
workbox.precaching.precacheAndRoute([
...
"js/app/libs/e392a867bee507b90b366637460259aa.min.js",
"js/app/libs/8fd5a965abed65cd11ef13e6a3408641.min.js",
"js/app/pages/5b4d14af61fc40df7d6bd62f3e2a86a4.min.js",
"js/app/pages/8684e75675485e7af7aab5ca10cc8da5.min.js",
"js/app/pages/88ea734e66b98120a5b835a5dfdf8f6c.min.js",
{
"url": "error.html",
"revision": "24d4cb67d5da47a373764712eecb7d86"
},
{
"url": "event/index.html",
"revision": "7127ba50c2b316ccbc33f2fad868c6a7"
},
{
"url": "events/index.html",
"revision": "6702e4d3b1554047e8655696875b396d"
},
{
"url": "fallback/index.html",
"revision": "d03fa5c7471ec4c84ca8bf8fefaddc2b"
},
....
]);
你可以构建你的列表并调用 preCacheAndRoute 方法,就像我展示的那样,但你也可以将预缓存列表分解成逻辑组,这样你可以使用 workbox.precaching.precache 方法单独提供这些组。在你提供了所有预缓存引用之后,你必须调用 addRoute 方法来完成此过程:
workbox.precaching.precache([
...
"js/app/libs/e392a867bee507b90b366637460259aa.min.js",
"js/app/libs/8fd5a965abed65cd11ef13e6a3408641.min.js",
"js/app/pages/5b4d14af61fc40df7d6bd62f3e2a86a4.min.js",
"js/app/pages/8684e75675485e7af7aab5ca10cc8da5.min.js",
"js/app/pages/88ea734e66b98120a5b835a5dfdf8f6c.min.js",
...
]);
workbox.precaching.precache([
...
{
"url": "error.html",
"revision": "24d4cb67d5da47a373764712eecb7d86"
},
{
"url": "event/index.html",
"revision": "7127ba50c2b316ccbc33f2fad868c6a7"
},
{
"url": "events/index.html",
"revision": "6702e4d3b1554047e8655696875b396d"
},
{
"url": "fallback/index.html",
"revision": "d03fa5c7471ec4c84ca8bf8fefaddc2b"
},
....
]);
workbox.precaching.andRoute();
如果你使用 CLI 生成预缓存文件和哈希值的列表,你需要修剪或限制它包含在列表中的文件。例如,PWA 票务应用程序自动为样式表和脚本创建哈希名称,但不为模板和 HTML 资产创建。
简单修改网站的 Workbox 配置文件可以移除从自动化过程中删除的 css 和 js 文件。只需从 globPatterns 正则表达式移除这些扩展名即可:
module.exports = {
"globDirectory": "C:\Source Code\PWA\pwa-ticket\www\public",
"globPatterns": [
"**/*.{html,eot,svg,ttf,woff,woff2,png,txt,jpg,json,gif,manifest}"
],
"globIgnores": ["qrcodes/*.gif", "img/venues/**/*.*", "img/people/*.*", "meta/**/*.*",
"html/pages/*.*", "css/webfonts/*.*", "img/pwa-tickets-logo*.*", "sw/cache.manifest"],
"swDest": "C:\Source Code\PWA\pwa-ticket\www\public\sw.js",
"swSrc": "C:\Source Code\PWA\pwa-ticket\www\public\sw.src.js"
};
还请注意,我在配置中添加了 globIgnores 属性。这告诉向导忽略任何匹配这些模式的文件。我知道二维码是由票据生成的,场馆图片应该按需加载。我还添加了一些额外的模式到忽略列表中。现在,这些资源不会进行预缓存,而不是预缓存超过 11 MB 的资源,服务工作者现在将在 39 个文件中预缓存 886 KB:

Workbox 提供了三种方式供你使用来生成修订值:
-
workbox-build:可以包含在任务运行器(如 grunt、gulp 或 npm 脚本)中 -
workbox-cli:可以生成列表并将其添加到脚手架(scaffolded)服务工作者中 -
workbox-webpack-plugin:适用于 webpack 用户
你不受 Workbox 工具生成的修订值(revision values)的限制,你可以在自己的构建过程中生成它们。修订值只需对每个文件版本唯一,以便 Workbox 服务工作者库可以检查是否需要更新资源。
Workbox 可以操纵请求以匹配缓存的版本。例如,营销标签通常添加在 QueryString 中,并且由于许多原因而变化。Workbox 可以配置为忽略 QueryString 模式以避免重复内容缓存。
这些是 Workbox 可以配置以优化的常见变化场景:
-
查询字符串和 URL 参数
-
默认文档:
index.html和default.html -
清洁 URL:自动将
.html添加到无扩展名的 URL 后面 -
自定义操作:这是一个机会,让你定义一个回调方法来返回可能的匹配数组
这需要使用 precaching.PrecacheController 对象。为此,你需要创建控制器的新实例并使用 addToCacheList 方法。此方法消耗与预缓存方法相同的数组(s)。
差别在于你必须手动管理服务工作者(service worker)的安装、激活和获取事件:
const precacheController = new workbox.precaching.PrecacheController();
precacheController.addToCacheList([
"/",
"img/pwa-tickets-logo-320x155.png",
"js/app/512df4f42ca96bc22908ff3a84431452.min.js",
"js/libs/ca901f49ff220b077f4252d2f1140c68.min.js",
//... the remaining URLs to pre-cache
"cart/"
]);
precacheController.addToCacheList([
{
"url": "html/pages/tickets.html",
"revision": "11c6e0cb67409cf544b162cd6a7ebfbf"
},
{
"url": "html/polyfils.html",
"revision": "337170ad8814e7571a7b8ddb8831ae04"
}
]);
self.addEventListener('install', (event) => {
event.waitUntil(precacheController.install());
});
self.addEventListener('activate', (event) => {
event.waitUntil(precacheController.cleanup());
});
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request).then(...));
});
在服务工作者事件安装和激活处理程序中,你需要调用 precacheController 的 install 和 cleanup 方法。
预缓存(Pre-caching)只是 Workbox 的众多优势之一。它的预缓存系统解决了预缓存中常见的常见问题,那就是如何在不清空整个缓存和重新加载的情况下保持缓存的资源更新。现在,你可以更新服务工作者,它只会更新已更改的资源,这是一个巨大的优势。
预缓存系统也是高度可配置和可定制的。尽管 Workbox 在抽象背后执行了大量工作,但你不需要感到失去了控制。
使用 Workbox 的动态路由
到目前为止,Workbox 部分已经关注了脚手架(scaffolding)、预缓存(pre-caching)和配置问题。但正如你应该知道的,服务工作者(service worker)的复杂性在增长,管理动态请求或任何没有预缓存响应的请求。
这正是 Workbox 真正发挥其肌肉力量的地方。
动态路由,如 PWA 票务事件页面,需要应用自定义的缓存逻辑。这就是workbox.routing对象发挥作用的地方。对于每个动态路由,你需要使用registerRoute方法注册路由和处理程序。它的签名如下:
workbox.routing.registerRoute(matchCb, handlerCb);
此方法需要一个匹配和处理回调方法。每个回调方法都提供了一个 URL 和事件(FetchEvent)对象。回调方法使用这些参数来确定要执行的操作。
回调签名可能看起来像这样:
const routeCallback = ({url, event}) => {
//do something here
};
匹配回调应该评估请求并返回一个布尔值,指示请求是否与模式匹配:
const EventsMatch = ({url, event}) => {
return (url.pathname.includes("/event/");
};
你还可以提供一个正则表达式对象(RegEx)。这为你提供了同时将路由处理器分配给多个相关资源的灵活性。这是你使用 Workbox 路由最常见的方式。
处理器回调可以自由地应用所需的任何缓存策略。再次强调,该方法应使用 URL 和事件对象来执行这些操作,就像你在前面的章节中学到的那样。
这个例子展示了你如何将第八章中演示的fetchAndRenderResponseCache功能集成进来,应用高级 Service Worker 缓存策略:
const eventsHandler = ({url, event, params}) => {
return responseManager.fetchAndRenderResponseCache({
request: event.request,
pageURL: rule.options.pageURL,
template: rule.options.template,
api: rule.options.api,
cacheName: cacheName
})
.then(response => {
invalidationManager.cacheCleanUp(cacheName);
return response;
});
};
在大多数情况下,你不需要创建自己的自定义回调方法,因为 Workbox 为大多数常见场景提供了策略模块。
你还可以为任何可能没有显式注册处理程序的路由定义一个默认处理程序:
workbox.routing.setDefaultHandler(workbox.strategies.cacheFirst());
注意我如何将其中一个缓存策略设置为默认值,cacheFirst?这应该有助于我们过渡到动态路由的下一步,即使用缓存策略。
当检索响应出现异常时,你可能需要有一个处理程序。Workbox 可以使用routing.setCacheHandler方法来完成此操作:
workbox.routing.setCatchHandler(({url, event, params}) => {
//create a custom response to provide a proper response for the error
});
缓存策略
Workbox 将五种最常见的缓存策略内置到库中:
-
Stale-While-Revalidate -
Cache-First -
Network-First -
Network-Only -
Cache-Only
这些策略中的每一个都在workbox.strategies命名空间中作为方法提供。你可以使用这些策略而不需要任何自定义配置,但正如你所看到的,Workbox 中的所有内容都是高度可配置的。
最好的部分是,这些策略方法返回一个指向正确配置的路由响应处理器的引用。
如果你想要使用这些策略中的任何一个与你的自定义 fetch 处理器一起使用,请随意。创建所需策略的新实例。然后,使用event.respondWith方法,提供策略的 handle 方法。你只需要将fetch event对象提供给 handle 方法:
self.addEventListener('fetch', (event) => {
if (event.request.url === '/my-special-url/') {
const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate();
event.respondWith(staleWhileRevalidate.handle({event}));
}
});
每个策略方法都允许你配置以下属性:
-
缓存名称
-
过期策略
-
扩展功能的插件
每个缓存策略方法都接受一个options对象。在这里,你可以指定这些自定义设置。首先,是cacheName。在这个例子中,我正在注册一个自定义路由来捕获单个事件页面请求并将它们缓存到名为 events 的缓存中:
workbox.routing.registerRoute(
new RegExp('/event/'),
workbox.strategies.cacheFirst({
cacheName: 'events'
})
);
我喜欢这个选项,因为它使得管理不同资产类型的缓存和失效变得更加容易。Workbox 通过一个自定义模块使管理失效变得更加简单。
Workbox 缓存失效
缓存失效由 Workbox 过期插件处理。此插件允许你控制特定规则可以缓存多少响应、生命周期或两者的组合。
过期插件应用于路由的处理程序或缓存策略方法。这是通过向策略的插件数组中添加一个新的过期插件引用来完成的:
workbox.routing.registerRoute(
new RegExp('/event/'),
workbox.strategies.cacheFirst({
cacheName: 'events',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 20,
maxAgeSeconds: 24 * 60 * 60 //1 day
})
]
})
);
如果你想要限制缓存响应的数量,为maxEntries属性提供一个数值。如果你想限制响应的有效时间,提供一个与响应有效秒数相匹配的数值。正如前面的例子所展示的,你可以同时使用这两个。在这种情况下,当任一条件为真时,清理逻辑就会被触发。
添加后台同步功能
服务工作者后台同步稍微有些复杂。它要求你将所有想要包含在同步逻辑中的网络相关活动包裹在标签中。这意味着在许多情况下,你必须修改或完全重写你的缓存逻辑。
此外,大多数浏览器目前还没有支持这个功能。我们正站在普遍支持的边缘,但这限制了这一重要功能的吸引力。
然而,Workbox 包含一个插件模块,使后台同步变得轻而易举。就像过期插件一样,你只需将后台同步插件添加到策略的列表中:
workbox.routing.registerRoute(
new RegExp('/event/'),
workbox.strategies.cacheFirst({
cacheName: 'events',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 20,
maxAgeSeconds: 24 * 60 * 60 //1 day
}),
new workbox.backgroundSync.Plugin('events', {
maxRetentionTime: 24 * 60 // Retry for max of 24 Hours
});
]
})
);
在插件内部,有一个队列与后台同步 API 一起工作,以确保请求被发送到服务器。你可以限制服务工作者尝试连接服务器的时长。在这个例子中,我将重试限制在了一天。与过期插件不同,maxRetentionTime属性是以分钟为单位衡量的,而不是秒。
后台同步给服务工作者代码增加的复杂性,这曾是我的热情的一大障碍。Workbox 使这个功能易于集成,这意味着你和我可以更容易地为我们的 Web 应用添加一个额外的功能层,而无需编写这段复杂的代码。
如果你期待从最终用户那里得到响应或数据,后台同步是一个重要的功能。我知道许多企业应用依赖于互联网连接,但员工可能不在稳定的 Wi-Fi 环境下。这让他们在间歇性或没有连接的情况下也能保持生产力。
作为一句忠告,我确实建议你给用户提供关于后台同步队列中挂起的请求状态的提示。这样,他们就知道他们提交的表单尚未提交到服务器。
使用 Google Analytics,即使用户离线
我经常被问到,也看到其他人在各种论坛上询问,“我如何使用像 Google Analytics 这样的分析服务与一个服务工作者一起使用?”
他们最终询问的是,当设备离线时,他们如何使用分析包,但服务工作者允许用户继续使用应用程序。用户活动是如何记录到分析工具中的?
好消息是,你可以使用服务工作者跟踪离线活动,但这确实需要你在部分额外的工作来存储所有活动在一个自定义队列中,并在设备重新上线时同步到分析包。
看起来很简单,对吧?
在这本书中,我没有花时间讨论的一个领域是后台同步 API。有几个很好的原因,主要是由于浏览器支持的有限以及它给服务工作者带来的额外复杂性。
如果你曾经编写过一个依赖于分析包(如 GA、Ensighten 等)的应用程序,你就知道事情可能会很复杂。利益相关者使用这些数据来了解他们的营销活动和网站是否有效,以及他们可以在哪里集中改进活动。
好消息是 Workbox 为你提供了支持,至少对于 Google Analytics 来说是这样。Workbox 是谷歌的一个项目,所以你应该期待他们对其产品提供易于使用的支持!简单可能低估了他们的解决方案,因为它只是一行代码:
workbox.googleAnalytics.initialize();
这启动了管理 Google 分析的工作库。每个请求和响应都通过后台同步层进行管理。如果你需要执行一些高级配置,你也有这个选项。
例如,如果你需要区分在线和离线活动,你可以提供覆盖。如果你理解自定义维度,cd1 参数对你来说将是有意义的:
workbox.googleAnalytics.initialize({
parameterOverrides: {
cd1: 'offline',
}
});
尽管 Workbox 只包括 Google Analytics 提供者,但这并不意味着你不能为你的分析包创建一个类似的处理器。你可以使用 GA 提供者作为参考或模板来创建你自己的分析包提供者。生产代码位于workbox-google-analytics.prod.js文件中。
不要仅仅在同步离线活动时考虑分析包。将其用作任何可能需要与之交互的在线 API 的模型,即使用户的设备离线,他们也可以继续交互。这对那些为现场代理提供业务线应用程序的公司来说非常有用。
摘要
我一直是个工具和自动化的粉丝,它们可以使我的应用编码更快,维护性更强,并且希望有更少的错误,但重要的是你要对任何生成的代码或组件的功能有一个清晰的理解。这就是为什么即使当你使用本章中我强调的渐进式 Web 应用工具时,你也需要能够识别它们的优点和局限性。
在开始使用 Workbox 这样的工具之前,你还需要对像服务工作者这样的复杂功能如何工作有一个清晰的理解。没有这些基本知识,你可能会迅速创建一个不符合你预期的服务工作者。此外,当你使用这些工具时遇到问题时,你也需要有强大的知识基础来帮助你调试。
我在本章中选择了四个我认为对开发者来说最有价值的工具进行回顾。这并不意味着所有可用的工具都能帮助你构建出色的渐进式 Web 应用。
Pinterest 在 GitHub 上有一系列你可能觉得有用的工具(github.com/pinterest/service-workers)。他们还有一个生成服务工作者的工具。还有一个 webpack 插件。但最引人入胜的工具可能是他们的服务工作者单元测试模拟环境。这允许你在不运行浏览器实例的情况下对你的服务工作者编写单元测试。
大多数主要框架也已经发布了命令行工具,以帮助你为单页应用生成路由逻辑。我也非常着迷于这些渲染引擎快速被包含到命令行工具中,以使用它们向客户端提供的相同逻辑将它们转换为服务器引擎。我认为这些静态网站围绕 PWA 逻辑构建起来要容易得多,因为它们有真实的 URL。
随着企业对渐进式 Web 应用需求的增加,开发者们处于一个需要深入了解如何创建新应用和更新现有应用以使其成为具有各种服务工作者复杂性的渐进式 Web 应用的位置。本章中调查的工具应该能帮助你构建这些解决方案。我希望这本书能帮助你建立起构建这些应用所需的坚实基础。


浙公网安备 33010602011771号