Angular 渐进式 Web 应用教程(四)
十、调试和测量工具
作为开发人员,我们每天都在使用调试工具,我们无法想象没有它们的编码。为了开发 PWA,我们可能需要更多的工具来帮助我们检查代码、查找 bug、运行、模拟离线模式以及测试服务工作器。为了逐步增强我们的应用,衡量不同的方面,如绩效和 PWA 标准,以及通过跟踪的参与度,似乎也很重要。
在这一章中,我将探索许多工具,这些工具将帮助我们更轻松愉快地检查、调试和开发以及测量 PWA。尽管您可能会觉得这些工具很熟悉,但我仍然希望将它们都放在一个章节中,以便您可以随时查阅。
排除故障
首先,让我们从研究调试的可能性开始。
NGW 调试
Angular 服务工作器有一个特定的 URL,以便检查ngsw的状态。要访问它,您应该导航到/ngsw/state到您的网站基本 URL。
例如,如果您在本地机器上运行生产应用,您应该能够导航到https://localhost:3000/ngsw/state并查看信息,如下所示:
NGSW Debug Info:
Driver state: NORMAL ((nominal))
Latest manifest hash: b15d32a87eae976c0909801e2b8962df20a7deec
Last update check: 13s304u
=== Version b15d32a87eae976c0909801e2b8962df20a7deec ===
Clients: 9d63b22a-f76b-f642-aab4-e6c8e627f66a, 20e02d5b-746e-8e48-b04e-232d3a43e760, 40ccc813-b89f-5643-8e67-a6e93b688ee9
=== Idle Task Queue ===
Last update tick: 13s647u
Last update run: 8s646u
Task queue:
Debug log:
[13s638u] Error(Response not Ok (fetchAndCacheOnce): request for https://fonts.googleapis.com/icon?family=Material+Icons returned response 0 , fetchAndCacheOnce/<@https://awesome-apress-pwa.firebaseapp.com/ngsw-worker.js:589:31
fulfilled@https://awesome-apress-pwa.firebaseapp.com/ngsw-worker.js:312:52
) while running idle task revalidate(ngsw:b15d32a87eae976c0909801e2b8962df20a7deec:assets, assets): https://fonts.googleapis.com/icon?family=Material+Icons
这种状态可以帮助您找到有用的信息,使调试更容易。
Web 应用清单
web 清单允许您控制应用在启动和向用户显示时的行为。除了服务工作器,它还为用户提供了添加到主屏幕选项。在第六章中,我们深入研究了 web 应用清单。
Chrome DevTools(铬 DevTools)
Chrome DevTools 打开后,进入 应用 面板,点击 清单 进行检查(见图 10-1 )。
图 10-1
Chrome 中的清单检查器
-
若要查看清单源,请单击应用清单标签下方的链接。
-
按下添加到主屏幕按钮,模拟添加到主屏幕事件。在 Chrome 桌面上,它触发浏览器将应用添加到货架上。在手机上,它会提示用户安装应用(将图标添加到主屏幕)。
-
Identity 和 Presentation 部分只是以更加用户友好的方式显示来自清单源的字段。
-
图标部分显示您指定的每个图标。
在线验证器
很容易找到许多也可以验证 web 应用清单的网站和在线工具,例如, manifest-validator.appspot.com 。
在线发电机
有时候,生成 web 应用清单可能很耗时或者很单调。于是,在线生成器就派上了用场,比如tomitm.github.io/appmanifest。
服务工作器
服务工作器为开发人员提供了拦截网络请求和创建真正离线优先的 web 应用的惊人能力。在第 4 和 5 章节中,我们通过 Angular 服务工作器介绍了服务工作器。
Chrome DevTools(铬 DevTools)
打开 DevTools 并进入应用面板(见图 10-2 )。点击服务工作器。
图 10-2
Chrome DevTools 中的服务工作器调试器
-
离线将相应标签页中的网站离线。
-
重新加载时的更新强制服务工作器在每次页面加载时进行更新。
-
绕过网络绕过服务工作器,并强制浏览器到网络获取请求的资源。
-
更新对指定的服务工作器执行一次性更新。
-
Push 模拟带有特定消息的推送通知。
-
Sync 模拟带有特定标签的后台同步事件。
-
注销注销指定的服务工作器。
-
源告诉你当前运行的服务工作器是什么时候安装的。如果您点击,它会将您重定向到源面板下的维修工人源。
-
状态告诉您服务工作器的状态。由于服务工作器被设计为可以在任何时候被浏览器停止和启动,我们可以使用 stop 按钮显式地停止服务工作器,这将模拟它来揭示由于对持久全局状态的错误假设而导致的错误。
-
客户端告诉您服务工作器的作用域的来源。
Firefox DevTools
about:debugging页面提供了与服务工作器交互的界面。about:debugging有几种不同的打开方式;然而,我会鼓励你打开调试器,只需在 Firefox 地址栏中输入命令。
你会看到一些选项,比如 push、debug 和 unregister,这类似于一个没有有效负载的 Chrome expect push emulate push 事件(见图 10-3 )。
图 10-3
Firefox DevTools 中的服务工作器调试器
服务工作器模拟
Pinterest 的工程师开发了一套工具来与服务工作器合作。 Service Worker Mock 是一个库,它创建了一个具有以下属性的环境,可以很容易地将 Node.js 环境转换成一个仿 Service Worker 环境,并且在您需要编写集成测试时会很有帮助。
const env = {
// Environment polyfills
skipWaiting: Function,
caches: CacheStorage,
clients: Clients,
registration: ServiceWorkerRegistration,
addEventListener: Function,
Request: constructor Function,
Response: constructor Function,
URL: constructor Function,
// Test helpers
listeners: Object,
trigger: Function,
snapshot: Function,
};
服务工作器模拟的最佳使用方法是将其结果应用到全局范围,然后调用 require('。/service-worker.js') 与您的服务工作器文件的路径。该文件将使用全局模拟来添加事件侦听器。让我们编写一个简单的测试:
// service-worker.js
const TESTCACHE = 'TESTCACHE';
const TESTCACHE_URLS = [
'index.html',
'./' // Alias for index.html
];
self.addEventListener('install', event => {
console.log('[SW.JS] Server worker has been installed');
event.waitUntil(
caches
.open(TESTCACHE)
.then(cache => cache.addAll(TESTCACHE_URLS))
.then(self.skipWaiting())
);
});
// The activate handler takes care of cleaning up old caches.
self.addEventListener('activate', event => {
console.log('[SW.JS] Server worker has been activated');
const currentCaches = [TESTCACHE];
event.waitUntil(
caches
.keys()
.then(cacheNames => cacheNames.filter(cacheName => !currentCaches.includes(cacheName)))
.then(cachesToDelete => {
return Promise.all(cachesToDelete.map(cacheToDelete => caches.delete(cacheToDelete)));
})
.then(() => self.clients.claim())
);
});
self.addEventListener('push', event => {
console.log(
'[SWJ.S] Debug Push',
event.data ? event.data.text() : 'no payload'
);
});
self.addEventListener('sync', event => {
console.log('[SWJ.S] Debug Sync', event.tag);
});
我将使用Jest框架和service-worker-mock库来编写我的测试。
// service-worker.test.js
const makeServiceWorkerEnv = require('service-worker-mock');
const makeFetchMock = require('service-worker-mock/fetch');
describe('Service worker', () => {
beforeEach(() => {
Object.assign(
global,
makeServiceWorkerEnv(),
makeFetchMock()
// If you're using sinon ur similar you'd probably use below instead of makeFetchMock
// fetch: sinon.stub().returns(Promise.resolve())
);
jest.resetModules();
});
it('should add listeners', () => {
require('./service-worker.js');
expect(self.listeners['install']).toBeDefined();
expect(self.listeners['activate']).toBeDefined();
expect(self.listeners['push']).toBeDefined();
expect(self.listeners['sync']).toBeDefined();
expect(self.listeners['fetch']).toBeUndefined();
});
it('should delete old caches on activate', async () => {
require('./service-worker.js');
// Create old cache
await self.caches.open('OLD_CACHE');
expect(self.snapshot().caches.OLD_CACHE).toBeDefined();
// Activate and verify old cache is removed
await self.trigger('activate');
expect(self.snapshot().caches.OLD_CACHE).toBeUndefined();
});
});
运行Jest或npm test.
PASS ./service-worker.test.js
Service worker
✓ should add listeners (7ms)
✓ should delete old caches on activate (15ms)
console.log service-worker.js:19
[SW.JS] Server worker has been activated
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.77s, estimated 1s
Ran all test suites.
注意
克隆 https://github.com/mhadaily/awesome-apress-pwa.git 并进入the第十章目录查看样品。npm test将运行测试。
仓库
您可能已经熟悉了许多类型的 web 存储。web 存储的标准,如本地存储、会话存储、IndexedDB(广泛使用)、Web SQL 和 Cookies,在所有主流浏览器中都可以找到。我对 IndexedDB 特别感兴趣,一般可以用在服务工作器身上。
Chrome DevTools(铬 DevTools)
在 DevTools 中,导航至应用选项卡(参见图 10-4 )。选择 IndexedDB。
图 10-4
Chrome DevTools 中的 IndexedDB
通过右键单击对象存储,您可以找到一个清除动作,通过单击数据库名称,您可以找到删除和刷新数据库按钮,您可以在其中分别删除或刷新数据库(见图 10-5 )。
图 10-5
Chrome DevTools/中的清除、刷新、删除索引数据库
您可以通过对象存储列表 UI 顶部的小操作按钮来清除和刷新对象存储。您还可以删除选定的数据。您可以通过右键单击每条数据找到刷新和删除操作(参见图 10-6 )。
图 10-6
清除、刷新、删除对象存储上的索引数据库
Firefox DevTools
当您打开 Firefox DevTools 时,默认情况下您可能看不到存储面板。您应该从图 10-7 所示的设置中启用它。
图 10-7
从 Firefox 中的 DevTools 设置启用存储
启用后,点击存储面板,您会发现如图 10-8 所示的 IndexedDB。
图 10-8
Firefox DevTools 中的存储面板
躲藏
“缓存存储”窗格提供了使用缓存 API 缓存的资源的只读列表。
Chrome DevTools(铬 DevTools)
请注意,第一次打开缓存并向其中添加资源时,DevTools 可能不会检测到更改。重新加载页面,您应该会看到缓存。如果您打开了两个或更多缓存,您会看到它们列在缓存存储缓存存储下拉列表的下方(参见图 10-9 )。
图 10-9
Chrome DevTools 中的缓存存储
当使用缓存 API 加载由服务工作器缓存存储缓存的资源时,DevTools 的网络面板显示其来自服务工作器(参见图 10-10 )。
图 10-10
来自服务工作器的 Chrome DevTools 缓存存储中的网络请求
Firefox DevTools
如图 10-11 所示,缓存名称位于存储缓存下。
图 10-11
Firefox DevTools 中的缓存存储
当使用缓存 API 加载由服务工作器缓存存储缓存的资源时,Firefox 在网络面板中显示它被缓存(参见图 10-12 )。
图 10-12
来自服务工作器的 Firefox DevTools 缓存存储中的网络请求
模拟脱机行为
为了验证我们的应用离线时一切按计划运行,我们需要确保我们能够模拟没有连接。
Chrome 和 Firefox 提供了一个方便的特性,我们可以利用它来模仿离线模式。
铬
除了应用面板下的服务工作器中的离线复选框,我们还可以使用网络面板下的离线复选框(参见图 10-13 )。
图 10-13
Chrome DevTools 网络面板下的离线模式
火狐浏览器
为了在 Firefox 中启用离线模式,点击菜单图标然后点击开发者➤离线工作(见图 10-14 )。
图 10-14
离线模式 Firefox
有时离线模拟器不能正常工作,你可能需要关闭你的网络并重新连接。例如,在我写这本书的时候,当你在 Service Worker 中使用后台同步时,你可能真的需要关闭你的互联网连接。
模拟不同的网络条件
在世界上的许多地方,3G 和 2G 速度是常态。此外,我们不断在各种连接状态之间移动。为了验证我们的应用对这些消费者是否有效,我们需要在不同的网络连接和设备中测试我们的应用。
在 Chrome 和 Firefox 中,我们都有一个节流选项,你可以在图 10-15 和 10-16 中找到。
图 10-16
Firefox 中的节流选项
图 10-15
Chrome 中网络选项卡下的节流选项;您可以根据需要添加自定义配置文件
注意
最终,Service Worker 是一个普通的 JavaScript 文件,在这里您可以使用所有的 JavaScript 调试特性,比如Debugger或 break point 来检查 Service Worker 内部的代码。
模拟移动设备
您可以在通过 USB 连接到浏览器的真实设备上运行您的 PWA,或者您可以运行仿真器并执行您的测试和检查您正在寻找的内容。
远程调试和测量
要将你的 Android 设备连接到 Chrome,你可以按照这个链接上的说明:https://goo.gl/syNfSR;进行操作,要连接到 Firefox,你可以在这个链接上找到说明:https://goo.gl/P7gFNE。
仿真器
要设置和运行 iOS 模拟器,请点击此链接:https://goo.gl/ymihLs。而对于 Android,按照这个链接上的说明:https://goo.gl/EGPpxx。
在线工具
BrowserStack 是一款跨浏览器的测试工具。有了它,您可以在多个操作系统和移动设备上跨浏览器测试您的网站,而无需单独的虚拟机、设备或模拟器。BrowserStack 还提供物理设备上的远程测试,所以如果你发现自己需要在许多设备上测试你的网站性能,它可以是一个有用的时间节省器。
尺寸
为了逐步交付高质量的应用,从速度、性能或用户体验等不同方面来衡量我们的应用总是很重要的。在这一节中,我将探索帮助我们更好地了解我们的应用的可能性,这允许我们不断地改进我们的应用。
审计
如前几章所述,由 Lighthouse 支持的 Chrome DevTools 中的审计面板是我们可以用来对应用进行审计的最佳工具之一。它有不同的选项,包括性能和 PWA(见图 10-17 )。
图 10-17
Chrome DevTools 审计标签中的灯塔,在这里可以选择和执行不同的审计
很可能我们会自动化我们的审计测试,或者将它添加到 CD/CI 1 管道中。Lighthouse 2 也可以作为节点命令行工具,也可以通过编程作为节点模块使用。
要在命令行中运行 Lighthouse,请执行以下操作:
-
确保安装了 Chrome for Desktop and Node。
-
安装灯塔。
npm install -g lighthouseto run an audit
lighthouse <url>for example
lighthouse https://awesome-apress-pwa.firebaseapp.com --viewYou can see more options by running
lighthouse --help
让我们看看如何以编程方式添加 Lighthouse。
带有铬发射器的灯塔
我们将编写一个运行chrome-launcher并执行灯塔审计的例子。当您为您的应用运行一个测试时,这个测试是很有帮助的,尤其是当您想要运行多个自动化测试时。
// lighthouse-chrome-launcher.js
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
function launchChromeAndRunLighthouse(url, opts, config = null) {
return chromeLauncher
.launch({ chromeFlags: opts.chromeFlags })
.then(chrome => {
opts.port = chrome.port;
return lighthouse(url, opts, config).then(results => {
// use results.lhr for the JS-consumeable output
// https://github.com/GoogleChrome/lighthouse/blob/master/types/lhr.d.ts
// use results.report for the HTML/JSON/CSV output as a string
// use results.artifacts for the trace/screenshots/other specific case you need (rarer)
return chrome.kill().then(() => results.lhr);
});
});
}
const opts = {
chromeFlags: ['--show-paint-rects'],
onlyCategories: ['performance', 'pwa'] // you can leave it empty for all audits
};
// Usage:
launchChromeAndRunLighthouse(
'https://awesome-apress-pwa.firebaseapp.com',
opts
).then(results => {
// Use results!
console.log({
pwa: results.categories.pwa.score,
performance: results.categories.performance.score
});
});
当您运行这个文件时,您将得到结果,并且您可以根据分数添加您的逻辑。
node lighthouse-chrome-launcher.js
你会明白的
{ pwa: 1, performance: 0\. 95 }
例如,如果某个页面在 PWA 中的得分低于 0.5,您可以退出构建并要求改进该页面。
灯塔与木偶师 3
Puppeteer 是一个节点库,它提供了一个高级 API 来控制 Chrome 或 DevTools 协议上的 Chrome。默认情况下,Puppeteer 是无头运行的,但是可以配置为运行完整的(非无头)Chrome 或 Chrome。Lighthouse 和 Puppeteer 是在我们的 CD/CI 中运行审计的一个很好的组合,在那里我们不能使用 Chrome 启动器。
// lighthouse-puppeteer.js
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const { URL } = require('url');
const run = async url => {
// Use Puppeteer to launch headful Chrome and don't use its default 800x600 viewport.
const browser = await puppeteer.launch({
headless: true,
defaultViewport: null
});
browser.on('targetchanged', async target => {
const page = await target.page();
function addStyleContent(content) {
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(content));
document.head.appendChild(style);
}
const css = '* {color: red}';
if (page && page.url() === url) {
const client = await page.target().createCDPSession();
await client.send('Runtime.evaluate', {
expression: `(${addStyleContent.toString()})('${css}')`
});
}
});
const { lhr } = await lighthouse(
url,
{
port: new URL(browser.wsEndpoint()).port,
output: 'json',
logLevel: 'error',
chromeFlags: ['--show-paint-rects'],
onlyCategories: ['performance', 'pwa']
},
{
extends: 'lighthouse:default'
}
);
await browser.close();
return {
pwa: lhr.categories.pwa.score,
performance: lhr.categories.performance.score
};
};
run('https://awesome-apress-pwa.firebaseapp.com').then(res => console.log(res));
然后,您可以运行该文件:
node lighthouse-puppeteer.js
你会明白的
{ pwa: 1, performance: 0.96 }
分析学
PWAs 允许应用提供以前不可能的功能:例如,向页面添加离线行为或允许用户从主屏幕启动网站。
通常,我们对三个事件感兴趣:
-
添加到主屏幕:这将允许我们了解用户对浏览器提示的反应,并且根据用户的选择,我们可以知道服务对用户有多大价值。
-
从主屏幕运行:在主屏幕上添加图标只是第一步。了解将我们的服务添加到主屏幕如何影响用户参与度将是有益的。
-
离线浏览量频率:这使我们能够跟踪有多少用户在离线时访问服务。
跟踪主屏幕提示
我们将使用beforeinstallprompt事件来跟踪有多少用户被要求将网站添加到他们的主屏幕上,他们会做出什么决定,基于此,我们将向我们的跟踪系统发送信息:例如,谷歌分析。
打开AddToHomeScreenService
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, you can create another service
// or you may use a library to send this event to Google Analytics
// ga('send', 'event', 'A2H', userChoice);
console.log(userChoice);
this.deferredPromptFired$.next(false);
}
从主屏幕跟踪会话
跟踪从主屏幕启动的会话的最可靠的方法之一是在我们的应用清单上为start_url添加一个定制的查询参数。例如,如果你正在使用谷歌分析,你可以添加自定义活动参数。4
通常,有五个参数可以添加到您的 URL 中:
-
标识广告客户、网站、出版物等。,这是发送流量到你的财产:例如,谷歌,newsletter4,广告牌。
-
utm_medium:广告或营销媒体:例如,cpc、横幅、电子邮件简讯。 -
utm_campaign:个人活动名称、口号、促销代码等。,对于一个产品来说。 -
utm_term:识别付费搜索关键词。如果您正在手动标记付费关键词活动,您也应该使用utm_term来指定关键词。 -
utm_content:用于区分同一广告中的相似内容或链接。例如,如果您在同一封电子邮件中有两个行动号召链接,您可以使用utm_content并为每个链接设置不同的值,这样您就可以知道哪个版本更有效。
举个例子:
// manifest.json
{ ...
"background_color": "#fafafa",
"display": "standalone",
"scope": "/",
"//": "Append tracking parameters to start_url",
"start_url": "/?utm_source=homescreen",
"icons":
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
...
}
要查看活动报告:
-
登录谷歌分析。
-
导航到您的视图。
-
打开报告。
-
选择收购➤活动。
您可以根据需要使用其他跟踪系统,使用相同的机制来创建您想要的东西。
跟踪离线浏览量
在写这本书的时候,Angular Service Worker 中还没有实现的解决方案。
Workbox正在为离线浏览量跟踪提供支持。在第 [13 和 14 章中,我们将实现这个模块,看看它是如何工作的。
在线工具
webpagetest.org是衡量绩效的常用工具。你会在这里找到深入的文档: sites.google.com/a/webpagetest.org/docs 。
web.dev/measure 是谷歌的一个新工具,帮助像你一样的开发者学习并应用网络的现代功能到你自己的网站和应用中。
真实设备
最后但同样重要的是,不要忘记真实设备测试,并根据您的分析系统在普通设备或访问量最大的设备上测量您的应用性能和行为。为了在真实世界中看到应用,有必要对真实设备进行概述。
摘要
在本章中,我们讨论了调试和测量 PWA 的最简便的工具。然而,事情不会总是这么顺利。在下一章中,我将揭示一些可能性,表明如果你的应用和服务工作器出错,你仍然能够生存。
Footnotes 1持续交付,持续集成。
2
https://developers.google.com/web/tools/lighthouse
3
无头 Chrome 节点 API https://pptr.dev .
4
https://support.google.com/analytics/answer/1033863%3Fhl%3Den
十一、安全服务工作器
服务工作器确实厉害,Angular 的服务工作器也不例外。他们为构建 web 应用做复杂和高级的工作。然而,根据我多年来构建 pwa 的经验,事情并不总是按照我们喜欢的方式发展。可能会发生服务工作器以不可预见的方式行事,它可能会中断用户体验,甚至使我们的应用完全无用和不可及。
当您在浏览器中注册了一个服务工作器后,摆脱一个服务工作器并不像看起来那么容易。知道如何从客户端注销服务工作器可能会使您的站点处于失败的挂起状态,这可能会给用户带来令人沮丧的体验。
一个简单的例子是,当您已经注册了一个服务工作器并且想要删除一个已注册的服务工作器文件时;因此,浏览器将不再找到服务工作器文件,并且旧的服务工作器将停留在浏览器上,直到新的服务工作器文件被注册。你会发现这个错误会对你的客户产生破坏性的影响。
幸运的是,Angular Service Worker 包含了一些解决方案,比如 Fail-safe,这是一种从浏览器中注销自身的自毁方式。在这一章中,我将向您展示不同的机制,即所谓的“kill switch”,此外还有 Angular 解决方案,您可以取消或注销您的服务工作器,清理缓存等,以避免为用户提供破坏性的 web 应用。您可以在调试时使用这些方法,甚至在您觉得需要为您的应用去除 PWA 特性时也可以使用这些方法。
自动防故障
Angular 提供了一个简单的解决方案来停用服务工作器。正如我们在前面的章节中看到的,ngsw-config.json(在dist文件夹中构建后的ngsw.json)是我们定义服务工作器规则和逻辑的清单。
Angular Service Worker 试图通过执行fetchLatestManifest方法,在应用初始化和检查导航请求的新更新时获取ngsw清单。让我们仔细看看这个方法:
fetchLatestManifest(ignoreOfflineError = false) {
return __awaiter$5(this, void 0, void 0, function* () {
const res = yield this.safeFetch(this.adapter.newRequest('ngsw.json?ngsw-cache-bust=' + Math.random()));
if (!res.ok) {
if (res.status === 404) {
yield this.deleteAllCaches();
yield this.scope.registration.unregister();
}
else if (res.status === 504 && ignoreOfflineError) {
return null;
}
throw new Error(`Manifest fetch failed! (status: ${res.status})`);
}
this.lastUpdateCheck = this.adapter.time;
return res.json();
});
}
正如在代码片段中看到的,Angular 试图用一个随机的 cache-bust 查询参数进行提取,以确保文件没有被缓存并且是新的。
如果这个文件不存在或者基本上响应状态码是 404,Angular Service Worker 将首先删除所有缓存,然后注销当前的 SW 注册。
因此,如果你的应用出错了,你可以简单地重命名或删除 ngsw.json文件,这实质上删除了所有的缓存;注销自身;或者换句话说,自我毁灭。
rm dist/ngsw.json
这是一个处理删除所有缓存的函数:
deleteAllCaches() {
return __awaiter$5(this, void 0, void 0, function* () {
yield (yield this.scope.caches.keys())
.filter(key => key.startsWith('ngsw:'))
.reduce((previous, key) => __awaiter$5(this, void 0, void 0, function* () {
yield Promise.all([
previous,
this.scope.caches.delete(key),
]);
}), Promise.resolve());
});
}
注意,如果将angular.json中的serviceWorker变为false,将不会产生ngsw.json;所以这个机制也会起作用。
安全工人
Angular 服务工作器包包含一个简单的无操作 1 服务工作器脚本,可以替换 ngsw-worker.js:
self.addEventListener('install', event => { self.skipWaiting(); });
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
self.registration.unregister().then(
() => { console.log('NGSW Safety Worker - unregistered old service worker'); });
});
让我们来分解这个脚本:
-
它甚至监听安装,并强制跳过等待,以便立即安装。
-
它监听激活事件:
-
确保所有客户端(例如选项卡)都已声明,以便使用最新安装的服务工作器。clients 的
claim()方法允许活动的服务工作器将其自身设置为其范围内所有客户端的控制器。这将在由该服务工作器控制的任何客户端的navigator.serviceWorker上触发一个controllerchange事件。 -
它会立即注销自己。
-
为了注销您当前的服务工作器,请将文件内容复制到ngsw-worker.js或任何已注册并正在使用的服务工作器名称中。
cp dist/satefy-worker.js dist/ngsw-worker.js
该脚本可用于停用 Angular 服务工作器以及网站上可能提供服务的任何其他服务工作器。
扩展安全工人
然而,在大多数情况下,一个简单的无操作服务工作器就可以工作。在某些情况下,我们可能需要删除所有缓存或强制刷新用户的标签(网站的每个客户端),以便接收最新的更新。例如,当你重定向你的网站到一个新的起点(域名)时,你的服务工作器可能会突然行为不端。
那么,这个怎么解决呢?
-
移除所有缓存:
caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => caches.delete(cacheName)) ); })如果您只想过滤 Angular 缓存名称并删除它们:
caches.keys().then(cacheNames => { return Promise.all( cacheNames .filter(key => key.startsWith('ngsw:')) .map(cacheName => caches.delete(cacheName)) ); }) -
刷新所有 Windows 类型的客户端(选项卡):将它们放在一起:
self.addEventListener('install', event => { self.skipWaiting(); }); self.addEventListener('activate', event => { event.waitUntil(self.clients.claim()); self.registration.unregister().then(async () => { console.log('NGSW Safety Worker - unregistered old service worker'); // Get all cache keys const cacheNames = await caches.keys(); // If you want to delete Only Angular Caches const AngularCaches = cacheNames.filter(key => key.startsWith('ngsw:')); // Delete all caches await Promise.all(AngularCaches.map(cacheName => caches.delete(cacheName))); // Grab a list of all tabs const clients = await self.clients.matchAll({ type: 'window' }); // Reload pages for (const client of clients) { client.navigate(client.url); } }); });-
获取所有窗口客户端(选项卡)的列表。
-
每个客户端都公开了一个名为 navigatem 的方法,允许我们将客户端重定向到另一个页面。
-
我们将每个客户端导航到自己,以便强制它重新加载页面!
self.clients.matchAll({ type: 'window' }) .then(clients => { for (const client of clients) { client.navigate(client.url); } });Put them all together:
-
摘要
虽然我们的目标是开发、构建和部署一个有效的应用,但是错误是不可避免的。在这一章中,我们制定了一个备份计划,即所谓的“终止开关”,以备在我们能够调试并修复问题之前,我们需要解雇一名有问题的维修人员。尤其是 Angular,它提供了几种方法来确保我们的应用尽可能完美地工作:比如故障安全和安全工人脚本机制。
我们还扩展了无操作服务工作器脚本,并学习了如何注销服务工作器、所有客户端的声明、清理缓存以及在必要时重新加载页面。我希望你永远不会使用这些方法,一切顺利;但是,您现在知道如果意外出错该怎么办了。
Footnotes 1no op(或 no-op)表示无操作,是一条占用少量空间但不指定任何操作的计算机指令。这里它指的是一个简单的服务工作器,除了注册自己之外不做任何事情,或者我们可以在活动事件中删除缓存,或者在必要时取消注册。
十二、现代 Web APIs
如果我告诉您,您可以构建一个 web 应用,连接到支持蓝牙低能耗的设备,并从您的 web 应用控制它,会怎么样?如果用户的登录凭证安全地保存在浏览器中,并且当用户访问网站时,他们会自动登录,会怎么样?如果登录 web 应用需要通过 USB 连接的设备来验证用户身份,该怎么办?如果我可以通过浏览器中的 JavaScript API 访问本地平台上的共享选项会怎么样?我知道你现在可能在想什么;但是,即使这些在 10 年前听起来像是梦想,今天它们中的大多数都是可以实现的,或者至少接近成为现实。
在过去的十年里,网络的大部分已经发生了显著的变化。新的 web APIs 允许开发人员通过蓝牙和 USB 将 web 应用连接到硬件。在线支付从未像今天这样简单。单点登录和无密码解决方案以最小的努力带来了更好的用户体验。通过跨所有设备和操作系统的相同 API 开发跨平台非常困难,而今天,这是开发和构建 web 应用的一种非常愉快的方式,尤其是渐进式 Web 应用(pwa ),因为许多新的 API 已经标准化,在我们的浏览器中提供了高级 JavaScript API,以访问平台的底层 API。
在这一章中,我选择了一些新技术和 API 来探索和集成 PWA note 应用,如凭据管理、支付请求、地理定位、媒体流、Web 蓝牙和 Web USB。我将确保这些 API 的基础知识将被涵盖。但是,您可能需要根据您的需要和要求为您的应用开发额外的。
此外,我建议密切关注 Web 共享、Web VR/AR、后台获取、可访问性改进、Web 组装以及更多正在开发或考虑中的新标准,这些标准将增强 Web 的能力,尤其是通过构建 PWA。
凭证管理
凭证管理 API 是一个基于承诺的标准浏览器 API,它通过提供网站和浏览器之间的接口来促进跨设备的无缝登录。该 API 允许用户通过帐户选择器使用一个标签登录,并帮助在浏览器中存储凭据,从而可以在设备之间同步。这有助于已经登录到一个浏览器的用户,如果他们使用同一个浏览器,他或她可以继续登录到所有其他设备。
该 API 不仅支持本机浏览器密码管理,还可以提供来自联合身份提供者的凭据信息。它的意思是:任何被网站信任来正确验证用户身份并为此提供 API 的实体都可以成为这个 API 中的提供者,以存储凭据并在必要时检索凭据。例如,Google Account、GitHub、Twitter、脸书或 OpenID Connect 都是联合身份提供者框架的例子。
请记住,这个 API 只有在来源安全的情况下才能工作;换句话说,类似于 PWA,你的网站必须在 HTTPS 上运行。
让我们开始在 Angular 项目中实现,看看它是如何工作的。
首先,我们将创建一个名为CredentialManagementService ,的服务,并导入到我的CoreModule中。
declare const PasswordCredential: any;
declare const FederatedCredential: any;
declare const navigator: any;
declare const window: any;
@Injectable({
providedIn: 'root'
})
export class CredentialManagementService {
isCredentialManagementSupported: boolean;
constructor(private snackBar: SnackBarService) {
if (window.PasswordCredential || window.FederatedCredential) {
this.isCredentialManagementSupported = true;
} else {
this.isCredentialManagementSupported = false;
console.log('Credential Management API is not supported in this browser');
}
}
async store({ username, password }) {
if (this.isCredentialManagementSupported) {
// You can either pass the passwordCredentialData as below
// or simply pass down your HTMLFormElement. A reference to an HTMLFormElement with appropriate input fields.
// The form should, at the very least, contain an id and password
.
// It could also require a CSRF token.
/*
<form id="form" method="post">
<input type="text" name="id" autocomplete="username" />
<input type="password" name="password" autocomplete="current-password" />
<input type="hidden" name="csrf_token" value="*****" />
</form>
<script>
const form = document.querySelector('#form');
const credential = new PasswordCredential(form);
// if you have a federated provider
const cred = new FederatedCredential({
id: id,
name: name,
provider: 'https://account.google.com',
iconURL: iconUrl
});
<script>
*/
// Create credential object synchronously.
const credential = new PasswordCredential({
id: username,
password: password
// name: name,
// iconURL: iconUrl
});
const isStored = await navigator.credentials.store(credential);
if (isStored) {
this.snackBar.open('You password and username saved in your browser');
}
}
}
async get() {
if (this.isCredentialManagementSupported) {
return navigator.credentials.get({
password: true,
mediation: 'silent'
// federated: {
// providers: ['https://accounts.google.com']
// },
});
}
}
preventSilentAccess() {
if (this.isCredentialManagementSupported) {
navigator.credentials.preventSilentAccess();
}
}
}
该服务有三个方法,基本上是主要凭证 API 方法的包装器,用于检查 API 在浏览器中是否可用。让我们来分解服务:
-
服务初始化时的功能检测,以确保该 API 可用。
if (window.PasswordCredential || window.FederatedCredential) {} -
store方法:-
接受用户名和密码,因此我们可以创建一个密码凭据,并将其存储在凭据中。
PasswordCredential构造函数接受HTMLFormElement和一个基本字段的对象。如果您想通过HTMLFormElement,请确保您的表单至少包含一个 ID 和密码以及 CSRF 令牌。在方法中,调用 ID 为的构造函数,它是用户名和密码。name和iconURL,分别是正在登录的用户的名字和用户的头像图像,可选。请记住,如果该功能可用,我们将运行此代码;否则,我们让用户正常使用应用。因为我们正在构建 PWA,所以为那些选择的浏览器不支持正在使用的功能的用户提供替代方案总是很重要的。
-
如果你打算使用第三方登录,你必须调用带有
id和provider端点的FederatedCredential构造函数。 -
凭证 API 在 navigator 上可用,存储函数是基于承诺的,通过调用它,我们可以在浏览器中保存用户凭证。
-
最后,我们向用户显示一条消息,通知他们我们将他们的密码存储在浏览器中。
-
-
get方法:在特征检测被检查后,我们通过传入配置如
password来调用navigation.credentials上的get,mediation. Mediation定义了我们想要如何告诉浏览器向用户显示账户选择器,它有三个值:optional, required,和silent。当中介为optional时,在调用了navigator.credentials.preventSilentAccess()之后,用户会被明确地显示一个帐户选择器来登录。这通常是为了确保在用户选择退出或注销后不会自动登录。一旦
navigator.credentials.get()解决了,它返回一个undefined或者一个credential object。要确定它是一个PasswordCredential还是一个FederatedCredential,只需查看对象的type属性,它将是password或federated。如果type是联合的,那么provider属性是一个表示身份提供者的字符串。 -
preventSilentAccess方法:我们称之为
preventSilentAccess() on navigator.credentials.,这将确保自动登录不会发生,直到用户下次启用自动登录。要恢复自动登录,用户可以通过从帐户选择器中选择他们希望登录的帐户来选择有意登录。然后,用户总是重新登录,直到他们明确注销。
为了继续使用UserContainerComponent,我们将首先注入这个服务,然后定义我的autoSignIn方法,并在signup和login方法上调用ngOnInit.方法,我们将从credential service调用存储方法来保存和更新用户凭证。
最后,当用户注销时,我们需要调用preventSilentAccess()。看起来是这样的:
constructor(
private credentialManagement: CredentialManagementService,
private fb: FormBuilder,
private auth: AuthService,
private snackBar: SnackBarService
) {}
ngOnInit() {
this.createLoginForm();
if (!this.auth.authenticated) {
this.autoSignIn();
}
}
private async autoSignIn() {
const credential = await this.credentialManagement.get();
if (credential && credential.type === 'password') {
const { password, id, type } = credential;
const isLogin = await this._loginFirebase({ password, email: id });
if (isLogin) {
// make sure to show a proper message to the user
this.snackBar.open(`Signed in by ${id} automatically!`);
}
}
}
public signUp() {
this.checkFormValidity(async () => {
const signup = await this.auth.signUpFirebase(this.loginForm.value);
const isLogin = await this.auth.authenticateUser(signup);
if (isLogin) {
const { email, password } = this.loginForm.value;
this.credentialManagement.store({ username: email, password });
}
});
}
public login() {
this.checkFormValidity(async () => {
const { email, password } = this.loginForm.value;
const isLogin = this._loginFirebase({ email, password });
if (isLogin) {
this.credentialManagement.store({ username: email, password });
}
});
}
public logOut() {
this.auth
.logOutFirebase()
.then(() => {
this.auth.authErrorMessages$.next(null);
this.auth.isLoading$.next(false);
this.auth.user$.next(null);
// prevent auto signin until next time user login explicity
// or allow us for auto sign in
this.credentialManagement.preventSilentAccess();
})
.catch(e => {
console.error(e);
this.auth.isLoading$.next(false);
this.auth.authErrorMessages$.next(
'Something is wrong when signing out!'
);
});
}
注意
克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入 12 章,01-凭证-管理-api 文件夹,找到所有样本代码。
使用登录表单上的autocomplete属性来帮助浏览器正确识别字段也是一个很好的实践(图 12-1 )。
图 12-1
自动完成属性允许浏览器为网站显示适当的用户名和密码
<input
matInput
placeholder="Enter your email"
autocomplete="username"
formControlName="email"
required
/>
<input
matInput
autocomplete="current-password"
placeholder="Enter your password"
[type]="hide ? 'password' : 'text'"
formControlName="password"
/>
我们在新的浏览器中运行该应用,然后我们将转到登录页面,通过输入我的凭据登录到网站。您将看到一条提示消息,要求用户在浏览器中保存凭证(参见图 12-2 )。
图 12-2
web 应用希望在浏览器中保存凭据时的凭据提示
为了测试我的auto sign-in,我们将打开一个新的干净的浏览器并进入登录页面,然后我们将注意到我们被重定向到笔记列表,并出现一条snackbar消息,显示我们已自动登录(见图 12-3 )。
图 12-3
网站 snackbar 消息和自动登录后来自浏览器本身的消息
最后,中介optional或required将显示一个帐户选择器提示,允许用户选择他们所选择的帐户,特别是如果他们保存了多个帐户(参见图 12-4 )。
图 12-4
中介是可选还是必需的帐户选择器
浏览器支持
到写这本书的时候,桌面和安卓的 Chrome,安卓浏览器,桌面和移动的 Opera,三星互联网浏览器都支持这个 API 目前正在考虑将其用于 Firefox。MS Edge 正在向 Chromium 平台迁移,这个 API 应该很快就会被覆盖。
付款申请
很有可能我们所有阅读这本书的人都在网上支付过——至少一次。所以我们都知道填写结帐表格是多么的耗时和无聊,尤其是如果它有不止一个步骤的话。
支付请求标准 API 是由 W3C 开发的,旨在确保消费者和商家的在线支付系统保持一致和顺畅。这不是一种新的支付方式;相反,这是一种旨在简化结账过程的方式。
有了这个 API,当消费者想要选择诸如送货地址、信用卡、联系方式等支付细节时,他们总能看到一个原生平台 UI。想象一下,一旦您在浏览器中保存了所有信息,您就可以在每个支持该 API 的结账页面中重用它们。这将是多么愉快的体验:忽略结帐表单中许多字段的填写、信用卡信息等等。相反,我们将看到保存的信息与熟悉的本地用户界面一致。只需几次点击或标签选择,它就完成了!
这种 API 的另一个优势是接受从各种处理程序到 web 的不同支付方式,并且相对容易集成:例如,Apple Pay、Samsung Pay、Google Pay。
长话短说,我准备在 PWA 笔记 app 里增加一个捐款按钮。
首先,我们将在 Angular 中创建一个名为WebPaymentService的服务,并在CoreModule中导入它。
export class WebPaymentService {
public isWebPaymentSupported: boolean;
private requestPayment = null;
private canMakePaymentPromise: Promise<boolean> = null;
private supportedPaymentMethods = [
{
// support credit card payment
supportedMethods: 'basic-card',
data: {
supportedNetworks: ['visa', 'mastercard', 'amex'],
supportedTypes: ['credit', 'debit']
}
}
// Apple pay, Google Pay, Samasung pay, Stripe and others can be added here too.
];
// just an example of a simple product details
private paymentDetails: any = {
total: {
label: 'Total Donation',
amount: { currency: 'USD', value: 4.99 }
},
displayItems: [
{
label: 'What I recieve',
amount: { currency: 'USD', value: 4.49 }
},
{
label: 'Tax',
amount: { currency: 'USD', value: 0.5 }
}
]
};
private requestPaymentOptions = {
requestPayerName: true,
requestPayerPhone: false,
requestPayerEmail: true,
requestShipping: false
shippingType: 'shipping'
};
constructor() {
if (window.PaymentRequest) {
// Use Payment Request API which is supported
this.isWebPaymentSupported = true;
} else {
this.isWebPaymentSupported = false;
}
}
constructPaymentRequest() {
if (this.isWebPaymentSupported) {
this.requestPayment = new PaymentRequest(
this.supportedPaymentMethods,
this.paymentDetails,
this.requestPaymentOptions
);
// ensure that user have a supported payment method if not you can do other things
if (this.requestPayment.canMakePaymentPromise) {
this.canMakePaymentPromise = this.requestPayment.canMakePayment();
} else {
this.canMakePaymentPromise = Promise.resolve(true);
}
} else {
// do something else for instance redirect user to normal checkout
}
return this;
}
async show(): Promise<any> {
/* you can make sure client has a supported method already if not do somethig else. For instance, fallback to normal checkout, or let them to add one active card */
const canMakePayment = await this.canMakePaymentPromise;
if (canMakePayment) {
try {
const response = await this.requestPayment.show();
// here where you can process response payment with your backend
// there must be a backend implementation too.
const status = await this.processResponseWithBackend(response);
// after backend responsed successfully, you can do any other logic here
// complete transaction and close the payment UI
response.complete(status.success);
return status.response;
} catch (e) {
// API Error or user closed the UI
console.log('API Error or user closed the UI');
return false;
}
} else {
// Fallback to traditional checkout for example
// this.router.navigateByUrl('/donate/traditional');
}
}
async abort(): Promise<boolean> {
return this.requestPayment.abort();
}
// mock backend response
async processResponseWithBackend(response): Promise<any> {
// check with backend and respond accordingly
return new Promise(resolve => {
setTimeout(() => {
resolve({ success: 'success', response });
}, 1500);
});
}
}
我们来分解一下。
-
一如既往,渐进增强的特征检测。
if (window.PaymentRequest) { this.isWebPaymentSupported = true; } else { this.isWebPaymentSupported = false; } -
对于每次支付,您需要构造一个接受三个参数的
PaymentRequest。new PaymentRequest( this.supportedPaymentMethods, this.paymentDetails, this.requestPaymentOptions ); -
定义
supportedPaymentMethods,,它是所有支持的支付方式的数组。在代码示例中,我刚刚定义了一个基本卡;然而,在本章的示例代码中,您会发现更多的方法,如 Apple Pay、Google Pay 和 Samsung Pay。你并不局限于它们;您可以实现任何受欢迎的方法,如 PayPal、Stripe 等支持该 API 的方法。private supportedPaymentMethods = [ { // support credit card payment supportedMethods: 'basic-card', data: { // you can add more such as discover, JCB and etc. supportedNetworks: ['visa', 'mastercard', 'amex'], supportedTypes: ['credit', 'debit'] } }, ]这个数组中的每个对象都有特定于方法本身的
supportedMethods和data属性。为了更好地理解,我也将提供一个 Apple Pay 对象作为示例:{ supportedMethods: 'https://apple.com/apple-pay', data: { version: 3, merchantIdentifier: 'merchant.com.example', merchantCapabilities: ['supports3DS', 'supportsCredit', 'supportsDebit'], supportedNetworks: ['amex', 'discover', 'masterCard', 'visa'], countryCode: 'US' } }, -
In Define
paymentDetails, for instance, in my example, I have a fixed donation number; however, you may have a cart page with different products and other details that need to be added to payment details accordingly.private paymentDetails: any = { total: { label: 'Total Donation', amount: { currency: 'USD', value: 4.99 } }, displayItems: [ { label: 'What I recieve', amount: { currency: 'USD', value: 4.49 } }, { label: 'Tax', amount: { currency: 'USD', value: 0.5 } } ] };主要有两个属性:
total表示总金额;和显示购物车商品的数组displayItems,。 -
Define
requestPaymentOptionsis optional; however, you may find it very useful for different purposes – for instance, if a shipping address is required or email must be provided.private requestPaymentOptions = { requestPayerName: true, requestPayerPhone: false, requestPayerEmail: true, requestShipping: false, shippingType: 'shipping' };在本例中,我们只要求付款人提供电子邮件和姓名。
-
最后但同样重要的是,我们在
requestPayment上展示了 call show 方法,以便显示付款本地提示页面。async show(): Promise<any> { const canMakePayment = await this.canMakePaymentPromise; if (canMakePayment) { try { const response = await this.requestPayment.show(); const status = await this.processResponseWithBackend(response); response.complete(status.success); return status.response; } catch (e) { return false; } } }
在requestPayment上有另一个基于承诺的方法叫做canMakePayment(),它本质上是一个助手,在show()被调用之前,确定用户是否有一个支持的支付方法来进行支付。它可能不在所有用户代理中;因此,我们需要进行特征检测。
然后,一旦用户完成,我们就调用show() ,,Promise 将通过用户的选择细节得到解析,包括联系信息、信用卡、运费等等。现在是用后端验证和处理付款的时候了。
打开header.component.html()并添加以下按钮(见图 12-5 ):
图 12-5
触发付款的捐赠按钮本机 UI
<button mat-menu-item (click)="donateMe()" *ngIf="isWebPaymentSupported">
<mat-icon>attach_money</mat-icon>
<span>Donate</span>
</button>
最后,将WebPaymentService注入header.component.ts。应该定义donateMe()方法,一旦解决了这个问题,它将调用requestPayment并向用户显示适当的消息。
public isWebPaymentSupported: boolean;
constructor(
private webPayment: WebPaymentService,
) {
this.isWebPaymentSupported = this.webPayment.isWebPaymentSupported;
}
async donateMe() {
const paymentResponse = await this.webPayment
.constructPaymentRequest()
.show();
if (paymentResponse) {
this.snackBar.open(
`Successfully paid, Thank you for Donation ${paymentResponse.payerName}`
);
} else {
// this.snackBar.open('Ops, sorry something went wrong with payment');
}
}
我们将构建应用,并在浏览器和手机中运行和测试(见图 12-6 和 12-7 )。
图 12-7
Safari、Chrome 和三星互联网浏览器中的 Apple pay 显示原生支付 UI
图 12-6
支付原生用户界面,Chrome,Mac
注意
克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入 13 章,02-请求-支付-api 文件夹,找到所有样本代码。
浏览器支持
在撰写本书时,几乎所有主流浏览器都支持这个 API,不管是在产品中还是在桌面和移动设备的夜间版本中,尽管它们也可能部分支持。
视频和音频捕捉
Media Streams 是一个与 WebRTC 相关的 API,它提供对流式音频和视频数据的支持。这个 API 已经存在一段时间了。新的基于承诺的getUserMedia()是一个请求用户允许麦克风和摄像头的方法;因此,您将可以访问实时流。
在本节中,我们将向“添加笔记”页面添加一个新功能,用户可以在其中将带有音频的交互式视频保存到他们的笔记中。
请注意,在本例中,我们不会将此视频发送到服务器,但是实现将准备好与后端通信,以便在需要时保存视频和音频。
在notes-add.component.html中,我们将添加以下 html 片段:
<div class="media-container" *ngIf="isMediaRecorderSupported">
<h1>Add video with audio Note</h1>
<div class="videos">
<div class="video">
<h2>LIVE STREAM</h2>
<video #videoOutput autoplay muted></video>
</div>
<div class="video">
<h2>RECORDED STREAM</h2>
<video #recorded autoplay loop></video>
</div>
</div>
<div class="buttons">
<button mat-raised-button color="primary" (click)="record()" *ngIf="disabled.record" > Start Recording</button>
<button mat-raised-button color="primary" (click)="stop()" *ngIf="disabled.stop"> Stop Recording </button>
<button mat-raised-button color="secondary" (click)="play()" *ngIf="disabled.play"> Play Recording</button>
<button mat-raised-button color="primary" (click)="download()" *ngIf="disabled.download"> Download Recording </button>
<a #downloadLink href="">Download Link</a>
</div>
</div>
这段代码非常简单明了。我们将逻辑添加到notes-add.component.ts:
export class NotesAddComponent {
@ViewChild('videoOutput') videoOutput: ElementRef;
@ViewChild('recorded') recordedVideo: ElementRef;
@ViewChild('downloadLink') downloadLink: ElementRef;
public disabled = { record: true, stop: false, play: false, download: false };
public userID;
public errorMessages$ = new Subject();
public loading$ = new Subject();
public isMediaRecorderSupported: boolean;
private recordedBlobs;
private liveStream: any;
private mediaRecorder: any;
constructor(
private router: Router,
private data: DataService,
private snackBar: SnackBarService
) {
if (window.MediaRecorder) {
this.isMediaRecorderSupported = true;
this.getStream();
} else {
this.isMediaRecorderSupported = false;
}
}
async getStream() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
this.handleLiveStream(stream);
} catch (e) {
this.isMediaRecorderSupported = false;
this.onSendError('No permission or something is wrong');
return 'No permission or something is wrong';
}
}
handleLiveStream(stream) {
this.liveStream = stream;
this.videoOutput.nativeElement.srcObject = stream;
}
getMediaRecorderOptions() {
let options = {
mimeType: 'video/webm;codecs=vp9',
audioBitsPerSecond: 1000000, // 1 Mbps
bitsPerSecond: 1000000, // 2 Mbps
videoBitsPerSecond: 1000000 // 2 Mbps
};
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
console.log(`${options.mimeType} is not Supported`);
options = { ...options, mimeType: 'video/webm;codecs=vp8' };
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
console.log(`${options.mimeType} is not Supported`);
options = { ...options, mimeType: 'video/webm' };
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
console.log(`${options.mimeType} is not Supported`);
options = { ...options, mimeType: " };
}
}
}
return options;
}
record() {
this.recordedBlobs = [];
this.disabled = { play: false, download: false, record: false, stop: true };
this.mediaRecorder = new MediaRecorder(
this.liveStream,
this.getMediaRecorderOptions
);
this.mediaRecorder.ondataavailable = e => {
{
if (e.data) {
this.recordedBlobs.push(e.data);
}
}
};
this.mediaRecorder.start();
console.log('MediaRecorder started', this.mediaRecorder);
}
stop() {
this.disabled = { play: true, download: true, record: true, stop: false };
this.mediaRecorder.onstop = e => {
this.recordedVideo.nativeElement.controls = true;
};
this.mediaRecorder.stop();
}
play() {
this.disabled = { play: true, download: true, record: true, stop: false };
const buffer = new Blob(this.recordedBlobs, { type: 'video/webm' });
this.recordedVideo.nativeElement.src = window.URL.createObjectURL(buffer);
}
download() {
const blob = new Blob(this.recordedBlobs, { type: 'video/webm' });
const url = window.URL.createObjectURL(blob);
this.downloadLink.nativeElement.url = url;
this.downloadLink.nativeElement.download = `recording_${new Date().getTime()}.webm`;
this.downloadLink.nativeElement.click();
setTimeout(() => {
window.URL.revokeObjectURL(url);
}, 100);
}
onSaveNote(values) {
this.data.addNote(values).then(
doc => {
this.snackBar.open(`LOCAL: ${doc.id} has been succeffully saved`);
},
e => {
this.errorMessages$.next('something is wrong when adding to DB');
}
);
this.router.navigate(['/notes']);
}
onSendError(message) {
this.errorMessages$.next(message);
}
}
这段代码很简单。一如既往,这是对MediaRecorder,的功能检测,如果浏览器支持,我们将继续向用户展示这一功能,并将初始化getUserMedia();因此,我们请求音频和视频许可,如图 12-8 所示。
图 12-8
浏览器请求摄像头和麦克风的权限
一旦许可被授予,Promise 得到解决,流将是可访问的(见图 12-9 )。当用户点击标签“开始记录”按钮时,MediaRecorder构造器被调用,带有实时流数据和已经定义的选项。
我们将每个 blob 存储在一个数组中,直到调用stop()方法。一旦录制停止,媒体就可以播放了。通过点击“play”按钮,我们将简单地创建一个流数组的流缓冲区,通过创建一个Blob URL,我们将把它分配给一个< video>标签的src。
图 12-9
请求获得在 Android mobile 上访问视频和音频的权限
哒哒,现在视频直接在浏览器里播放了。我们还可以制作该视频的下载版本(见图 12-10 )。
图 12-10
实时流和录制回放
通过选项卡或单击“下载”按钮,我们将从一个数组recordedBlob创建一个 Blob,然后将创建一个URL并将我在模板中用display: none定义的< a>标签分配给它,然后调用click()来强制浏览器为用户打开下载模式,以便询问他们该文件必须保存在系统的什么位置。
注意
克隆 https://github.com/mhadaily/awesome-apress-pwa.git 并转到章节 12 ,03-camera-and-microphone-api 文件夹找到所有示例代码。
浏览器支持
在写这本书的时候,Opera,Chrome,和 Firefox 在桌面上;Android 上的 Chrome 和三星互联网支持大多数标准规格。微软 Edge 也在考虑这个 API。它也适用于 Safari 12 / iOS 12。我相信 API 的未来是光明的。
地理定位
地理位置 API 提供用户的位置坐标,并将其公开给 web 应用。出于隐私原因,浏览器会请求许可。这种基于承诺的 API 已经存在很长时间了。你甚至可能已经开始使用它了。
我们将通过创建一个名为GeolocationService的服务来探索这个 API,您可以在modules/core/geolocation.service.ts.下找到它
export interface Position {
coords: {
accuracy: number;
altitude: number;
altitudeAccuracy: number;
heading: any;
latitude: number;
longitude: number;
speed: number;
};
timestamp: number;
}
@Injectable()
export class GeolocationService {
public isGeoLocationSupported: boolean;
private geoOptions = {
enableHighAccuracy: true, maximumAge: 30000, timeout: 27000
};
constructor() {
if (navigator.geolocation) {
this.isGeoLocationSupported = true;
} else {
// geolocation is not supported, fall back to other options
this.isGeoLocationSupported = false;
}
}
getCurrentPosition(): Observable<Position> {
return Observable.create(obs => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
position => {
obs.next(position);
obs.complete();
},
error => {
obs.error(error);
}
);
}
});
}
watchPosition(): Observable<Position> {
return Observable.create(obs => {
if (navigator.geolocation) {
navigator.geolocation.watchPosition(
position => {
obs.next(position);
},
error => {
obs.error(error);
},
this.geoOptions
);
}
});
}
}
我们来分解一下。
-
像往常一样,确保地理位置的特征检测是可用的。
-
定义
getCurrentPosition(),我要把geolocation.getCurrentPosition()回调转换成可观察的。 -
定义
watchPosition(),我们对geolocation.watchPosition()做同样的事情,把它的回调变成可观察的。 -
我们已经定义了地理定位方法提供的位置接口。
我想做的是为每个笔记添加用户坐标,以在保存时保持位置。因此,我们可以稍后使用第三方地图供应器(如 Google Map)显示用户笔记的坐标或确切地址。由于我们正在保存所有的坐标数据,因此我们将能够根据应用的需要,在后端甚至在前端使用第三方地图提供程序将这种坐标转换为有意义的地址。
目前,为了保持简洁,我们只向用户显示当前的纬度和经度。
首先,我们将地理定位服务注入到NotesAddComponent中,然后我们将调用getCurrentPosition()并将它赋给我的本地location$变量,在这里我们将位置object转换成简单的string。
public isGeoLocationSupported = this.geoLocation.isGeoLocationSupported;
public location$: Observable<string> = this.geoLocation
.getCurrentPosition()
.pipe(map(p =>
`Latitude:${p.coords.latitude}
Longitude:${p.coords.longitude}`
));
constructor(
private router: Router,
private data: DataService,
private snackBar: SnackBarService,
private geoLocation: GeolocationService
) {}
最后,添加下面的 html 片段,其中我们使用了带有async管道的location$ observable;然而,我们首先使用*ngIf检查地理定位是否可用(参见图 12-11 中的权限对话框)。
图 12-11
浏览器要求位置许可
<h4 *ngIf="isGeoLocationSupported">You location is {{ location$ | async }}</h4>
一旦用户允许,浏览器将提供每个方法调用的协调数据(见图 12-12 )。
图 12-12
Android 上的地理位置许可对话框;一旦它被解决,协调显示
注意
克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入第十二章,04-地理定位-api 文件夹,找到所有样本代码。
浏览器支持
所有主流浏览器都支持这种 API,根据统计,这种 API 覆盖了全球 93%的市场。com 网站。 1
蓝牙 Web
这种基于承诺的 API 是一种新技术,通过 web 为物联网开辟了一个新时代。它允许 web 应用连接到蓝牙低能耗(BLE)设备。
想象一下,开发一个 PWA,我们能够访问蓝牙并控制智能家电、健康配件等设备,只需在不同平台的所有浏览器上保持 web API 的一致性。
请记住,该 API 仍在开发中,将来可能会略有变化。我推荐 GitHub 上的以下实现状态文档。 2
在我们继续之前,我建议学习蓝牙低能耗(BLE)和通用属性配置文件(GATT) 3 工作原理的基础知识。
在本节中,我们使用 Android 上的 BLE 外设模拟器 4 应用模拟 BLE 设备,并将我的 PWA note 应用与该设备配对,以便接收电池电量。我们所做的是:
-
已安装 BLE 外围设备模拟器应用
-
选择要广告的电池服务
-
保持屏幕打开,将电池电量调到 73
我们开始吧。
首先,我们将创建我的WebBluetoothService并将其导入到CoreModule中。
@Injectable()
export class WebBluetoothService {
public isWebBluetoothSupported: boolean;
private GATT_SERVICE_NAME = 'battery_service';
private GATT_SERVICE_CHARACTERISTIC = 'battery_level';
constructor() {
if (navigator.bluetooth) {
this.isWebBluetoothSupported = true;
}
}
async getBatteryLevel(): Promise<any> {
try {
// step 1, scan for devices and pair
const device = await navigator.bluetooth.requestDevice({
// acceptAllDevices: true
filters: [{ services: [this.GATT_SERVICE_NAME] }]
});
// step 2: connect to device
const connectedDevice = await this.connectDevice(device);
// step 3 : Getting Battery Service
const service = await this.getPrimaryService(connectedDevice, this.GATT_SERVICE_NAME);
// step 4: Read Battery level characterestic
const characteristic = await this.getCharacteristic(service, this.GATT_SERVICE_CHARACTERISTIC);
// step 5: ready battery level
const value = await characteristic.readValue();
// step 6: return value
return `Battery Level is ${value.getUint8(0)}%`;
} catch (e) {
console.error(e);
return `something is wrong: ${e}`;
}
}
private connectDevice(device): Promise<any> {
return device.gatt.connect();
}
private getPrimaryService(connectedDevice, serviceName): Promise<any> {
return connectedDevice.getPrimaryService(serviceName);
}
private getCharacteristic(service, characterestic): Promise<any> {
return service.getCharacteristic(characterestic);
}
}
这项服务很简单。我们遵循以下步骤:
-
检测
bluetooth是否可用。 -
使用适当的配置调用
requestDevice(),我们要求浏览器过滤并显示我们感兴趣的内容。可能存在要求检查所有设备的选项;然而,就电池健康而言,不建议这样做。为了使服务简单,我们静态定义了 GATT 服务名称和特征。
-
出现提示模式时,尝试连接到设备。
-
调用
getPrimaryService()获取电池服务。 -
通过调用
getCharacteristic(),,我们将要求battery_level. -
一旦特征被解析,我们将读取该值。
虽然这是一个非常简单的设备,文档也很清晰,但看起来有点复杂和混乱。你使用这些类型的设备和技术越多,你就能越好地理解这一切。
您只能要求浏览器通过单击或点击按钮来发现设备;因此,我们将在header.component.html中的菜单下添加一个按钮,并用ngIf确保该按钮在受支持时出现。
<button mat-menu-item (click)="getBatteryLevel()" *ngIf="isWebBluetoothSupported">
<mat-icon>battery_unknown</mat-icon>
<span>Battery Level</span>
</button>
最后,我将在header.component.ts,中定义我的getBatteryLevel方法,该方法仅在所有承诺都解决后显示电池电量信息(见图 12-13 )。
async getBatteryLevel() {
const level = await this.bluetooth.getBatteryLevel();
this.snackBar.open(level);
}
注意
克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入 12 章,05-web-bluetooth-api 文件夹,找到所有样本代码。
上面的例子展现了从 BLE 设备读取的可能性;但是,将 5 写入蓝牙特性,订阅 6 接收 GATT 通知也是另外一种情况。
图 12-13
Web Bluetooth API:配对一个设备,读取一个特征,并在所有承诺完成后显示一条消息
The example above unfolds read possibilities from a BLE device; however, writing
我们已经回顾了网络蓝牙的基础知识,希望它能让你开始使用这项令人敬畏的网络技术。
我的社区朋友 Wassim Chegham 为 Web Bluetooth 提供了一个很棒的 Angular 库,带有 Observable API,您可以通过运行以下命令进行安装:
npm i -S @manekinekko/angular-web-bluetooth @types/web-bluetooth
在 GitHub https://github.com/manekinekko/angular-web-bluetooth 上找到文档。
浏览器支持
在我写这本书的时候,支持这种 API 的浏览器是 Chrome 桌面,适用于 Windows 和 Mac,以及 Android、Samsung internet 和 Opera。我希望在未来,尤其是当你正在阅读这一节的时候,会有更多的浏览器支持 Web 蓝牙 API。
USB web
这种基于承诺的 API 提供了一种安全的方式,通过使用 JavaScript 高级 API 的浏览器将 USB 设备暴露给 web。这仍然是一个相对较新的 API,可能会随着时间的推移而改变,实现是有限的,并且会报告一些错误。
默认情况下,Web USB API 需要 HTTPS,与 Web 蓝牙类似,它必须通过用户手势(如触摸或鼠标点击)来调用。类似于键盘和鼠标的设备不能被这个 API 访问。
我相信 Web USB 打开了一扇新的窗口,它为学术目的、学生、制造商和开发者带来了很多机会。想象一下,这不是一个可以直接访问 USB 板的在线开发工具,也不是需要编写原生驱动程序的制造商;相反,他们将能够开发一个跨平台的 JavaScript SDK。想象一个硬件支持中心,他们可以通过自己的网站直接访问我的设备并进行诊断或调试。我们可以统计越来越多的案例研究;然而,我应该提到,这项技术仍在发展中,即使不是现在,也将是未来网络的一个令人兴奋的特性。的确,网络是惊人的;不是吗?
说得够多了,让我们开始探索 API 吧。为了简单起见,并让您了解 Web USB 是如何工作的,我们将连接我的“创见笔驱动器”,一旦连接上,我将显示一条消息,其中显示包括“序列号”在内的硬件信息
首先,我编写一个名为WebUSBService的服务,并导入到CoreModule。
@Injectable()
export class WebUSBService {
public isWebUSBSupported: boolean;
constructor(private snackBar: SnackBarService) {
if (navigator.usb) {
this.isWebUSBSupported = true;
}
}
async requestDevice() {
try {
const usbDeviceProperties = { name: 'Transcend Information, Inc.', vendorId: 0x8564 };
const device = await navigator.usb.requestDevice({ filters: [usbDeviceProperties] });
// await device.open();
console.log(device);
return `
USB device name: ${device.productName}, Manifacture is ${device.manufacturerName}
USB Version is: ${device.usbVersionMajor}.${device.usbVersionMinor}.${device.usbVersionSubminor}
Product Serial Number is ${device.serialNumber}
`;
} catch (error) {
return 'Error: ' + error.message;
}
}
async getDevices() {
const devices = await navigator.usb.getDevices();
devices.map(device => {
console.log(device.productName); // "Mass Storage Device"
console.log(device.manufacturerName); // "JetFlash"
this.snackBar.open(
`this. USB device name: ${device.productName}, Manifacture is ${device.manufacturerName} is connected.`
);
});
}
}
让我们来分解一下:
-
功能检测以确保“usb”可用。
-
定义
requestDevice方法,它调用navigator.usb.requestDevice()。我需要通过vendorID明确过滤我的 USB 设备。我没有神奇地想出供应商的十六进制数;我所做的就是在这个列表中搜索并找到我的设备名‘创见’www . Linux-USB . org/USB . ids。 -
定义
getDevices方法,它调用navigator.usb.getDevices();解析后,它将返回连接到源的设备列表。
我们在header.component.html ,中添加了两个按钮,点击后分别调用getDevices()和requestDevice()方法。
<button mat-menu-item (click)="getUSBDevices()" *ngIf="isWebUSBSupported">
<mat-icon>usb</mat-icon>
<span>USB Devices List</span>
</button>
<button mat-menu-item (click)="pairUSBDevice()" *ngIf="isWebUSBSupported">
<mat-icon>usb</mat-icon>
<span>USB Devices Pair</span>
</button>
将WebUSBService注入header.component.ts。如果isWebUSBSupported为true,确保按钮可见。
constructor(private webUsb: WebUSBService) {
this.isWebUSBSupported = this.webUsb.isWebUSBSupported;
}
getUSBDevices() {
this.webUsb.getDevices();
}
async pairUSBDevice() {
const message = await this.webUsb.requestDevice();
this.snackBar.open(message);
}
点击“USB 设备配对”,会出现一个列表,显示我的设备,我可以进行配对(参见图 12-14 )。
图 12-14
调用requestDevice()时基于过滤器选项的列表中的设备。配对后,根据逻辑,会出现一条消息,显示设备信息,如序列号、设备名称、制造商、USB 版本等。设备连接后,就可以传入和传出数据了。
配对成功完成后,设备就可以打开,数据可以传入传出。
例如,下面是一个设备与之通信的示例:
await device.open();
await device.selectConfiguration(1) // Select configuration #1
await device.claimInterface(0) // Request exclusive control over interface #0
await device.controlTransferOut({
"recipient": "interface",
"requestType": "class",
"request": 9,
"value": 0x0300,
"index": 0 })
const result = await device.transferIn(8, 64); // Ready to receive data7
// and you need to read the result...
该信息特定于每个设备。然而,这些方法是浏览器中的 API。
一般来说,Web USB API 提供所有端点类型的 USB 设备:
- 中断传输:
通过调用transferIn(endpointNumber, length)和transferOut(endpointNumber, data)用于典型的非周期性小型设备“启动”通信
- 控制权转移:
通过调用controlTransferIn(setup, length)和controlTransferOut(setup, data)用于命令和状态操作
- 批量转账:
用于通过调用 t ransferIn(endpointNumber, length)和transferOut(endpointNumber, data)发送到打印机的大量数据,如打印作业
- 同步传输:
用于连续和周期性数据,如通过调用isochronousTransferIn(endpointNumber, packetLengths)和isochronousTransferOut(endpointNumber, data, packetLengths)的音频或视频流
最后但并非最不重要的一点是,用户可能会将设备与系统连接或断开。有两个事件可以监听并据此采取行动。
navigator.usb.onconnect = event => {
// event.device will bring the connected device
// do something here
console.log('this device connected again: ' + event.device);
};
navigator.usb.ondisconnect = event => {
// event.device will bring the disconnected device
// do something here
console.log('this device disconnected: ', event.device);
};
在 Chrome 中调试 USB 更容易,有了内部页面chrome://device-log,你可以在一个地方看到所有与 USB 设备相关的事件。
注意
克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入第十二章,06-web-usb-api 文件夹,找到所有样本代码。
浏览器支持
在写这本书的时候,支持这个 API 的浏览器是 Chrome For desktop 和 Android 以及 Opera。虽然 API 正在迅速发展,但我希望我们很快能在浏览器中看到更好的支持。
摘要
在这一章中,我们刚刚探索了六个 web APIs。虽然它们不是 PWA 的重要组成部分,但它们有助于构建更接近原生应用的应用。
正如我在这一章的介绍中所写的,这些并不是唯一出现在 web 上的新 API。还有许多其他的正在开发中或考虑很快开发出来。
我对 web 开发的未来感到非常兴奋,因为我可以看到它将如何在我们面前打开无数的机会,来构建和发布一个更好的 web 应用。
Footnotes 1https://caniuse.com/#search=geolocation
2
https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md
3
https://www.bluetooth.com/specifications/gatt/generic-attributes-overview
4
在 Google Play 商店搜索“BLE 外设模拟器”。
5
6
7
beyond logic . org/USB nutshell/us B4 . shtml