Angular 渐进式 Web 应用教程(二)
四、Angular 的服务工作器
到目前为止,我们构建的应用没有 PWA 特征。从本章开始,我们将逐步添加 PWA 功能并深入研究。Angular 提供了一个名为service-worker的模块来处理缓存策略和即时通知。Angular Service Worker 高度可配置,可以满足 Angular app 需求。但是,在我们开始实施本模块之前,我们应该对服务工作器有一个基本的了解。
本章从服务工作器基础和缓存 API 开始,因为当我们使用 Angular Service Worker 编码时,了解幕后发生的事情是至关重要的。然后,Angular CLI 将通过使用@angular/pwa原理图帮助我们搭建支架并将我们的项目转化为 PWA。
虽然重点是 CLI v6,但为了让我们更好地了解手动实现时需要做的工作,每个修改都将被分解,例如,在 Angular 版本 5 或更低版本中。
服务工作器:艾滋病人的大脑
你的大脑是决策的中心,完全控制着你的身体。服务器工人类似于我们的大脑。它们的核心是用 JavaScript 编写的工作脚本,在现代浏览器中只需几行代码就能实现,并在后台运行。一旦激活,开发人员就能够拦截网络请求,处理推送通知,管理缓存,并执行许多不同的任务。
你可能会问,不支持怎么办? 1
如果它没有在用户的浏览器中实现,它只是后退,网站将正常运行。PWA 的定义是,任何人——无论选择何种浏览器和操作系统——都应该能够浏览网站并获得最佳用户体验。这个描述指的是被称为“完美渐进增强”的短语
了解服务工作器
为了理解服务工作器,想象你自己坐在你大脑的中心。你被提供了不同的工具来控制你的身体。你什么都看得到,你可以做任何决定。这取决于你,要么让你的身体做它正常做的事情,要么把决定导向一个不同的方向。你甚至可能完全停止大脑的运作。这是您可以在服务工作器中对网络请求执行的操作;它的作用类似于网站和服务器之间的代理。完全接管网络请求的能力使服务工作器非常强大,并允许您做出反应和响应!
值得一提的是,尽管 Service Worker 是用 JavaScript 编写的,但它的行为略有不同,如下所示:
图 4-1
服务工作器可以在不同的线程中运行并拦截请求
-
在不同的线程中运行,与支持应用的主 JavaScript 不同。图 4-1 展示了服务工作器是如何坐在不同的线程上截取网络请求的。
-
在它自己的全球环境中运行。
-
设计为完全异步;因此,它不能访问诸如同步 XHR 和本地存储之类的东西。
-
在 worker 上下文中运行——因此,它不能访问 DOM。
-
除了用于开发的 Localhost 之外,仅在生产中运行 HTTPS。
-
在 1:1 范围内运行,这意味着每个范围只能有一个服务工作器。
-
可以随时终止。
服务工作器是事件驱动的。因此,一旦了解了事件的基本情况,就比你想象的更容易开始。简单地挑选你想参加的活动,你就可以开始了。让我们来看看服务工作器中的主要事件。
服务工作器生命周期
服务工作器在其生命周期中有不同的阶段。慢慢来,看看图 4-2 ,它展示了服务工作器生命周期是如何分四步进行的。想象一下,你的网站将由一名服务工作器提供服务:
步骤 1,当用户导航到网站时,通过调用register()函数,浏览器检测到服务工作器 JavaScript 文件;因此,它下载、解析并开始执行阶段。Register 函数返回一个承诺 2 ,在错误的情况下,注册被拒绝,服务工作器注册过程停止。
然而,第 2 步,如果注册顺利并得到解决,服务工作器状态变为 installed 。因此,一个安装事件在预缓存所有静态资产的最佳位置触发。请记住,安装事件仅在注册后第一次发生。
步骤 3,一旦安装成功完成,服务工作器就会被激活,并在自己的范围内拥有完全控制权。类似于安装事件,激活仅在注册后第一次发生,并且一旦安装完成。
图 4-2
服务器工作生命周期
注意
作用域用于指定您希望服务器工作程序控制的内容子集,可以通过register()函数第二个参数中的可选参数scope来定义,也可以默认定义服务器工作程序 JavaScript 文件所在的位置。例如,如果服务器 worker 文件位于应用的根目录中,它就可以控制所有页面。然而, /sw-test/指定只能访问这个原点下的所有页面。图 4-3 展示了示波器如何工作。
步骤 4,安装和激活事件无误完成后,服务工作器将开始工作。但是,如果它在安装、激活期间失败,或者被新的替换,它仍然是多余的,不会影响应用。
图 4-3
服务工作器范围演示
如前所述,没有服务工作器的网站不会处理任何请求;然而,一旦安装并激活了它,它就可以控制自己范围内的每一个请求。因此,要在第一次安装和激活后启动 Service Worker 中的逻辑,需要刷新网站,或者我们应该导航到另一个页面。
最后但同样重要的是,可能会发生这样的情况,我们想要改变一个注册并激活的服务工作器。如果在注册的文件中有字节大小的变化,浏览器会考虑它,所有的步骤,如上所述,会再次发生。但是,由于我们已经激活了一个服务工作器,因此流程略有不同。这一次,服务工作器不会立即被激活;因此,Service Worker 中的逻辑不会执行。它保持等待,直到所有运行旧服务工作器的标签和客户端被终止。换句话说,所有打开网站的标签都必须关闭,然后重新打开。因为我们是开发人员,并且知道忍者技巧,我们可以简单地跳过 DevTools 的等待,或者如果我们愿意,我们也可以在服务工作器逻辑中以编程方式完成。我们将在本章中对此进行详细的回顾。
服务工作器功能事件
除了安装和激活事件之外,获取、推送和同步事件在服务工作器中也是可用的,称为功能事件。简而言之:
-
Fetch :每次浏览器请求静态资产或动态内容时发生;例如,对图像、视频、CSS、JS、HTML 的请求,甚至 ajax 请求。
-
推送:web app 收到推送通知时发生。
-
Sync :让您推迟操作,直到用户拥有稳定的连接。这有助于确保用户想要发送的任何内容都被实际发送。该 API 还允许服务器向应用推送定期更新,以便应用可以在下次上线时进行更新。
Chrome DevTools(铬 DevTools)
没有合适的调试工具,任何开发人员都不会感到舒服。在所有浏览器中,在写这本书的时候,Chrome DevTools 是调试服务工作器的最佳选择。让我们来看一下 Chrome DevTools,看看它提供了哪些选项来帮助我们简化调试并更好地增强 PWAs。
控制台、应用和审计是 Chrome DevTools 中调试服务工作器的主要面板。审计小组利用 Lighthouse 、3T5,这是一个开源的自动化工具,用于提高网站质量,可用于运行可访问性、性能、SEO、最佳实践和 PWA 审计测试。我们使用审计面板来鉴定网页,特别是渐进式网络应用,这是我们的目标(见图 4-4 )。
图 4-4
Chrome 中的审计面板,我们在这里对网页进行审计测试
查看了应用面板后,我们看到以下内容:
-
清单:我们可以调试 Web App 清单 的地方。 4
-
服务工作器:在这里我们调试服务工作器,有很多选项,比如更新服务工作器、移除、跳过等待,以及不同的选项与网络一起工作(图 4-5 )。
-
离线:在浏览器中模拟无法上网。
-
重新加载时更新:每次页面重新加载时下载服务工作器,因此所有生命周期事件,包括安装和激活,都在重新加载时发生。这对调试非常有用。
-
绕过网络:将强制浏览器忽略任何活动的服务工作器,并从网络获取资源。这对于您希望处理 CSS 或 JavaScript,而不必担心服务工作器意外缓存和返回旧文件的情况非常有用。
-
-
清除存储:在这里我们可以删除所有缓存。
-
本地存储、会话存储、索引数据库、Web SQL 和 cookies 都是您可能熟悉的不同类型的存储。索引数据库将是本书的重点,因为它是异步的,服务工作器可以访问它。
-
缓存存储:这是浏览器中新的缓存 API,基于键值,能够存储请求和响应。我们打开这个缓存来存储我们的大部分资产和动态内容。这种缓存非常强大,在应用和服务工作器中都可用。
如果你有兴趣了解更多关于 Chrome DevTools 的信息,你可以在谷歌开发者网站的 https://developers.google.com/web/tools/chrome-devtools/ 中查看详细文档。我强烈建议您花点时间深入探索关于 DevTools 的信息,我相信这会让您更有效率。
图 4-5
Chrome DevTools 应用面板下的服务工作器选项
我知道您迫不及待地想要开始编码和查看示例代码,所以让我们开始吧。
服务工作器示例代码
是时候写几行代码来看看我们如何注册一个服务工作器并探索它自己的生命周期了。首先,我将创建一个简单的 html 文件,在</body>之前,我将打开一个<script>标签,并将注册service-worker.js文件,该文件位于index.html.旁边的根目录中
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Apress Simple Service Worker Registartion</title>
</head>
<body>
<div style="text-align: center; padding: 3rem">
<h1>Apress Simple Service Worker Registartion</h1>
</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/service-worker.js')
.then(registration => { // registeration object
console.log('Service worker is registered', registration);
})
.catch(e => {
console.error('Something went wrong while registaring service worker.')
});
}
</script>
</body>
</html>
渐进式改进意味着允许所有用户加载我们的网页,无论他们使用的是旧版本还是最新版本的浏览器。因此,我们应该经常检查不同浏览器中可能没有的功能。上面的代码由一个特性检查语句if ('serviceWorker' in navigator) {}.开始。一旦确保了可用性,就通过传递服务工作器路径来调用 register 方法register('/service-worker.js')。这个方法有第二个参数,这个参数是可选的,用于向方法传递额外的选项:例如,定义范围。因为 register 方法中没有第二个参数,所以 scope 应该是缺省值;在这种情况下,它是服务工作器文件所在的根目录。then和catch分别在承诺注册被解析或拒绝时返回注册或错误对象。
server-worker.js中的逻辑是激活和安装事件的监听器,其中我们在控制台的回调函数中记录两条消息。Self在这里指的是ServiceWorkerGlobalScope。
//service-worker.js
self.addEventListener("install", (event) => {
console.log("[SW.JS] Step 2, Service worker has been installed");
});
self.addEventListener("activate", (event) => {
console.log("[SW.JS] Step 2, Service worker has been activated");
});
当您在控制台面板中打开 devTools 时,您将能够看到日志(参见图 4-6 )。
注意
可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter04/01-simple-service-worker 。运行npm install然后运行npm start。它在端口 8080 上运行一个 web 服务器。可以导航到localhost:8080。如果您将书中的代码复制并粘贴到项目中,您需要一个 web 服务器来运行您的代码。
重新加载网页;从现在开始直到service-wokrer.js中的新变化,你将只能看到登录在控制台中的注册对象,并且安装和激活不再被触发(见图 4-7 )。
图 4-7
一旦服务工作器被激活,第二次重新加载不再触发安装和激活事件
图 4-6
首次注册时的服务工作器生命周期。如您所见,安装和激活事件时有发生。
Reload the web page; from now on until the new change in
只需在服务工作器文件中添加几行,然后在应用面板中观察服务工作器的同时重新加载应用。
// modified service-worker.js
// this is equivalent to following addEventistener
// self.oninstall = (event) => { };
self.addEventListener("install", event => {
console.log("[SW.JS] Step 2, Service worker has been installed");
console.log("Just added something;");
});
// this is equivalent to following addEventistener
// self.onactivate = (event) => { };
self.addEventListener("activate", event => {
console.log("[SW.JS] Step 3, Service worker has been activated");
});
重新加载后,您将看到一个新的服务工作器正在等待,直到所有客户端都被终止。一旦浏览器检测到服务工作器的新变化,就安装这个文件;然而,直到所有的客户端都被声明,它才被激活——换句话说,所有的标签页都需要被关闭并重新打开,以编程方式在 Service Worker 中执行skipWaiting,或者你可以手动点击 Chrome DevTools 中的SkipWaiting,如图 4-8 所示。
图 4-8
在 DevTools 中,您可以单击 SkipWaiting 来激活新的服务工作器
到目前为止,我们已经发现了 Service Worker 及其生命周期是如何工作的。现在是时候展示缓存 API 功能了,并在下一节中看到它的实际应用。
缓存 API
连通性独立性是 PWAs 的一个顶级特征,它使 PWAs 与众不同。缓存 API 是浏览器中一个新的缓存存储,我们可以将请求存储为键,将响应存储为值。在本节中,我们将快速浏览一下缓存 API,以了解离线特性是如何工作的。
我改变了应用的结构,加入了app.js文件和style.css文件,前者操纵 DOM 显示标题,后者包含一个title使标题居中。
.
├── app.js
├── index.html
├── service-worker.js
└── style.css
// app.js
const title = document.querySelector(".title");
title.innerHTML = "<h1>Apress Simple Service Worker Registartion</h1>";
// style.css
.title {
text-align: center;
padding: 3rem;
}
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Apress Simple Service Worker Registartion</title>
<link href="/style.css" rel="stylesheet">
</head>
<body>
<div class="title"></div>
<script src="/app.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
}
</script>
</body>
</html>
预缓存静态资产
每个 web 应用都包含许多静态资产,包括样式、JavaScript 和图像。正如本章前面提到的,一旦安装事件触发,就可以利用该事件并编写所需的逻辑。在服务工作器控制所有内容之前,它会在每次安装时触发一次;因此,这里是打开缓存并将数据添加到缓存存储的最佳位置之一,在这里加载应用基础是必不可少的。
server-worker.js
// always add version to your cache
const CACHE_VERSION = "v1";
const PRECACHE_ASSETS = ["/", "/style.css", "/index.html", "/app.js"];
self.oninstall = event => {
console.log("Install event, start precaching...");
event.waitUntil(
caches.open(CACHE_VERSION).then(cache => {
return cache.addAll(PRECACHE_ASSETS);
})
);
};
让我们分解代码。首先,我们定义了一个缓存存储名称,它被指定为版本名称。其次,这个应用要求,为了在没有互联网连接的情况下运行,它的一些静态资产必须列在一个数组中。
一旦 Service Worker 中的 install 事件触发,不管 callback 中的逻辑结果是什么,它都会被关闭。因此,我们需要一种机制来告诉服务工作器在行动解决之前保持不动。因此,waitUntil()是一个方法,它告诉浏览器保持在同一个事件中,直到将要传递给该方法的一个或多个承诺被解析。
最后,caches.open()接受一个名字并打开缓存将数据存入其中.其他的Caches方法有:
-
delete(cacheName) :删除整个缓存名,返回 Boolean。
-
has(cacheName) :查找缓存名,返回 Boolean。
-
keys() :检索所有缓存名称并返回字符串数组。
-
匹配(请求):匹配一个请求,如果有的话。
-
open(cacheName) :打开一个缓存,添加请求/响应。
所有缓存 API 都是基于承诺的。
一旦一个缓存打开,我们可以一个接一个地或者作为一个数组添加我们所有的资产。
其他可用的缓存方法如下:
-
add(request) :添加请求,可以字符串形式添加名称。
-
addAll(requests) :添加请求数组或字符串数组。
-
delete(request) :删除请求或名称字符串,返回布尔值。
-
keys() :检索所有缓存名称并返回字符串数组。
-
匹配(请求):匹配一个请求,如果有的话。
-
matchAll(requests) :匹配请求数组,如果有的话。
-
put(request,response) :用新的响应修改现有的请求。
您可能会问,我应该将缓存转储到哪里?很好的问题——就在服务工作器控制其范围内的所有页面之前,这意味着激活一个事件。假设我们已经将缓存版本升级到 v2 ,我们想要删除所有过时的缓存,这有助于清理过时的缓存并释放空间(参见图 4-9 )。
图 4-9
在安装事件中有两个版本的缓存可用,因为新的服务工作器尚未激活
我们需要过滤掉除当前缓存之外的所有其他缓存,并删除所有缓存。
// service-worker.js
self.onactivate = event => {
console.log("activate event, clean up all of our caches...");
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(cacheName => cacheName !== CACHE_VERSION) .map(cacheName => caches.delete(cacheName));
})
);
};
我们调用waitUntil()方法来告诉浏览器停留在激活事件中,直到传递给该方法的所有承诺都被解析。正如你在上面的代码中看到的,所有的密钥被检索,然后在不等于当前版本的地方被过滤,然后删除所有以前的缓存(见图 4-10 )。
图 4-10
一旦新的服务工作器被激活,所有先前更新的缓存将被删除
在回顾了服务工作器和缓存 API 之后,我迫不及待地期待开始 Angular 服务工作器模块。
Angular 维修工人模块
从概念上讲,Angular Service Worker 类似于安装在最终用户 web 浏览器中的转发缓存或 CDN edge,它满足 Angular 应用对本地缓存中的资源或数据的请求,而无需等待网络。像任何缓存一样,它有内容过期和更新的规则。
在向项目添加任何东西之前,让我们使用审计面板中的 Lighthouse 来分析我们的应用。
导航到 awesome-apress-pwa.firebaseapp.com5或您已经部署了应用的 Firebase URL。
注意
可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter04/03-analyze-using-lighthouse 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行一个生产应用。可以导航到localhost:4200。您可能还需要将这段代码部署到 Firebase,以便在添加服务工作器之前评估您的应用。
接下来,在 Chrome 中打开开发者工具 6 ,点击审计面板。我们的主要目标群体是移动用户。因此,最好在手机上选择仿真,并取消选中除Progress Web App7之外的所有复选框,并选择模拟快速 3G,4 倍 CPU 减速中的节流 8 选项,以确保我们的测试环境与普通真实移动用户设备相似。确保清除存储也被选中,因为重点访问者是第一次加载网页的人。
按下运行审计,等待 Lighthouse 生成报告。结果显示一个 54/100 9 的分数;那是因为我们有一些审核通过了。如图 4-11 所示,六个故障主要与服务工作器、渐进式增强和 Web App 清单有关。
注意
如果你在本地主机上运行审计,请记住,因为你不是用 HTTPS 运行你的应用,你可能会看到一个较低的分数。
图 4-11
向项目添加任何新优化之前的初始结果
对服务工作器的支持
Angular schematics10已引入 Angular CLI 6,并对我们如何快速搭建 Angular 应用产生了显著影响。因此,添加 PWA 功能(包括服务工作器)是一个简单的过程,非常容易。由于@angular/cli已经在全球范围内安装,只需在您的终端中运行以下命令。
ng add @angular/pwa
该命令 11 将通过扩展样板代码和添加新文件到 Angular app 结构中来自动修改一些现有文件。让我们仔细看看修改。
CREATE ngsw-config.json (441 bytes)
CREATE src/manifest.json (1085 bytes)
CREATE src/assets/icons/icon-128x128.png (1253 bytes)
CREATE src/assets/icons/icon-144x144.png (1394 bytes)
CREATE src/assets/icons/icon-152x152.png (1427 bytes)
CREATE src/assets/icons/icon-192x192.png (1790 bytes)
CREATE src/assets/icons/icon-384x384.png (3557 bytes)
CREATE src/assets/icons/icon-512x512.png (5008 bytes)
CREATE src/assets/icons/icon-72x72.png (792 bytes)
CREATE src/assets/icons/icon-96x96.png (958 bytes)
UPDATE angular.json (4049 bytes)
UPDATE package.json (1646 bytes)
UPDATE src/app/app.module.ts (1238 bytes)
UPDATE src/index.html (652 bytes)
如你所见,不同大小的图标, ngsw-config.json, manifest.json,和ngsw-worker.js 12 被添加到项目中while angular.json, app.module.ts, index.html,和package.json被修改。
让我们来分解一下变化,看看它在哪些方面发生了变化:
-
package . JSON:Angular Service Worker
"@angular/service-worker"已经被添加到依赖列表中,在撰写本书时,已经安装了 6.1.0 版本。当你读到这本书时,它可能会升级或增加一个新版本。 -
ngsw-config.json :添加到项目的根,包含一个 Service Worker 配置。在这一章中,我们将看一看它并浏览基础知识,而在下一章中,我们将深入研究它并添加更多的高级配置以及提示和技巧。
{ "index": "/index.html", "assetGroups": [ { "name": "app", "installMode": "prefetch", "resources": { "files": [ "/favicon.ico", "/index.html", "/*.css", "/*.js" ] } }, { "name": "assets", "installMode": "lazy", "updateMode": "prefetch", "resources": { "files": [ "/assets/**" ] } } ] } -
manifest.json :添加到项目中的 /src/ 文件夹。它包含一个使应用可安装的配置。在第六章中,
manifest.json将被深入回顾。{ "name": "lovely-offline", "short_name": "lovely-offline", "theme_color": "#1976d2", "background_color": "#fafafa", "display": "standalone", "scope": "/", "start_url": "/", "icons": [ { "src": "assets/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" }, { "src": "assets/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, { "src": "assets/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "assets/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" }, { "src": "assets/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" }, { "src": "assets/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "assets/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, { "src": "assets/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ] } -
不同图标:在 src/assets/icons/ 中增加,并在
manifest.json中重复使用。我们将在第六章回到这些图标。 -
Angular.json :如你所知,这个文件包含了所有 Angular CLI 配置。由于
manifest.json需要在公共/构建文件夹中公开,因此必须在适用的架构配置中将它添加到assets数组中。例如,请参见下面的代码片段:"architect": { "build": { "builder": "@angular-devkit/build-angular:browser", "options": { ... "assets": [ "src/favicon.ico", "src/assets", "src/manifest.json" ], "styles": [ ... "src/styles.scss" ], "scripts": [] }, ...There will be one more change here.
serviceWorkerhas been added to the production configuration to inform Angular CLI that this feature is enabled. Let take a look at the configuration’s snippet:"configurations": { "production": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" } ], "optimization": true, "outputHashing": "all", "sourceMap": false, "extractCss": true, "namedChunks": false, "aot": true, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, "serviceWorker": true } } -
【Index.html】:将
manifest.json添加到项目后,需要通过 index.html 头部的rel=manifest进行暴露,让浏览器知道这个文件是项目的清单配置文件。主题颜色 meta 告诉浏览器用什么颜色来着色 UI 元素,比如地址栏。<link rel="manifest" href="manifest.json"> <meta name="theme-color" content="#1976d2"> -
app.module.ts :是我们的主应用模块,已经修改为导入
ServiceWorkerModule,以便为项目添加服务工作器功能和特性。该模块注册了ngsw-worker.js服务工作器 JavaScript 文件,该文件由 Angular 团队编写和维护,将在 prod 构建后添加到项目的根目录中。它还有第二个参数,以确保只有当应用准备好生产时才启用注册,并且不会中断开发环境。ServiceWorkerModule.register("ngsw-worker.js", { enabled: environment.production })Angular 中的服务工作器还可以在另外两个选项中注册:
-
在
index.html中添加注册脚本,请参考上一节我们注册一个简单的服务工作器。记得注册ngsw-worker.js。我不推荐这个选项;相反,如有必要,请使用下一个选项。 -
在
bootstrapModule()被解析后,在main.ts中使用相同的注册码,// main.ts platformBrowserDynamic().bootstrapModule(AppModule) .then(() => { if ('serviceWorker' in navigator && environment.production) { window.addEventListener('load', () => { navigator.serviceWorker.register('/ngsw-worker.js') ; }); } }) .catch(err => console.log(err));
注意
ServiceWorkerModule.register()除了enable还有scope选项。
虽然@angular/pwa原理图有助于快速建立一个 Angular PWA 项目,但有些情况下我们需要手动完成上述所有步骤。例如:
-
如果您在生产中运行 Angular 5,仍然有机会将 Angular Service Worker 模块添加到您的应用中。简单地回到每一步,尝试一个接一个地添加或修改所有的更改。运行
npm install以确保@angular/service-worker已成功安装,您可以开始运行了! -
您可能只需要单独的 ServiceWorker 模块,而不需要其余的特性:例如, manifest.json.
看起来每一个部分都已经就位,可以开始生产了。在下一部分中,我们将检查 dist 文件夹并探索新的内容。
ngsw-config.json 解剖学
Angular Server Worker 是为大型应用而设计和编程的;因此,它是高度可配置的。
规则写在ngsw-config json文件中。顶级 Angular 服务工作器配置对象接口指示有五个主要属性可以使用。
interface Config {
appData?: {};
index: string;
assetGroups?: AssetGroup[];
dataGroups?: DataGroup[];
navigationUrls?: string[];
}
默认情况下,index.html已被添加为主入口点。看了一下 assetGroups 接口,它是一个为 JavaScript、图像、图标、CSS 和 HTML 文件等静态资产设置规则的数组。
type Glob = string;
interface AssetGroup {
name: string;
installMode?: 'prefetch' | 'lazy';
updateMode?: 'prefetch' | 'lazy';
resources: {
files?: Glob[];
versionedFiles?: Glob[];
urls?: Glob[];
};
}
注意
VersionedFiles 是贬值的,从 v6 开始,“versionedFiles”和“Files”选项具有相同的行为。请改用“文件”。
我们已经看到 Angular CLI 向ngsw-config.json添加了默认规则:
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"]
}
}
]
如图所示,这个数组中有两个对象。让我们探索第一个对象。
-
name :定义组名,并将成为缓存 API 存储名的一部分。
-
installMode :决定缓存或提取组资源时缓存策略的行为。它有两个选项:
-
prefetch :表示所有的资源都被下载,并且应该在 install 事件时立即被缓存;这类似于我们在本章前面看到的预缓存资产。这种模式用于缓存应用引导(如 app-shell)所需的资产,以使应用完全具备离线能力。
-
lazy :意思是每个资源在运行时被请求时被单独缓存。
-
-
resources:要缓存的资源的明确列表。有两种方法来设置它们:文件或网址。如上所述,VersionedFiles 是贬值的,其行为与文件相同。
-
files :包含与根中的文件匹配的 globs 列表(在本例中)。代表已经用适当的文件扩展名定义的文件名。例如, 。js 表示所有的 JavaScript 文件, / 表示它们位于根目录。总之, **/。js* 表示位于项目根目录下的所有 JavaScript 文件。
-
urls :包含一个应该被缓存的外部 URL 列表(相对的,绝对的路径,或者在不同的原点上):例如,Google 字体。URL 不能被散列,因此通过配置的改变,它们将被更新。在默认配置中,没有 URL,但是在下一章中我们将需要它来添加我们的外部资源。
-
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
}
},
注意
文件的内容将被散列到ngsw.json文件的散列表 13 节点中。它有助于有一个准确的版本。请记住,文件路径被映射到应用的 URL 空间,从基本 href 开始。
显然,它试图预先缓存运行 Angular 应用所需的基本文件,即使在没有网络的情况下。
前进到第二个对象,它具有类似的配置,除了它以所有文件为目标,而不考虑它们在/assets文件夹下的文件扩展名,这些文件将在运行时一被获取就被缓存。如果这些资产中的每一个有新的变化,它将被立即获取和更新。
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"]
}
}
-
安装模式:请参考对象一描述。
-
updateMode :确定当应用具有新版本并被下载时,每个缓存的资产应该如何表现;类似于安装模式,它有两个选项:
-
预取:意味着每一个新的应用版本都应该刷新每一项资产(如果需要的话)。Angular 创建 hashTable 来比较哈希值,如果有新的变化,就会下载资产。以这种方式,缓存中的 URL 将总是被刷新(具有 If-Modified-Since14请求)。
-
懒惰:然而,当请求资源时,执行类似于上面的流程。这个模式只有在
installMode也很懒的情况下才有效。
-
-
资源:请参考对象一描述。
- 档案 : **代表一切。在这种情况下,
/assets/**表示资产文件下的所有文件,包括图像、图标等。
- 档案 : **代表一切。在这种情况下,
注意
installMode和updateMode的默认值为ngsw.js中的prefetch
我相信这样一句话:理解,不要模仿。“评估每个对象应该有助于我们根据应用中的需求编写自己的规则。基本面是一样的;然而,你可能需要更高级的设置,比如为外部资源和导航 URL 编写规则,这将在下一章讨论。
与 Angular 的服务工作器一起构建项目
ServiceWorker模块仅在我们运行生产版本时启用。运行以下命令,开始在生产环境中构建一个应用:
npm run build:prod // or ng build --prod
ngsw-worker.js是我们的服务工作器文件,而ngsw.json是我们的配置,将由服务工作器获取并相应地实施。
.
├── 0.c570a2562d2874d34dc4.js
├── 1.71eb2445db7dfda9e415.js
├── 2.df6bb6a6fde654fe8392.js
├── 3rdpartylicenses.txt
├── assets
├── favicon.ico
├── index.html
├── main.873527a6348e9dfb2cc1.js
├── manifest.json
├── ngsw-worker.js
├── ngsw.json
├── polyfills.8883ab5d81bf34ae13b1.js
├── runtime.e14ed3e6d31ff77728e9.js
├── safety-worker.js
├── styles.7a3dc1d11e8502df3926.css
└── worker-basic.min.js
ngsw-worker被注册为服务工作器逻辑文件,ngsw.json正在基于ngsw-config.json创建。所有的配置和资源都是在ngsw.json清单中生成的,它是由ngsw-worker中的编写逻辑自动获取的,并根据该文件中定义的 URL、文件和策略添加、更新或删除缓存。它包含一个根据 build-hash 和 Angular Service Worker 的哈希表。如果有任何变化,检查该散列以更新在 dist 文件夹中的资源。
如果你打开 ngsw manifest 文件,静态资产和 JavaScript 文件在构建之后已经被神奇地添加了。最终,Angular CLI 将匹配我们所有的文件,并将它们添加到 ngsw,因为我们需要每个文件的完整路径。ngsw.json还通知 Angular 将这些资源提取到缓存中,并相应地更新它们。值得一提的是,尽管这个文件是为 Angular Service Worker 设计的,但对于我们这些开发人员来说,这是一个非常可读的文件。
让我们按照启动本地服务器的命令运行:
npm run prod
导航到localhost:4200并打开你的 Chrome 开发工具。打开应用面板并检查服务工作器。图 4-12 清楚地显示了ngsw-worker.js已经成功安装,以及缓存存储器中不同的缓存是如何创建的。
在下一章中,ngsw manifest 和ngsw-worker将被深入回顾。
图 4-12
ngsw-worker.js已安装,资源已添加到缓存存储中
我们需要像往常一样运行以下命令来部署一个新的构建到 Firebase,并查看我们在设置中的所有工作是如何进行的:
npm run deploy
一旦部署完成,在 Chrome DevTools 中打开审计面板,按运行审计(见图 4-13 ) *。*记住,我们应该保持本章前面所做的所有设置。
是的,这是真的:图 4-13 所示的 100/100 分已经通过在 Angular 中添加几个步骤实现了,这主要是通过 CLI 完成的。这很好,但是我们还有很多事情要做。
图 4-13
通过 ng CLI 为 PWA 原理图设置 Angular 后,得分为 100
注意
第十三章和第十四章致力于构建一个带有 Workbox 的 PWA,这是一个创建我们的服务工作器和缓存策略的工具。我们的目标是对所有 Angular 应用进行 100% PWA 覆盖,无论其版本如何。因此,如果您的 Angular 版本没有 Angular Service Worker 模块,或者 Angular Service Worker 不符合您的基本要求,请不要担心。你很快就会被覆盖。
摘要
Angular 团队的目标是使 PWA 特性尽可能简单。如您所见,在 Angular 项目中设置这些特性是一个简单的过程。在本章中,我们已经了解了如何使用 Angular CLI 将我们的 Angular 应用转换为 PWA,不仅使用 pwa 原理图,还使用定义的步骤手动再现,同时解释了默认配置。
虽然这个应用得到了 100 分,但这并不意味着我们已经完成了在任何情况下运行我们的应用所需的所有内容。因此,请耐心等待,我们将深入探讨更多配置、设置和高级技术,以满足所有生产就绪型应用的要求。
话虽如此,我鼓励你继续下一章。
Footnotes 1所有主流浏览器,支持服务工作器。勾选 https://caniuse.com/#feat=serviceworkers
2
https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise
3
https://developers.google.com/web/tools/lighthouse/
4
第六章专门讨论 Web 应用清单。
5
或者,您可以运行ng serve --prod to run production ready app served a locally runned server, then nagivate to localhost:4200.
6
在 Windows 中按 Ctrl + Shift + I,在 Mac 中按 Cmd + Shift + I。
7
我们确实运行了本书中的所有其他选项,并进行优化以达到 100/100 的分数。
8
在灯塔阅读更多网络节流: https://github.com/GoogleChrome/lighthouse/blob/master/docs/throttling.md 。
9
Lighthouse 验证了 PWA 的许多方面,PWA 具体基于 https://developers.google.com/web/progressive-web-apps/checklist 。
10
点击 https://blog.angular.io/schematics-an-introduction-dc1dfbc2a2b2 了解更多原理图。
11
Angular cli 和 PWA 原理图的 6.1.3 版有问题。所以请升级或降级到更低的版本,可能是 6.1.0 或 6.2+。
12
您需要构建生产环境才能在/dist 文件夹下找到该文件。
13
https://en.wikipedia.org/wiki/Hash_table
14
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
五、高级 Angular 服务工作器和运行时缓存
在前一章中,我们实现了 Angular Service Worker,并看到 Angular CLI 帮助我们以最小的努力运行 PWA。基本配置是我们创建带 Angular PWA 之旅的开始。很明显,随着应用的发展,它将需要先进的技术和策略。因此,Angular Service Worker 提供了更多功能来处理各种情况。
在这一章中,我将把配置扩展到一个更高的层次,以便创建一个完全脱机的应用。然而,我们从学习服务工作器中复杂的缓存策略开始,这使我们能够理解 Angular 服务工作器实现的基础。
缓存策略
Service Worker 中有几种处理请求和响应的模式。它因应用而异。根据需要,您可以使用下面几节中讨论的一个或多个策略。
仅缓存
在这种策略中,请求总是在缓存中寻找匹配,并做出相应的响应。这对于“版本化”文件来说是理想的,因为它们应该存在于您的应用中,并且在下一次部署之前被认为是静态的和不变的。通常应用需要运行的所有静态资产,我们在安装事件时缓存它们。图 5-1 是显示其工作原理的简单图示。
图 5-1
仅缓存策略说明
下面的代码片段显示了我们如何使用这个策略。
self.addEventListener("fetch", event => {
event.respondWith(caches.match(event.request));
});
请注意,如果在缓存中找不到匹配的请求,respond 将看起来像一个连接错误。
仅网络
有些用例没有离线的对等物。假设您有一个股票交易网站,并且总是需要向用户显示最新的汇率。图 5-2 简单展示了其工作原理。
图 5-2
仅网络
self.addEventListener("fetch", event => {
event.respondWith(fetch(event.request));
});
有可能你没有调用event.respondWith ,,这导致了默认的浏览器行为。
缓存退回到网络或缓存优先
这为您提供了仅缓存和仅网络的组合,其中它尝试匹配来自缓存的请求,如果它不存在,则它退回到从网络获取请求。参见图 5-3 了解其工作原理。
图 5-3
缓存退回到网络或缓存优先
self.addEventListener('fetch', function(event) {
const request = event.request;
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
我们可以利用这种策略来动态缓存内容。
self.addEventListener("fetch", event => {
const request = event.request;
event.respondWith(
caches.match(request).then(res => {
// Fallback
return (
res || fetch(request).then(newRes => {
// Cache fetched response
caches
.open(DYNAMIC_CACHE_VERSION)
.then(cache => cache.put(request, newRes));
// Response can be used once, we need to clone to use it more in the context
return newRes.clone();
})
);
})
);
});
请记住,更新缓存的内容将在同一请求的下一次访问中可用。
网络退回到缓存或网络优先
这种策略适用于那些无论应用版本或版本化文件如何都应该更新的资源:例如,在社交媒体中显示最新文章或时间线(见图 5-4 )。最终,最新的内容将显示给我们的在线用户,而在离线模式下,用户将收到内容的旧缓存版本。与前面的策略类似,当网络请求成功时,我们很可能希望更新缓存条目。
图 5-4
网络退回到缓存或网络优先
self.addEventListener("fetch", event => {
const request = event.request;
event.respondWith(
fetch(request)
.then(res => {
// Cache latest version
caches
.open(DYNAMIC_CACHE_VERSION)
.then(cache => cache.put(request, res));
return res.clone();
}) // Fallback to cache
.catch(err => caches.match(request))
);
});
然而,在慢速或间歇连接的情况下,用户面临不可接受的和不愉快的体验,因为获取需要非常长的时间;因此,从用户的 Angular 来看,这将是令人沮丧的。如果你正在寻找更好的选择,请看下一个模式。
缓存和网络
其思想是首先向用户显示旧的缓存内容(如果存在的话),然后在网络请求成功时更新 UI。换句话说,您必须在页面中发出两个 fetch 请求,并且在 Service Worker 中,您应该总是使用最新的 fetch 响应来更新缓存。图 5-5 展示了其工作原理。
你在 Twitter 等许多社交媒体平台上看到过这种模式,它们通常会显示旧的缓存内容,然后在时间轴上添加新的内容,并调整滚动位置,以便用户不受干扰。总而言之,这非常适合需要经常更新的内容,比如文章或活动时间表。
虽然这种策略给了我们的用户更好的体验,但它也可能是破坏性的:例如,当用户阅读网站上的内容时。突然间,为了更新用户界面并向他们显示新的数据,一大块内容消失了。因此,重要的是我们要确保用户与应用的互动,永远不要中断,以使它尽可能平稳。请记住,PWA 最重要的目标之一是为我们的用户提供更好的体验。
应用中的代码如下所示:
图 5-5
缓存和网络
const hasFetchData = false;
// fetch fresh data
const freshDataFromNetwork = fetch(YOUR_API)
.then((response) => response.json())
.then((data) => {
hasFetchData = true;
showDataInPage();
});
// fetch cached data
caches.match(YOUR_API)
.then((response) => response.json())
.then(function(data) {
if (!hasFetchData) {
showDataInPage(data);
}
})
.catch((e)=>{
// in case if cache is not availble, we hope data is received by network fetch
return freshDataFromNetwork;
})
注意
除了服务工作器之外,缓存 API 在窗口对象和其他工作器中可用。
服务工作器中的代码类似于网络在更新缓存时回退到缓存。
self.addEventListener("fetch", event => {
const request = event.request;
event.respondWith(
caches.open(DYNAMIC_CACHE_VERSION).then(cache => {
return fetch(request).then(res => {
cache.put(request, res.clone());
return res;
});
})
);
});
你可能会问,网络和缓存都失效怎么办?查看下一个模式,了解更多信息。
通用回退
这种模式非常适合于替代那些在缓存和网络中都不可用的请求:例如,当一个用户有一个虚拟角色,而从网络和缓存获取失败时。因此,我们可以简单地用照片占位符替换这个请求。另一个例子是当请求失败时向用户显示一个离线页面。您可以简单地预缓存 offline.html 页面,并在必要时从缓存中进行匹配。图 5-6 说明了它是如何工作的。
图 5-6
通用回退
self.addEventListener("fetch", event => {
const request = event.request;
event.respondWith(
// check with cache first
caches
.match(request)
.then(res => {
// Fall back to network and if both failes catch error
return res || fetch(request);
})
.catch(() => {
// If both fail, show a generic fallback:
return caches.match("/offline.html");
})
);
});
在实际的应用中,即使您可以向用户显示脱机替换,您也可能希望将数据存储到 indexedDB 中,并让您的用户知道请求被成功保留并将被同步。我们将在第九章一起回顾离线存储。
注意
很可能在一个应用中使用所有或许多缓存策略取决于我们需要实现什么。评估您的特定用例,然后选择一个适合它的模式。
在我们回顾 Angular 运行时缓存之前,理解 Service Worker 中大多数常见的缓存模式是很重要的。我相信您会对 Angular 缓存策略有更好的理解,因为您知道它们是如何工作的。让我们继续学习 Angular Service Worker 高级配置。
Angular Service Worker 中的运行时缓存
使用ngsw-config.json配置 Angular 维修工人。在 Angular CLI 的帮助下,运行准系统 Angular 应用的默认设置已经就绪。但是随着应用的开发,我们发现需要缓存外部文件、CDN 资源以及从远程 API 调用填充数据。它变得更加复杂,我们希望缓存所有数据或至少部分缓存具有增强的性能、更快的应用和流畅的体验。我的目标是在这一节中介绍应用在数据和外部文件缓存方面的需求。让我们继续。
注意
运行时缓存也可以称为动态内容缓存。其思想是在应用运行时获取或请求数据时缓存数据,而数据在安装事件时尚未存储到缓存中,这被称为预缓存。
外部资源
不同来源或 CDN 上托管字体、JavaScript、样式、图像和其他类型的文件被视为外部资源。无论我们是想预先缓存还是在运行时将它们添加到缓存中,我们都需要在ngsw-config.json中定义它们。必须使用urls键将它们添加到assetGroup中,其中值将为array of Glob, meaning we can also use glob pattern to specify urls。URL 没有经过哈希处理;因此,只要配置发生变化,它们就会更新。如前几章所述,我们在应用中添加了两种字体。
<head>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#1976d2">
</head>
现在我们想缓存这些字体。代码类似于以下内容:
// this is our application ngsw-config.json file
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"],
"urls": [
https://fonts.googleapis.com/icon?family=Material+Icons,
https://fonts.googleapis.com/css?family=Roboto:300,400,500,
https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2,
https://fonts.gstatic.com/s/materialicons/v41/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"],
}
}
这里有一个例子,我们可以添加精确的网址,因为我们已经知道这些网址。然而,并不总是清楚确切的 URL 是什么。因此,我们可以添加一个 glob 模式来缓存 googleapis.com 和 gstatic.com 托管的所有URL,以便动态托管 woff 字体。
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"],
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"],
"urls": [
https://fonts.googleapis.com/**,
https://fonts.gstatic.com/**
]
}
}
除非另有明确说明,否则模式在配置中使用有限的 glob 格式。
-
****** 匹配 0 个或多个路径段
-
//*。html** 指定所有的 html 文件
-
//*。js** 指定所有的 js 文件
-
example.com**/* ***指定主机名匹配的所有请求
-
-
***** 匹配 0 个或更多字符,不包括/
-
/*。html 仅指定根目录中的 html 文件
-
/a/folder/*。png 只指定了/a/文件夹/中的 png 文件
-
-
**?**只匹配一个字符,不包括/
- /什么?ver.js 指定了根目录下的所有 js 文件,其中第 5 个字符可以是任何东西
-
该!前缀的作用是否定的,意味着只有与模式不匹配的文件才会被包含进来。
-
!//*.地图**排除所有源地图
-
!/*.pdf 排除根目录中的所有 pdf 文件
-
注意
urls不支持负 glob 模式和**?**会按字面匹配;这意味着什么?将不匹配除。本身。
运行 build 命令。完成后,导航到/dist文件夹,打开 Angular CLI 基于 ngsw-config.json 生成的ngsw.json。
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"updateMode": "prefetch",
"urls": [
"/0.c570a2562d2874d34dc4.js",
"/1.71eb2445db7dfda9e415.js",
"/2.df6bb6a6fde654fe8392.js",
"/favicon.ico",
"/index.html",
"/main.f224c8a2c47bceb8bef0.js",
"/polyfills.8883ab5d81bf34ae13b1.js",
"/runtime.e14ed3e6d31ff77728e9.js",
"/styles.7a3dc1d11e8502df3926.css"
],
"patterns": []
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"urls": [
"/assets/icons/icon-128x128.png",
"/assets/icons/icon-144x144.png",
"/assets/icons/icon-152x152.png",
"/assets/icons/icon-192x192.png",
"/assets/icons/icon-384x384.png",
"/assets/icons/icon-512x512.png",
"/assets/icons/icon-72x72.png",
"/assets/icons/icon-96x96.png"
],
"patterns": [
"https:\\/\\/fonts\\.googleapis\\.com\\/.*",
"https:\\/\\/fonts\\.gstatic\\.com\\/.*"
]
}
],
通过查看生成的ngsw-worker.js和ngsw.json,,我们注意到 glob 变成了一个作为 regex 使用的模式。下面是从ngsw-worker.js中提取的class AssetGroup中的一段将模式映射到正则表达式的代码:
// Patterns in the config are regular expressions disguised as strings. Breathe life into them.
this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));
在未来,在代码中,它被用作:
// Either the request matches one of the known resource URLs, one of the patterns for
// dynamically matched URLs, or neither. Determine which is the case for this request // in order to decide how to handle it.
if (this.config.urls.indexOf(url) !== -1 || this.patterns.some(pattern => pattern.test(url))) {
添加这些是为了在拦截请求并将其存储在缓存存储中时匹配请求。
无哈希资源的重新验证策略
当哈希存在于缓存中时,某些资源可能没有哈希。Angular 服务工作器将检查该请求有多长时间了,并确保它仍然可用。关于资源重新验证,Angular Service Worker 中应用了三种不同的策略:
-
请求有一个
Cache-Control头,因此过期需要基于它的年龄。-
这为请求和响应中的缓存机制指定了指令。客户端可以在 HTTP 请求中使用标准的缓存控制指令。
Cache-Control: max-age=<seconds> Cache-Control: max-stale[=<seconds>] Cache-Control: min-fresh=<seconds> Cache-Control: no-cache Cache-Control: no-store Cache-Control: no-transform Cache-Control: only-if-cached -
取决于条件角寻找:最大年龄或日期标题。
-
-
请求有一个
Expires头,到期时间基于当前时间戳。-
Expires头包含日期/时间,而无效日期,如值 0,表示资源已经过期。如果响应中有带有"max-age"或"s-maxage"指令的Cache-Control报头,则 Expires 报头将被忽略。 -
例如:
Expires: Wed, 21 Oct 2019 07:28:00 GMT.
-
-
该请求没有适用的缓存头,必须重新验证。
- 如果无法评估陈旧性,则假设响应已经陈旧。
因此,将缓存控件添加到您的资源中是一个很好的做法;它不仅有助于浏览器重新验证响应,而且 Angular Service Worker 有助于有效地保持更新。
数据组设置
除了 assetGroups,还有dataGroups.与资产资源不同,本节定义的数据请求独立于应用版本,而 assetGroups 缓存更新策略不同:如果单个资源被更新,我们回收整个版本缓存。它们遵循自己手动配置的策略,这对于处理 API 请求和其他数据依赖等情况非常有用。我们可以使用它们来缓存来自外部服务的响应,以防应用离线。
看了一下DataGroup Typescript 接口,下面的属性揭示了:
export interface DataGroup {
name: string;
urls: string[];
version?: number;
cacheConfig: {
maxSize: number;
maxAge: string;
timeout?: string;
strategy?: 'freshness' | 'performance';
};
}
-
name:*(必需)*将包含在高速缓存 API 存储名称中的组的名称。它应该是
string,描述我们的知识,并且是唯一标识的。 -
urls:*(必需)*根据此数据策略,用于匹配这些 URL 以进行缓存的 glob 模式列表。与
assetGroups类似,不支持负 glob 模式。会完全匹配也就是说。仍将是。性格和什么都不搭。 -
version:*(可选)*API 中的版本控制很常见。在某种程度上,有时新版本格式将不会与旧 API 向后兼容;因此,现有的缓存包含较旧的格式,可能会破坏应用,因为它与较新的 API 结构不匹配。尽管
version是可选的,并且整数字段默认为0,但是它提供了一种机制来指示正在缓存的 API 响应是否已经以向后不兼容的方式进行了更改。因此,存储该 API 响应的所有旧缓存条目必须被丢弃、根除,并用新响应替换。 -
cacheConfig:(required) settings that define the policies and strategies by which matching requests will be cached:
-
maxSize:*(必选)*当缓存打开接受无限数量的响应时,取决于你的 app 大小,它可以迅速增长,最终超过存储配额, 1 调用驱逐。因此,我们可以在这里定义条目或响应的最大数量。
-
maxAge:*(必需)*表示响应在被标记为无效并被逐出之前,允许在缓存中保留多长时间。指定持续时间的字符串,可以设置为
d:天、h:小时、m:分、s:秒、u:毫秒。例如, 10d12h4m 将内容缓存长达 10 天半零 4 分钟。 -
timeout:(可选)虽然这是一个可选参数,但它告诉 Angular Service Worker 在返回缓存内容之前应该等待网络响应多长时间。当
strategy是新鲜度时,这是有效的,这意味着网络优先(见下一个属性)。持续时间指定类似于maxAge单位的持续时间。 1d 考虑 1 天。 -
策略
(optional) it can have two options for all data resources:
-
performance:它指的是缓存优先策略。不经常改变的内容可以属于这种策略,因为它已经针对更快的响应进行了优化。
它首先检查缓存,如果资源存在,并且根据
maxAge它没有过期,则根据maxAge缓存的版本将立即被提供,以换取更好的性能。如果内容过期,它会尝试更新缓存。例如,我们有一个端点来检索用户的期望列表。基于我们的 app,我们真的不需要调用这个 API 因此,我们可以设置 1 小时的
maxAge和性能策略,以向用户显示更快的响应。 -
freshness:这种策略被认为是网络优先的,因为它总是试图只从网络获取数据。根据
timeout,如果网络没有相应的响应,请求会退回到缓存中。它适合所有需要频繁更新的数据。例如:显示用户积分余额的用户仪表板。
-
-
注意
默认情况下。Angular Service Worker 不缓存运行时获取的任何数据或文件。它们必须被明确地定义和配置。
现在是时候配置我们的 Note 应用了。我将使用网络优先的策略,通过** glob 从 Firebase 端点检索注释。我希望将大小设置为 100,最大缓存年龄设置为 10 天零 5 秒,超时后请求将退回到缓存(如果存在)。
为了更好地理解,我将在data.service.ts中创建两个新方法来直接向 Firestore API 发出 GET 请求,并创建另一个方法来获取一个随机的爸爸笑话。新方法看起来像下面的代码:
// data.service.ts
// DataService
protected readonly FIRESTORE_ENDPOINT =
'https://firestore.googleapis.com/v1beta1/projects/awesome-apress-pwa/databases/(default)/documents/';
protected readonly DAD_JOKE = 'https://icanhazdadjoke.com';
// Get a random joke
getRandomDadJoke(): Observable<string> {
return this.http
.get<Joke>(this.DAD_JOKE, {
headers: {
Accept: 'application/json'
}
})
.pipe(map(data => data.joke));
}
// Get note Details
getNoteFromDirectApi(id): Observable<any> {
return this.auth.getToken().pipe(
switchMap(idToken => {
return this.http.get(
`${this.FIRESTORE_ENDPOINT}users/${this.auth.id}/notes/${id}`,
{
headers: {
Authorization: `Bearer ${idToken}`
}
}
);
}),
map(notes => this.transfromNote(notes))
);
}
// List all notes for current user
initializeNotes(): Observable<any> {
return this.auth.getToken().pipe(
switchMap(idToken => {
return this.http.get(
`${this.FIRESTORE_ENDPOINT}users/${this.auth.id}/notes`,
{
headers: {
Authorization: `Bearer ${idToken}`
}
}
);
}),
map((data: { documents: { fields: {} }[] }) => data.documents),
map(notes => this.transfromNotes(notes)),
tap(notes => {
this.isLoading$.next(false);
})
);
}
private transfromNotes(notes) {
return notes.map(note => this.transfromNote(note));
}
// since I am calling google API directly, a simple transfromationm make it easy to use data in our application
private transfromNote(note) {
const _note = {};
_note['id'] = note.name.split('/').reverse()[0];
for (const prop in note.fields) {
if (note.fields[prop]) {
_note[prop] =
note.fields[prop]['stringValue'] || note.fields[prop]['integerValue'];
}
}
return _note;
}
那我就分别把notes-list.component.ts``note-details.component.ts``,中的getNotes()换成initializeNotes(),把getNote()换成getNoteFromDirectApi()。最后,我将在我的app.component.ts.中加入一个笑话
@Component({
selector: 'app-root',
template: `
<div class="appress-pwa-note">
<app-header></app-header>
<div class="main">
<div *ngIf="joke$ | async as joke" class="joke">
{{ joke }}
</div>
<router-outlet></router-outlet>
</div>
<app-footer></app-footer>
</div>
`,
styles: [
`
.joke {
margin-top: 0.5rem;
padding: 1rem;
border: 1px solid #ccc;
}
`
]
})
export class AppComponent implements OnInit {
joke$: Observable<string>;
constructor(private db: DataService) {}
ngOnInit() {
this.joke$ = this.db.getRandomDadJoke();
}
}
基于我在应用中的策略,我决定使用freshness作为 Firestore Google API 端点,使用performance作为随机笑话端点,因为这不需要被多次调用;每 15 分钟一次应该足够了。相应的配置如下所示:
"dataGroups": [
{
"name": "api-network-first",
"version": 1,
"urls": ["https://firestore.googleapis.com/v1beta1/**"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 100,
"maxAge": "10d",
"timeout": "5s"
}
},
{
"name": "api-cache-first",
"version": 1,
"urls": ["https://icanhazdadjoke.com"],
"cacheConfig": {
"strategy": "performance",
"maxSize": 20,
"maxAge": "15m"
}
}
]
现在,我将构建我的生产就绪应用,并在本地提供服务。
npm run prod
导航至localhost:4200并查看缓存存储和服务工作器选项卡。你会注意到现在我们有了缓存名来存储我们的两个策略,如图 5-7 所示。
图 5-7
运行时缓存
现在花一点时间使用应用,几分钟后,关闭网络,如图 5-8 所示,然后重新加载应用。
图 5-8
选中“脱机”以断开网络连接
惊喜!即使您没有任何网络连接,您看到的所有数据,包括笔记、静态资产和笑话,现在都是可用的。让我们看看网络选项卡中的所有请求。您可能会注意到,在图 5-9 中,没有对笑话端点的请求。
图 5-9
离线模式网络请求
是的,这是正确的,因为我们已经为该端点设置了performance(缓存优先)策略,并且由于maxAge,为 15 分钟,该策略尚未过期,Angular Service Worker 将丢弃该请求,直到其过期,然后将重新验证该请求,并使用适当的响应更新缓存。
导航缓存
在单页面应用中,路由在前端处理。前端中的所有路由最终被指向index.html,其中框架,特别是 Angular 路由器模块,将导航请求匹配到特定视图。
什么使一个请求被认为是导航请求分为三个要点:
-
它的模式是导航。
请求接口的模式只读属性,用于确定跨源请求是否导致有效响应,以及响应的哪些属性是可读的——值为 cors、no-cors、同源或导航。导航是一种支持导航的模式,仅供 HTML 导航使用。只有在文档间导航时,才会创建导航请求。22
-
它接受文本/html 响应(由 Accept 头的值决定)。
-
它的 URL 符合某些标准,默认为:
-
URL 不得包含文件扩展名(即*)。*、 a . 最后一个路径段。
-
URL 不得包含 __ 。
-
看了一下Config界面,你会注意到有一个 Angular 或自定义导航navigationUrls的特定属性。如您所见,这是可选的,使我们能够定制一个 URL 列表。
export interface Config {
appData?: {};
index: string;
assetGroups?: AssetGroup[];
dataGroups?: DataGroup[];
navigationUrls?: string[];
}
URL 可以是 URL 数组,也可以是在运行时匹配的类似 glob 的 URL 模式。支持负模式和非负模式。
虽然默认值在大多数情况下已经足够,但有时需要配置不同的规则。假设我们的应用中有一些特定的 URL 需要在后端提供服务,我们需要将它们传递给服务器进行处理,因为它们不是有 Angular 的路由。
如果省略nagivationUrls,默认值将被替换:
"navigationUrls": [
"/**", // Include all URLs.
"!/**/*.*", // Exclude URLs to files.
"!/**/*__*", // Exclude URLs containing `__` in the last segment.
"!/**/*__*/**" // Exclude URLs containing `__` in any other segment.
]
结果会是这样的:
"navigationUrls": [
{
"positive": true,
"regex": "^\\/.*$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?[^/]*\\.[^/]*$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?[^/]*__[^/]*$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$"
}
]
作为一个例子,我将实现一个不需要缓存的路由。
我将生成一个名为NoCacheRouteComponent的组件。
@Component({
selector: 'app-no-cache-route',
template: `
<div class="appress-pwa-note">No-cache</div>
`
})
export class NoCacheRouteComponent {}
然后我再加一条到app-routing.module.ts的路由。
{
path: 'no-cache-route',
component: NoCacheRouteComponent
}
最后,我将在ngsw-config.json中排除这个 URL。
"navigationUrls": [
"/**",
"!/**/*.*",
"!/**/*__*",
"!/**/*__*/**",
"!/**/no-cache-route"
]
注意
可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter05/03-no-cache-route 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行生产应用。可以导航到localhost:4200。
appdata config(appdata config)
此属性也是可选的,可能包含该特定版本的应用元数据。服务工作器不使用appData,但可以在服务器工作人员更新中确定,它可以用于在 UI 通知中显示附加信息,以通知用户或在应用上做出明智的决定。
例如,诸如发布日期、构建散列、指示服务器安全缺陷的标志之类的信息可以在下次重新加载时应用,而不会中断用户。
我将在下一节中使用这个对象,并在后面的其他章节中查看这个配置。
处理更新
通过在我们的应用中实现 Service Worker,与已缓存和使用的应用相比,处理过时版本的应用迟早会成为一个问题,因为新版本的 Service Worker 只会在页面重新加载时激活。Angular Service Worker 通过提供一个SwUpdate类来解决这个问题,这个类可以很容易地检查可用的更新。让我们来看看这个课程:
class SwUpdate {
available: Observable<UpdateAvailableEvent>
activated: Observable<UpdateActivatedEvent>
isEnabled: boolean
checkForUpdate(): Promise<void>
activateUpdate(): Promise<void>
}
让我们来分解这个类:
-
available: an observable that emits UpdateAvailableEvent whenever a new app version is available.interface Version { hash: string; appData?: Object; } interface UpdateAvailableEvent { type: 'UPDATE_AVAILABLE'; current: Version available: Version; }界面非常简单明了。如您所见,在当前和可用属性中,
appData是一个选项,如果我们在ngsw-config.json.中定义它,它将是可用的For example:
{ "index": "/index.html", "appData": { "version": "1.0.1" }, "assetGroups": [] } -
activated:每当应用更新到新版本时发出UpdateActivateEvent的可观察对象。interface UpdateActivatedEvent { type: 'UPDATE_ACTIVATED'; previous?: Version; current: Version; } -
isEnabled:布尔值,用于检查浏览器是否支持服务工作器,是否通过 ServiceWorkerModule 启用。 -
当有更新时,这个承诺将被兑现,它允许我们定期检查更新。
-
将通过强制服务工作器更新来解决的承诺。我们可能需要在解决此功能后采取其他措施。例如,我们需要重新加载应用,因为当前加载的资源变得无效。
现在是时候在我们的应用中实现了,看看结果如何。
export class AppComponent implements OnInit {
joke$: Observable<string>;
constructor(private db: DataService, private swUpdates: SwUpdate, private snackbar: SnackBarService) {}
ngOnInit() {
this.joke$ = this.db.getRandomDadJoke();
this.swUpdateFlow();
}
swUpdateFlow() {
// check if service worker is enabled and only check if it's production
if (this.swUpdates.isEnabled && environment.production) {
// subscribe to recieve update when it's available
this.swUpdates.available.subscribe((event: UpdateAvailableEvent) => {
// console log version on appData Object defined in ngsw-config.js
console.log(`Version: ${event.current.appData['version']}`);
// an update is available, inform user
and take an action
this.snackbar
.action(
`${event.type}: current is ${event.current.hash} but available is ${event.available.hash}`,
'Activate'
)
.subscribe(() => {
// force to activate update
this.swUpdates
.activateUpdate()
.then(() => {
this.snackbar.open('Update has been applied', 1000);
// force to reload to ensure new update is in place
// (<any>window).location.reload();
})
.catch(e => {
this.snackbar.open('Something is wrong, please reload manually');
});
});
});
// subscribe to receive an notification when new version is activated
this.swUpdates.activated.subscribe((event: UpdateActivatedEvent) => {
// console log version on appData Object defined in ngsw-config.js
console.log(`Version: ${event.current.appData['version']}`);
this.snackbar
.action(`${event.type}, current is ${event.current.hash} but previously was ${event.previous.hash}`, 'Reload')
.subscribe(() => {
// force to reload to ensure new update is in place
(<any>window).location.reload();
});
});
}
}
}
在app.component.ts,我会先注射SwUpdate。然后,我将确保在生产和服务工作人员上运行代码。我将订阅可用的 observables,一旦有更新可用,我将显示 snackbar 通知,并通知用户有一个新版本的应用可用,并要求他们重新加载页面,以便查看应用的最新版本。
注意
可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter05/04-notification-updates 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行一个生产应用。您可以导航到localhost:4200.
部署到火力基地
现在我们已经准备好构建我们的应用并部署到 Firebase。像往常一样,只需运行:
npm run deploy
✓ hosting[awesome-apress-pwa]: file upload complete
i database: releasing rules...
✓ database: rules for database awesome-apress-pwa released successfully
i hosting[awesome-apress-pwa]: finalizing version...
✓ hosting[awesome-apress-pwa]: version finalized
i hosting[awesome-apress-pwa]: releasing new version...
✓ hosting[awesome-apress-pwa]: release complete
✓ Deploy complete!
Project Console: https://console.firebase.google.com/project/awesome-apress-pwa/overview
Hosting URL: https://awesome-apress-pwa.firebaseapp.com
让我们导航到该网站,并检查服务工作器。如图 5-10 所示,已经安装并激活了新的服务工作器,并且创建了新的缓存。
图 5-10
成功部署到 Firebase
注意
可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter05/02-runtime-cache 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行一个生产应用。可以导航到localhost:4200。您可能还需要将这段代码部署到 Firebase,以便在添加服务工作器之前评估您的应用。
摘要
在前两章中,我深入研究了 Angular Service Worker 的配置和设置,为我们的应用实现了最佳策略,并部署了一个离线就绪的应用。尽管我们的应用独立于 connection 工作,但仍有许多增强用户体验的可能性。
在下一章中,我们将详细了解应用清单,它使我们的应用可以安装在用户可以从主屏幕运行我们的应用的地方。
Footnotes 1所有浏览器都有一个存储限制,即你的 web 应用源可以使用的存储空间,并且每个设备、每个浏览器都有所不同。如果原点驱逐不能释放足够的空间,浏览器将抛出一个QuotaExceededError.
2
开发者。mozilla。org/en-US/docs/Web/API/Request/mode
六、应用清单和可安装的 Angular 应用
到目前为止,我们已经关注了渐进式 Web 应用(PWA)的核心特性,即服务工作器。它使我们能够缓存静态资产和动态内容。该应用将继续离线工作,这在移动设备上尤为重要。然而,一个应用的“外观和感觉”是另一个重要因素,它可以增强用户体验,真正让用户高兴。
在这一章中,我们重点关注视觉吸引力,以及一些有助于提高应用参与度的不同方式。我们探讨了添加到主屏幕和定制等功能,这些功能会提示用户将网络添加到他们的设备主屏幕。
Web 应用清单
Web 应用清单是一个遵循 Web 应用清单规范的 JSON 文本文件,它提供了有关应用的信息,如名称、作者、图标和描述。但更重要的是,这个文件允许用户在他们的设备上安装应用,并允许我们修改主题、应该打开的 URL、闪屏、主页上的图标等等。
让我们来看看 Angular CLI 默认创建的位于/src/,中的manifest.json。
{
"name": "lovely-offline",
"short_name": "ApressPWA",
"theme_color": "#1976d2",
"background_color": "#fafafa",
"display": "standalone",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "assets/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "assets/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "assets/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "assets/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
大多数属性都是不言自明的,但是我将尝试提供描述性的定义。清单文件中的每个属性都有一个角色,并告诉浏览器关于我们应用的外观和感觉的信息。尽管 Angular CLI 添加的默认manifest.json对于大多数用例来说应该没问题,但是我们可以添加更多属性来更好地增强用户体验,这取决于我们的需求和要求。
让我们来分解一下:
-
name:向用户显示或作为图标标签的应用的可读名称。
-
short_name:如果由于空间不足而不合适,替换名称的短名称。
-
theme_color:定义应用的默认主题颜色,以给操作系统或浏览器相关的用户界面着色:例如,浏览器的工具栏或 Android 的任务切换器。可以使用十六进制代码或颜色名称。
-
background_color:甚至在用户代理加载网站样式表之前,定义应用的预期背景颜色。通常,在启动 web 应用和加载站点内容之间会有一个短暂的间隔。这将创建一个平滑的过渡来填充延迟。您可以使用颜色十六进制代码或标准颜色的名称。请注意,在样式表可用之后,用户代理将不会使用这个背景。
-
display:网站的首选显示模式。根据规范,有四个选项可用,但并非在所有浏览器中都可用:
fullscreen:使用所有可用的显示。如果不支持,则退回到standalone模式。大多数浏览器元素都是隐藏的。感觉像一个独立的应用。在这种模式下,用户代理将排除用于控制导航的 UI 元素,但可以包括其他 UI 元素,如状态栏。如果不支持,则退回到
minimal-ui模式。这个应用看起来像一个独立的应用;但是,浏览器的基本用户界面仍然可见,如导航按钮。不支持,退回到
browser模式。常规的浏览器标签或新窗口。
It’s interesting to know what there is a feature in CSS where you can detect
display-mode. See code below:@media all and (display-mode: minimal-ui) { /* ... */ } @media all and (display-mode: standalone) { /* ... */ } -
scope:或多或少类似于定义该网站上下文导航范围的服务工作器范围。如果页面在此范围之外,它会返回到浏览器选项卡/窗口内的正常网页。对于相对 URL,基本 URL 将是清单的 URL。如果省略,默认为清单目录和所有子目录下的所有内容。
-
start_url:用户启动应用时加载的 URL。它可以不同于主页,例如,如果您希望您的 PWA 用户直接进入登录页面或注册页面,而不是主页。出于分析目的,可以精心制作
start_url来指示应用是从浏览器外部启动的,这可以转换为 PWA。也就是:"start_url": "/?launcher=homescreen" -
icons:一组图像文件,根据上下文来指定应用图标。每个图像都有三个属性:
src:图像文件的路径;对于相对 URL,基本 URL 将是清单的 URL。sizes:指定图标尺寸(甚至是包含空格分隔的图像尺寸的多个尺寸)。我们应该支持各种不同的屏幕尺寸;我们包含的维度越多,图标的质量就越好。type:图像的媒体类型1;如果用户代理不支持这种类型,他们可以很快忽略它。 -
prefer_related_applications:这要求浏览器通过 PWA 向用户指示在下一个属性中推荐的指定本机应用。虽然这听起来可能很傻,但有时我们有一个非常具体的原生功能,而这在网络上并不存在,所以我们希望我们的用户使用原生应用来代替。如果省略,默认值为
false。 -
related_applications:recommended native applications that are installable or accessible from underlying platform store. For example, link to an Android app from Google Play Store. The objects may contain
platform,url,andid.{ "platform": "play", "url": "https://play.google.com/store/apps/details?id=com.example.app1", "id": "com.example.app1" }, { "platform": "itunes", "url": "https://itunes.apple.com/app/example-app1/id123456789" } -
orientation:sets the app work on default orientation. Orientation may be one of the following values:
any, natural, landscape, landscape-primary, landscape-secondary portrait, portrait-primary, portrait-secondary -
dir:指定
name、short_name和description的主要文本方向。有两个值:ltr, auto,和rtl。省略该值时,默认为auto。 -
lang:与
dir一起指定正确的显示语言。默认是en-US.2 -
description:网站功能的一般描述。
-
serviceWorker:this member represents an intended service worker registration in form of a registration object.
"serviceworker": { "src": "sw.js", "scope": "/foo", "update_via_cache": "none" }
此功能可能无法在任何浏览器中运行。
-
categories:以小写形式指定 web 应用所属的预期应用类别的字符串数组。
-
screenshots:在常见使用场景中表示 web 应用的图像资源数组。这可能还不能在任何浏览器或平台上运行。
-
iarc_rating_id:代表国际年龄分级联盟(IARC) 3 的 web 应用的认证代码。
为了引用清单文件,我们需要在 web 应用的所有页面的head之间添加一个link标签。然而,我们有一个带有 Angular 的单页面应用,ng-cli已经添加了到index.html和angular.json的链接,以便在构建后将这个文件复制到根文件夹中。
// index.html where we added manifest.json link.
<head>
.
.
<base href="/">
<link rel="manifest" href="manifest.json">
.
.
</head>
调试 Web 应用清单
现在我已经介绍了 Web 应用清单并引用了 index HTML 页面,我们应该能够运行一个应用,然后在Chrome中导航到该应用。在DevTools,中,转到Application选项卡,点击左侧Service Workers正上方的manifest选项(见图 6-1 )。
详细信息显示在那里,包括错误(如果有)。还有一个选项可以测试将应用添加到主屏幕的提示。
图 6-1
DevTools 中的应用清单详细信息
尽管 Chrome DevTools 可以很好地调试您的清单文件,但是您可以使用一些工具来验证您的清单文件是否符合 W3C 规范。一个例子是 manifest-validator.appspot.com,在那里你可以简单地审计你的清单文件。图 6-2 显示了 Web 清单验证器的屏幕截图。
图 6-2
Web 清单验证器是一个可以调试清单文件的工具
添加到主屏幕
默认情况下,本机应用会安装在您的主屏幕上。你会看到一个图标和短名称,当你需要运行这个应用时,很容易回到主屏幕,点击图标打开应用。作为网页开发者,吸引用户并让他们继续使用我们的应用是非常重要的。因此,作为原生应用的功能是解决参与度的难题之一。让我们的用户无缝地将我们的 web 应用添加到他们的主屏幕的一个很好的方法是添加到主屏幕(你可能会看到 A2HS)功能,也称为 web 应用安装横幅。
该功能使得在移动或桌面设备上安装 PWA 变得容易。它会显示一个提示,用户接受后,你的 PWA 会被添加到他们的启动器或主屏幕上。它将像任何其他已安装的应用一样运行,并且看起来与原生应用相似。
但是,除非满足以下条件,否则不会显示 web 应用安装横幅提示:
-
在 HTTPS 上空服务(这是 PWA 的核心概念之一,也是服务工作器的要求)。
-
Web 应用清单必须包括:
-
short_name或name -
icons必须包括一个 192 像素和一个 512 像素大小的图标 -
start_url必须有适当的值 -
显示必须是下列之一:
fullscreen, standalone, or minimal-ui
-
-
web 应用尚未安装。
-
适当的用户参与启发式。
这一项可能会随着时间的推移而改变,所以你应该随时了解最新的消息,并不时查看不同浏览器的标准列表。在写这本书的时候,一个用户必须和这个域进行至少 30 秒的交互。
-
App 有一个带有
fetch事件处理程序的注册服务工作器。
虽然这个列表有点不稳定,并且经常更新,但是如果满足这些标准,Google Chrome 将触发一个名为beforeinstallprompt的事件,我们应该使用它来向用户显示提示。关注不同的浏览器,查看最新消息,看看它们是否支持这个事件或类似的事件。
虽然 Safari 不支持自动添加到主屏幕提示或beforeinstsallprompt事件,但手动添加到主屏幕是通过轻按共享按钮来显示的,即使它的行为与其他浏览器略有不同。我希望当你读到这本书时,Safari 和所有其他浏览器都将支持这一功能的自动版本。
注意
Chrome 67 和更早的版本显示了“添加到主屏幕”的横幅。它在 Chrome 68 中被删除,如果收听beforeinstallprompt并且用户点击具有正确手势事件的元素,将会显示一个对话框。
处理安装事件(推迟提示)
正如我们已经看到的,当满足所有标准时,beforeinstallprompt事件在window对象上触发。监听该事件以指示应用何时可安装是至关重要的,我们需要相应地对 web 应用采取行动,以显示适当的 UI 来通知我们的用户,他们可以在主屏幕上安装该应用。
虽然添加到主屏幕是我们的主要目标,但此事件也可用于其他目的,例如:
-
将用户选择发送到我们的分析系统。
-
推迟显示通知,直到我们确定这是显示哪个用户将点击或点击的最佳时间。
为了保存已经触发的事件,我们需要编写如下代码:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', event => {
// Prevent automatically showing the prompt if browser still supports it
event.preventDefault();
// Stash the event so it can be triggered later.
deferredPrompt = event;
// This is time to update UI, notify the user they can install app to home screen
const button = document.getElementById('add-to-home-screen-button');
button.style.display = 'block';
button.addEventListner('click', () => {
if (deferredPrompt) {
// will show prompt
deferredPrompt.prompt();
// Wait for the user to respond to the prompt
deferredPrompt.userChoice.then(choiceResult => {
// outcome is either "accepted" or "dismissed"
if (choiceResult.outcome === 'accepted') {
// User accepted the A2HS prompt
// send data to analytics
// do whatever you want
} else {
// User dismissed the A2HS prompt
// send data to analytics
// do whatever you want
}
// we don't need this event anymore
deferredPrompt = null;
// delete or hide this button as it's not needed anymore
button.style.display = 'none';
});
}
});
});
只能对延迟事件调用一次prompt()。如果用户不理会它,我们需要等到浏览器在下一个页面导航中触发beforeinstallprompt事件。
迷你信息栏
在撰写本书时,mini-info bar是 Android 上 Chrome 的临时体验;它正朝着创建跨所有平台的一致体验的方向发展,包括 omnibox 中的安装按钮,如图 6-3 所示。
图 6-3
Android 上谷歌 Chrome 浏览器的迷你信息栏 4
这是 Chrome UI 组件,我们无法控制它。一旦被用户取消,它将不会再次出现,直到足够长的时间。不管beforeinstallprompt事件上的preventDefault(),如果网站满足以上所有标准,这个迷你吧就会出现。
这种实验功能在未来可能是可控的或完全根除的。
在 Angular App 中实现功能
现在让我们在 Angular sample 项目中实现上面的代码。首先创建一个名为AddToHomeScreenService的服务,并将其导入到CoreModule.
该服务将保存提示事件,并将根据模块共享该事件。
@Injectable({
providedIn: 'root'
})
export class AddToHomeScreenService {
public deferredPromptFired$ = new BehaviorSubject<boolean>(false);
public deferredPrompt;
get deferredPromptFired() {
this.deferredPromptFired$.next(!!this.deferredPrompt);
return this.deferredPromptFired$;
}
public showPrompt() {
if (this.deferredPrompt) {
// will show prompt
this.deferredPrompt.prompt();
// Wait for the user to respond to the prompt
this.deferredPrompt.userChoice.then(choiceResult => {
// outcome is either "accepted" or "dismissed"
if (choiceResult.outcome === 'accepted') {
// User accepted the A2HS prompt
// send data to analytics
// do whatever you want
this.sendToAnalytics(choiceResult.userChoice);
} else {
// User dismissed the A2HS prompt
// send data to analytics
// do whatever you want
this.sendToAnalytics(choiceResult.userChoice);
}
// we don't need this event anymore
this.deferredPrompt = null;
this.deferredPromptFired$.next(false);
});
}
}
public sendToAnalytics(userChoice) {
// for example, send data to Google Analytics
console.log(userChoice);
}
}
在app.component.ts文件中,通过添加@HostListener,我们将监听一个beforeinstallprompt事件,通过注入AddToHomeScreenService,我们可以访问deferredPrompt,,这有助于保持我们的事件对象。
export class AppComponent implements OnInit {
joke$: Observable<string>;
@HostListener('window:beforeinstallprompt', ['$event'])
onEventFire(e) {
this.a2hs.deferredPrompt = e;
}
constructor(
private db: DataService,
private a2hs: AddToHomeScreenService
) {}
ngOnInit() {
this.joke$ = this.db.getRandomDadJoke();
}
}
接下来,我决定在 notes list 页面上向我的用户显示一个通知框。我认为这是询问用户是否愿意安装该应用的最佳位置,因为他们已经从该应用中受益,他们很可能会接受提示。因此,最好不要用不想要的提示或通知打扰用户,而是在有意义的时候询问他们。
AddToHomeScreenService被注入到NotesListComponent中,并相应地创建了 UI。
export class NotesListComponent implements OnInit {
isAddToHomeScreenEnabled$;
constructor(private db: DataService,
private a2hs: AddToHomeScreenService) {}
ngOnInit() {
// this.notes$ = this.db.getNotes();
this.notes$ = this.db.initializeNotes();
this.isDbLoading$ = this.db.isLoading$;
this.isAddToHomeScreenEnabled$ = this.a2hs.deferredPromptFired;
}
}
在 notes-list.component.html 文件中,在页面的顶部,我会添加一个简单的卡片,询问用户是否愿意在提示准备好的时候与它进行交互。
<mat-card *ngIf="isAddToHomeScreenEnabled$ | async">
<mat-card-subtitle>Add To Home Screen</mat-card-subtitle>
<mat-card-content>
Do you know you can install this app on your homescreen?
<button mat-raised-button color="primary" (click)="showPrompt()">Show me</button>
</mat-card-content>
</mat-card>
<div *ngIf="notes$ | async as notes; else notFound">
<app-note-card *ngFor="let note of notes" [note]="note" [loading]="isDbLoading$ | async" [routerLink]="['/notes', note.id]">
</app-note-card>
</div>
<ng-template #notFound>
<mat-card>
<mat-card-title>
Either you have no notes
</mat-card-title>
</mat-card>
</ng-template>
将所有这些放在一起,构建一个用于生产的应用,然后部署到 Firebase。
添加到手机和桌面的主屏幕
现在我们已经实现了所有的标准,是时候在移动和桌面上测试它了。由于谷歌 Chrome 为安装应用提供了最好的支持,你可能会问,当用户接受提示时,谷歌 Chrome 实际上做了什么?
Chrome 为我们处理了大部分繁重的工作:
-
手机:
Chrome 将生成一个 WebAPK, 5 ,为用户带来更好的综合体验。
-
桌面:
您的应用已安装,并将在 Mac 和 Windows 机器上的应用窗口 6 中运行。
注意
要在 Mac 上测试桌面 PWA 的安装流程,您需要在 Google Chrome 中启用#enable-desktop-pwas 标志。可能是以后或者你在看这本书的时候默认的。
让我们看看这在 Mac 和 Android 手机上的运行情况,如图 6-4 所示。
图 6-4
Android 和 Mac 上的 Chrome 一旦触发beforeinstallprompt,就会显示通知
当点击按钮显示提示时,会出现浏览器对话框提示(见图 6-5 )。
图 6-5
Mac 上 Chrome 中的提示对话框
点击“安装”后,该应用将安装在 Chrome Apps 文件夹中,并可作为独立应用使用(见图 6-6 )。这个功能在 Windows 10 上也有。
图 6-6
PWA 安装在 Mac 上的 Chrome 应用中
微软视窗 7
边缘的 PWA 是一等公民。一旦 PWA 通过微软商店发布,拥有 6 亿多月活跃用户的整个 Windows 10 安装群就是你的潜在应用受众!
有趣的是,当 pwa 在 Windows 10 中时,它们作为通用 Windows 平台应用运行,并将获得以下技术优势:
-
独立窗口
-
独立于浏览器的进程(隔离缓存,开销更少)
-
没有存储配额(用于索引数据库、本地存储等。)
-
离线和后台进程通过 JavaScript 访问本机 Windows 运行时(WinRT)API
-
出现在“应用”上下文中,如 Windows 开始菜单和 Cortana 搜索结果
最大的特性之一是能够访问 WinRT APIs。这只是确定您需要使用什么,获得必要的权限,并使用特性检测在支持的环境中调用该 API 的问题(参见图 6-7 )。让我们看一个例子:
图 6-7
Microsoft Edge 和 Windows 应用上的上下文菜单
if (window.Windows && Windows.UI.Popups) {
document.addEventListener('contextmenu', function (e) {
// Build the context menu
var menu = new Windows.UI.Popups.PopupMenu();
menu.commands.append(new Windows.UI.Popups.UICommand("Option 1", null, 1));
menu.commands.append(new Windows.UI.Popups.UICommandSeparator);
menu.commands.append(new Windows.UI.Popups.UICommand("Option 2", null, 2));
// Convert from webpage to WinRT coordinates
function pageToWinRT(pageX, pageY) {
var zoomFactor = document.documentElement.msContentZoomFactor;
return {
x: (pageX - window.pageXOffset) * zoomFactor,
y: (pageY - window.pageYOffset) * zoomFactor
};
}
// When the menu is invoked, execute the requested command
menu.showAsync(pageToWinRT(e.pageX, e.pageY)).done(function (invokedCommand) {
if (invokedCommand !== null) {
switch (invokedCommand.id) {
case 1:
console.log('Option 1 selected');
// Invoke code for option 1
break;
case 2:
console.log('Option 2 selected');
// Invoke code for option 2
break;
default:
break;
}
} else {
// The command is null if no command was invoked.
console.log("Context menu dismissed");
}
});
}, false);
}
安卓和 Chrome
Android 中 Chrome 的 Flow 类似。beforeinstallprompt事件被触发。一旦我们点击我们实现的按钮,对话框将会显示(见图 6-8 )。
图 6-8
向用户安装应用通知并添加到主屏幕对话框
一旦用户接受安装,应用图标和short_name将被放置在主屏幕中其他本地应用图标的旁边,如图 6-9 所示。
注意
三星互联网浏览器的行为与 Chrome 相似,但反应略有不同。
当你点击打开应用时,没有浏览器 chrome(导航按钮、地址栏、菜单选项等。)在全屏选项下可见,你会注意到顶部的状态栏采用了我们在应用中配置的theme_color,(见图 6-10 )。
图 6-10
打开后,PWA 看起来类似于本机应用
图 6-9
应用安装在主屏幕上,一旦点击打开,带有配置背景和图标的闪屏显示
When you tap to open the app, no browser
手动添加到主屏幕
不保证总是触发对话框提示。因此,有可能手动将 PWA 添加到主屏幕。Safari iOS 上也有这个功能。
在 Chrome 中,如果你点击浏览器右上角的菜单上下文菜单,你会看到菜单选项,你可以找到添加到主屏幕,点击它,一个提示对话框 UI 出现。
在 Safari 中,“添加到主屏幕”功能隐藏在“共享”按钮下。你应该明确点击共享,然后你会发现添加到主屏幕,如图 6-11 所示。然而,Safari 并不完全遵循 Web 应用清单规范,将来可能会有所改变——希望是在你阅读这本书的时候。
图 6-11
Safari 和 Chrome 上都有“添加到主屏幕”按钮
进一步增强
在不支持 web 清单的 Apple 和 Microsoft 中,有一些标签可用于改进 UI。我将它们添加到head标签之间的index.html。尽管这是一个微小的改进,但我们仍然在逐步增强我们的用户体验,这是我们在 PWA 中的目标。
<!-- Enhancement for Safari-->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="ApressNote">
<link rel="apple-touch-startup-image" href="/assets/icons/icon-512x512.png">
<link rel="apple-touch-icon" sizes="57x57" href="/assets/icons/icon-96x96.png">
<link rel="apple-touch-icon" sizes="76x76" href="/assets/icons/icon-72x72.png">
<link rel="apple-touch-icon" sizes="114x114" href="/assets/icons/icon-114x114.png">
<link rel="apple-touch-icon" sizes="167x167" href="/assets/icons/apple-icon-384x384.png">
<link rel="apple-touch-icon" sizes="152x152" href="/assets/icons/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-icon-384x384.png">
<link rel="apple-touch-icon" sizes="192x192" href="/assets/icons/icon-192x192.png">
<!-- Tile icon for Win8 (144x144 + tile color) -->
<meta name="msapplication-TileImage" content="/asseimg/icon-144x144.png">
<meta name="msapplication-TileColor" content="#3372DF">
<meta name="msapplication-starturl" content="/">
<meta name="application-name" content="ApressPWA">
<!-- Mobile specific browser color -->
<meta name="theme-color" content="#3f51b5">
apple-mobile-we b-app-capable:如果设置为 yes,其行为类似于全屏显示模式。我们使用 Safari 中的window . navigator . standalone来确定网页是否以全屏模式显示。
apple-mobile-we B- app-status-bar-style:这个 meta 标签没有任何作用,除非你首先按照中的描述指定全屏模式。如果内容设置为默认,状态栏显示正常。如果设置为黑色,状态栏背景为黑色。如果设置为黑色半透明,状态栏为黑色半透明。如果设置为默认或黑色,web 内容将显示在状态栏下方。如果设置为黑色半透明,web 内容会显示在整个屏幕上,部分内容会被状态栏遮住。默认值为 default。
apple-touch-startup-image:指定 web 应用启动时显示的启动屏幕图像。默认情况下,使用 web 应用上次启动时的屏幕截图。
apple-mobile-we b-app-title:指定启动图标的 web 应用标题。默认情况下,使用<标题>标签。
apple-touch-icon:8指定一个图标,代表用户可能想要添加到主屏幕的网络应用或网页。这些由图标表示的链接称为 Web 剪辑。
应用名称 : 9 默认名称用钉住的站点磁贴(或图标)显示。
msapplication-starturl :被钉住站点的根 url,类似于 web 清单中的start_url。
msapplication-TileColor :设置动态磁贴的背景色。
msapplication-TileImage :指定实时平铺背景图像中所需图像的 URI。
虽然您可以自己手动添加所有的增强功能,但 Google Chrome 团队有一个库可以帮助您自动缓解这个问题。
PWACompat 库 10
PWAcompat 是一个库,它将 Web 应用清单提供给不兼容的浏览器,以获得更好的 PWAs 你可以使用 PWACompat 库,我们将通过遗留的 HTML 标签为图标和主题填充空白,以便在大多数浏览器中获得更广泛的支持。基本上,您只需要在页面中包含库脚本,就大功告成了!
<link rel="manifest" href="manifest.json" />
<script async src="https://cdn.jsdelivr.net/npm/pwacompat@2.0.7/pwacompat.min.js"></script>
这个库实际上做的是更新你的页面和以下内容:
-
为清单中的所有图标创建元图标标签(例如,对于收藏夹图标、旧浏览器)
-
为各种浏览器(如 iOS、WebKit/Chromium forks 等)创建后备元标签。)描述 PWA 应该如何打开
-
根据清单设置主题颜色
对于 Safari,PWACompat 还:
-
将 apple-mobile-web-app-capable(不使用浏览器 chrome 打开)设置为独立、全屏或最小 ui 显示模式
-
创建苹果触摸图标图像,将清单背景添加到透明图标:否则,iOS 会将透明度渲染为黑色
-
创建动态的闪屏图像,与基于 Chromium 的浏览器生成的闪屏图像非常相似
对于可以访问 UWP API 的 Windows 上的 pwa:
- 设置标题栏的颜色
请关注库,查看最新版本和功能。
摘要
高级缓存和添加到主屏幕已经实现。我们离原生应用又近了一步。在下一章中,我们将提高 Angular 性能,并在 App Shell 上工作,以将我们的应用提升到一个新的水平。
Footnotes 1https://www.iana.org/assignments/media-types/media-types.xhtml#image
2
https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang
3
https://www.globalratings.com/
4
https://developers.google.com/web/updates/2018/06/a2hs-updates
5
https://developers.google.com/web/fundamentals/integration/webapks
6
https://developers.google.com/web/progressive-web-apps/desktop#app-window
7
https://docs.microsoft.com/en-us/microsoft-edge/progressive-web-apps
8
9
https://technet.microsoft.com/en-us/windows/dn255024 (v=vs.60), to find out more about window site metadata。
10
https://github.com/GoogleChromeLabs/pwacompat